diff --git a/README.md b/README.md index 316c1c5..e4492c0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.11.4_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.5_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index b65e6a1..c67f747 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.11.4-beta", + "version": "0.11.5-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 7d2d108..b4f4d90 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from 'express'; import { randomUUID } from 'crypto'; +import { Client as SshClient } from 'ssh2'; import { query, queryOne } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { encrypt, decrypt } from '../utils/crypto'; @@ -1773,4 +1774,109 @@ router.post('/:id/tools/wol', requireWrite, async (req: Request, res: Response) } }); +// POST /api/devices/:id/tools/capture — packet capture via RouterOS sniffer + SFTP download +router.post('/:id/tools/capture', requireWrite, async (req: Request, res: Response) => { + const { interface: iface, filter_ip, duration } = req.body as { + interface?: string; filter_ip?: string; duration?: number; + }; + const captureSec = Math.min(Math.max(5, Number(duration) || 10), 60); + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!device.ssh_username || !device.ssh_password_encrypted) { + return res.status(400).json({ error: 'SSH credentials required for packet capture. Add SSH username/password in device settings.' }); + } + + const fileName = `cap-${Date.now()}`; + const remotePath = `/flash/${fileName}.pcap`; + const client = makeToolClient(device); + + try { + await client.connect(); + const setParams: Record = { 'file-name': fileName, 'file-limit': '10240' }; + if (iface) setParams['interface'] = iface; + if (filter_ip) setParams['filter-ip-address'] = filter_ip; + await client.execute('/tool/sniffer/set', setParams); + await client.execute('/tool/sniffer/start'); + await new Promise((r) => setTimeout(r, captureSec * 1000)); + await client.execute('/tool/sniffer/stop').catch(() => {}); + client.disconnect(); + + // Download PCAP via SFTP + const pcapBuffer = await new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + ssh.sftp((err, sftp) => { + if (err) { ssh.end(); return reject(err); } + const chunks: Buffer[] = []; + const stream = sftp.createReadStream(remotePath); + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => { ssh.end(); resolve(Buffer.concat(chunks)); }); + stream.on('error', (e: Error) => { ssh.end(); reject(e); }); + }); + }); + ssh.on('error', reject); + ssh.connect({ + host: device.ip_address, + port: device.ssh_port ?? 22, + username: device.ssh_username!, + password: decrypt(device.ssh_password_encrypted!), + readyTimeout: 10_000, + }); + }); + + res.setHeader('Content-Type', 'application/vnd.tcpdump.pcap'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}.pcap"`); + return res.send(pcapBuffer); + } catch (err) { + client.disconnect(); + return res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/devices/:id/tools/btest — bandwidth test via RouterOS +router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response) => { + const { address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as { + address?: string; direction?: string; duration?: number; protocol?: string; + }; + if (!address) return res.status(400).json({ error: 'address is required' }); + const testSec = Math.min(Math.max(1, Number(duration) || 5), 30); + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + + const client = new RouterOSClient( + device.ip_address, device.api_port, device.api_username, + decrypt(device.api_password_encrypted), + 15_000, + (testSec + 10) * 1000 + ); + try { + await client.connect(); + const rows = await client.executeStreaming('/tool/bandwidth-test', { + address, + direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both', + duration: String(testSec), + protocol: ['tcp', 'udp'].includes(protocol) ? protocol : 'tcp', + }, (testSec + 10) * 1000); + + // Return the final summary row (last row that has tx/rx total averages) + const lastRow = rows.filter((r) => r['tx-total-average'] !== undefined || r['rx-total-average'] !== undefined).at(-1); + const toMbps = (v: string | undefined) => v ? Math.round(parseInt(v) / 1_000_000) : 0; + + return res.json({ + tx_mbps: toMbps(lastRow?.['tx-total-average']), + rx_mbps: toMbps(lastRow?.['rx-total-average']), + direction, + protocol, + duration: testSec, + raw: lastRow ?? rows.at(-1) ?? null, + }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + client.disconnect(); + } +}); + export default router; diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index fdea932..d349430 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -32,6 +32,7 @@ export interface DeviceRow { name: string; ip_address: string; api_port: number; + ssh_port?: number; api_username: string; api_password_encrypted: string; ssh_username?: string; diff --git a/frontend/package.json b/frontend/package.json index 4194eb8..1860918 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.4-beta", + "version": "0.11.5-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/ToolsTab.tsx b/frontend/src/components/device-detail/ToolsTab.tsx index 41e975a..851c6fb 100644 --- a/frontend/src/components/device-detail/ToolsTab.tsx +++ b/frontend/src/components/device-detail/ToolsTab.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Play, Loader2, CheckCircle, XCircle, Wifi, RotateCcw } from 'lucide-react'; +import { Play, Loader2, CheckCircle, XCircle, Wifi, RotateCcw, Download, Activity } from 'lucide-react'; import { deviceToolsApi, devicesApi } from '../../services/api'; import clsx from 'clsx'; import { useCanWrite } from '../../hooks/useCanWrite'; @@ -379,6 +379,167 @@ function RebootSection({ deviceId }: { deviceId: number }) { ); } +// ─── Packet Capture ─────────────────────────────────────────────────────────── + +function PacketCaptureTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) { + const [iface, setIface] = useState(interfaces[0] || ''); + const [filterIp, setFilterIp] = useState(''); + const [duration, setDuration] = useState('10'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [ready, setReady] = useState(false); + const [downloadUrl, setDownloadUrl] = useState(''); + const [fileName, setFileName] = useState(''); + + const run = async () => { + setLoading(true); setError(''); setReady(false); + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + try { + const res = await deviceToolsApi.capture(deviceId, { + interface: iface || undefined, + filter_ip: filterIp.trim() || undefined, + duration: parseInt(duration) || 10, + }); + const blob = new Blob([res.data as BlobPart], { type: 'application/vnd.tcpdump.pcap' }); + const url = URL.createObjectURL(blob); + const name = `capture-${deviceId}-${Date.now()}.pcap`; + setDownloadUrl(url); + setFileName(name); + setReady(true); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || (e as Error).message || 'Capture failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Captures packets using the RouterOS sniffer and downloads a PCAP file you can open in Wireshark. + Requires SSH credentials on the device. +

