import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { eq, devices, type Db } from "@parking/db"; import { deviceEvents } from "../device-events.js"; import { verifyDigest } from "../digest-auth.js"; // Inbound device push endpoints. The Dingtian board's "Input Link URL" feature // HTTP-calls us when an input (button) fires — no polling. We translate the // push into an internal device event; the entry flow decides what to do // (print a ticket, then command the relay). See wiki/concepts/device-input-flow.md. // // AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed — // both tested on hardware). The password is never sent on the wire; the secret // is NOT in the URL. Per-device credentials live in lane_devices (written on // assign). This is defence-in-depth on a flat network; the signed event log is // the real anti-fraud guarantee (an open with no matching signed event is an // anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth // (machine call from the device). interface InputParams { deviceId: string; n: string; edge: string; } interface DingtianDeviceConfig { host?: string; pushUser?: string; pushPassword?: string; } function clientIp(req: FastifyRequest): string { return req.ip.replace(/^::ffff:/, ""); } export async function deviceRoutes(app: FastifyInstance, db: Db): Promise { const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => { const { deviceId, n, edge } = req.params; const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); const cfg = row?.config as DingtianDeviceConfig | undefined; // Unknown device / not a dingtian / no push creds / wrong source IP → 404. if ( !row || row.driverId !== "dingtian" || !cfg?.pushUser || !cfg.pushPassword || !cfg.host || clientIp(req) !== cfg.host ) { app.log.warn(`rejected device push: device=${deviceId} ip=${clientIp(req)}`); return reply.code(404).send({ error: "not found" }); } // Digest auth — issues a 401 challenge on first hit; the device retries with // the hashed response (verifyDigest sends the challenge + returns false). if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) { return; // 401 already sent } const input = Number(n); const ed = edge === "off" ? "off" : "on"; app.log.info(`[dingtian:${deviceId}] input ${input} ${ed} (push)`); deviceEvents.emitInput({ driverId: "dingtian", deviceId, input, edge: ed, at: new Date().toISOString(), source: "push", }); return { ok: true }; }; for (const method of ["GET", "POST"] as const) { app.route({ method, url: "/api/devices/dingtian/:deviceId/input/:n/:edge", handler: handle, }); } }