chahinebrini a95e66560d feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)
Devices/Magic:
- Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im
  Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic
- Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar)
- Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung
- Realtime auf user_devices → Settings aktualisiert Magic-Bindings live
- Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut

Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie):
- magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill)
- Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook),
  per Resend-Mail + in-Response
- Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert)

Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:25 +02:00

138 lines
4.8 KiB
TypeScript

// DNS profile generator — DoH URL uses per-user subdomain for iOS compatibility
import { randomUUID } from "node:crypto";
import { execSync } from "node:child_process";
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { getProfile } from "../../db/profile";
/**
* Generiert ein iOS/macOS .mobileconfig DNS-Profil.
*
* Das Profil zeigt auf unseren eigenen DNS-Server (dns.rebreak.de),
* der sowohl globale Blocklist als auch User-Custom-Domains blockiert.
*
* Free-User: Nur eigene Custom Domains werden blockiert
* Pro-User: Globale Blocklist + Custom Domains
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
// Get user plan
const profile = await getProfile(user.id);
const isPro = profile?.plan === "pro" || profile?.plan === "legend";
const profileUUID = randomUUID().toUpperCase();
const payloadUUID = randomUUID().toUpperCase();
// DNS Server URL - points to our DNS proxy via per-user subdomain
// Subdomain carries userId so iOS DoH profile has user context without query params
// Staging: <uid>.dns-staging.rebreak.org | Prod: <uid>.rebreak.org
const config = useRuntimeConfig();
const isStaging = config.public.appUrl.includes("staging");
const dnsServerUrl = isStaging
? `https://${user.id}.dns-staging.rebreak.org/dns-query`
: `https://${user.id}.rebreak.org/dns-query`;
// Get counts for description
const { getActiveBlocklistCount, getUserCustomDomains } = await import("../../db/domains");
const [globalCount, customDomains] = await Promise.all([
isPro ? getActiveBlocklistCount() : Promise.resolve(0),
getUserCustomDomains(user.id),
]);
const customCount = customDomains.length;
const totalCount = globalCount + customCount;
const description = isPro
? `Aktiviert automatisches Blocking von Glücksspielseiten auf deinem Gerät. ${totalCount.toLocaleString()} Domains werden blockiert.`
: `Aktiviert automatisches Blocking deiner personalisierten Sperrliste auf deinem Gerät. ${customCount} Domains werden blockiert.`;
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz</string>
<key>PayloadDescription</key>
<string>${description}</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection</string>
<key>PayloadUUID</key>
<string>${profileUUID}</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.payload</string>
<key>PayloadUUID</key>
<string>${payloadUUID}</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>ProhibitDisablement</key>
<true/>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>${dnsServerUrl}</string>
</dict>
</dict>
</array>
</dict>
</plist>`;
setResponseHeaders(event, {
"Content-Type": "application/x-apple-aspen-config",
"Content-Disposition":
'attachment; filename="rebreak-protection.mobileconfig"',
});
// Cert base path based on environment
const certBase = isStaging
? "/etc/letsencrypt/live/staging.rebreak.org"
: "/etc/letsencrypt/live/rebreak.org";
const certFile = `${certBase}/cert.pem`;
const keyFile = `${certBase}/privkey.pem`;
const chainFile = `${certBase}/chain.pem`;
if (existsSync(certFile) && existsSync(keyFile)) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tmpIn = join(tmpdir(), `rebreak-profile-${id}.xml`);
const tmpOut = join(tmpdir(), `rebreak-profile-${id}.der`);
try {
writeFileSync(tmpIn, xml, "utf8");
execSync(
`openssl smime -sign -signer ${certFile} -inkey ${keyFile}${existsSync(chainFile) ? ` -certfile ${chainFile}` : ""} -nodetach -in ${tmpIn} -out ${tmpOut} -outform DER`,
{ stdio: "pipe", timeout: 5000 },
);
const signed = readFileSync(tmpOut);
return signed;
} catch {
// Fallback: unsigniert (funktioniert noch, zeigt nur Warnung in iOS)
} finally {
try {
unlinkSync(tmpIn);
} catch {
/* ignore */
}
try {
unlinkSync(tmpOut);
} catch {
/* ignore */
}
}
}
// Unsigned fallback (Entwicklung / Zertifikat nicht verfügbar)
return xml;
});