245 lines
7.9 KiB
TypeScript

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, hardwareId, hostname, model, osVersion, platform } = body as {
deviceId?: string;
hardwareId?: string;
hostname?: string;
model?: string;
osVersion?: string;
platform?: string;
};
if (!hostname) {
throw createError({
statusCode: 400,
message: "hostname required",
});
}
if (!deviceId && !hardwareId) {
throw createError({
statusCode: 400,
message: "deviceId oder hardwareId required",
});
}
// Für neue Magic-Registrierungen: hardwareId wird gleichzeitig deviceId,
// damit das Backend keine eigene ID generieren muss.
const effectiveDeviceId = deviceId?.trim() || hardwareId!.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)
// Priorität: hardwareId → deviceId → Migration über Modell/Plattform/OS.
let existing = null;
if (hardwareId) {
existing = await db.userDevice.findFirst({
where: { userId: user.id, hardwareId },
select: {
id: true,
userId: true,
deviceId: true,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicRemovalPassword: true,
},
});
}
if (!existing && deviceId) {
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,
},
});
}
// Migration: bestehendes Gerät ohne hardwareId anhand von Modell/Plattform/OS finden.
if (!existing && hardwareId) {
existing = await db.userDevice.findFirst({
where: {
userId: user.id,
hardwareId: null,
platform: devicePlatform,
model: model ?? null,
osVersion: osVersion ?? null,
},
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,
hardwareId: hardwareId ?? null,
magicDnsToken: dnsToken,
magicEnrolledAt: new Date(),
magicHostname: hostname,
magicRemovalPassword: removalPassword,
},
update: {
hardwareId: hardwareId ?? undefined,
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,
},
};
});