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"); + }); +});