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)
195 lines
5.7 KiB
TypeScript
195 lines
5.7 KiB
TypeScript
import { usePrisma } from "../utils/prisma";
|
|
|
|
export interface ProtectedDeviceRecord {
|
|
id: string;
|
|
platform: string;
|
|
label: string;
|
|
status: string;
|
|
installedAt: Date | null;
|
|
degradedAt: Date | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord {
|
|
dnsToken: string;
|
|
userId: string;
|
|
}
|
|
|
|
const DEVICE_SELECT = {
|
|
id: true,
|
|
platform: true,
|
|
label: true,
|
|
status: true,
|
|
installedAt: true,
|
|
degradedAt: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
const DEVICE_SELECT_WITH_TOKEN = {
|
|
...DEVICE_SELECT,
|
|
dnsToken: true,
|
|
userId: true,
|
|
} as const;
|
|
|
|
/** Alle nicht-revoked Devices eines Users, neueste zuerst. */
|
|
export async function listProtectedDevices(
|
|
userId: string,
|
|
): Promise<ProtectedDeviceRecord[]> {
|
|
const db = usePrisma();
|
|
return db.protectedDevice.findMany({
|
|
where: { userId, status: { not: "revoked" } },
|
|
orderBy: { createdAt: "desc" },
|
|
select: DEVICE_SELECT,
|
|
});
|
|
}
|
|
|
|
/** Anzahl der aktiven+pending Devices für Limit-Check (degraded zählt NICHT — Slot freigegeben). */
|
|
export async function countActiveProtectedDevices(
|
|
userId: string,
|
|
): Promise<number> {
|
|
const db = usePrisma();
|
|
return db.protectedDevice.count({
|
|
where: { userId, status: { in: ["active", "pending"] } },
|
|
});
|
|
}
|
|
|
|
/** Lookup by id — inkl. dnsToken und userId (für mobileconfig-Generation + ownership-check). */
|
|
export async function getProtectedDevice(
|
|
id: string,
|
|
): Promise<ProtectedDeviceWithToken | null> {
|
|
const db = usePrisma();
|
|
return db.protectedDevice.findUnique({
|
|
where: { id },
|
|
select: DEVICE_SELECT_WITH_TOKEN,
|
|
});
|
|
}
|
|
|
|
/** Lookup by dnsToken — für DoH-Blocklist-Endpoint (Token aus URL). */
|
|
export async function getProtectedDeviceByToken(
|
|
dnsToken: string,
|
|
): Promise<ProtectedDeviceWithToken | null> {
|
|
const db = usePrisma();
|
|
return db.protectedDevice.findUnique({
|
|
where: { dnsToken },
|
|
select: DEVICE_SELECT_WITH_TOKEN,
|
|
});
|
|
}
|
|
|
|
/** Anlegen eines neuen ProtectedDevice (status=pending). */
|
|
export async function createProtectedDevice(opts: {
|
|
userId: string;
|
|
dnsToken: string;
|
|
platform: string;
|
|
label: string;
|
|
}): Promise<ProtectedDeviceWithToken> {
|
|
const db = usePrisma();
|
|
return db.protectedDevice.create({
|
|
data: {
|
|
userId: opts.userId,
|
|
dnsToken: opts.dnsToken,
|
|
platform: opts.platform,
|
|
label: opts.label,
|
|
status: "pending",
|
|
},
|
|
select: DEVICE_SELECT_WITH_TOKEN,
|
|
});
|
|
}
|
|
|
|
/** User bestätigt Installation — setzt installedAt + status=active. */
|
|
export async function confirmProtectedDeviceInstalled(
|
|
id: string,
|
|
userId: string,
|
|
): Promise<ProtectedDeviceRecord | null> {
|
|
const db = usePrisma();
|
|
const device = await db.protectedDevice.findFirst({
|
|
where: { id, userId, status: { not: "revoked" } },
|
|
});
|
|
if (!device) return null;
|
|
|
|
return db.protectedDevice.update({
|
|
where: { id },
|
|
data: {
|
|
status: "active",
|
|
installedAt: new Date(),
|
|
},
|
|
select: DEVICE_SELECT,
|
|
});
|
|
}
|
|
|
|
/** Soft-delete: setzt status=revoked + revokedAt. Ownership-check via userId. */
|
|
export async function revokeProtectedDevice(
|
|
id: string,
|
|
userId: string,
|
|
): Promise<boolean> {
|
|
const db = usePrisma();
|
|
const device = await db.protectedDevice.findFirst({
|
|
where: { id, userId, status: { not: "revoked" } },
|
|
});
|
|
if (!device) return false;
|
|
|
|
await db.protectedDevice.update({
|
|
where: { id },
|
|
data: { status: "revoked", revokedAt: new Date() },
|
|
});
|
|
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.
|
|
*
|
|
* Returns:
|
|
* 'active' → volle Blocklist liefern
|
|
* 'grace' → volle Blocklist liefern (innerhalb 14-Tage-Grace)
|
|
* 'passthrough' → nur minimale/leere Liste liefern
|
|
* 'revoked' → Token unbekannt oder revoked → Passthrough
|
|
*/
|
|
export async function getDeviceBlocklistMode(
|
|
dnsToken: string,
|
|
): Promise<"active" | "grace" | "passthrough" | "revoked"> {
|
|
const device = await getProtectedDeviceByToken(dnsToken);
|
|
if (!device) return "revoked";
|
|
if (device.status === "revoked") return "revoked";
|
|
if (device.status === "active" || device.status === "pending") return "active";
|
|
if (device.status === "degraded") {
|
|
const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
const gracedAt = device.degradedAt
|
|
? device.degradedAt.getTime() + GRACE_MS
|
|
: 0;
|
|
if (Date.now() <= gracedAt) return "grace";
|
|
return "passthrough";
|
|
}
|
|
return "passthrough";
|
|
}
|