Setup wizard: show + override the backend push IP (multi-NIC hosts)

The backend IP baked into a push-capable device at assign time is
auto-derived by subnet-matching a local NIC. That's non-deterministic
when two NICs match the device subnet, and null when none does. Surface
it: backendIpCandidates() lists all local IPv4 NICs (on-subnet first),
GET /api/setup/backend-ips serves them, and the wizard renders an
editable Backend push IP dropdown after a successful test (pre-filled
with the auto-pick, warns when no NIC is on the device subnet). The
chosen IP overrides the auto-pick on assign and is recorded in config.
This commit is contained in:
2026-06-14 18:47:23 +02:00
parent 7fd407ac82
commit 382c32f2bc
4 changed files with 152 additions and 7 deletions
+39
View File
@@ -28,3 +28,42 @@ export function backendIpForDevice(deviceHost: string): string | null {
export function backendPort(): number { export function backendPort(): number {
return Number(process.env.PORT ?? 3000); 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));
}
+25 -6
View File
@@ -12,7 +12,7 @@ import {
type DeviceCategory, type DeviceCategory,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; 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 // First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md. // per lane. See wiki/concepts/first-run-setup.md.
@@ -22,6 +22,9 @@ interface AssignBody {
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; config: Record<string, string | number | boolean>;
/** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */
backendIp?: string;
} }
interface TestBody { interface TestBody {
@@ -113,6 +116,18 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// 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 // Assign a device to a lane. Validates the chosen driver + config, configures
// the device (fix preconditions + set up Digest-authenticated input push — no // 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 // 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<void> {
"/api/setup/assign", "/api/setup/assign",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { lane, category, driverId, config } = req.body; const { lane, category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver || driver.category !== category) { if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
@@ -162,10 +177,11 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (hasPushConfig(device)) { if (hasPushConfig(device)) {
const host = String(config.host ?? ""); const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host); // Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
if (!backendIp) { const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({ 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"; const pushUser = "dingtian";
@@ -173,13 +189,16 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
// (longer is silently truncated → auth mismatch), so keep it short. // (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex"); const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({ await device.configureInputPush({
host: backendIp, host: pushHost,
port: backendPort(), port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`, pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword }, auth: { user: pushUser, password: pushPassword },
}); });
fullConfig.pushUser = pushUser; fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword; 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) { } catch (err) {
return reply return reply
+72 -1
View File
@@ -2,8 +2,10 @@ import { useState, useEffect } from "react";
import { import {
assignDevice, assignDevice,
discoverDevices, discoverDevices,
fetchBackendIps,
fetchCatalog, fetchCatalog,
testDevice, testDevice,
type BackendIpCandidate,
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
@@ -102,6 +104,39 @@ function CategoryPicker({
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(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<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>("");
// (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() { async function scan() {
if (!selected) return; if (!selected) return;
setScanning(true); setScanning(true);
@@ -157,7 +192,13 @@ function CategoryPicker({
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
await assignDevice({ lane, category, driverId: selected.id, config: mergedConfig() }); await assignDevice({
lane,
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
setSaved(true); setSaved(true);
} catch (e) { } catch (e) {
setSaveError((e as Error).message); setSaveError((e as Error).message);
@@ -260,6 +301,36 @@ function CategoryPicker({
)} )}
</div> </div>
)} )}
{/* 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 && (
<div style={{ margin: "0.5rem 0 0" }}>
<label>
Backend push IP{" "}
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled>
Choose an address…
</option>
)}
{backendIps.map((c) => (
<option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
</option>
))}
</select>
</label>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<span style={{ marginLeft: 8, color: "#d97706" }}>
⚠ no NIC on the device's subnet — the device may not reach the backend
</span>
)}
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
The address this device will POST input events to.
</p>
</div>
)}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>} {saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>} {saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
</div> </div>
+16
View File
@@ -136,11 +136,27 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise<Test
}); });
} }
export interface BackendIpCandidate {
ip: string;
iface: string;
onDeviceSubnet: boolean;
}
/** Local IPs the device could push to (on-subnet first), for the wizard to
* pre-fill/override. Matters on multi-NIC hosts. */
export function fetchBackendIps(
host: string,
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
}
export interface AssignBody { export interface AssignBody {
lane: number; lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: DeviceConfig; config: DeviceConfig;
/** Backend IP the device should push to (overrides auto-pick). */
backendIp?: string;
} }
/** Save + configure the device (preconditions, push setup), then persist. */ /** Save + configure the device (preconditions, push setup), then persist. */