chahinebrini 0e4c3787c2 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)
2026-05-15 22:41:17 +02:00

58 lines
2.5 KiB
TypeScript

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