From 46bfecf3e7395ae1b8a9b1655358d4f387d6a88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 29 Oct 2026 15:06:10 +0000 Subject: [PATCH] Add a printer through the gateway without the cloud keeping its credential POST /farms/:id/printers (owner) is the console's add-printer call. It needs a connected gateway and relays the printer's access code to it in a single provision_printer frame; the gateway connects to the printer to prove the code, saves it in its own database, and only then does the cloud create the printer row. A refused or timed-out add leaves no row, and a row insert that fails after the gateway said yes tells the gateway to forget the printer. The access code is held in the request and the one frame and nowhere else: not in any table, not in a log line, not in a response or an error (the gateway's error text is scrubbed of it as a second line of defence). Deleting a printer now also tells the gateway to forget it and its stored credential, best effort, so an unplugged gateway cannot block an owner. Only Bambu is accepted, because it is the only vendor with a working driver; the rest are found by discovery but cannot yet be driven. e2e-provision.sh drives a stand-in gateway over the real socket and, after adding a printer, searches every table of the database and the backend's log for the code (checked to fail when the code is logged). --- scripts/e2e-provision.sh | 246 ++++++++++++++++++++++++++++++++++ scripts/fake-gateway-serve.ts | 46 +++++++ src/provision/plan.ts | 40 ++++++ src/routes/farms.ts | 118 +++++++++++++++- src/routes/printers.ts | 26 +++- src/routes/ws/edge.ts | 36 +++++ tests/provision.test.ts | 40 ++++++ 7 files changed, 546 insertions(+), 6 deletions(-) create mode 100755 scripts/e2e-provision.sh create mode 100644 scripts/fake-gateway-serve.ts create mode 100644 src/provision/plan.ts create mode 100644 tests/provision.test.ts diff --git a/scripts/e2e-provision.sh b/scripts/e2e-provision.sh new file mode 100755 index 0000000..f6205ed --- /dev/null +++ b/scripts/e2e-provision.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# End-to-end test of adding a printer from the console: the access code must +# reach the gateway over the real /ws/edge/v1 and be kept nowhere in the cloud +# - not in any table, not in the backend's log, not in a response. +# +# This is the test that the unit tests cannot be. tests/authz.test.ts proves +# tokens are verified and that the role ladder orders correctly; only this +# proves that each route actually asks, that scoping is applied in the query +# rather than after it, and that credentials stay out of responses - the three +# things that were wrong when every route was unauthenticated. +# +# Four users, two organisations, no printers or gateways needed: +# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A +# outsider_b in org B, which owns nothing +# +# Usage: +# scripts/dev-env.sh up && scripts/dev-env.sh schema +# scripts/e2e-authz.sh +# +# KEEP_DATA=1 leave the seeded organisations behind for inspection +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +PORT="${PORT:-3032}" +AUTH_PORT="${AUTH_PORT:-3033}" +BASE="http://localhost:$PORT" +DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}" +VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}" +EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}" +# Presigning a PUT is local HMAC with no network call, so POST /jobs works +# offline - but the SDK refuses to sign with empty credentials. +R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" +R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" +R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}" +export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT +export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT + +WORK="$(mktemp -d)" +BACKEND_PID="" +AUTH_PID="" +FAILURES=0 + +# `uuidgen` is not installed everywhere; the kernel's generator always is. +new_uuid() { + if [ -r /proc/sys/kernel/random/uuid ]; then + cat /proc/sys/kernel/random/uuid + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} + +# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test +# reads that variable, so a psql pointed anywhere else seeds fixtures into a +# different database than the one being exercised - and the failure message +# below names DATABASE_URL, which would then be naming a URL nothing tried. +psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } + +cleanup() { + local rc=$? + # Never `kill $PID` with an unset PID: `kill 0` signals the whole process + # group, which includes this script and whatever launched it. + local pids=() + [ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID") + [ -n "$AUTH_PID" ] && pids+=("$AUTH_PID") + if [ ${#pids[@]} -gt 0 ]; then + kill "${pids[@]}" 2>/dev/null || true + sleep 0.3 + kill -9 "${pids[@]}" 2>/dev/null || true + fi + if [ -z "${KEEP_DATA:-}" ]; then + for org in "${ORG_A:-}" "${ORG_B:-}"; do + [ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true + done + fi + if [ $rc -ne 0 ]; then + echo + echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true + echo "--- fake gateway log ---"; cat "$WORK/gw.log" 2>/dev/null || true + fi + rm -rf "$WORK" + exit $rc +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo " ok: $*"; } +bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); } + +# `req [json body]` -> prints the status code, +# leaves the response body in $WORK/last.json. +req() { + local method="$1" path="$2" token="$3" body="${4:-}" + local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path") + [ "$token" != "-" ] && args+=(-H "authorization: Bearer $token") + [ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body") + curl "${args[@]}" +} + +# `expect [body] -- ` +expect() { + local want="$1"; shift + local method="$1" path="$2" token="$3"; shift 3 + local body="" + if [ "${1:-}" != "--" ]; then body="$1"; shift; fi + shift # the -- + local what="$*" + local got; got="$(req "$method" "$path" "$token" "$body")" + if [ "$got" = "$want" ]; then + ok "$what ($got)" + else + bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")" + fi +} + +body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; } +body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; } +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; } + +# --- 0. preconditions ------------------------------------------------------ + +echo "==> checking the database is up and migrated" +psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up" +psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \ + || fail "farms.org_id is missing - run scripts/dev-env.sh schema" +psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \ + || fail "org_members is missing - run scripts/dev-env.sh schema" +ok "schema has the org layer" + +# --- 1. the issuer and the backend ---------------------------------------- + +echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT" +bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & +AUTH_PID=$! +AUTH_URL="http://localhost:$AUTH_PORT" +for _ in $(seq 1 40); do + curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break + sleep 0.25 +done +curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \ + || fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")" +export SUPABASE_URL="$AUTH_URL" +ok "issuer up, SUPABASE_URL=$SUPABASE_URL" + +mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; } + + +echo "==> starting continuum-backend on :$PORT" +DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 & +BACKEND_PID=$! +for _ in $(seq 1 60); do + curl -sf "$BASE/health" >/dev/null 2>&1 && break + sleep 0.5 +done +curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")" +ok "backend healthy" + +# --- 2. a farm, an owner, an operator ----------------------------------- + +OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")" +OPERATOR_ID="$(new_uuid)"; OPERATOR="$(mint "$OPERATOR_ID")" +OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")" +expect 201 POST /orgs "$OWNER" '{"name":"e2e provision org"}' -- "create an org" +ORG_A="$(json 'd["id"]')" +expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_ID" "$OWNER" '{"role":"operator"}' -- "add an operator" +expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm" +FARM_A="$(json 'd["id"]')" + +CODE="SECRETCODE-$(new_uuid | cut -c1-8)" +BODY="{\"name\":\"Bench P1P\",\"vendor\":\"bambu\",\"host\":\"192.168.0.50\",\"accessCode\":\"$CODE\",\"serialNumber\":\"01P00A000000001\"}" + +echo "==> who may add a printer, and what happens with no gateway" +expect 403 POST "/farms/$FARM_A/printers" "$OPERATOR" "$BODY" -- "an operator cannot add a printer" +expect 404 POST "/farms/$FARM_A/printers" "$OUTSIDER" "$BODY" -- "an outsider cannot see the farm exists" +expect 401 POST "/farms/$FARM_A/printers" - "$BODY" -- "anonymous is refused" +expect 409 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "no gateway connected: refused, nothing queued" +body_lacks "$CODE" +N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")" +[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a refused add: $N" + +# --- 3. with a gateway ----------------------------------------------------- + +expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token" +PROXY_TOKEN="$(json 'd["edgeProxyToken"]')" +expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT" +EDGE_JWT="$(json 'd["token"]')" +FRAMES="$WORK/frames.jsonl"; : > "$FRAMES" +REFUSED="REFUSEME-$(new_uuid | cut -c1-8)" +FARM_ID="$FARM_A" bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 40 "$FRAMES" "$REFUSED" > "$WORK/gw.log" 2>&1 & +GW_PID=$! +for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done +grep -q ready "$WORK/gw.log" || fail "the fake gateway did not connect: $(cat "$WORK/gw.log")" +sleep 0.5 + +echo "==> adding a printer through the gateway" +expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/bambu/klipper}" -- "a vendor with no working driver is refused" +expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/192.168.0.50/not a host}" -- "a malformed address is refused" +expect 201 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "the owner adds a printer" +PRINTER_ID="$(json 'd["id"]')" +GWID="$(json 'd["gatewayPrinterId"]')" +body_lacks "$CODE" +ok "the response does not contain the access code (printer $PRINTER_ID, gateway id $GWID)" +[ "$(json 'd["lanAddress"]')" = "192.168.0.50" ] && ok "address recorded" || bad "lanAddress wrong" + +GOT="$(python3 -c " +import json,sys +for l in open('$FRAMES'): + m=json.loads(l) + if m['type']=='provision_printer': print(m['printer']['access_code'], m['printer']['id'], m['printer']['sn']) +")" +[ "$GOT" = "$CODE $GWID 01P00A000000001" ] && ok "the gateway received the access code, its id, and the serial" || bad "gateway frame was: '$GOT'" + +echo "==> the cloud kept the credential nowhere" +LEAK_ROWS="$(psql_q "SELECT count(*) FROM printers WHERE row_to_json(printers)::text LIKE '%$CODE%'")" +[ "$LEAK_ROWS" = "0" ] && ok "not in the printers table" || bad "the access code is in the printers table" +# Every table, so a future column or table holding it cannot hide. +ALL_LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ',') FROM information_schema.tables WHERE table_schema='public'" \ + | tr ',' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')" +[ -z "$ALL_LEAK" ] && ok "not in any table of the database" || bad "found in: $ALL_LEAK" +grep -q "$CODE" "$WORK/backend.log" && bad "the access code is in the backend log" || ok "not in the backend's log" + +echo "==> a gateway that refuses leaves nothing behind" +REFUSE_BODY="${BODY/$CODE/$REFUSED}" +expect 502 POST "/farms/$FARM_A/printers" "$OWNER" "$REFUSE_BODY" -- "the printer rejected the code" +body_has GATEWAY_REFUSED +body_has "access code was rejected" +body_lacks "$REFUSED" +N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")" +[ "$N" = "1" ] && ok "still exactly one printer row" || bad "expected 1 printer row, found $N" +grep -q "$REFUSED" "$WORK/backend.log" && bad "the refused code is in the backend log" || ok "nor was the refused code logged" + +echo "==> removing a printer tells the gateway" +expect 200 DELETE "/printers/$PRINTER_ID" "$OWNER" -- "the owner deletes the printer" +[ "$(json 'd["gatewayForgot"]')" = "True" ] && ok "the gateway confirmed it forgot the printer" || bad "gatewayForgot: $(cat "$WORK/last.json")" +grep -q "\"remove_printer\".*\"$GWID\"" "$FRAMES" && ok "the gateway received remove_printer for $GWID" || bad "no remove_printer frame for $GWID" + +kill "$GW_PID" 2>/dev/null || true + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "PASS - the access code reaches the gateway and rests nowhere in the cloud." +else + echo "FAIL - $FAILURES assertion(s) failed." >&2 + exit 1 +fi diff --git a/scripts/fake-gateway-serve.ts b/scripts/fake-gateway-serve.ts new file mode 100644 index 0000000..caa80b2 --- /dev/null +++ b/scripts/fake-gateway-serve.ts @@ -0,0 +1,46 @@ +/** + * A stand-in gateway that stays connected and answers printer provisioning, + * for scripts/e2e-provision.sh. + * + * Needs FARM_ID in the environment (the hello must name the token's farm). + * + * Usage: bun scripts/fake-gateway-serve.ts [refuse-access-code] + * + * Every provision_printer / remove_printer frame it receives is appended to + * as one JSON line, so the script can prove the credential + * arrived here. A provision whose access_code equals is + * answered as a failure, like a printer that rejected the login. + */ +import { appendFileSync } from "node:fs"; + +const [url, jwt, seconds, framesFile, refuseCode] = process.argv.slice(2); +if (!url || !jwt || !seconds || !framesFile) { + console.error("usage: fake-gateway-serve.ts [refuse-code]"); + process.exit(2); +} + +const ws = new WebSocket(url, { headers: { authorization: `Bearer ${jwt}` } } as never); +ws.onerror = () => { + console.error("socket error"); + process.exit(1); +}; +ws.onopen = () => { + // farm_id is required by the hello schema; the backend checks it against the token's farm. + ws.send(JSON.stringify({ type: "hello", gateway_id: "fake-gw", farm_id: process.env.FARM_ID })); + console.log("ready"); +}; +ws.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (message.type !== "provision_printer" && message.type !== "remove_printer") return; + appendFileSync(framesFile, JSON.stringify(message) + "\n"); + const refused = message.type === "provision_printer" && refuseCode && message.printer.access_code === refuseCode; + ws.send( + JSON.stringify({ + type: "command_result", + command_id: message.request_id, + ok: !refused, + ...(refused ? { error: "could not connect to the printer: the access code was rejected" } : {}), + }), + ); +}; +setTimeout(() => process.exit(0), Number(seconds) * 1000); diff --git a/src/provision/plan.ts b/src/provision/plan.ts new file mode 100644 index 0000000..287b9c1 --- /dev/null +++ b/src/provision/plan.ts @@ -0,0 +1,40 @@ +/** + * Pure helpers for adding a printer through a gateway, with no imports so + * they test without a database - same reasoning as `dispatch/job_status.ts`. + */ + +/** Vendors a gateway can actually drive today. Everything else is found by discovery but not yet controllable. */ +export const PROVISIONABLE_VENDORS = ["bambu"] as const; + +/** + * The id the gateway will know this printer by, chosen here so telemetry + * arrives under an id the cloud already holds. A readable slug of the name + * plus a random suffix: readable because it shows up in the gateway's logs + * and certificate-pin file names, random because two printers may share a + * name. Constrained to what the gateway accepts (`^[A-Za-z0-9_-]{1,64}$`). + */ +export function gatewayIdFor(name: string, random: () => number = Math.random): string { + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "printer"; + const suffix = Math.floor(random() * 0xffff) + .toString(16) + .padStart(4, "0"); + return `${slug}-${suffix}`; +} + +/** + * What to tell the operator when the gateway says no, without ever repeating + * the credential back. The gateway's messages do not contain it by design, + * and this is the second line of that defence: anything that does is dropped. + */ +export function safeGatewayError(error: string | undefined, secrets: string[]): string { + let message = error ?? "the gateway did not say why"; + for (const secret of secrets) { + if (secret) message = message.split(secret).join(""); + } + return message; +} diff --git a/src/routes/farms.ts b/src/routes/farms.ts index 828b76b..33ed515 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -1,12 +1,13 @@ import { Elysia, t } from "elysia"; import { and, desc, eq } from "drizzle-orm"; -import { NotFoundError } from "@continuum/ts-core"; +import { ConflictError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms, printers } from "../db/schema"; -import { farmProjection } from "../db/projections"; +import { farmProjection, printerProjection } from "../db/projections"; +import { PROVISIONABLE_VENDORS, gatewayIdFor, safeGatewayError } from "../provision/plan"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; -import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge"; +import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge"; const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); @@ -193,6 +194,117 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) * its own - it means either "everything is claimed" or "no gateway has ever * connected", and those need completely different things done about them. */ + /** + * Adds a printer to a farm: tells the farm's gateway to connect to it, and + * creates the printer row once it has. + * + * **The access code passes through this process and is kept nowhere.** It + * is read from the body, placed in one frame to the gateway, and dropped: + * not stored, not logged, not echoed in a response or an error. The + * gateway keeps it in its own database. That is the platform's rule for + * printer credentials, and it is why this is a request to a *connected* + * gateway rather than a row that a gateway picks up later - there is no + * place in the cloud for the secret to wait. + * + * The gateway is asked first and the row created second. The other order + * would show a printer in the console that the gateway then refuses + * (wrong code, wrong address) and leave cleaning up to us; this way a + * failure leaves nothing behind. If the row insert fails after the + * gateway said yes, the gateway is told to forget the printer again. + * + * `owner`, like creating a printer any other way. + */ + .post( + "/:id/printers", + async ({ user, params, body, set }) => { + await requireFarm(user.id, params.id, "owner", "adding a printer"); + + if (!(PROVISIONABLE_VENDORS as readonly string[]).includes(body.vendor)) { + set.status = 422; + return { + error: { + code: "VENDOR_NOT_SUPPORTED", + message: `only ${PROVISIONABLE_VENDORS.join(", ")} printers can be added and driven today`, + }, + }; + } + if (!isGatewayOnline(params.id)) { + throw new ConflictError( + "this farm's gateway is not connected, so it cannot be given the printer's access code - start it and try again", + ); + } + + const gatewayPrinterId = gatewayIdFor(body.name); + const secrets = [body.accessCode]; + const answer = await requestGateway(params.id, (requestId) => ({ + type: "provision_printer", + request_id: requestId, + printer: { + id: gatewayPrinterId, + name: body.name, + vendor: body.vendor, + host: body.host, + access_code: body.accessCode, + sn: body.serialNumber, + }, + })).catch((err: unknown) => ({ + ok: false, + error: err instanceof Error ? err.message : String(err), + })); + + if (!answer.ok) { + set.status = 502; + return { + error: { + code: "GATEWAY_REFUSED", + message: safeGatewayError(answer.error, secrets), + }, + }; + } + + try { + const [printer] = await db + .insert(printers) + .values({ + farmId: params.id, + name: body.name, + // Narrowed by the PROVISIONABLE_VENDORS check above. + vendor: body.vendor as (typeof PROVISIONABLE_VENDORS)[number], + lanAddress: body.host, + serialNumber: body.serialNumber, + gatewayPrinterId, + }) + .returning(printerProjection.returning); + set.status = 201; + return printer; + } catch (err) { + // The gateway now holds a printer the cloud has no row for. Best + // effort to take it back out; either way the caller is told. + await requestGateway( + params.id, + (requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }), + 10_000, + ).catch(() => undefined); + throw err; + } + }, + { + params: IdParams, + body: t.Object({ + name: t.String({ minLength: 1, maxLength: 200 }), + vendor: t.String({ minLength: 1, maxLength: 32 }), + // An address or hostname: letters, digits, dots, dashes, colons (IPv6). + host: t.String({ minLength: 1, maxLength: 255, pattern: "^[A-Za-z0-9.:_-]+$" }), + accessCode: t.String({ minLength: 1, maxLength: 128 }), + // Bambu's MQTT topics are addressed by serial number. + serialNumber: t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" }), + }), + detail: { + tags: ["farms"], + summary: "Add a printer through the farm's gateway (the access code is relayed, never stored)", + }, + }, + ) .get( "/:id/unclaimed-printers", async ({ user, params }) => { diff --git a/src/routes/printers.ts b/src/routes/printers.ts index 27a5035..bce4fdf 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -10,7 +10,7 @@ import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../au import { PaginationQuery, paginate } from "./pagination"; import { resolveStream } from "../stream"; import { mintIceServers, turnConfigured } from "../camera/ice"; -import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws/edge"; +import { isGatewayOnline, negotiateCameraStream, requestGateway, sendPrinterCommand } from "./ws/edge"; const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); @@ -93,9 +93,29 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) .delete( "/:id", async ({ user, params }) => { - await requirePrinter(user.id, params.id, "owner", "deleting a printer"); + const { farmId } = await requirePrinter(user.id, params.id, "owner", "deleting a printer"); + const printer = await db.query.printers.findFirst({ + columns: { gatewayPrinterId: true }, + where: eq(printers.id, params.id), + }); await db.delete(printers).where(eq(printers.id, params.id)); - return { deleted: params.id }; + + // Tell the gateway to forget it - and its stored credential - when it + // can be reached. Best effort and after the row is gone: an owner + // deleting a printer must not be blocked by an unplugged gateway. If + // the gateway misses this, the printer keeps being reported and shows + // up as unclaimed, which is visible rather than silently stale. + let gatewayForgot: boolean | null = null; + const gatewayPrinterId = printer?.gatewayPrinterId; + if (gatewayPrinterId && isGatewayOnline(farmId)) { + const answer = await requestGateway( + farmId, + (requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }), + 10_000, + ).catch(() => ({ ok: false })); + gatewayForgot = answer.ok; + } + return { deleted: params.id, gatewayForgot }; }, { params: IdParams, detail: { tags: ["printers"] } }, ) diff --git a/src/routes/ws/edge.ts b/src/routes/ws/edge.ts index db7753e..1fd1a94 100644 --- a/src/routes/ws/edge.ts +++ b/src/routes/ws/edge.ts @@ -518,6 +518,42 @@ export async function sendPrinterCommand( return { ...result, commandId }; } +/** + * Longer than a command: the gateway connects to the new printer before it + * answers, and its own connect timeout is 30s. + */ +const PROVISION_TIMEOUT_MS = 40_000; + +/** + * Asks a farm's gateway to add or forget a printer, and waits for its answer. + * + * **The message carries a printer credential.** It is built by the caller, + * handed straight to the socket, and kept nowhere: not in `pending` (which + * holds only the resolver), not in a log line, not in an error. A gateway + * that is not connected is a thrown error before anything is built into a + * frame, so there is no queue it could sit in either - the console tells the + * operator to retry rather than this process holding a secret for later. + */ +export async function requestGateway( + farmId: string, + build: (requestId: string) => EdgeServerMessage, + timeoutMs = PROVISION_TIMEOUT_MS, +): Promise<{ ok: boolean; error?: string }> { + const wsId = gatewayByFarm.get(farmId); + const conn = wsId ? connections.get(wsId) : undefined; + if (!conn) throw new Error(`no gateway is connected for this farm`); + + const requestId = crypto.randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + conn.pending.delete(requestId); + resolve({ ok: false, error: `the gateway did not answer within ${Math.round(timeoutMs / 1000)}s` }); + }, timeoutMs); + conn.pending.set(requestId, { resolve, timer }); + conn.send(build(requestId)); + }); +} + /** * Relays a browser's WebRTC offer to the gateway that owns this printer and * returns go2rtc's answer. diff --git a/tests/provision.test.ts b/tests/provision.test.ts new file mode 100644 index 0000000..e77d638 --- /dev/null +++ b/tests/provision.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { gatewayIdFor, safeGatewayError } from "../src/provision/plan"; + +describe("gatewayIdFor", () => { + const accepted = /^[A-Za-z0-9_-]{1,64}$/; + + test("makes a readable id the gateway will accept", () => { + expect(gatewayIdFor("Bench P1P", () => 0)).toBe("bench-p1p-0000"); + expect(gatewayIdFor("Bench P1P", () => 0.999999)).toMatch(accepted); + }); + + test("survives names with nothing usable in them", () => { + expect(gatewayIdFor("***", () => 0.5)).toMatch(/^printer-[0-9a-f]{4}$/); + expect(gatewayIdFor("Принтер", () => 0.5)).toMatch(accepted); + }); + + test("never exceeds what the gateway accepts, whatever the name", () => { + expect(gatewayIdFor("x".repeat(500))).toMatch(accepted); + expect(gatewayIdFor("../../etc/passwd")).toMatch(accepted); + }); + + test("two printers with the same name get different ids", () => { + const ids = new Set(Array.from({ length: 50 }, () => gatewayIdFor("Same name"))); + expect(ids.size).toBeGreaterThan(30); + }); +}); + +describe("safeGatewayError", () => { + test("passes an ordinary message through", () => { + expect(safeGatewayError("could not connect to the printer", ["12345678"])).toBe( + "could not connect to the printer", + ); + }); + test("removes a credential that somehow got into the message", () => { + expect(safeGatewayError("bad login 12345678 for host", ["12345678"])).toBe("bad login for host"); + }); + test("has an answer when the gateway gave none", () => { + expect(safeGatewayError(undefined, [])).toBe("the gateway did not say why"); + }); +});