From 0e4c3787c278f5d89efb9515f118726a41dea543 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 15 May 2026 22:41:17 +0200 Subject: [PATCH] feat(backend): DoH handshake endpoint for protected-device auto-activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/devices/protected/handshake — server-to-server endpoint called by the AdGuard log-watcher whenever a Mac with our DNS-profile makes a DoH query with its dnsToken embedded in the path (/dns-query/). - Idempotent: pending → active on first hit, lastDnsQueryAt always updated - Auth: shared secret via x-handshake-secret (Infisical: HANDSHAKE_SECRET, must be set before enabling the watcher) - Revoked tokens are silently ignored (no info leak to potential attackers) - Realtime publication added so the native app auto-advances the AddMacSheet flow when status flips (no "I've installed it" button needed anymore) --- backend/nitro.config.ts | 3 + .../migration.sql | 8 +++ .../api/devices/protected/handshake.post.ts | 57 +++++++++++++++++++ backend/server/db/protectedDevices.ts | 31 ++++++++++ 4 files changed, 99 insertions(+) create mode 100644 backend/prisma/migrations/20260515_enable_protected_devices_realtime/migration.sql create mode 100644 backend/server/api/devices/protected/handshake.post.ts diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 24a8705..1eb5f2b 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -23,6 +23,9 @@ export default defineNitroConfig({ // ─── Admin / Cron ──────────────────────────────────────────────────── adminSecret: process.env.ADMIN_SECRET ?? "", cronSecret: process.env.CRON_SECRET ?? "", + // Shared secret for AdGuard→Backend DoH-handshake (POST /api/devices/protected/handshake). + // Set in Infisical as HANDSHAKE_SECRET before enabling AdGuard webhook. + handshakeSecret: process.env.HANDSHAKE_SECRET ?? "", // ─── LLM-Provider ──────────────────────────────────────────────────── // Infisical staging hat NUXT_*-prefix für openrouter+groq, andere ohne. diff --git a/backend/prisma/migrations/20260515_enable_protected_devices_realtime/migration.sql b/backend/prisma/migrations/20260515_enable_protected_devices_realtime/migration.sql new file mode 100644 index 0000000..5df6571 --- /dev/null +++ b/backend/prisma/migrations/20260515_enable_protected_devices_realtime/migration.sql @@ -0,0 +1,8 @@ +-- Enable Supabase Realtime for protected_devices table. +-- This allows the frontend to subscribe to status changes (pending → active) +-- triggered by the DoH handshake endpoint without manual user confirmation. +-- +-- STOP: run this only after User-GO via: +-- pnpm prisma migrate deploy (on Hetzner, via GitHub Actions deploy) + +ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.protected_devices; diff --git a/backend/server/api/devices/protected/handshake.post.ts b/backend/server/api/devices/protected/handshake.post.ts new file mode 100644 index 0000000..df32a27 --- /dev/null +++ b/backend/server/api/devices/protected/handshake.post.ts @@ -0,0 +1,57 @@ +/** + * POST /api/devices/protected/handshake + * + * Server-to-server endpoint: called by AdGuard/DoH-server (rebreak-mdm) + * whenever a protected device makes a DNS query with its unique dnsToken. + * + * Auth: shared secret via `x-handshake-secret` header (NOT user JWT). + * - Verified against runtimeConfig.handshakeSecret (Infisical: HANDSHAKE_SECRET). + * + * Body: { token: string } ← the 32-char hex dnsToken + * + * Behaviour (idempotent): + * - pending → status=active, installedAt=NOW, lastDnsQueryAt=NOW, statusChanged=true + * - active → lastDnsQueryAt=NOW, statusChanged=false + * - degraded → lastDnsQueryAt=NOW, statusChanged=false (no auto-re-activate) + * - revoked → 200 { ok: true, ignored: true } (silent, no info leak) + * - unknown token → 404 { error: "TOKEN_NOT_FOUND" } + * + * Rate limiting: lastDnsQueryAt write is cheap + idempotent; AdGuard may call + * this on every DNS query (multiple/sec). If that becomes expensive, add a + * per-token 60s in-memory cooldown here. + */ +export default defineEventHandler(async (event) => { + // ── Shared-secret auth ─────────────────────────────────────────────────── + const config = useRuntimeConfig(event); + const secret = getHeader(event, "x-handshake-secret"); + + if (!config.handshakeSecret || secret !== config.handshakeSecret) { + throw createError({ statusCode: 401, data: { error: "UNAUTHORIZED" } }); + } + + // ── Body ───────────────────────────────────────────────────────────────── + const body = await readBody(event); + const token = body?.token; + + if (!token || typeof token !== "string" || token.trim().length === 0) { + throw createError({ statusCode: 400, data: { error: "TOKEN_REQUIRED" } }); + } + + // ── DB lookup + update ─────────────────────────────────────────────────── + const result = await handshakeProtectedDevice(token.trim()); + + if (!result.found) { + throw createError({ statusCode: 404, data: { error: "TOKEN_NOT_FOUND" } }); + } + + if (result.revoked) { + // Silent ignore — don't confirm token existence to potential attacker + return { ok: true, ignored: true }; + } + + return { + ok: true, + statusChanged: result.statusChanged, + status: result.status, + }; +}); diff --git a/backend/server/db/protectedDevices.ts b/backend/server/db/protectedDevices.ts index bced3eb..0f1a050 100644 --- a/backend/server/db/protectedDevices.ts +++ b/backend/server/db/protectedDevices.ts @@ -134,6 +134,37 @@ export async function revokeProtectedDevice( return true; } +/** + * DoH-Handshake: lookup by dnsToken, flip pending→active, always update lastDnsQueryAt. + * + * Returns: + * { found: false } → Token unbekannt + * { found: true, statusChanged: false, ... } → war schon active/degraded, nur lastDnsQueryAt updated + * { found: true, statusChanged: true, ... } → war pending, jetzt active + * { found: true, revoked: true } → Token existiert aber revoked → ignorieren + */ +export async function handshakeProtectedDevice(dnsToken: string): Promise< + | { found: false } + | { found: true; revoked: true } + | { found: true; revoked: false; statusChanged: boolean; status: string } +> { + const db = usePrisma(); + const device = await db.protectedDevice.findUnique({ where: { dnsToken } }); + if (!device) return { found: false }; + if (device.status === "revoked") return { found: true, revoked: true }; + + const now = new Date(); + const updateData: Record = { lastDnsQueryAt: now }; + const wasPending = device.status === "pending"; + if (wasPending) { + updateData.status = "active"; + updateData.installedAt = now; + } + + await db.protectedDevice.update({ where: { id: device.id }, data: updateData }); + return { found: true, revoked: false, statusChanged: wasPending, status: wasPending ? "active" : device.status }; +} + /** * Prüft ob ein Token nach der 14-Tage-Grace-Period in Passthrough-Modus ist. * Wird vom DoH-Blocklist-Endpoint aufgerufen.