UHPPOTE hardware bring-up + entry-flow blocker
Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end and recorded a procurement-level blocker. Verified on hardware: - discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically actuated; reason="remote open door"), and live button capture (reason="push button ok"). Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts): - broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the global 255.255.255.255 -> EACCES); - Config broadcast must match the target's subnet for unicast reply routing (fixes the health-check timeout: 5s -> 24ms ready); - discover across all local subnets, dedupe by serial; - serialize all controller I/O (concurrent calls collided on UDP :60001). Server/UX: - load .env via node --env-file-if-exists (vars weren't being read before); - SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed otherwise; surfaced as catalog.authBypass so the wizard drops the token field; - .env.example documents all vars; inline favicon stops a 404. - apps/server/scripts/: uhppote-listen (live events, restores prior listener) and uhppote-relay (guarded door-open test). BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's push-button input auto-opens the relay in firmware with no report-without-open mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane hardware decision paused to focus on the business side. wiki: access-controller-button-flow (blocker), zkteco-controller (stub + assessment), uhppote-controller callout, index + log.
This commit is contained in:
@@ -24,12 +24,26 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
|
||||
// TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup
|
||||
// endpoints skip the admin guard so devices can be discovered/assigned before
|
||||
// the login flow exists. Remove once real admin login is wired.
|
||||
//
|
||||
// Hardened (flagged by security review): this can NEVER silently open auth in
|
||||
// a deployable config. It is honoured ONLY when all hold, else the server
|
||||
// FAILS CLOSED (throws) rather than running unauthenticated:
|
||||
// (a) NODE_ENV !== 'production'
|
||||
// (b) the listener is bound to loopback (HOST is 127.0.0.1 / ::1 / localhost)
|
||||
// See server.ts TODO + wiki/concepts/first-run-setup.md.
|
||||
const { guard: adminGuard, bypassed: authBypass } = resolveAdminGuard(app);
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
|
||||
// `authBypass` tells the UI the setup endpoints aren't requiring a token
|
||||
// (testing only), so it can drop the admin-token requirement.
|
||||
app.get("/api/setup/catalog", async () => {
|
||||
const catalog = registry.catalog();
|
||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||
return { ...catalog, discoverable };
|
||||
return { ...catalog, discoverable, authBypass };
|
||||
});
|
||||
|
||||
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
|
||||
@@ -37,7 +51,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
|
||||
app.get<{ Params: { driverId: string } }>(
|
||||
"/api/setup/discover/:driverId",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const driver = registry.get(req.params.driverId);
|
||||
if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` });
|
||||
@@ -67,7 +81,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Current setup status + assignments.
|
||||
app.get(
|
||||
"/api/setup/state",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||
const assignments = await db.select().from(laneDevices).all();
|
||||
@@ -79,7 +93,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// registry before persisting; rejects unknown drivers / missing config.
|
||||
app.post<{ Body: AssignBody }>(
|
||||
"/api/setup/assign",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { lane, category, driverId, config } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
@@ -107,7 +121,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Mark first-run setup complete.
|
||||
app.post(
|
||||
"/api/setup/complete",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: adminGuard },
|
||||
async () => {
|
||||
const completedAt = new Date().toISOString();
|
||||
await db
|
||||
@@ -118,3 +132,38 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
/**
|
||||
* Resolve the setup admin guard. Returns the real admin role guard unless the
|
||||
* SETUP_AUTH_BYPASS escape hatch is both requested AND safe; if it's requested
|
||||
* but unsafe, throws so the server fails closed instead of running open.
|
||||
* `bypassed` is surfaced to the UI so it can drop the admin-token requirement.
|
||||
*/
|
||||
function resolveAdminGuard(app: FastifyInstance): {
|
||||
guard: ReturnType<typeof requireRole>;
|
||||
bypassed: boolean;
|
||||
} {
|
||||
if (process.env.SETUP_AUTH_BYPASS !== "1") {
|
||||
return { guard: requireRole("admin"), bypassed: false };
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const isLoopback = LOOPBACK_HOSTS.has(host);
|
||||
|
||||
if (isProd || !isLoopback) {
|
||||
// Fail closed: never honour an auth bypass in production or on a non-loopback
|
||||
// listener (that would expose unauthenticated setup endpoints on the network).
|
||||
throw new Error(
|
||||
`SETUP_AUTH_BYPASS refused: requires NODE_ENV!=production (is "${process.env.NODE_ENV ?? "undefined"}") ` +
|
||||
`and a loopback HOST (is "${host}"). Set HOST=127.0.0.1 for local testing, or unset the bypass.`,
|
||||
);
|
||||
}
|
||||
|
||||
app.log.warn(
|
||||
`⚠️ SETUP_AUTH_BYPASS=1 — /api/setup/* admin auth DISABLED on ${host} (testing only)`,
|
||||
);
|
||||
return { guard: async () => {}, bypassed: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user