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:
parent
db377da7ce
commit
0e4c3787c2
@ -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.
|
||||
|
||||
@ -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;
|
||||
57
backend/server/api/devices/protected/handshake.post.ts
Normal file
57
backend/server/api/devices/protected/handshake.post.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@ -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<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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user