feat: packet capture, bandwidth test (v0.11.5 Beta)

- Packet capture: RouterOS sniffer + SFTP download to PCAP for Wireshark analysis
- Bandwidth test: RouterOS /tool/bandwidth-test with TX/RX Mbps result display
- Both exposed in the Tools tab on device detail pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 15:50:31 -07:00
co-authored by Claude Sonnet 4.6
parent a1ca4d0a44
commit 64bb8b1b93
8 changed files with 291 additions and 5 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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": {
+106
View File
@@ -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<string, string> = { '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<Buffer>((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;
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.11.4-beta",
"version": "0.11.5-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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 (
<div className="space-y-4">
<p className="text-xs text-gray-500 dark:text-slate-400">
Captures packets using the RouterOS sniffer and downloads a PCAP file you can open in Wireshark.
Requires SSH credentials on the device.
</p>
<div className="flex flex-wrap gap-3">
{interfaces.length > 0 && (
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
<option value="">All interfaces</option>
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
</select>
)}
<input
className="input flex-1 min-w-36"
placeholder="Filter IP (optional)"
value={filterIp}
onChange={(e) => setFilterIp(e.target.value)}
/>
<select className="input w-28" value={duration} onChange={(e) => setDuration(e.target.value)}>
{[5, 10, 15, 30, 60].map((s) => <option key={s} value={s}>{s}s</option>)}
</select>
<RunButton loading={loading} onClick={run} label="Capture" />
</div>
{loading && (
<p className="text-sm text-gray-500 dark:text-slate-400">
Capturing for {duration}s — please wait…
</p>
)}
{error && <ErrorBox message={error} />}
{ready && downloadUrl && (
<a
href={downloadUrl}
download={fileName}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-sm font-medium transition-colors"
>
<Download className="w-4 h-4" />
Download {fileName}
</a>
)}
</div>
);
}
// ─── 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 (
<div className="space-y-4">
<p className="text-xs text-gray-500 dark:text-slate-400">
Runs RouterOS /tool/bandwidth-test to measure throughput between this device and a target IP.
The target must have the bandwidth-test server enabled.
</p>
<div className="flex flex-wrap gap-3">
<input
className="input flex-1 min-w-48"
placeholder="Target IP address"
value={address}
onChange={(e) => setAddress(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && run()}
/>
<select className="input w-32" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="both">Both</option>
<option value="receive">Receive</option>
<option value="transmit">Transmit</option>
</select>
<select className="input w-24" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<select className="input w-24" value={duration} onChange={(e) => setDuration(e.target.value)}>
{[5, 10, 15, 30].map((s) => <option key={s} value={s}>{s}s</option>)}
</select>
<RunButton loading={loading} onClick={run} label="Test" />
</div>
{loading && (
<p className="text-sm text-gray-500 dark:text-slate-400">
Running {duration}s bandwidth test — please wait…
</p>
)}
{error && <ErrorBox message={error} />}
{result && (
<div className="grid grid-cols-2 gap-3">
{(result.direction === 'both' || result.direction === 'transmit') && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">{result.tx_mbps} Mbps</div>
<div className="text-xs text-blue-500 dark:text-blue-400 mt-1">TX (Upload)</div>
</div>
)}
{(result.direction === 'both' || result.direction === 'receive') && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-center">
<div className="text-2xl font-bold text-green-700 dark:text-green-300">{result.rx_mbps} Mbps</div>
<div className="text-xs text-green-500 dark:text-green-400 mt-1">RX (Download)</div>
</div>
)}
<div className="col-span-2 text-xs text-gray-500 dark:text-slate-400 text-center">
{result.duration}s {result.protocol.toUpperCase()} test
</div>
</div>
)}
</div>
);
}
// ─── 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 }) {
<WolTool deviceId={deviceId} interfaces={interfaceNames} />
</ToolCard>
)}
{canWrite && (
<ToolCard title="Packet Capture" icon={Download}>
<PacketCaptureTool deviceId={deviceId} interfaces={interfaceNames} />
</ToolCard>
)}
{canWrite && (
<ToolCard title="Bandwidth Test" icon={Activity}>
<BandwidthTestTool deviceId={deviceId} />
</ToolCard>
)}
</div>
);
}
+6
View File
@@ -335,6 +335,12 @@ export const deviceToolsApi = {
api.post<Record<string, string>[]>(`/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<string, string> | null }>(
`/devices/${id}/tools/btest`, body, { timeout: 60_000 }
),
};
// ─── Events ───────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.11.4 Beta';
export const APP_VERSION = 'v0.11.5 Beta';