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)
58 lines
2.5 KiB
TypeScript
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,
|
|
};
|
|
});
|