+
+ {interfaces.length > 0 && ( + + )} + setFilterIp(e.target.value)} + /> + + +
+ {loading && ( +

+ Capturing for {duration}s — please wait… +

+ )} + {error && } + {ready && downloadUrl && ( + + + Download {fileName} + + )} +
+ ); +} + +// ─── Bandwidth Test ─────────────────────────────────────────────────────────── + +function BandwidthTestTool({ deviceId }: { deviceId: number }) { + const [address, setAddress] = useState(''); + const [direction, setDirection] = useState('both'); + const [duration, setDuration] = useState('5'); + const [protocol, setProtocol] = useState('tcp'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number } | null>(null); + + const run = async () => { + if (!address) return; + setLoading(true); setError(''); setResult(null); + try { + const { data } = await deviceToolsApi.btest(deviceId, { address, direction, duration: parseInt(duration), protocol }); + setResult(data); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || (e as Error).message || 'Bandwidth test failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Runs RouterOS /tool/bandwidth-test to measure throughput between this device and a target IP. + The target must have the bandwidth-test server enabled. +

+
+ setAddress(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && run()} + /> + + + + +
+ {loading && ( +

+ Running {duration}s bandwidth test — please wait… +

+ )} + {error && } + {result && ( +
+ {(result.direction === 'both' || result.direction === 'transmit') && ( +
+
{result.tx_mbps} Mbps
+
TX (Upload)
+
+ )} + {(result.direction === 'both' || result.direction === 'receive') && ( +
+
{result.rx_mbps} Mbps
+
RX (Download)
+
+ )} +
+ {result.duration}s {result.protocol.toUpperCase()} test +
+
+ )} +
+ ); +} + // ─── Main tab ───────────────────────────────────────────────────────────────── function ToolCard({ title, icon: Icon, children }: { title: string; icon: React.FC<{ className?: string }>; children: React.ReactNode }) { @@ -440,6 +601,18 @@ export default function ToolsTab({ deviceId }: { deviceId: number }) { )} + + {canWrite && ( + + + + )} + + {canWrite && ( + + + + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 91c2dae..c3585b3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -335,6 +335,12 @@ export const deviceToolsApi = { api.post[]>(`/devices/${id}/tools/ip-scan`, body, { timeout: TOOL_TIMEOUT }), wol: (id: number, body: { mac: string; interface: string }) => api.post<{ success: boolean; message: string }>(`/devices/${id}/tools/wol`, body, { timeout: TOOL_TIMEOUT }), + capture: (id: number, body: { interface?: string; filter_ip?: string; duration?: number }) => + api.post(`/devices/${id}/tools/capture`, body, { timeout: 120_000, responseType: 'blob' }), + btest: (id: number, body: { address: string; direction?: string; duration?: number; protocol?: string }) => + api.post<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number; raw: Record | null }>( + `/devices/${id}/tools/btest`, body, { timeout: 60_000 } + ), }; // ─── Events ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 7dafdf7..8ad401a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.4 Beta'; +export const APP_VERSION = 'v0.11.5 Beta';