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:
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user