feat(backend): DoH handshake endpoint for protected-device auto-activation

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/<token>).

- 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)
This commit is contained in:
chahinebrini 2026-05-15 22:41:17 +02:00
parent db377da7ce
commit 0e4c3787c2
4 changed files with 99 additions and 0 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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,
};
});

View File

@ -134,6 +134,37 @@ export async function revokeProtectedDevice(
return true;
}
/**
* DoH-Handshake: lookup by dnsToken, flip pendingactive, 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<string, unknown> = { 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.