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
+25 -6
View File
@@ -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<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 {
@@ -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
// 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<void> {
"/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<void> {
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<void> {
// (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