diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..37a44e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +frontend/node_modules +backend/node_modules diff --git a/backend/src/routes/networkServices.ts b/backend/src/routes/networkServices.ts index 5c1ec89..cd799b6 100644 --- a/backend/src/routes/networkServices.ts +++ b/backend/src/routes/networkServices.ts @@ -85,17 +85,19 @@ router.get('/overview', async (_req: Request, res: Response) => { router.get('/dhcp', async (req: Request, res: Response) => { const deviceId = deviceIdParam(req, res); if (!deviceId) return; await withDevice(deviceId, res, async (collector) => { - const [v4, v6, pv4, pv6] = await Promise.allSettled([ + const [v4, v6, pv4, pv6, ifaces] = await Promise.allSettled([ collector.getDhcpServers('ipv4'), collector.getDhcpServers('ipv6'), collector.getDhcpPools('ipv4'), collector.getDhcpPools('ipv6'), + collector.getDhcpInterfaces(), ]); res.json({ ipv4: v4.status === 'fulfilled' ? v4.value : [], ipv6: v6.status === 'fulfilled' ? v6.value : [], pools_v4: pv4.status === 'fulfilled' ? pv4.value : [], pools_v6: pv6.status === 'fulfilled' ? pv6.value : [], + interfaces: ifaces.status === 'fulfilled' ? ifaces.value : [], }); }); }); diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 3fdaacf..474950f 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -1771,10 +1771,17 @@ export class DeviceCollector { if (servers[1]) ntpConfig['secondary-ntp'] = (servers[1] as Record)['address'] || ''; } + // RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no" + const dnsRaw = (dns[0] as Record) || {}; + const dnsNormalized: Record = {}; + for (const [k, v] of Object.entries(dnsRaw)) { + dnsNormalized[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v; + } + return { identity: (identity[0] as Record)?.['name'] || '', ntp: ntpConfig, - dns: (dns[0] as Record) || {}, + dns: dnsNormalized, }; } @@ -2205,6 +2212,13 @@ export class DeviceCollector { // DHCP ──────────────────────────────────────────────────────────────────────── + async getDhcpInterfaces(): Promise[]> { + const all = await this.client.execute('/interface/print', { detail: '' }).catch(() => [] as Record[]); + // Exclude interface types that can't meaningfully serve as a DHCP binding + const excluded = new Set(['pppoe-out', 'pptp-client', 'l2tp-client', 'ovpn-client', 'sstp-client', 'lte', 'lo', 'ipip', 'eoip', 'gre', 'vxlan', 'wireguard']); + return all.filter(i => !excluded.has(i['type'] || '')); + } + async getDhcpServers(protocol: 'ipv4' | 'ipv6'): Promise[]> { const cmd = protocol === 'ipv4' ? '/ip/dhcp-server/print' : '/ipv6/dhcp-server/print'; return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); @@ -2267,7 +2281,13 @@ export class DeviceCollector { async getDnsSettings(): Promise> { const rows = await this.client.execute('/ip/dns/print', {}).catch(() => [] as Record[]); - return rows[0] ?? {}; + const row = rows[0] ?? {}; + // RouterOS 7 DNS returns booleans as "true"/"false" instead of "yes"/"no" + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + out[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v; + } + return out; } async setDnsSettings(settings: { @@ -2314,13 +2334,24 @@ export class DeviceCollector { server: Record; client: Record; }> { + // RouterOS 7 NTP module returns boolean fields as "true"/"false" rather than + // the "yes"/"no" used by every other RouterOS API. Normalize here so the rest + // of the app can use a consistent contract. + const normalizeBools = (r: Record): Record => { + const out: Record = {}; + for (const [k, v] of Object.entries(r)) { + out[k] = v === 'true' ? 'yes' : v === 'false' ? 'no' : v; + } + return out; + }; + const [server, client] = await Promise.allSettled([ - this.client.execute('/system/ntp/server/print', {}).then(r => r[0] ?? {}), - this.client.execute('/system/ntp/client/print', {}).then(r => r[0] ?? {}), + this.client.execute('/system/ntp/server/print', {}).then(r => normalizeBools(r[0] ?? {})), + this.client.execute('/system/ntp/client/print', {}).then(r => normalizeBools(r[0] ?? {})), ]); return { - server: server.status === 'fulfilled' ? server.value as Record : {}, - client: client.status === 'fulfilled' ? client.value as Record : {}, + server: server.status === 'fulfilled' ? server.value : {}, + client: client.status === 'fulfilled' ? client.value : {}, }; } @@ -2337,7 +2368,7 @@ export class DeviceCollector { if (settings.server_broadcast !== undefined) serverParams['broadcast'] = settings.server_broadcast ? 'yes' : 'no'; if (settings.server_manycast !== undefined) serverParams['manycast'] = settings.server_manycast ? 'yes' : 'no'; if (Object.keys(serverParams).length > 0) { - await this.client.execute('/system/ntp/server/set', serverParams).catch(() => {}); + await this.client.execute('/system/ntp/server/set', serverParams); } const clientParams: Record = {}; @@ -2345,7 +2376,7 @@ export class DeviceCollector { if (settings.client_mode) clientParams['mode'] = settings.client_mode; if (settings.client_servers !== undefined) clientParams['servers'] = settings.client_servers; if (Object.keys(clientParams).length > 0) { - await this.client.execute('/system/ntp/client/set', clientParams).catch(() => {}); + await this.client.execute('/system/ntp/client/set', clientParams); } } diff --git a/frontend/src/pages/NetworkServicesDHCPPage.tsx b/frontend/src/pages/NetworkServicesDHCPPage.tsx index bf4b687..4fe1645 100644 --- a/frontend/src/pages/NetworkServicesDHCPPage.tsx +++ b/frontend/src/pages/NetworkServicesDHCPPage.tsx @@ -40,17 +40,34 @@ interface ServerFormProps { protocol: 'ipv4' | 'ipv6'; existing?: NS; pools: NS[]; + interfaces: NS[]; deviceId: number; onClose: () => void; + conflictDevices?: { name: string }[]; } -function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerFormProps) { +function ServerForm({ protocol, existing, pools, interfaces, deviceId, onClose, conflictDevices }: ServerFormProps) { const qc = useQueryClient(); const [name, setName] = useState(existing?.['name'] || ''); const [iface, setIface] = useState(existing?.['interface'] || ''); const [pool, setPool] = useState(existing?.['address-pool'] || ''); const [leaseTime, setLeaseTime] = useState(existing?.['lease-time'] || '00:10:00'); + // Group interfaces for the dropdown + const vlanIfaces = interfaces.filter(i => i['type'] === 'vlan'); + const bridgeIfaces = interfaces.filter(i => i['type'] === 'bridge'); + const otherIfaces = interfaces.filter(i => i['type'] !== 'vlan' && i['type'] !== 'bridge'); + + function ifaceLabel(i: NS): string { + const base = i['name']; + if (i['type'] === 'vlan') { + const vid = i['vlan-id']; + const parent = i['interface']; + return vid ? `${base} — VLAN ${vid}${parent ? ` on ${parent}` : ''}` : base; + } + return i['comment'] ? `${base} — ${i['comment']}` : base; + } + const save = useMutation({ mutationFn: () => { const body: NS & { protocol: 'ipv4' | 'ipv6' } = { @@ -81,7 +98,37 @@ function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerForm
- setIface(e.target.value)} placeholder="bridge1" /> + {interfaces.length > 0 ? ( + + ) : ( + setIface(e.target.value)} placeholder="bridge1, vlan10, ether1" /> + )} +

+ VLAN interfaces scope the DHCP server to that VLAN. Bridge interfaces serve all untagged clients. +

@@ -100,6 +147,14 @@ function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerForm

Format: HH:MM:SS or e.g. 10m, 1h, 1d

+ {!existing && conflictDevices && conflictDevices.length > 0 && ( +
+
+ + Active {protocol.toUpperCase()} DHCP server(s) already running on: {conflictDevices.map(d => d.name).join(', ')}. Multiple DHCP servers on the same network may cause IP address conflicts. +
+
+ )}
@@ -499,8 +580,10 @@ export default function NetworkServicesDHCPPage() { protocol={serverForm.protocol} existing={serverForm.existing} pools={serverForm.protocol === 'ipv4' ? (dhcp?.pools_v4 || []) : (dhcp?.pools_v6 || [])} + interfaces={dhcp?.interfaces || []} deviceId={deviceId} onClose={() => setServerForm(null)} + conflictDevices={serverForm.protocol === 'ipv4' ? conflictDevicesV4 as { name: string }[] : conflictDevicesV6 as { name: string }[]} /> )} {poolForm && setPoolForm(null)} />} @@ -545,6 +628,20 @@ export default function NetworkServicesDHCPPage() { )}
+ {/* Cross-device conflict warnings */} + {conflictDevicesV4.length > 0 && deviceId > 0 && ( +
+ + Active IPv4 DHCP server(s) already running on: {conflictDevicesV4.map(d => d.name as string).join(', ')}. Adding another DHCP server on the same network may cause IP address conflicts. +
+ )} + {conflictDevicesV6.length > 0 && deviceId > 0 && ( +
+ + Active IPv6 DHCP server(s) already running on: {conflictDevicesV6.map(d => d.name as string).join(', ')}. Adding another DHCP server on the same network may cause IP address conflicts. +
+ )} + {deviceId === 0 &&
Select a device above.
} {deviceId > 0 && isLoading &&
Loading…
} {deviceId > 0 && error && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 456ad8b..6340fa2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -520,7 +520,7 @@ export const networkServicesApi = { // ── DHCP ────────────────────────────────────────────────────────────────── getDhcp: (deviceId: number) => - api.get<{ ipv4: NS[]; ipv6: NS[]; pools_v4: NS[]; pools_v6: NS[] }>( + api.get<{ ipv4: NS[]; ipv6: NS[]; pools_v4: NS[]; pools_v6: NS[]; interfaces: NS[] }>( '/network-services/dhcp', { params: { deviceId } } ), addDhcpServer: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>