import { randomBytes } from "crypto"; import { countActiveMagicBindings, listMagicDevices } from "../../db/devices"; import { countActiveProtectedDevices } from "../../db/protectedDevices"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; import { requireUser } from "../../utils/auth"; import { createAdGuardClient } from "../../utils/adguard"; import { sendDeviceAddedPush } from "../../services/push"; import { generateRemovalPassword } from "../../utils/magic-lock"; /** * POST /api/magic/register * * Body: { deviceId: string, hostname: string, model?: string, osVersion?: string } * * Mac-App ruft nach Login auf. Registriert das Device als Magic-Client, * generiert DNS-Token und provisioniert AdGuard Persistent Client. * * Idempotent: wenn bereits gebunden → return existing token. * Wenn Limit erreicht → 409 mit activeBindings-Liste. */ export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); const { deviceId, hostname, model, osVersion, platform } = body as { deviceId?: string; hostname?: string; model?: string; osVersion?: string; platform?: string; }; if (!hostname) { throw createError({ statusCode: 400, message: "hostname required", }); } if (!deviceId) { throw createError({ statusCode: 400, message: "deviceId required", }); } const effectiveDeviceId = deviceId.trim(); // Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows" const devicePlatform = platform === "windows" ? "windows" : "macos"; const db = usePrisma(); // 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent) let existing = await db.userDevice.findUnique({ where: { userId_deviceId: { userId: user.id, deviceId } }, select: { id: true, userId: true, deviceId: true, magicDnsToken: true, magicEnrolledAt: true, magicRevokedAt: true, magicRemovalPassword: true, }, }); // Wenn Token existiert und nicht revoked → return existing if ( existing?.magicDnsToken && existing.magicEnrolledAt && !existing.magicRevokedAt ) { return { success: true, data: { deviceId, dnsToken: existing.magicDnsToken, profileUrl: `/api/magic/profile.mobileconfig?token=${existing.magicDnsToken}`, existing: true, }, }; } // 2. Plan-gated Desktop-Slot-Check (nur wenn kein vorheriges Binding existiert). // Pro: 1 stationäres Gerät (Mac ODER Windows), Legend: 2 (§ Geräte-Matrix). // Grandfathering-Pattern wie bei Custom-Domains: bestehende Bindings bleiben // nach Downgrade aktiv, nur NEUE Registrierungen werden hier geblockt. if (!existing || !existing.magicEnrolledAt) { const profile = await getProfile(user.id); const desktopLimit = getPlanLimits(profile?.plan ?? "pro").maxProtectedDevices; // Cross-Counting: Magic-Bindings + legacy ProtectedDevices (manueller // Profil-Download) teilen sich denselben Desktop-Slot-Pool. const [magicCount, protectedCount] = await Promise.all([ countActiveMagicBindings(user.id), countActiveProtectedDevices(user.id), ]); const activeCount = magicCount + protectedCount; if (activeCount >= desktopLimit) { const activeBindings = await listMagicDevices(user.id); throw createError({ statusCode: 409, message: `Geräte-Limit erreicht (max. ${desktopLimit} Computer in deinem Plan).`, data: { code: "limit_reached", limit: desktopLimit, activeBindings, }, }); } } // 3. Generiere DNS-Token (48 char hex) // WICHTIG: hex (DNS-Label-konform, kein `_`) UND ≤63 Zeichen. AdGuard nutzt die // clientid im DoH-Pfad `/dns-query/{id}` und lehnt >63 Zeichen ab // ("hostname label is too long: got 64, max 63"). randomBytes(32).hex = 64 → 1 zu viel. // 24 Bytes = 48 hex (192 bit Entropie), wie der historisch funktionierende Client. const dnsToken = randomBytes(24).toString("hex"); // Hard-Lock: server-gehaltenes Removal-Passwort. Stabil über Re-Registrierungen // (sonst würde ein laufender Offboarding-Cooldown sein PW wechseln). const removalPassword = existing?.magicRemovalPassword ?? generateRemovalPassword(); // 4. Provisioniere AdGuard Client const adguardClientName = `magic_${deviceId}`; try { await createAdGuardClient(adguardClientName, dnsToken, { use_global_settings: false, filtering_enabled: true, parental_enabled: false, safebrowsing_enabled: true, blocked_services: [], // TODO: Gambling-Filter via AdGuard Blocked-Services }); } catch (err: any) { console.error("[Magic] AdGuard provisioning failed:", err); throw createError({ statusCode: 502, message: "DNS-Provisioning fehlgeschlagen", }); } // 5. Upsert UserDevice (platform="macos" | "windows") // Bei Migration behalten wir die bestehende deviceId bei. const upsertDeviceId = existing?.deviceId || effectiveDeviceId; const device = await db.userDevice.upsert({ where: { userId_deviceId: { userId: user.id, deviceId: upsertDeviceId } }, create: { userId: user.id, deviceId: upsertDeviceId, platform: devicePlatform, model: model ?? null, name: hostname, osVersion: osVersion ?? null, magicDnsToken: dnsToken, magicEnrolledAt: new Date(), magicHostname: hostname, magicRemovalPassword: removalPassword, }, update: { magicDnsToken: dnsToken, magicEnrolledAt: new Date(), magicRevokedAt: null, // Clear falls vorher revoked magicHostname: hostname, model: model ?? undefined, osVersion: osVersion ?? undefined, lastSeenAt: new Date(), magicRemovalPassword: removalPassword, magicReleaseRequestedAt: null, // Re-Bind bricht laufenden Release ab }, select: { deviceId: true, magicDnsToken: true, }, }); // Account-Security-Push „Neues Gerät verbunden" — nur bei NEUER Bindung // (idempotente Re-Registrierungen oben returnen vorher mit existing:true). // Fire-and-forget: blockt die Response nicht. void sendDeviceAddedPush({ userId: user.id, deviceLabel: hostname, platform: devicePlatform, }); return { success: true, data: { deviceId: device.deviceId, dnsToken: device.magicDnsToken, profileUrl: `/api/magic/profile.mobileconfig?token=${device.magicDnsToken}`, existing: false, }, }; });