diff --git a/apps/server/src/net.ts b/apps/server/src/net.ts index aead25c..613dd50 100644 --- a/apps/server/src/net.ts +++ b/apps/server/src/net.ts @@ -28,3 +28,42 @@ export function backendIpForDevice(deviceHost: string): string | null { export function backendPort(): number { return Number(process.env.PORT ?? 3000); } + +export interface BackendIpCandidate { + ip: string; + iface: string; + /** True if this interface's subnet contains the device IP (the likely one). */ + onDeviceSubnet: boolean; +} + +/** + * List local IPv4 addresses the device could call back on, with the ones on the + * device's own subnet flagged + sorted first. Lets the admin see/override the + * auto-pick (important on multi-NIC hosts). BACKEND_HOST_IP, if set, is the only + * candidate (the deterministic override). + */ +export function backendIpCandidates(deviceHost: string): BackendIpCandidate[] { + if (process.env.BACKEND_HOST_IP) { + return [{ ip: process.env.BACKEND_HOST_IP, iface: "BACKEND_HOST_IP", onDeviceSubnet: true }]; + } + + const dev = deviceHost.split(".").map(Number); + const validDev = dev.length === 4 && !dev.some((o) => Number.isNaN(o)); + const out: BackendIpCandidate[] = []; + + for (const [iface, ifaces] of Object.entries(networkInterfaces())) { + for (const i of ifaces ?? []) { + if (i.family !== "IPv4" || i.internal) continue; + const addr = i.address.split(".").map(Number); + const mask = i.netmask.split(".").map(Number); + const onDeviceSubnet = + validDev && + addr.length === 4 && + mask.length === 4 && + dev.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!)); + out.push({ ip: i.address, iface, onDeviceSubnet }); + } + } + // On-subnet candidates first. + return out.sort((a, b) => Number(b.onDeviceSubnet) - Number(a.onDeviceSubnet)); +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 3c27db2..aa3342f 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -12,7 +12,7 @@ import { type DeviceCategory, } from "@parking/devices"; import { requireRole } from "../auth.js"; -import { backendIpForDevice, backendPort } from "../net.js"; +import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; // First-run setup API. The admin reads the driver catalog and assigns devices // per lane. See wiki/concepts/first-run-setup.md. @@ -22,6 +22,9 @@ interface AssignBody { category: DeviceCategory; driverId: string; config: Record; + /** Optional: the backend IP the device should push to (overrides auto-pick; + * matters on multi-NIC hosts). */ + backendIp?: string; } interface TestBody { @@ -113,6 +116,18 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }, ); + // Candidate backend IPs the device can push to, for a given device host. The + // wizard pre-fills with the on-subnet one and lets the admin override (matters + // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. + app.get<{ Querystring: { host?: string } }>( + "/api/setup/backend-ips", + { preHandler: adminGuard }, + async (req) => { + const candidates = backendIpCandidates(req.query.host ?? ""); + return { candidates, port: backendPort() }; + }, + ); + // Assign a device to a lane. Validates the chosen driver + config, configures // the device (fix preconditions + set up Digest-authenticated input push — no // manual device-web-UI step by the admin), then persists. Fails the save if @@ -121,7 +136,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { "/api/setup/assign", { preHandler: adminGuard }, async (req, reply) => { - const { lane, category, driverId, config } = req.body; + const { lane, category, driverId, config, backendIp } = req.body; const driver = registry.get(driverId); if (!driver || driver.category !== category) { return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); @@ -162,10 +177,11 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { if (hasPushConfig(device)) { const host = String(config.host ?? ""); - const backendIp = backendIpForDevice(host); - if (!backendIp) { + // Admin-provided backend IP wins; else auto-derive (on-subnet NIC). + const pushHost = backendIp ?? backendIpForDevice(host); + if (!pushHost) { return reply.code(400).send({ - error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`, + error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`, }); } const pushUser = "dingtian"; @@ -173,13 +189,16 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { // (longer is silently truncated → auth mismatch), so keep it short. const pushPassword = randomBytes(12).toString("hex"); await device.configureInputPush({ - host: backendIp, + host: pushHost, port: backendPort(), pathBase: `/api/devices/${driverId}/${id}/input`, auth: { user: pushUser, password: pushPassword }, }); fullConfig.pushUser = pushUser; fullConfig.pushPassword = pushPassword; + // Record the backend IP the device was told to push to — lets us detect + // a later mismatch if the host's IP changes. + fullConfig.backendIp = pushHost; } } catch (err) { return reply diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 5dba15b..d9ed102 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -2,8 +2,10 @@ import { useState, useEffect } from "react"; import { assignDevice, discoverDevices, + fetchBackendIps, fetchCatalog, testDevice, + type BackendIpCandidate, type Catalog, type CatalogEntry, type DeviceCategory, @@ -102,6 +104,39 @@ function CategoryPicker({ const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); + // Backend push IP: which of OUR addresses the device should call back on. We + // auto-pick the NIC on the device's subnet, but surface it editable here so a + // multi-NIC host can be corrected (the chosen IP is baked into the device on + // save). Only relevant for drivers that push (the field hides if no candidates). + const [backendIps, setBackendIps] = useState(null); + const [backendIp, setBackendIp] = useState(""); + + // (Re)load backend-IP candidates whenever the device host changes after a + // successful test (the test confirms the host is real + reachable). + const testedHost = tested ? String(mergedConfig().host ?? "") : ""; + useEffect(() => { + if (!testedHost) { + setBackendIps(null); + return; + } + let live = true; + fetchBackendIps(testedHost) + .then(({ candidates }) => { + if (!live) return; + setBackendIps(candidates); + // Pre-fill with the on-subnet auto-pick (the first candidate, since the + // server sorts on-subnet first), unless the admin already chose one. + setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); + }) + .catch(() => { + if (live) setBackendIps(null); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [testedHost]); + async function scan() { if (!selected) return; setScanning(true); @@ -157,7 +192,13 @@ function CategoryPicker({ setSaving(true); setSaveError(null); try { - await assignDevice({ lane, category, driverId: selected.id, config: mergedConfig() }); + await assignDevice({ + lane, + category, + driverId: selected.id, + config: mergedConfig(), + ...(backendIp ? { backendIp } : {}), + }); setSaved(true); } catch (e) { setSaveError((e as Error).message); @@ -260,6 +301,36 @@ function CategoryPicker({ )} )} + + {/* Backend push IP — only for push-capable devices (candidates present). + Pre-filled with the auto-pick; editable for multi-NIC hosts. */} + {backendIps && backendIps.length > 0 && ( +
+ + {!backendIps.some((c) => c.onDeviceSubnet) && ( + + ⚠ no NIC on the device's subnet — the device may not reach the backend + + )} +

+ The address this device will POST input events to. +

+
+ )} {saveError &&

Save failed: {saveError}

} {saved &&

Saved and configured ✓

} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 10f31f5..5ae0b90 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -136,11 +136,27 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise { + return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`); +} + export interface AssignBody { lane: number; category: DeviceCategory; driverId: string; config: DeviceConfig; + /** Backend IP the device should push to (overrides auto-pick). */ + backendIp?: string; } /** Save + configure the device (preconditions, push setup), then persist. */