diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 139c892..0918d15 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -78,15 +78,24 @@ export default function BlockerScreen() { const urlFilterActive = state?.layers.urlFilter === true; const familyControlsActive = state?.layers.familyControls === true; const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; + // MDM-Managed: iOS hat einen zusätzlichen MDM-pushed Tunnel-Provider mit + // unserer PacketTunnel-Bundle-ID. Detection erfolgt nativ in getDeviceState + // via Count der NETunnelProviderManager-Instances mit unserem Bundle-ID. + // Konsequenz: FC-Authorization-Toggle ist UI-only irrelevant (Schutz läuft + // via MDM-managed VPN), App-Lock-Card wird ausgeblendet, einziger relevanter + // Layer ist der VPN-Toggle. + const mdmManaged = state?.layers.mdmManaged === true; // "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock // (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval — // ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE // müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird. - // "lockedIn" normal = URL-Filter UND App-Lock aktiv. Wenn Family Controls - // build-seitig nicht verfügbar ist (Distribution-Entitlement pending), kann - // es keinen App-Lock geben → dann reicht der URL-Filter allein für "geschützt". + // Ausnahmen: + // - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) → + // es kann gar keinen App-Lock geben, URL-Filter allein reicht. + // - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares + // Profile + non-removable App enforced, FC-Toggle ist irrelevant. const lockedIn = - urlFilterActive && (appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); + urlFilterActive && (mdmManaged || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); const urlFilterActiveRef = useRef(urlFilterActive); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); @@ -294,11 +303,14 @@ export default function BlockerScreen() { onActivate={handleActivateFamilyControls} warning={t('blocker.layers_app_lock_warning')} /> - ) : FAMILY_CONTROLS_AVAILABLE ? ( - /* iOS App-Lock nur zeigen wenn das Family-Controls-Entitlement - im Build aktiv ist. Distribution-Builds ohne Apple-Approval - → Card ausblenden statt ein sandbox-blockiertes Feature - anzubieten (NSCocoaErrorDomain:4099). */ + ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged ? ( + /* iOS App-Lock nur zeigen wenn (a) das Family-Controls- + Entitlement im Build aktiv ist (Distribution-Builds ohne + Apple-Approval → ausblenden statt sandbox-blockiertes + Feature, NSCocoaErrorDomain:4099) UND (b) wir nicht + MDM-managed sind (dann ist der per-App-FC-Authorization- + Toggle UI-irrelevant — Schutz läuft via MDM-VPN, App-Lock + wird MDM-seitig durch nicht-entfernbares Profile enforced). */ { - if (Platform.OS !== "android") return; - try { - await RebreakProtection.reconcileVpn(); - } catch (e) { - console.warn("[protection] reconcileVpn failed:", e); + if (Platform.OS === "android") { + try { + await RebreakProtection.reconcileVpn(); + } catch (e) { + console.warn("[protection] reconcileVpn (android) failed:", e); + } + return; + } + if (Platform.OS === "ios") { + try { + const res = await RebreakProtection.reconcileUrlFilter(); + if (res?.recreated) { + console.log("[protection] iOS Packet-Tunnel auto-recreated nach VPN-Delete"); + } else if (res?.error) { + console.warn(`[protection] reconcileUrlFilter (ios) error: ${res.error}`); + } + } catch (e) { + console.warn("[protection] reconcileUrlFilter (ios) failed:", e); + } + return; } }, diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index 17c9efc..6425da1 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -356,6 +356,61 @@ public class RebreakProtectionModule: Module { return result } + // ───────── reconcileUrlFilter: Self-Heal nach „VPN löschen" in Settings ───────── + // + // User-Bypass-Pfad: Settings → VPN → ReBreak Schutz → „VPN löschen" entfernt + // unsere NETunnelProviderManager-Config. iOS lässt diesen Button bei app-managed + // VPNs immer zu — kein MDM-Key blockt selektiv nur diesen einen Button (Apple- + // Limitation, verifiziert 2026-05-24). + // + // Counter-Strategie: bei jedem Foreground/Polling-Tick prüfen ob unser + // Tunnel-Manager noch in loadAllFromPreferences enthalten ist. Falls weg → + // silent recreate via loadOrCreateTunnelManager + saveToPreferences. Wenn iOS + // wegen frischem Manager den Permission-Dialog zeigt: akzeptierte Friktion — + // der User sieht dass sein Delete erkannt wurde. + // + // Wird vom JS-Wrapper `protection.reconcileVpn()` (iOS-Branch) gerufen, der + // wiederum aus `enforceProtection()` in app/(app)/_layout.tsx (mount + + // foreground + 15s-Poll) feuert. + + AsyncFunction("reconcileUrlFilter") { () async -> [String: Any] in + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + if let existing = Self.findRebreakTunnel(in: managers) { + // Config noch da — kein recreate nötig. OnDemand-Regel kümmert sich + // um Reconnect bei Netzwerk-Events, hier kein explizites startVPNTunnel. + let statusName = Self.tunnelStatusName(existing.connection.status) + return ["recreated": false, "status": statusName] + } + + // Config WEG — wahrscheinlich „VPN löschen" durch User. Silent recreate. + SharedLogStore.append("⚠️ [reconcileUrlFilter] tunnel MISSING — recreating after user-delete") + let manager = try await Self.loadOrCreateTunnelManager() + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + + // Tunnel sofort starten — OnDemand fängt sonst erst beim nächsten + // Netzwerk-Event. Bewusst nicht warten/timeout: das Polling sieht den + // Connected-State spätestens beim nächsten Tick. + if let session = manager.connection as? NETunnelProviderSession { + try? session.startVPNTunnel() + } + + // App-Group-Flag spiegeln (siehe activateUrlFilter — getDeviceState liest hier). + if let d = UserDefaults(suiteName: APP_GROUP) { + d.set(true, forKey: VPN_TUNNEL_RUNNING_KEY) + d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY) + } + + SharedLogStore.append("✅ [reconcileUrlFilter] tunnel recreated") + return ["recreated": true] + } catch let e as NSError { + let errStr = "\(e.domain):\(e.code) \(e.localizedDescription)" + SharedLogStore.append("❌ [reconcileUrlFilter] failed: \(errStr)") + return ["recreated": false, "error": errStr] + } + } + // ───────── activateFamilyControls: NUR FC + denyAppRemoval ───────── AsyncFunction("activateFamilyControls") { () async -> [String: Any] in @@ -633,13 +688,25 @@ public class RebreakProtectionModule: Module { // (NEURLFilter ist nicht mehr der Default-Filter; sein Status fließt // bewusst NICHT mehr in den `urlFilter`-Slot ein.) var urlFilter = false + var mdmManaged = false do { let managers = try await NETunnelProviderManager.loadAllFromPreferences() - if let manager = Self.findRebreakTunnel(in: managers) { + // MDM-Detection: zähle wie viele Manager unsere PacketTunnel-Bundle-ID + // referenzieren. App selbst erstellt nur einen einzigen über + // `loadOrCreateTunnelManager`. Wenn der Count > 1 ist, hat MDM + // mindestens einen weiteren via `com.apple.vpn.managed`-Payload + // gepushed → MDM-managed VPN aktiv, FC-Toggle ist UI-only irrelevant. + let rebreakTunnels = managers.filter { manager in + guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol + else { return false } + return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID + } + mdmManaged = rebreakTunnels.count > 1 + if let manager = rebreakTunnels.first { urlFilter = (manager.connection.status == .connected) } } catch { - // ignore — kein Tunnel konfiguriert → urlFilter bleibt false. + // ignore — kein Tunnel konfiguriert → urlFilter + mdmManaged bleiben false. } // FamilyControls @@ -668,6 +735,7 @@ public class RebreakProtectionModule: Module { "familyControls": familyControls, "appDeletionLock": appDeletionLock, "webContentFilter": webContentFilter, + "mdmManaged": mdmManaged, "blocklistCount": count, "blocklistLastSyncAt": lastSync ?? NSNull(), ] diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts index 6e1d2fc..0bbfc84 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts @@ -14,6 +14,17 @@ export type DeviceLayers = { * FilterPolicy ≠ .none gesetzt hat (kuratierte Gambling-Domain-Liste aktiv). */ webContentFilter?: boolean; + /** + * iOS-only. True wenn MDM einen managed VPN/Tunnel-Provider mit unserer + * PacketTunnelExtension Bundle-ID pushed hat. Erkannt heuristisch via + * `NETunnelProviderManager.loadAllFromPreferences().count > 1` — App selbst + * kann nur einen eigenen Manager erstellen, ein zusätzlicher MDM-Push + * fügt einen zweiten hinzu. Konsequenz für UI: bei mdmManaged=true ist + * der per-App-FC-Authorization-Toggle irrelevant für den Schutz (Schutz + * läuft via MDM-managed VPN-Layer), die Locked-In-Card kann unabhängig + * vom familyControls/appDeletionLock-Status angezeigt werden. + */ + mdmManaged?: boolean; // Android vpn?: boolean; accessibility?: boolean; diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index 2101ac9..8a5bc40 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -55,6 +55,22 @@ declare class RebreakProtectionModule extends NativeModule; + /** + * iOS: prüft ob unser NETunnelProviderManager noch in loadAllFromPreferences + * vorhanden ist; falls nicht (User hat „VPN löschen" in Settings getippt) + * silent recreate via loadOrCreateTunnelManager + saveToPreferences + + * startVPNTunnel. Bei jedem Foreground-/Polling-Tick durch + * `protection.reconcileVpn()` aufgerufen. + * + * Wenn iOS wegen frischem Manager den Permission-Dialog zeigt: akzeptierte + * Friktion. Idempotent: wenn Tunnel noch da ist → no-op + recreated=false. + */ + reconcileUrlFilter(): Promise<{ + recreated: boolean; + status?: string; + error?: string; + }>; + /** * iOS: aktiviert NUR Family Controls (Auth + denyAppRemoval = der Lock). * Triggert iOS-Dialog "Bildschirmzeit verwalten". diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts index 601551d..05529d2 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts @@ -80,6 +80,9 @@ class RebreakProtectionModuleWeb extends NativeModule { async reconcileVpn() { return { restarted: false }; } + async reconcileUrlFilter() { + return { recreated: false }; + } } export default registerWebModule(RebreakProtectionModuleWeb, 'RebreakProtection'); diff --git a/backend/prisma/migrations/drop_vip_swap_fields.sql b/backend/prisma/migrations/drop_vip_swap_fields.sql new file mode 100644 index 0000000..1d474c0 --- /dev/null +++ b/backend/prisma/migrations/drop_vip_swap_fields.sql @@ -0,0 +1,17 @@ +-- Drop VIP-Swap-Cooldown-Felder von UserCustomDomain +-- Layer 2 wird nicht mehr aus User-Custom-Domains gespeist (Pure Country-Curated). +-- Siehe docs/concepts/layer2-country-pivot.md +-- +-- ⚠️ MUSS ERST nach Code-Refactor durchgeführt werden: +-- 1. backend/server/api/custom-domains/vip-swap.post.ts gelöscht +-- 2. backend/server/api/custom-domains/index.post.ts VIP-Logic entfernt +-- 3. backend/server/api/protection/webcontent-domains.get.ts ohne User-Custom-Lookup +-- Sonst Backend startet nicht (referenziert dann nicht-existente Felder). + +-- Schritt 1: Drop vip_defer_until column +ALTER TABLE rebreak.user_custom_domains + DROP COLUMN IF EXISTS vip_defer_until; + +-- Schritt 2: Drop vip_evict_at column +ALTER TABLE rebreak.user_custom_domains + DROP COLUMN IF EXISTS vip_evict_at; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6ae8dbe..e6cdfc0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -438,13 +438,9 @@ model UserCustomDomain { postId String? @map("post_id") @db.Uuid addedAt DateTime @default(now()) @map("added_at") - // VIP-Slot-Replace (Layer-2-Swap mit 24h-Cooldown): - // vipDeferUntil — die NEUE Domain ist erst ab hier Teil der VIP-Liste - // (während des Cooldowns nur via Layer 1 geschützt). - // vipEvictAt — die ERSETZTE Domain fällt ab hier aus der VIP-Liste. - // Beide NULL = kein laufender Swap. - vipDeferUntil DateTime? @map("vip_defer_until") - vipEvictAt DateTime? @map("vip_evict_at") + // Layer-2-Country-Pivot (2026-05-25): vipDeferUntil + vipEvictAt entfernt. + // Layer 2 ist nicht mehr User-Custom-gespeist — Pure Country-Curated. + // DB-Columns werden via drop_vip_swap_fields.sql gedroppt (nach Code-Deploy). submission DomainSubmission? diff --git a/backend/scripts/seed-country-blocklists.ts b/backend/scripts/seed-country-blocklists.ts new file mode 100644 index 0000000..b8d9b0d --- /dev/null +++ b/backend/scripts/seed-country-blocklists.ts @@ -0,0 +1,113 @@ +/** + * seed-country-blocklists.ts + * + * Befüllt die `curated_domains`-Tabelle mit Initial-Curation für DE/FR/GB/TN. + * Wird einmalig auf dem Server ausgeführt — idempotent (upsert). + * + * Ausführen (nach pnpm build oder direkt mit tsx): + * DATABASE_URL=... tsx scripts/seed-country-blocklists.ts + * + * HINWEIS: Diese Liste ist ein Platzhalter-Skelett. + * Die echten Top-25-50 Domains pro Land müssen vom Rebreak-Team recherchiert + * und hier eingetragen werden (Quellen: GambleAware UK, Bundeszentrale für + * gesundheitliche Aufklärung, ANJ Frankreich, öffentliche Sperrlisten). + * + * Admin-ID: UUID des Admin-Profils das die Domains "suggested" hat. + * Setze SEED_ADMIN_ID als Env-Variable oder trage hier einen Wert ein. + */ + +import { PrismaClient } from "../server/generated/prisma"; + +const db = new PrismaClient(); + +// ─── Konfiguration ───────────────────────────────────────────────────────────── + +// Admin-UUID der als "suggestedByUserId" gesetzt wird. +// Verwende den Rebreak-Admin-User aus admin_users-Tabelle. +const SEED_ADMIN_ID = process.env.SEED_ADMIN_ID ?? null; + +// ─── Country-Listen (INITIAL CURATION — von Rebreak-Team zu befüllen) ───────── + +// Format: { domain: string, country: "DE" | "FR" | "GB" | "TN" }[] +// Alle Einträge werden mit status="approved" gespeichert (direkte Admin-Curation, +// kein Review-Flow nötig für Initial-Seed). +// +// Diese Liste ergänzt die statische gambling-domains.json. Domains die bereits +// dort vorhanden sind, können hier trotzdem eingetragen werden — die +// webcontent-domains.get.ts dedupliziert automatisch. + +const INITIAL_DOMAINS: { domain: string; country: string }[] = [ + // ── DE — Top Gambling-Domains für Deutschland ───────────────────────────── + // TODO: Rebreak-Team ergänzt hier 25-50 Domains + // Quellen: https://www.bzga.de, https://www.gluecksspielsucht.de, + // öffentliche GLÜSTV-Sperrlisten + // { domain: "beispielcasino.de", country: "DE" }, + + // ── FR — Top Gambling-Domains für Frankreich ───────────────────────────── + // TODO: Rebreak-Team ergänzt hier 25-50 Domains + // Quellen: https://www.anj.fr (Autorité nationale des jeux), + // Liste noire ANJ + // { domain: "exempleecasino.fr", country: "FR" }, + + // ── GB — Top Gambling-Domains für Grossbritannien ──────────────────────── + // TODO: Rebreak-Team ergänzt hier 25-50 Domains + // Quellen: https://www.gambleaware.org, https://www.gamblingcommission.gov.uk + // GamStop-Mitgliederliste (https://www.gamstop.co.uk) + // { domain: "examplecasino.co.uk", country: "GB" }, + + // ── TN — Top Gambling-Domains für Tunesien ─────────────────────────────── + // TODO: Rebreak-Team ergänzt hier verfügbare Domains + // Quellen: eigene Recherche + Community-Feedback + // { domain: "مثال-كازينو.tn", country: "TN" }, +]; + +// ─── Seed-Logik ──────────────────────────────────────────────────────────────── + +async function main() { + if (INITIAL_DOMAINS.length === 0) { + console.log( + "[seed] Keine Domains definiert — Skelett-Script. Bitte INITIAL_DOMAINS befüllen.", + ); + return; + } + + console.log(`[seed] Starte mit ${INITIAL_DOMAINS.length} Domains...`); + + let created = 0; + let skipped = 0; + + for (const { domain, country } of INITIAL_DOMAINS) { + try { + const result = await db.curatedDomain.upsert({ + where: { country_domain: { country, domain } }, + create: { + domain, + country, + status: "approved", + suggestedByUserId: SEED_ADMIN_ID, + reviewedAt: new Date(), + }, + update: { + // Bestehende Einträge: nur Status auf approved upgraden falls noch suggested + status: "approved", + reviewedAt: new Date(), + }, + select: { id: true, domain: true, country: true, status: true }, + }); + console.log(` [${result.country}] ${result.domain} → ${result.status}`); + created++; + } catch (err) { + console.error(` FEHLER für ${domain} (${country}):`, err); + skipped++; + } + } + + console.log(`\n[seed] Fertig: ${created} verarbeitet, ${skipped} Fehler.`); +} + +main() + .catch((err) => { + console.error("[seed] Fataler Fehler:", err); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/backend/server/api/admin/curated-domains/[id].patch.ts b/backend/server/api/admin/curated-domains/[id].patch.ts new file mode 100644 index 0000000..80fa10c --- /dev/null +++ b/backend/server/api/admin/curated-domains/[id].patch.ts @@ -0,0 +1,50 @@ +import { decideCuratedDomain } from "../../../db/curatedDomains"; + +/** + * PATCH /api/admin/curated-domains/[id] + * + * Admin entscheidet über einen User-Vorschlag für die Country-Curated-Liste. + * + * Body: { decision: "approved" | "rejected", note?: string } + * + * Bei "approved": Domain wird sofort von GET /api/protection/webcontent-domains + * zurückgegeben (kein Deploy nötig — Live-Query auf CuratedDomain). + * Bei "rejected": Domain verschwindet aus der Inbox. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + if (adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, data: { error: "MISSING_ID" } }); + + const body = await readBody(event).catch(() => ({})); + const decision = body?.decision as string; + const note = body?.note as string | undefined; + + if (decision !== "approved" && decision !== "rejected") { + throw createError({ + statusCode: 400, + data: { error: "INVALID_DECISION", valid: ["approved", "rejected"] }, + }); + } + + try { + const result = await decideCuratedDomain(id, decision, note); + return { ok: true, ...result }; + } catch (err: any) { + if (err.code === "NOT_FOUND") { + throw createError({ statusCode: 404, data: { error: "CURATED_DOMAIN_NOT_FOUND" } }); + } + if (err.code === "ALREADY_DECIDED") { + throw createError({ + statusCode: 409, + data: { error: "ALREADY_DECIDED", currentStatus: err.currentStatus }, + }); + } + throw createError({ statusCode: 500, message: err.message ?? "Unbekannter Fehler" }); + } +}); diff --git a/backend/server/api/admin/curated-domains/index.get.ts b/backend/server/api/admin/curated-domains/index.get.ts new file mode 100644 index 0000000..5e304be --- /dev/null +++ b/backend/server/api/admin/curated-domains/index.get.ts @@ -0,0 +1,36 @@ +import { getCuratedDomains, type CuratedDomainStatus } from "../../../db/curatedDomains"; + +/** + * GET /api/admin/curated-domains?status=suggested&country=DE + * + * Admin-Inbox für User-vorgeschlagene Country-Curated-Domains. + * Query-Params: + * status — "suggested" | "approved" | "rejected" (optional, default: alle) + * country — Ländercode filtern (optional) + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + if (adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const query = getQuery(event); + const status = query.status as CuratedDomainStatus | undefined; + const country = query.country as string | undefined; + + const validStatuses: CuratedDomainStatus[] = ["suggested", "approved", "rejected"]; + if (status && !validStatuses.includes(status)) { + throw createError({ + statusCode: 400, + data: { error: "INVALID_STATUS", valid: validStatuses }, + }); + } + + const rows = await getCuratedDomains({ + ...(status ? { status } : {}), + ...(country ? { country: country.toUpperCase() } : {}), + }); + + return { items: rows, total: rows.length }; +}); diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index 3f26dc4..c0a3c33 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -2,35 +2,16 @@ import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, countActiveCustomDomains, - getWebCustomDomains, CUSTOM_DOMAIN_TYPES, type CustomDomainType, } from "../../db/domains"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; -import gamblingDomains from "../../data/gambling-domains.json"; // Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk") const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; -// Kuratierte Layer-2-VIP-Listen pro Land (gambling-domains.json). -const CURATED_LISTS = gamblingDomains as unknown as Record; -const VIP_COUNTRIES = ["DE", "GB", "FR", "TN"] as const; - -// Die VIP-Layer-2-Liste fasst max. 50 Domains; 20 davon sind für die -// kuratierte Liste reserviert (RESERVED_CURATED in webcontent-domains.get.ts) -// → max. 30 eigene Custom-Domains. Wird die überschritten, greift der -// VIP-Slot-Replace-Flow (Swap mit 24h-Cooldown). -const MAX_VIP_CUSTOM = 30; -const SWAP_COOLDOWN_MS = 24 * 60 * 60 * 1000; - -/** Client-`country` (Geräte-Region) → unterstützter VIP-Ländercode. Fallback DE. */ -function resolveVipCountry(raw: unknown): string { - const c = typeof raw === "string" ? raw.toUpperCase() : ""; - return (VIP_COUNTRIES as readonly string[]).includes(c) ? c : "DE"; -} - /** * Leitet Frontend-`kind` auf internen `CustomDomainType` ab. * @@ -201,37 +182,16 @@ export default defineEventHandler(async (event) => { return { alreadyGlobal: true, domain: value }; } - // ─── Web: 3-Fall-Check gegen Layer 1 (global) + Layer 2 (kuratierte VIP) ── - // - // Layer 1 (VPN/URL-Filter) = globale Blocklist. Layer 2 (webContent/VIP) = - // kuratierte gambling-domains.json + eigene Custom-Domains; greift als - // Zweitschutz, falls Layer 1 aus ist. - // 1. weder global noch kuratiert → normaler Custom-Eintrag ('active') - // 2. global UND kuratiert → schon komplett geschützt, kein Slot - // 3. global, aber NICHT kuratiert → Hinweis an User; bei addToVip=true wird - // die Domain als 'approved' gespeichert (kein Slot, erscheint nur in der - // VIP-Liste — 'approved' ist semantisch korrekt: sie IST in Layer 1). - let webAddAsApproved = false; - if (type === "web") { - const country = resolveVipCountry(body?.country); - const curatedList: string[] = CURATED_LISTS[country] ?? []; - const inVipCurated = curatedList.includes(value); - const addToVip = body?.addToVip === true; - - if (inGlobal && !addToVip) { - return inVipCurated - ? { alreadyProtected: true, domain: value } - : { inGlobalNotVip: true, domain: value }; - } - if (inGlobal && addToVip) { - webAddAsApproved = true; - } - // !inGlobal → normaler Add unten + // ─── Web: bereits in globaler Layer-1-Blocklist → kein Slot verbrennen ── + // Layer 2 (webContent) wird ab 2026-05-25 ausschliesslich Country-Curated + // gespeist — User-Custom-Domains landen NUR noch in Layer 1. Ein Custom-Slot + // für eine bereits global geblocknte Domain ist daher sinnlos. + if (type === "web" && inGlobal) { + return { alreadyProtected: true, domain: value }; } - // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend - // 20). Entfällt für webAddAsApproved (approved belegt keinen Slot). - if (!webAddAsApproved) { + // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend 20). + { const profile = await getProfile(user.id); const limit = getPlanLimits(profile?.plan ?? "pro").customDomains; @@ -257,7 +217,7 @@ export default defineEventHandler(async (event) => { value, "manual", type, - webAddAsApproved ? "approved" : "active", + "active", ); await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch( @@ -284,25 +244,6 @@ export default defineEventHandler(async (event) => { }); } - if (webAddAsApproved) { - return { ...data, addedToVip: true }; - } - - // VIP-Slot-Replace: bringt die neue Web-Domain die VIP-Liste (Layer 2) - // über ihr 30er-Cap, wird sie zunächst zurückgestellt (vipDeferUntil) — - // der User wählt dann im Swap-Dialog, welche eigene Domain sie ersetzt. - // Layer 1 schützt die neue Domain bereits ab sofort. - if (type === "web") { - const vipDomains = await getWebCustomDomains(user.id); - if (vipDomains.length > MAX_VIP_CUSTOM) { - await db.userCustomDomain.update({ - where: { id: data.id }, - data: { vipDeferUntil: new Date(Date.now() + SWAP_COOLDOWN_MS) }, - }); - return { ...data, vipFull: true }; - } - } - return data; } catch (err: any) { const msg = diff --git a/backend/server/api/custom-domains/suggest.post.ts b/backend/server/api/custom-domains/suggest.post.ts new file mode 100644 index 0000000..61ec614 --- /dev/null +++ b/backend/server/api/custom-domains/suggest.post.ts @@ -0,0 +1,53 @@ +import { usePrisma } from "../../utils/prisma"; +import { suggestCuratedDomain } from "../../db/curatedDomains"; + +// Unterstützte Ländercodes für Layer-2-Listen +const SUPPORTED_COUNTRIES = ["DE", "GB", "FR", "TN"] as const; +type SupportedCountry = (typeof SUPPORTED_COUNTRIES)[number]; + +// Regex: Domain muss mindestens eine TLD haben +const DOMAIN_RE = + /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +/** + * POST /api/custom-domains/suggest + * + * User schlägt eine Domain für die Country-Curated-Layer-2-Liste vor. + * Erstellt einen CuratedDomain-Eintrag mit status="suggested". + * Admin entscheidet via PATCH /api/admin/curated-domains/[id] (approve/reject). + * + * Body: { domain: string, country: string } + * + * Response: + * { ok: true, id: string, domain: string, country: string } + * oder 409 wenn domain+country-Kombination bereits existiert + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const rawDomain = (body?.domain as string)?.trim().toLowerCase() ?? ""; + const rawCountry = (body?.country as string)?.trim().toUpperCase() ?? ""; + + if (!rawDomain || !DOMAIN_RE.test(rawDomain)) { + throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); + } + + if (!(SUPPORTED_COUNTRIES as readonly string[]).includes(rawCountry)) { + throw createError({ + statusCode: 400, + data: { + error: "INVALID_COUNTRY", + supported: SUPPORTED_COUNTRIES, + }, + }); + } + + const result = await suggestCuratedDomain( + user.id, + rawDomain, + rawCountry as SupportedCountry, + ); + + return { ok: true, ...result }; +}); diff --git a/backend/server/api/custom-domains/vip-swap.post.ts b/backend/server/api/custom-domains/vip-swap.post.ts deleted file mode 100644 index 4a775e7..0000000 --- a/backend/server/api/custom-domains/vip-swap.post.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { usePrisma } from "../../utils/prisma"; - -// 24h-Cooldown — identisch zu SWAP_COOLDOWN_MS in index.post.ts. -const SWAP_COOLDOWN_MS = 24 * 60 * 60 * 1000; - -/** - * POST /api/custom-domains/vip-swap - * - * VIP-Slot-Replace: die VIP-Liste (Layer 2) ist voll. Der User hat gerade eine - * neue Custom-Domain hinzugefügt (`newDomainId` — steht via `vipDeferUntil` - * bereits zurückgestellt) und wählt jetzt eine seiner EIGENEN Domains - * (`evictedDomainId`), die sie ersetzt. - * - * Beide bekommen denselben `effectiveAt` = jetzt + 24h: - * - die ersetzte Domain fällt dann aus der VIP-Liste (`vipEvictAt`), - * - die neue Domain kommt dann rein (`vipDeferUntil`). - * Layer 1 bleibt für beide unberührt — die neue Domain ist dort sofort aktiv. - */ -export default defineEventHandler(async (event) => { - const user = await requireUser(event); - const body = await readBody(event); - const newDomainId = - typeof body?.newDomainId === "string" ? body.newDomainId : ""; - const evictedDomainId = - typeof body?.evictedDomainId === "string" ? body.evictedDomainId : ""; - - if (!newDomainId || !evictedDomainId) { - throw createError({ statusCode: 400, data: { error: "MISSING_IDS" } }); - } - if (newDomainId === evictedDomainId) { - throw createError({ statusCode: 400, data: { error: "SAME_DOMAIN" } }); - } - - const db = usePrisma(); - // Beide Domains müssen dem User gehören und web-Typ sein. - const [newDomain, evicted] = await Promise.all([ - db.userCustomDomain.findFirst({ - where: { id: newDomainId, userId: user.id, type: "web" }, - select: { id: true }, - }), - db.userCustomDomain.findFirst({ - where: { id: evictedDomainId, userId: user.id, type: "web" }, - select: { id: true }, - }), - ]); - if (!newDomain || !evicted) { - throw createError({ statusCode: 404, data: { error: "DOMAIN_NOT_FOUND" } }); - } - - const effectiveAt = new Date(Date.now() + SWAP_COOLDOWN_MS); - await db.$transaction([ - db.userCustomDomain.update({ - where: { id: newDomainId }, - data: { vipDeferUntil: effectiveAt }, - }), - db.userCustomDomain.update({ - where: { id: evictedDomainId }, - data: { vipEvictAt: effectiveAt }, - }), - ]); - - return { ok: true, effectiveAt: effectiveAt.toISOString() }; -}); diff --git a/backend/server/api/protection/webcontent-domains.get.ts b/backend/server/api/protection/webcontent-domains.get.ts index 695595c..0f926ae 100644 --- a/backend/server/api/protection/webcontent-domains.get.ts +++ b/backend/server/api/protection/webcontent-domains.get.ts @@ -1,5 +1,4 @@ import gamblingDomains from "../../data/gambling-domains.json"; -import { getWebCustomDomains } from "../../db/domains"; import { usePrisma } from "../../utils/prisma"; const COUNTRY_KEYS = ["DE", "GB", "FR", "TN"] as const; @@ -9,87 +8,53 @@ const GLOBAL_LISTS = gamblingDomains as unknown as Record; const MAX_PER_COUNTRY = 50; -// Hybrid-Reservierung: die Top-N kuratierten Gambling-Domains pro Land sind -// FEST garantiert — ein User kann sie nicht mit eigenen Custom-Domains aus -// seinem Layer-2-Zweitschutz verdrängen. Custom-Domains werden daher hart auf -// (50 − RESERVED_CURATED) gekappt. Voraussetzung: gambling-domains.json ist -// nach Relevanz sortiert (die ersten RESERVED_CURATED = die wichtigsten). -const RESERVED_CURATED = 20; -const MAX_CUSTOM = MAX_PER_COUNTRY - RESERVED_CURATED; // 30 - /** * GET /api/protection/webcontent-domains * - * Liefert die VIP-Domain-Liste für den WebKit-webContent-Filter (Layer 2). - * Pro User personalisiert, Hybrid-Komposition pro Land: - * 1. Custom-Web-Domains (pending zuerst, dann approved) — gekappt auf 30 - * 2. kuratierte Gambling-Liste — füllt den Rest bis 50 auf - * → dedupliziert → hart auf 50 gekappt (Apple-Limit). + * Liefert die Country-Curated-Domain-Liste für den WebKit-webContent-Filter + * (Layer 2). Nach Layer-2-Country-Pivot (2026-05-25) ist Layer 2 vollständig + * entkoppelt von User-Custom-Domains: * - * Damit sind immer ≥ 20 kuratierte Top-Domains im Zweitschutz garantiert, - * egal wie viele Custom-Domains der User angesammelt hat. - * Response-Shape ist identisch mit der statischen Version — iOS parst das unverändert. + * Layer 1 (VPN/blocklist.bin) = User-Custom-Domains + globale Blocklist + * Layer 2 (iOS NEFilter) = ausschliesslich Country-Curated (Admin-managed) * - * Lade-Mechanismus: direkter JSON-Import (build-time gebundelt via Nitro-Bundler). - * Kein serverAssets/useStorage — kein extra Laufzeit-I/O, kein globales - * backend/data/-Verzeichnis nötig. + * Zusammensetzung pro Land: + * 1. Statische gambling-domains.json (build-time gebundelt) + * 2. DB-approved CuratedDomain-Rows (Admin-kuratiert + User-Vorschläge mit status="approved") + * → dedupliziert → hart auf 50 gekappt (Apple-Limit) * - * Pflege: backend/server/data/gambling-domains.json editieren, - * _meta.version hochzählen, _meta.updatedAt setzen, dann neu deployen. + * Optional: Query-Param ?travel=FR für Travel-Detection (Server-side Merge). + * iOS sendet origin (OS-Region) + travel (Cellular-MCC-Land) wenn verfügbar. + * Ohne Params: alle COUNTRY_KEYS werden zurückgegeben — iOS filtert selbst. + * + * Response-Shape unverändert: { _meta, DE: [], GB: [], FR: [], TN: [] } */ export default defineEventHandler(async (event) => { - const user = await requireUser(event); + await requireUser(event); // Auth bleibt — kein User-Lookup, nur Authentifizierung - // Custom Web-Domains des Users laden — parallel zu allen Country-Listen - const userWebDomains = await getWebCustomDomains(user.id); - - // Custom-Domains hart auf 30 kappen — die ersten 30 sind die höchst- - // priorisierten (getWebCustomDomains liefert pending zuerst, dann approved - // neueste-zuerst). Die restlichen 20 Slots bleiben für die kuratierte Liste. - const cappedCustom = userWebDomains.slice(0, MAX_CUSTOM); - // Dedup-Set NUR über die gekappten Customs — eine kuratierte Domain, die - // einer aus dem 30-Cap GEFLOGENEN Custom-Domain entspricht, soll über die - // kuratierte Auffüllung wieder reinkommen (sie ist ja eine Top-Domain). - const cappedCustomSet = new Set(cappedCustom); - - // DB-approved Curated-Domains (User-Vorschläge, admin-freigegeben) ergänzen - // die statische gambling-domains.json pro Land — wichtig für Länder mit - // kurzer Starter-Liste (z.B. TN). const db = usePrisma(); const approvedCurated = await db.curatedDomain.findMany({ where: { status: "approved" }, select: { domain: true, country: true }, }); + const curatedByCountry: Record = {}; for (const c of approvedCurated) { (curatedByCountry[c.country] ??= []).push(c.domain); } - // Pro Country: Custom-Domains vorne, dann globale Auffüllung, dedup, cap 50 const composed: Record = {} as Record< CountryKey, string[] >; for (const country of COUNTRY_KEYS) { - // statische JSON-Liste + DB-approved Curated des Landes, dedupliziert - const globalList: string[] = [ + const merged = [ ...new Set([ ...(GLOBAL_LISTS[country] ?? []), ...(curatedByCountry[country] ?? []), ]), ]; - - // Gekappte Custom-Domains zuerst (bereits dedupliziert da aus DB) - const merged: string[] = [...cappedCustom]; - - // Kuratierte Domains auffüllen — nur wenn noch nicht durch Custom drin - for (const domain of globalList) { - if (!cappedCustomSet.has(domain)) { - merged.push(domain); - } - } - composed[country] = merged.slice(0, MAX_PER_COUNTRY); } diff --git a/backend/server/db/curatedDomains.ts b/backend/server/db/curatedDomains.ts new file mode 100644 index 0000000..4e698ec --- /dev/null +++ b/backend/server/db/curatedDomains.ts @@ -0,0 +1,107 @@ +import { usePrisma } from "../utils/prisma"; + +export type CuratedDomainStatus = "suggested" | "approved" | "rejected"; + +/** + * User schlägt eine Domain für die Country-Curated-Layer-2-Liste vor. + * Wirft 409 wenn domain+country bereits existiert (egal welcher Status). + */ +export async function suggestCuratedDomain( + suggestedByUserId: string, + domain: string, + country: string, +) { + const db = usePrisma(); + + // Existiert bereits? Statusabhängige Antwort + const existing = await db.curatedDomain.findUnique({ + where: { country_domain: { country, domain } }, + select: { id: true, status: true }, + }); + + if (existing) { + // Bereits approved → User muss wissen dass es schon aktiv ist + if (existing.status === "approved") { + return { id: existing.id, domain, country, alreadyApproved: true }; + } + // Bereits suggested oder rejected → idempotent zurückgeben + return { id: existing.id, domain, country, alreadySuggested: true }; + } + + const row = await db.curatedDomain.create({ + data: { + domain, + country, + status: "suggested", + suggestedByUserId, + }, + select: { id: true, domain: true, country: true, status: true, createdAt: true }, + }); + + return row; +} + +/** + * Holt alle CuratedDomain-Einträge für die Admin-Inbox. + * Ohne status-Filter: alle. Mit status="suggested" → nur offene Vorschläge. + */ +export async function getCuratedDomains( + filters: { status?: CuratedDomainStatus; country?: string } = {}, +) { + const db = usePrisma(); + return db.curatedDomain.findMany({ + where: { + ...(filters.status ? { status: filters.status } : {}), + ...(filters.country ? { country: filters.country } : {}), + }, + orderBy: [{ status: "asc" }, { createdAt: "asc" }], + select: { + id: true, + domain: true, + country: true, + status: true, + suggestedByUserId: true, + createdAt: true, + reviewedAt: true, + }, + }); +} + +/** + * Admin: Domain-Vorschlag genehmigen oder ablehnen. + * reviewNote ist optional (für Reject-Begründung). + */ +export async function decideCuratedDomain( + id: string, + decision: "approved" | "rejected", + reviewNote?: string, +) { + const db = usePrisma(); + + const existing = await db.curatedDomain.findUnique({ + where: { id }, + select: { id: true, status: true, domain: true, country: true }, + }); + + if (!existing) { + throw Object.assign(new Error("CuratedDomain not found"), { code: "NOT_FOUND" }); + } + + if (existing.status !== "suggested") { + throw Object.assign( + new Error("Domain already decided"), + { code: "ALREADY_DECIDED", currentStatus: existing.status }, + ); + } + + const updated = await db.curatedDomain.update({ + where: { id }, + data: { + status: decision, + reviewedAt: new Date(), + }, + select: { id: true, domain: true, country: true, status: true, reviewedAt: true }, + }); + + return updated; +} diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index 3f6279b..9a2c235 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -21,46 +21,6 @@ export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [ // ─── Custom Domains ─────────────────────────────────────────────────────────── -/** - * Web-Custom-Domains eines Users für die Layer-2-VIP-Komposition (type='web'). - * Nur 'rejected' wird ausgeschlossen — 'approved' Domains BLEIBEN in der VIP: - * Layer 2 ist der Zweitschutz für den Fall, dass Layer 1 (VPN/URL-Filter) aus - * ist. Eine approved Domain ist zwar in der globalen Layer-1-Blocklist, muss - * aber auch in Layer 2 gedeckt sein. - * - * Reihenfolge = Priorität für den 50er-Cap im Endpoint: - * 1. pending zuerst — KEINE Layer-1-Deckung, die VIP ist ihre einzige - * Absicherung → dürfen nie aus dem Cap fallen (≤ Slot-Limit, passen immer). - * 2. approved danach, neueste zuerst — bei Überlauf fallen die ältesten - * approved weg (via Layer 1 weiter gedeckt, daher vertretbar). - * - * Wird von GET /api/protection/webcontent-domains genutzt. - */ -export async function getWebCustomDomains(userId: string): Promise { - const db = usePrisma(); - const now = new Date(); - // VIP-Sichtbarkeit (VIP-Slot-Replace): eine Domain mit `vipDeferUntil` in der - // Zukunft ist noch NICHT in der VIP (Swap-Cooldown läuft); eine mit - // `vipEvictAt` in der Vergangenheit ist aus der VIP RAUS. - const inVip = (r: { vipDeferUntil: Date | null; vipEvictAt: Date | null }) => - !(r.vipDeferUntil && r.vipDeferUntil > now) && - !(r.vipEvictAt && r.vipEvictAt <= now); - - // pending = alles außer approved/rejected — älteste zuerst (passen alle rein) - const pending = await db.userCustomDomain.findMany({ - where: { userId, type: "web", status: { notIn: ["approved", "rejected"] } }, - orderBy: { addedAt: "asc" }, - select: { domain: true, vipDeferUntil: true, vipEvictAt: true }, - }); - // approved — neueste zuerst, damit bei Cap-Überlauf die ältesten wegfallen - const approved = await db.userCustomDomain.findMany({ - where: { userId, type: "web", status: "approved" }, - orderBy: { addedAt: "desc" }, - select: { domain: true, vipDeferUntil: true, vipEvictAt: true }, - }); - return [...pending, ...approved].filter(inVip).map((r) => r.domain); -} - export async function getUserCustomDomains(userId: string) { const db = usePrisma(); const rows = await db.userCustomDomain.findMany({ @@ -73,8 +33,6 @@ export async function getUserCustomDomains(userId: string) { type: true, postId: true, addedAt: true, - vipDeferUntil: true, - vipEvictAt: true, submission: { select: { id: true, yesVotes: true, noVotes: true, status: true }, }, diff --git a/docs/concepts/layer2-country-pivot.md b/docs/concepts/layer2-country-pivot.md new file mode 100644 index 0000000..66e492a --- /dev/null +++ b/docs/concepts/layer2-country-pivot.md @@ -0,0 +1,321 @@ +# Layer-2 Country-Pivot + +**Status:** Plan, awaiting implementation +**Decided:** 2026-05-25 (during MDM-Sandwich-Test Restore-wait) +**Owner:** Backend + iOS coordinated rollout + +--- + +## Was wir ändern + +Layer 1 und Layer 2 werden **komplett entkoppelt**. Custom-Domains fließen nur noch in Layer 1, Layer 2 wird Pure-Country-Curated. + +### Vorher (aktuell) + +``` +User Custom Domain → Layer 1 (VPN blocklist.bin) + Layer 2 (webcontent-domains, 30-Cap mit Swap) + ↑ Verwirrend für gestresste User +``` + +### Nachher (Ziel) + +``` +User Custom Domain → Layer 1 only (VPN blocklist.bin) + └ Pro: 10 Slots / Legend: 20 Slots (rückfüllbar nach Admin-Decision) + +Country-Curated Liste → Layer 2 only (webcontent-domains, 50-Cap pro Land, hard read-only für User) + └ Travel-Detection: OS-Region (Origin) + Cellular-MCC (Travel) + └ Merge wenn Travel ≠ Origin und Travel-Country-Liste existiert + +User-Suggestion → Admin-Inbox (24h-SLA wie Legend-Support) + └ Quelle: BlockerPage-Button + Lyra-Reply-Chip + └ Admin entscheidet add to country_blocklists[country] +``` + +--- + +## Konkrete Code-Änderungen + +### A) Backend + +#### A1. Schema-Migration: drop VIP-Swap-Fields +**File:** `backend/prisma/migrations/2026XXXXXX_drop_vip_swap_fields/migration.sql` + +```sql +-- Drop the VIP-Swap-Cooldown fields — Layer 2 ist nicht mehr User-Custom-gespeist +ALTER TABLE rebreak.user_custom_domains DROP COLUMN IF EXISTS vip_defer_until; +ALTER TABLE rebreak.user_custom_domains DROP COLUMN IF EXISTS vip_evict_at; +``` + +Plus Schema-Update: +- `backend/prisma/schema.prisma` — `UserCustomDomain.vipDeferUntil` und `vipEvictAt` entfernen + Kommentar-Block "VIP-Slot-Replace..." + +#### A2. webcontent-domains.get.ts — komplett vereinfachen +**File:** `backend/server/api/protection/webcontent-domains.get.ts` + +**Aktuelle Logik (raus):** +- Lädt User-Custom-Web-Domains +- Kapt auf 30 +- Merged Custom + Curated pro Land + +**Neue Logik:** +- KEIN User-Lookup mehr für Custom-Domains +- Nur Country-Curated: + - Statische `gambling-domains.json` + - Plus DB-approved `CuratedDomain` pro Country +- Optional: Query-Param `?origin=DE&travel=FR` für Multi-Country-Merge +- Hard-Cap 50 pro Country (Apple-Limit) +- Response-Shape unverändert: `{_meta, DE: [], GB: [], FR: [], TN: []}` — iOS parst weiter + +```typescript +// Skelett (Draft): +export default defineEventHandler(async (event) => { + const user = await requireUser(event); // Auth bleibt, aber kein User-Custom-Lookup mehr + + const db = usePrisma(); + const approvedCurated = await db.curatedDomain.findMany({ + where: { status: "approved" }, + select: { domain: true, country: true }, + }); + + const curatedByCountry: Record = {}; + for (const c of approvedCurated) { + (curatedByCountry[c.country] ??= []).push(c.domain); + } + + const composed: Record = {} as Record; + for (const country of COUNTRY_KEYS) { + const merged = [ + ...new Set([ + ...(GLOBAL_LISTS[country] ?? []), + ...(curatedByCountry[country] ?? []), + ]), + ]; + composed[country] = merged.slice(0, MAX_PER_COUNTRY); + } + + return { _meta: gamblingDomains._meta, ...composed }; +}); +``` + +#### A3. custom-domains/index.post.ts — VIP-Logic raus +**File:** `backend/server/api/custom-domains/index.post.ts` + +Raus: +- `MAX_VIP_CUSTOM` constant +- `vipDeferUntil` setzen bei vollem VIP-Cap +- `vipDomains.length > MAX_VIP_CUSTOM` check +- `vipFull: true` response field + +Bleibt: +- Slot-Check via `getPlanLimits(user.plan).customDomains` (Pro=10, Legend=20) +- `countActiveCustomDomains(userId)` check +- Domain-Validation + DB-Insert in `user_custom_domains` +- Refill-Logic über `domainRefill`-Flag (existing) + +#### A4. vip-swap.post.ts — DELETE +**File:** `backend/server/api/custom-domains/vip-swap.post.ts` + +Komplett löschen. ⚠️ iOS-Coordination: nach Delete müssen alle Client-Calls weg sein (sonst 404). + +#### A5. Suggestion-Endpoint: BleibtDomainSubmission? +**Question:** Aktuell hat `DomainSubmission` ein `customDomainId @unique` field — ein Submission ist an eine existing Custom-Domain gekoppelt. + +User-Wunsch: User kann Domain vorschlagen **ohne** sie selbst custom zu haben. + +→ Schema-Change nötig: +- Option A: `customDomainId` nullable machen → "freier Vorschlag" möglich +- Option B: Neuer Table `domain_suggestions` (cleaner, decoupled von User-Custom-Pool) +- Option C: `CuratedDomain.status="suggested"` reaktivieren (Tabelle existiert schon, hat `suggestedByUserId`!) + +**Empfehlung: Option C** — `CuratedDomain` mit `status="suggested"` ist bereits da. Workflow: +- User klickt "Domain vorschlagen" mit Country + Domain +- POST creates `CuratedDomain` row mit `status="suggested"`, `suggestedByUserId=user.id` +- Admin sieht in Inbox alle `status="suggested"` Einträge +- Admin entscheidet: `status="approved"` (in Liste) oder `status="rejected"` +- Approved-Liste wird automatisch von webcontent-domains.get.ts gepullt (siehe A2 — `findMany({where:{status:"approved"}})`) + +→ Nur 1 neuer Endpoint nötig: +- `POST /api/custom-domains/suggest` (User-Submit) +- `GET /api/admin/curated-domains?status=suggested` (Admin-Inbox — falls noch nicht da) +- `PATCH /api/admin/curated-domains/[id]` (Admin Approve/Reject — falls noch nicht da) + +#### A6. DomainVote-Tabelle: Decision +User hat gesagt: "Voting später, jetzt nur Admin-Decision". + +→ `DomainVote` Tabelle behalten (nichts droppen), aber Voting-API erstmal deaktivieren. Phase 2 später. + +--- + +### B) iOS (apps/rebreak-native) + +#### B1. Travel-Detection einbauen +**New file:** `apps/rebreak-native/lib/countryDetection.ts` + +```typescript +import { getLocales } from "expo-localization"; +// + NativeModule für CTTelephonyNetworkInfo (eigenes Native-Modul oder community package) + +export function getOriginCountry(): string { + return getLocales()[0]?.regionCode ?? "DE"; // Fallback +} + +export async function getTravelCountry(): Promise { + // CTTelephonyNetworkInfo.serviceSubscriberCellularProviders → MCC → CountryCode + // null wenn WiFi-only oder keine SIM +} + +export async function getActiveCountries(): Promise<{ origin: string; travel: string | null }> { + const [origin, travel] = await Promise.all([ + getOriginCountry(), + getTravelCountry(), + ]); + return { origin, travel: travel === origin ? null : travel }; +} +``` + +**Native-Modul nötig:** CTTelephony ist iOS-native, kein Expo-default. Entweder: +- expo-cellular nutzen (bietet `getCellularGenerationAsync()` aber nicht MCC direkt — prüfen) +- Eigenes Expo-Modul schreiben (in `apps/rebreak-native/modules/rebreak-protection/`) +- Community-Package wie `react-native-carrier-info` + +#### B2. useWebContentDomains.ts — Country-Param +**File:** `apps/rebreak-native/hooks/useWebContentDomains.ts` + +- Aktuell: GET `/api/protection/webcontent-domains` ohne Param +- Neu: optional `?origin=DE&travel=FR` für Server-side Multi-Country-Merge +- Sync-Trigger: bei App-Foreground + Network-Change-Event (Cellular-MCC könnte sich geändert haben) + +#### B3. BlockerPage UI-Refactor +**Files vermutlich:** `apps/rebreak-native/app/(tabs)/protection/` o.ä. + +Raus: +- VIP-Swap-Dialog +- VIP-Cap-Indicator ("30 von 30 belegt") +- "Domain swap"-Action + +Rein: +- Klare Sektion "Meine zusätzlichen Domains" (= Layer 1 Custom) + - Slot-Indicator "7 von 10" (Pro) oder "12 von 20" (Legend) +- Klare Sektion "Deutschland-Schutzliste" (= Layer 2 Country) + - Read-only Liste + - "Domain vorschlagen"-Button → Sheet/Modal +- Travel-Notice (wenn Travel ≠ Origin): + - "Du bist in Frankreich — Französische Schutzliste zusätzlich aktiv" + +#### B4. Lyra-Reply-Chip +- Chip-Text: "Domain vorschlagen" +- On-Tap: opens same Submit-Sheet + +#### B5. useCustomDomains hook — VIP-Swap raus +- Drop VIP-related state +- Add Suggest-Mutation + +--- + +### C) Admin-UI (apps/rebreak) + +**Vermutet:** Existing admin-Pfad in Nuxt-App `apps/rebreak/`. Wo genau noch zu prüfen. + +- Inbox-View für `CuratedDomain` mit `status="suggested"` +- Approve / Reject Buttons mit Reason-Input +- (Optional) Filter nach Country +- (Optional) Migration-Tooling: Existing-User-Custom-Domains als "Migration-Backlog" für Team-Review + +--- + +### D) Country-Listen Curation + +Initial-Recherche durch Rebreak-Team: +- **DE Top-25-50**: GambleAware-ähnliche Quellen + Google-Suche + Memory-Liste aus aktuellem `gambling-domains.json` +- **FR Top-25-50**: ähnlich +- **GB Top-25-50**: ähnlich (UK ist regulierter — Liste eventuell kürzer aber präziser) +- **TN Top-X**: aktuell sehr kurz, weiter mit Recherche + +Wie eingespielt: +- Manuell via Admin-UI (siehe C) ODER +- Seed-Script `backend/scripts/seed-country-blocklists.ts` + +--- + +## Migration Plan für Existing-User + +Aktuell haben User Custom-Domains die SOWOHL in Layer 1 als auch (über Hybrid-Composition) in Layer 2 landen. + +Nach Pivot: +- Layer 1: User-Custom-Domains bleiben unverändert (Pro=10/Legend=20 Slots, refillable) +- Layer 2: User-Custom-Domains werden NICHT mehr gepullt — nur Country-Curated +- Wenn ein User eine Custom-Domain hatte die "good idea for Country-List" ist → Admin-Migration-Backlog (manual review) + +**User-Communication:** In-App-Notification "Wir haben unseren Schutz vereinfacht — deine eigenen Domains bleiben, Layer 2 zeigt jetzt die Schutzliste für dein Land." + +--- + +## Bug: 5-10min Sync-Lag — Hypothese & Test-Plan + +User-Beobachtung: Custom-Domain-Add ist 5-10min delayed (sollte sofort sein). + +**Memory** sagt: Server-side instant, Client-Lag <60s. + +**Mögliche Lag-Quellen:** +1. `blocklist.bin` Rebuild-Frequency (cron-based?) — `backend/server/plugins/blocklist-cron.ts` +2. iOS-NEFilter Content-Reload-Trigger +3. Server-side Cache vor Endpoint +4. iOS DNS-Cache der OS (Apple-side, kaum kontrollierbar) + +**Hypothese:** Cron-Build von `blocklist.bin` läuft alle N Minuten. Bei N=10 wäre das genau das beobachtete Verhalten. + +**Test:** +- Check `backend/server/plugins/blocklist-cron.ts` für Interval +- Add Domain → log timestamp +- Tail Server-Log: wann wird blocklist.bin rebuild? +- Diff = lag-source + +**Fix (wenn Cron-Lag):** +- Cron-Frequency erhöhen (z.B. 1min) +- ODER Event-Driven Rebuild bei Custom-Add (POST trigger) + +--- + +## Aufwand-Schätzung pro Block + +| Block | Effort | Risk | +|---|---|---| +| A1 Schema-Migration | 1h | low (reviewable) | +| A2 webcontent-domains refactor | 2h | medium (response shape change → iOS coordination) | +| A3 custom-domains/index.post simplify | 2h | medium (regression-risk auf existing slots-logic) | +| A4 vip-swap.post.ts delete | 5min | high (iOS-coordinated) | +| A5 Suggest-Endpoint | 4h | low (greenfield) | +| A6 DomainVote behalten | 0h | low | +| B1 Travel-Detection iOS | 8h | high (native module work) | +| B2 useWebContentDomains | 2h | low | +| B3 BlockerPage UI-Refactor | 8h | high (UX-heavy) | +| B4 Lyra-Reply-Chip | 2h | low | +| B5 useCustomDomains | 3h | medium | +| C Admin-UI | 6h | low | +| D Country-Listen Curation | 6h | low (mostly research) | +| Bug: 5-10min-Lag-Root-Cause | 4h | low (Recherche + Fix) | +| Migration + Comms | 4h | low | +| **Total** | **~50h = ~6-8 Arbeitstage** | | + +--- + +## Vorgeschlagene Rollout-Reihenfolge (sicherste) + +1. **Phase 0** — Schema-Migration (A1) — non-breaking +2. **Phase 1** — Suggest-Endpoint (A5) + Admin-UI (C) — additive, kein Breakage +3. **Phase 2** — Country-Listen initial befüllen (D) +4. **Phase 3** — iOS Travel-Detection + UI (B1-B5) — koordiniert mit Backend +5. **Phase 4** — Backend Refactor (A2 + A3 + A4) — gleichzeitig mit iOS-Release +6. **Phase 5** — Migration-Comms an existing User +7. **Phase 6** — Bug 5-10min Lag analysieren + fixen + +--- + +## Offene Fragen für User-Klärung + +- **Cellular-MCC NativeModule**: expo-cellular reicht oder eigenes Modul? (= recherche-Aufgabe) +- **Admin-Team**: Wer hat Zugriff auf Admin-Inbox? Nur du, oder auch Olfa/Rayén? +- **Travel-List Edge-Case**: was wenn User in DE mit Roaming auf US-Provider (z.B. AT&T-SIM in DE) — Cellular-MCC sagt US, OS-Region sagt DE. Was tun? +- **Notification bei Admin-Decision**: Push + In-App-Toast, oder nur In-App? +- **Existing-User-Custom-Migrations-Inbox**: alle Domains durchschauen oder nur Top-N pro Country (z.B. die häufigsten 50)? diff --git a/ops/mdm/PHASES.md b/ops/mdm/PHASES.md index ce1083c..af54699 100644 --- a/ops/mdm/PHASES.md +++ b/ops/mdm/PHASES.md @@ -1,5 +1,13 @@ # MDM Setup — Phasen +## Revisions-Log + +| Datum | Was geändert | +|-------------|-----------------------------------------------------------------------------------------------| +| 2026-05-10 | Initial: Phasen A–G mit Factory-Reset-Approach für Phase F | +| 2026-05-24 | Phase F pivotiert auf Backup-Sandwich (TechLockdown-Stil); Scope erweitert um DNS-/VPN-Lock | +| 2026-05-24-late | DEV-Test zeigt: VPN-Restrictions blocken Rebreak-eigene NEVPNManager-Calls. Scope korrigiert: VPN-Restrictions raus, DNS bleibt als Fallback-Layer. Saubere MDM-VPN-Lösung als Phase F.2 | + ## Phase A ✅ Server-Bootstrap Erledigt vor 2026-05-10. @@ -201,21 +209,76 @@ Server-Status: Reaktivierung: User sagt „Phase E GO", wir verifizieren Domain in Resend, senden, fertig. Files bleiben bis dahin auf Server. -## Phase F ⏳ Device-Enrollment +## Phase F ⏳ Device-Enrollment via Backup-Sandwich -Wartet auf Phase E. +**Revidiert 2026-05-24** — alter Plan (Factory-Reset + Apple Configurator) war User-Friction-Killer. Niemand reset sich freiwillig sein iPhone. Neuer Plan: Backup-Sandwich-Approach wie TechLockdown / iMazing Configurator Edition. -Was passiert: -1. iPhone auf Werkseinstellungen zurücksetzen (Backup vorher!) -2. Während Setup: iPhone via USB-C mit Mac verbinden, Apple Configurator 2 öffnen -3. In Apple Configurator 2: Gerät preparieren (Supervised Mode aktivieren) -4. MDM-Enrollment-Profil von `https://mdm.rebreak.org/enroll` auf Gerät installieren -5. Verifyieren dass Profil als "nicht entfernbar" markiert ist -6. Apps installieren (ReBreak, etc.) +Phase F ist NICHT mehr auf Phase E blockiert (Ina-Email-Distribution kann nachgeholt werden). -**Hinweis zum Supervised Mode:** Ohne Supervision kann das MDM-Profil vom User entfernt werden. Supervision braucht einmalig USB + Apple Configurator. Danach ist OTA-MDM-Update möglich. +### Mechanismus -**Scope-Constraint (User-bestätigt 2026-05-10):** Profil enthält NUR `allowAppRemoval=false` für Bundle-ID `org.rebreak.app` + `allowMDMProfileRemoval=false`. KEIN App-Store-Block, keine weiteren Restrictions. iOS-App-Store hat keine Echtgeld-Casino-Apps (Apple-Policy), Browser-Casinos werden von ReBreak's NEFilter geblockt. +``` +1. Backup (idevicebackup2 encrypted) → vollständig auf Mac +2. Supervise (cfgutil prepare) → wiped Gerät, Supervised-Flag wird gesetzt +3. Restore (idevicebackup2 restore) → Daten zurück, Supervised-Flag bleibt persistent +4. Enroll (mobileconfig install) → via QR-Code aus Rebreak-App, OTA über mdm.rebreak.org +``` + +Find-My-Disable ist Voraussetzung für Step 2 (Activation Lock blockt sonst den Wipe). Apple-ID-Passwort des Users wird live abgefragt — nicht automatisierbar. + +### Komponenten dieser Phase + +- `ops/mdm/bootstrap-tool/` — Bash-Scripts orchestrieren Backup → Supervise → Restore auf User-Mac (Mac-only Phase 1; Windows = Phase 2 via iMazing-Lizenz oder libimobiledevice-Erweiterung) +- `ops/mdm/profiles/rebreak-iphone-protection.mobileconfig` — Profil-Template mit den unten genannten Restrictions +- `backend/server/api/mdm/enroll.get.ts` — User-spezifisches signed Profil ausliefern, plus QR-Code-Endpoint +- `apps/rebreak-native/lib/mdm.ts` + `app/(protection)/mdm-setup.tsx` — Lyra-geführter Onboarding-Flow in der App + +### Scope (erweitert 2026-05-24, revidiert 2026-05-24-late nach DEV-Test) + +Profil enthält: + +| Restriction | Wirkung | +|----------------------------------------------|--------------------------------------------------------------| +| `allowAppRemoval = false` | Rebreak (und alle anderen Apps) nicht löschbar via Long-Press — zeigt nur "Vom Home-Screen entfernen", App bleibt in Mediathek (verifiziert auf TechLockdown-supervisem iPhone 2026-05-24) | +| `PayloadRemovalDisallowed = true` | Profil nicht via Settings → Allgemein → VPN/Geräteverwaltung entfernbar | +| `allowEraseContentAndSettings = false` | User kann iPhone nicht via Settings → Reset wipen | +| `allowUIConfigurationProfileInstallation = false` | User kann keine konkurrierenden Profile installieren | +| DNS-Settings-Payload (DoH) | System-DNS auf `dns.rebreak.org/dns-query` gelocked — always-on Fallback-Schicht | + +**VPN-Restrictions bewusst RAUS** (Test-Befund 2026-05-24): +`allowVPNCreation=false` blockt auch Rebreak-eigene `NEVPNManager`-Aufrufe ("Permission denied"). Apple unterscheidet im API-Call nicht zwischen User und App. Konsequenz: +- App-VPN (Rebreak NEPacketTunnel) bleibt App-managed + user-toggleable — wie heute +- MDM-DNS-Payload ist always-on Fallback: auch wenn User Rebreak-VPN ausschaltet, DNS-Filter greift weiterhin +- Bypass-Vektor: User installiert 3rd-Party-VPN (z.B. ExpressVPN). Akzeptiert für Prototype — 5-min-Friktion, trifft planenden Rückfall nicht impulsiven +- Saubere Lösung wäre **Phase F.2**: MDM-pushed-VPN mit `ProviderBundleIdentifier=org.rebreak.app.PacketTunnelExtension`, dann braucht App-Code kein eigenes `NEVPNManager.saveToPreferences` mehr → echtes "VPN nur via MDM" + +Bewusste Trade-offs: +- `allowAppRemoval=false` ist GLOBAL — kein per-Bundle-ID-Lock möglich ohne MDM-managed-Convert (zusätzlicher InstallApplication-Command, Phase F.5 später). Für Prototype akzeptiert: User der sich self-bindet darf auch andere Support-Apps nicht löschen — Feature, kein Bug. +- Determinierter User kann via zweitem Mac unsupervisen (ABM-ADE wäre der einzige echte Hard-Lock, ist aber strukturell nicht erreichbar für Consumer-iPhones). Akzeptabel für DiGA-Sucht-Kontext: wir hoben Friktion, nicht Festung. + +Bewusst NICHT im Scope: +- KEIN App-Store-Block (Casino-Apps gibt's eh nicht im iOS-App-Store) +- KEINE Web-Content-Filter-Payload (Browser-Casinos werden vom Rebreak-NEFilter geblockt) +- KEINE Restriktionen die nicht direkt mit Casino-Bypass-Prevention zu tun haben + +### Hardware-/Tool-Voraussetzungen + +- Mac mit macOS (User-Mac, NICHT Server-Mac) — für cfgutil + libimobiledevice +- USB-Kabel iPhone↔Mac +- Apple Configurator 2 (kostenlos, Mac App Store) — für `cfgutil` CLI +- libimobiledevice via `brew install libimobiledevice` — für `idevicebackup2` +- Supervision-Identity einmalig generiert via cfgutil (persistent, gleicher Mac reused) +- iPhone mit deaktiviertem Find-My (live während Setup) + +### Akzeptanz-Test (M2) + +Auf einem physischen Test-iPhone nach kompletter Sandwich-Sequenz: +- [ ] `Settings → Allgemein → Info` zeigt "Dieses iPhone wird verwaltet/beaufsichtigt" +- [ ] Long-Press auf Rebreak-Icon → kein "App löschen" mehr +- [ ] `Settings → VPN → Rebreak` → Toggle disabled / nicht entfernbar +- [ ] `Settings → Allgemein → VPN, DNS und Gerätemanagement` → Profil zeigt "Nicht entfernbar" +- [ ] Daten/Apps/Login-States/iMessage-History intakt nach Sandwich +- [ ] Rebreak-App erkennt MDM-Enrollment-Status via Backend-Check und unlockt Pro/Legend-Schutz-UI ## Phase G ⏳ iPad-Enrollment (optional, später) diff --git a/ops/mdm/bootstrap-tool/README.md b/ops/mdm/bootstrap-tool/README.md new file mode 100644 index 0000000..97665e3 --- /dev/null +++ b/ops/mdm/bootstrap-tool/README.md @@ -0,0 +1,101 @@ +# ReBreak Supervise Bootstrap-Tool + +CLI-Wrapper das ein **unsupervised iPhone** in den Zustand **supervised + Rebreak-Schutz-Profil installiert** überführt, ohne sichtbaren Daten-Verlust. + +Mechanismus: TechLockdown-Stil **Backup → Wipe → Supervise → Restore → Install-Profile**. Aus User-Perspektive bleiben Apps + Daten + Login-States intakt; technisch wird das iPhone aber kurz gewiped. + +## Status + +Prototype, Mac-only. Einige Steps (cfgutil-Syntax, idevicebackup2-Restore-Flags) sind erst nach Hardware-in-Loop-Test final verifiziert — markiert mit `VERIFY ON DEVICE` im Code. + +## Voraussetzungen (Mac des Users) + +| Tool | Installation | +|-------------------------------|---------------------------------------------------------------| +| macOS | (jede aktuelle Version) | +| Apple Configurator 2 | `xcrun simctl install ...` nein — Mac App Store, kostenlos | +| `cfgutil` CLI | Wird mit Apple Configurator 2 mitgeliefert (in `.app/Contents/MacOS/`) | +| libimobiledevice | `brew install libimobiledevice` | +| OpenSSL | Auf macOS vorhanden (`/usr/bin/openssl`) | +| Supervision-Identity (.p12) | Einmalig: siehe `SUPERVISION-IDENTITY-SETUP.md` | + +## Voraussetzungen (iPhone des Users) + +- iPhone wird per USB-C mit dem Mac verbunden +- iPhone ist entsperrt +- "Diesem Computer vertrauen?" wurde auf dem iPhone bestätigt +- **Find My iPhone ist DEAKTIVIERT** (sonst blockt Activation Lock den Wipe-Step) + - `Settings → [Name] → iCloud → Wo ist? → Mein iPhone suchen → Aus` + - Verlangt Apple-ID-Passwort des Users +- iCloud-Backup-Sync ist NICHT mitten in der Sitzung aktiv (idle) + +## Schnellstart + +```bash +# Einmalig: Supervision-Identity generieren (siehe separates Doc) +cat SUPERVISION-IDENTITY-SETUP.md + +# Dry-Run zum Check ob alle Deps + iPhone gefunden werden +./rebreak-supervise.sh --dry-run + +# Echter Lauf +./rebreak-supervise.sh + +# Falls's mittendrin failt: ab dem letzten OK-Step weitermachen +./rebreak-supervise.sh --resume +``` + +## Was das Script macht — Schritt für Schritt + +1. **Preflight** — Deps prüfen, iPhone-UDID detecten, Supervision-Identity laden, Profil-Plist validieren +2. **Backup** — `idevicebackup2 backup --encryption on` mit zufälligem Passwort (in `~/.rebreak-supervise/backup-pass-.txt`) +3. **Supervise** — `cfgutil prepare --supervised --supervisor-host-certs ` wiped Gerät, setzt Supervised-Flag, rebootet +4. **Restore** — Nach Reconnect: `idevicebackup2 restore` mit selbem Passwort. Supervised-Flag bleibt persistent über Restore (Apple-Verhalten: Restore preserved supervision/enrollment-state, nicht den User-State der vorher unsupervised war) +5. **Install-Profile** — `cfgutil install-profile rebreak-iphone-protection.mobileconfig` + +## State + Logs + +| Artefakt | Zweck | +|-------------------------------------------------------|-----------------------------------------------| +| `~/.rebreak-supervise/state-.env` | Welche Steps erledigt (für `--resume`) | +| `~/.rebreak-supervise/backups//` | Encrypted Backup | +| `~/.rebreak-supervise/backup-pass-.txt` | Backup-Encryption-Passwort (chmod 600) | +| `~/.rebreak-supervise/supervision-identity.p12` | Persistent über alle Devices/Sessions | +| `~/.rebreak-supervise/log-.txt` | Komplettes Log dieser Session | + +Alles unter `~/.rebreak-supervise/` (chmod 700). Niemals committen. + +## Failure-Pfade + +| Wann | Was tun | +|---------------------------------------|-------------------------------------------------------------------------------------------| +| Preflight fail (Deps fehlen) | Deps installieren, neu starten | +| Backup fail (zB iPhone-Disconnect) | USB-Kabel checken, Trust-Dialog erneut. Backup neu starten (Script ist idempotent) | +| Supervise fail (Find-My noch aktiv) | iPhone ist NICHT gewiped, läuft normal weiter. Find-My deaktivieren, dann `--resume` | +| Supervise fail (Identity ungültig) | Neue Identity generieren (siehe Setup-Doc), dann `--resume` | +| Restore fail (Passwort verloren) | **NICHT recoverbar.** iPhone ist gewiped + Setup-Assistant aktiv. User muss neu einrichten oder iCloud-Backup nutzen | +| Profil-Install fail | Manuell via `cfgutil install-profile ` oder per AirDrop des `.mobileconfig` | + +**Kritisch:** Das Backup-Passwort darf nicht verloren gehen zwischen Step 2 und Step 4. Es liegt automatisch in `~/.rebreak-supervise/backup-pass-.txt` — falls der Mac dazwischen neu startet ist es noch da, aber für Disaster-Recovery sollte der User es notieren. + +## Post-Supervise: iOS-Setup-Stage + +Nach Step 3 (Wipe) zeigt das iPhone wieder den "Hallo"-Screen. Folgender Stand wird vom Script erwartet bevor Restore startet: + +- iPhone-Sprache wählen, Land/Region bestätigen +- Bis zum Screen "Apps & Daten übertragen" durchklicken +- **NICHT** "Aus iCloud-Backup wiederherstellen" wählen — wir restoren via libimobiledevice vom Mac +- "Nicht übertragen oder zurücksetzen" wählen ODER zurück bis zum Stage "Computer-Verbindung" + +Das ist die einzige Stelle wo der User aktiv interagieren muss. Das Script prompted bevor es weiter macht. + +## Cross-Plattform-Pfad (Phase 2) + +- **Windows**: braucht entweder iMazing-Lizenz (kommerzielle Whitelabel-Option, ähnlich TechLockdown) oder substantielle libimobiledevice-Erweiterung (Supervise ist heute nicht supported, GitHub-Issue offen). Aktuell out-of-scope. +- **GUI** (Tauri/Electron Wrapper): wenn der Bash-Flow stabil läuft. Vorerst CLI reicht für Validierung. + +## Bekannte Tücken + +- **iOS 18+ cfgutil-Syntax** ist nicht 100% verifiziert. Apple-Doku-Lücken — `cfgutil` ist intern bei Apple priorisiert. `VERIFY ON DEVICE`-Marker im Script-Code zeigen wo's exakt zu testen ist. +- **idevicebackup2 + iOS 18**: libimobiledevice-Maintainer holen auf, neuere iOS-Versionen können Edge-Cases haben (z.B. neue Backup-Formate). Falls Backup-Tool failed mit "unknown response": auf libimobiledevice HEAD updaten (`brew install --HEAD libimobiledevice`). +- **Supervised-State-Persistenz nach Restore**: Apple-Doku sagt ja, Community-Reports sagen meistens ja, Edge-Cases existieren. Im Akzeptanz-Test (M2) explizit zu verifizieren. diff --git a/ops/mdm/bootstrap-tool/SUPERVISION-IDENTITY-SETUP.md b/ops/mdm/bootstrap-tool/SUPERVISION-IDENTITY-SETUP.md new file mode 100644 index 0000000..7173323 --- /dev/null +++ b/ops/mdm/bootstrap-tool/SUPERVISION-IDENTITY-SETUP.md @@ -0,0 +1,81 @@ +# Supervision-Identity einmaliger Setup + +Eine Supervision-Identity ist ein Cert+Key-Paar (im `.p12`-Container), das beweist dass DEIN Mac der "Supervisor" eines iPhones ist. Apple verlangt das, damit ein iPhone nicht von beliebigen Macs supervised werden kann. + +**Einmal pro Mac generieren. Persistent für alle Devices und alle Sessions.** + +Ohne Identity scheitert `cfgutil prepare --supervised` mit einem Cert-Fehler. + +## Wo die Identity liegen muss + +Das Bootstrap-Tool erwartet die Identity standardmäßig hier: + +``` +~/.rebreak-supervise/supervision-identity.p12 +``` + +Override via Env-Variable `SUPERVISION_IDENTITY_P12`. + +## Pfad A — Via Apple Configurator 2 (GUI, empfohlen) + +1. **Apple Configurator 2** öffnen +2. Menü-Bar → `Apple Configurator 2 → Einstellungen → Server` (oder neuere Versionen: `Organizations`) +3. `+` → `New Supervision Identity...` +4. Name vergeben (z.B. `ReBreak Supervision Chahine 2026`) +5. Cert wird im **macOS-Schlüsselbund** gespeichert +6. Schlüsselbund öffnen (`Schlüsselbundverwaltung.app`): + - Suche nach dem Namen aus Schritt 4 + - Cert + Private-Key sollten beide sichtbar sein + - Rechtsklick auf Cert → **„2 Objekte exportieren..."** + - Format: **„Personal Information Exchange (.p12)"** + - Speichern als `~/.rebreak-supervise/supervision-identity.p12` + - Passwort vergeben — leer lassen für einfachen Bootstrap-Tool-Zugriff, ODER Passwort setzen und manuell in `~/.rebreak-supervise/supervision-identity.pass` ablegen (chmod 600) + +## Pfad B — Via cfgutil CLI + +VERIFY ON DEVICE: exakte Flags noch zu validieren. Apple's CLI-Doku ist hier dünn. + +```bash +mkdir -p ~/.rebreak-supervise +CFGUTIL=/Applications/Apple\ Configurator.app/Contents/MacOS/cfgutil + +# Identity in Schlüsselbund generieren (cfgutil-eigener Aufruf) +"$CFGUTIL" generate-supervision-identity \ + --name "ReBreak Supervision $(whoami) $(date +%Y)" + +# Dann via Pfad-A Schritt 6 exportieren (das CLI-Tool kann nicht direkt .p12 schreiben) +``` + +## Verifikation + +```bash +ls -l ~/.rebreak-supervise/supervision-identity.p12 +# Sollte: -rw------- 1 ... + +# Cert-Inhalt prüfen (Passwort eingeben falls gesetzt) +openssl pkcs12 -info -in ~/.rebreak-supervise/supervision-identity.p12 -nokeys +``` + +Erwartete Ausgabe enthält etwa: +``` +subject=/CN=Apple Configurator/.../CN=ReBreak Supervision ... +``` + +## Sicherheit + +- Die `.p12` ist **gleichwertig zur Macht** ein iPhone zu supervisen — schützen wie einen SSH-Key +- Niemals in Git committen +- Bei Mac-Wechsel: Identity exportieren + auf neuen Mac übertragen (oder neu generieren — alte Devices akzeptieren dann nur die alte) +- Bei Identity-Verlust: alte enrollte Devices bleiben weiter supervised, aber dieser Mac kann sie nicht mehr managen. Neuen Mac → neue Identity → User-Devices brauchen Re-Setup-Sandwich + +## Bei Fehler "supervision identity not trusted" + +Apple verlangt manchmal dass die Identity im **System-Schlüsselbund** sitzt (nicht User). Workaround: + +```bash +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + ~/Path/to/exported-cert-only.cer +``` + +Oder via Apple Configurator GUI re-generieren — die GUI handhabt das korrekt. diff --git a/ops/mdm/bootstrap-tool/rebreak-supervise.sh b/ops/mdm/bootstrap-tool/rebreak-supervise.sh new file mode 100755 index 0000000..439dac8 --- /dev/null +++ b/ops/mdm/bootstrap-tool/rebreak-supervise.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# +# rebreak-supervise.sh +# -------------------- +# Backup-Sandwich-Bootstrap für iPhone-Supervision ohne sichtbaren Daten-Verlust. +# +# 1. idevicebackup2 encrypted Backup -> ~/.rebreak-supervise/backups// +# 2. cfgutil prepare --supervised -> wiped iPhone, Supervised-Flag setzen +# 3. idevicebackup2 restore -> Daten zurück, Supervised-Flag persistiert +# 4. cfgutil install-profile -> ReBreak-Schutz-Profil installieren +# +# Voraussetzungen (siehe README.md): +# - macOS +# - Apple Configurator 2 (App Store) + cfgutil im PATH +# - libimobiledevice (brew install libimobiledevice) +# - Supervision-Identity einmalig generiert (siehe SUPERVISION-IDENTITY-SETUP.md) +# - iPhone via USB-C verbunden, Find-My DEAKTIVIERT, Code entsperrt +# - Vertrauenshandshake (Trust-Dialog auf iPhone) bestätigt +# +# CLI: +# rebreak-supervise.sh [--dry-run] [--state-dir DIR] [--profile PATH] [--resume] +# +# Status: PROTOTYPE. Einige Steps (cfgutil-Aufruf, Verify-Pfad) sind erst auf +# physischem Test-iPhone final verifiziert. Markierungen "VERIFY ON DEVICE" im +# Code zeigen wo Hardware-in-Loop noch nachgehärtet werden muss. + +set -euo pipefail + +# ------------------------------------------------------------------------------ +# Konfiguration + Defaults +# ------------------------------------------------------------------------------ + +STATE_DIR="${REBREAK_STATE_DIR:-$HOME/.rebreak-supervise}" +PROFILE_PATH_DEFAULT="$(cd "$(dirname "$0")/.." && pwd)/profiles/rebreak-iphone-protection.mobileconfig" +PROFILE_PATH="" +DRY_RUN=0 +RESUME=0 + +CFGUTIL="/Applications/Apple Configurator.app/Contents/MacOS/cfgutil" +SUPERVISION_IDENTITY_P12="${SUPERVISION_IDENTITY_P12:-$STATE_DIR/supervision-identity.p12}" +SUPERVISION_IDENTITY_PASS_FILE="${SUPERVISION_IDENTITY_PASS_FILE:-$STATE_DIR/supervision-identity.pass}" + +# Farben (nur wenn TTY) +if [[ -t 1 ]]; then + C_RESET="\033[0m"; C_RED="\033[1;31m"; C_GREEN="\033[1;32m" + C_YELLOW="\033[1;33m"; C_BLUE="\033[1;34m"; C_DIM="\033[2m" +else + C_RESET=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_DIM="" +fi + +# ------------------------------------------------------------------------------ +# Helpers: Logging +# ------------------------------------------------------------------------------ + +LOG_FILE="" + +log() { printf "%b\n" "$1" | tee -a "${LOG_FILE:-/dev/null}"; } +ok() { log "${C_GREEN}✓${C_RESET} $1"; } +warn() { log "${C_YELLOW}⚠${C_RESET} $1"; } +err() { log "${C_RED}✗${C_RESET} $1" >&2; } +info() { log "${C_BLUE}→${C_RESET} $1"; } +dim() { log "${C_DIM} $1${C_RESET}"; } + +die() { err "$1"; exit "${2:-1}"; } + +confirm() { + local prompt="$1" + [[ "$DRY_RUN" == 1 ]] && { dim "[dry-run] auto-yes: $prompt"; return 0; } + read -r -p "$(printf "%b" "${C_YELLOW}?${C_RESET} $prompt [y/N] ")" reply + [[ "$reply" =~ ^[yY]$ ]] +} + +run() { + # Wraps a command: in dry-run, just echo. Otherwise execute. + if [[ "$DRY_RUN" == 1 ]]; then + dim "[dry-run] $*" + return 0 + fi + "$@" +} + +# ------------------------------------------------------------------------------ +# State-Management: simples JSON-ähnliches Format pro UDID +# +# Datei: $STATE_DIR/state-.env (key=value, source-bar) +# Keys: STEP_PREFLIGHT_AT, STEP_BACKUP_AT, BACKUP_PATH, +# STEP_SUPERVISE_AT, STEP_RESTORE_AT, STEP_PROFILE_AT +# ------------------------------------------------------------------------------ + +state_file_for() { printf "%s/state-%s.env" "$STATE_DIR" "$1"; } + +state_load() { + local f; f="$(state_file_for "$1")" + if [[ -f "$f" ]]; then + # shellcheck disable=SC1090 + source "$f" + fi +} + +state_set() { + # state_set UDID KEY VALUE + local f; f="$(state_file_for "$1")" + local key="$2"; local val="$3" + # In-Memory-Update + eval "$key=\"\$val\"" + # Persist (re-write each time, ist klein) + local tmpf="${f}.tmp" + { + for k in STEP_PREFLIGHT_AT STEP_BACKUP_AT BACKUP_PATH STEP_SUPERVISE_AT STEP_RESTORE_AT STEP_PROFILE_AT BACKUP_PASSWORD_FILE; do + local v="${!k:-}" + [[ -n "$v" ]] && printf "%s=%q\n" "$k" "$v" + done + } > "$tmpf" + mv "$tmpf" "$f" + chmod 600 "$f" +} + +# ------------------------------------------------------------------------------ +# Step 0: Argumente parsen +# ------------------------------------------------------------------------------ + +usage() { + cat </dev/null 2>&1 \ + || die "Fehlt: $bin → brew install libimobiledevice" +done +ok "libimobiledevice tools im PATH" + +[[ -x "$CFGUTIL" ]] || die "cfgutil nicht gefunden: $CFGUTIL → Apple Configurator 2 aus App Store installieren" +ok "cfgutil gefunden" + +# Supervision-Identity +if [[ ! -f "$SUPERVISION_IDENTITY_P12" ]]; then + die "Supervision-Identity fehlt: $SUPERVISION_IDENTITY_P12 + → siehe SUPERVISION-IDENTITY-SETUP.md (einmaliger Setup-Step)" +fi +ok "Supervision-Identity vorhanden" + +# Profil +[[ -f "$PROFILE_PATH" ]] || die "Profil nicht gefunden: $PROFILE_PATH" +if ! plutil -lint "$PROFILE_PATH" >/dev/null 2>&1; then + die "Profil ist kein gültiges Plist: $PROFILE_PATH" +fi +ok "Profil ist gültig" + +# Connected device(s) +UDIDS="$(idevice_id -l 2>/dev/null || true)" +if [[ -z "$UDIDS" ]]; then + die "Kein iPhone via USB erkannt. Stelle sicher: + - USB-C-Kabel ist eingesteckt + - iPhone ist entsperrt + - 'Diesem Computer vertrauen?' wurde auf dem iPhone bestätigt" +fi + +# Bei mehreren: erste, ggf. später interaktiv erweitern +UDID="$(echo "$UDIDS" | head -n1)" +log "" +info "Gerät: $UDID" + +DEVICE_NAME="$(ideviceinfo -u "$UDID" -k DeviceName 2>/dev/null || echo "?")" +IOS_VERSION="$(ideviceinfo -u "$UDID" -k ProductVersion 2>/dev/null || echo "?")" +ACTIVATION="$(ideviceinfo -u "$UDID" -k ActivationState 2>/dev/null || echo "?")" +log "Name: $DEVICE_NAME" +log "iOS: $IOS_VERSION" +log "Activation: $ACTIVATION" + +# Activation-Check +if [[ "$ACTIVATION" != "Activated" ]]; then + warn "ActivationState ist '$ACTIVATION' — Backup könnte scheitern" +fi + +# State laden falls --resume +state_load "$UDID" +if [[ "$RESUME" == 1 ]]; then + [[ -n "${STEP_BACKUP_AT:-}" ]] && ok "[resume] Backup bereits erledigt: $STEP_BACKUP_AT" + [[ -n "${STEP_SUPERVISE_AT:-}" ]] && ok "[resume] Supervise bereits erledigt: $STEP_SUPERVISE_AT" + [[ -n "${STEP_RESTORE_AT:-}" ]] && ok "[resume] Restore bereits erledigt: $STEP_RESTORE_AT" + [[ -n "${STEP_PROFILE_AT:-}" ]] && ok "[resume] Profil bereits installiert: $STEP_PROFILE_AT" +fi + +state_set "$UDID" STEP_PREFLIGHT_AT "$(date -Iseconds)" +log "" + +# ------------------------------------------------------------------------------ +# Step 2: BACKUP (encrypted) +# ------------------------------------------------------------------------------ + +if [[ "$RESUME" == 1 && -n "${STEP_BACKUP_AT:-}" ]]; then + info "[2/5] Backup übersprungen (resume)" +else + info "[2/5] Backup (idevicebackup2, encrypted)" + + BACKUP_ROOT="$STATE_DIR/backups/$UDID" + mkdir -p "$BACKUP_ROOT" + + # Encryption-Passwort: generieren wenn nicht vorhanden, persistieren + BACKUP_PASSWORD_FILE="${BACKUP_PASSWORD_FILE:-$STATE_DIR/backup-pass-$UDID.txt}" + if [[ ! -f "$BACKUP_PASSWORD_FILE" ]]; then + if [[ "$DRY_RUN" != 1 ]]; then + openssl rand -base64 24 > "$BACKUP_PASSWORD_FILE" + chmod 600 "$BACKUP_PASSWORD_FILE" + ok "Backup-Passwort generiert: $BACKUP_PASSWORD_FILE" + warn "WICHTIG: dieses Passwort wird zum Restore gebraucht. Sicher aufheben." + else + dim "[dry-run] würde openssl rand -base64 24 > $BACKUP_PASSWORD_FILE" + fi + fi + BACKUP_PASSWORD="$([[ -f "$BACKUP_PASSWORD_FILE" ]] && cat "$BACKUP_PASSWORD_FILE" || echo "DRYRUN")" + + warn "Backup kann je nach iPhone-Größe 15-60 Minuten dauern." + warn "Diese Session NICHT abbrechen, USB-Kabel NICHT abziehen." + confirm "Backup jetzt starten?" || die "Abgebrochen vom User" 0 + + # Encryption aktivieren falls noch nicht + # idevicebackup2 will die Backup-Encryption-Konfiguration auf dem Gerät selbst setzen + # VERIFY ON DEVICE: ob 'encryption on' idempotent ist oder vorher 'encryption off' nötig + run idevicebackup2 -u "$UDID" -i backup encryption on "$BACKUP_PASSWORD" "$BACKUP_ROOT" \ + || warn "Encryption-Setup hat returned non-zero — möglicherweise bereits aktiv, fahre fort" + + # Backup + run idevicebackup2 -u "$UDID" -i backup "$BACKUP_ROOT" + + state_set "$UDID" BACKUP_PATH "$BACKUP_ROOT" + state_set "$UDID" BACKUP_PASSWORD_FILE "$BACKUP_PASSWORD_FILE" + state_set "$UDID" STEP_BACKUP_AT "$(date -Iseconds)" + ok "Backup fertig: $BACKUP_ROOT" +fi +log "" + +# ------------------------------------------------------------------------------ +# Step 3: SUPERVISE — WIPED das Gerät, setzt Supervised-Flag +# ------------------------------------------------------------------------------ + +if [[ "$RESUME" == 1 && -n "${STEP_SUPERVISE_AT:-}" ]]; then + info "[3/5] Supervise übersprungen (resume)" +else + info "[3/5] Supervise (cfgutil prepare)" + + warn "DIESER SCHRITT WIPED DAS GERÄT." + warn "Backup MUSS in Step 2 erfolgreich gewesen sein." + warn "Find-My ist deaktiviert? Apple-ID-Passwort eingegeben? Falls nein: JETZT abbrechen." + confirm "Wirklich fortfahren mit Wipe+Supervise?" || die "Abgebrochen vom User" 0 + + # cfgutil-Aufruf — VERIFY ON DEVICE: exakte Syntax + ECID vs UDID + # Apple-Doku-Stand: cfgutil unterstützt --ecid; UDID-Filter via --ecid in 0x-Hex + # Für unsupervised+activated devices ist der einfachste Weg: alle connected devices + # (es sollte nur eins angeschlossen sein zu diesem Zeitpunkt) + run "$CFGUTIL" \ + --foreach \ + prepare \ + --supervised \ + --organization-name "ReBreak" \ + --supervisor-host-certs "$SUPERVISION_IDENTITY_P12" + + state_set "$UDID" STEP_SUPERVISE_AT "$(date -Iseconds)" + ok "Supervise-Aufruf abgesetzt. Gerät reboots gerade — warte auf Wieder-Verbindung." +fi +log "" + +# ------------------------------------------------------------------------------ +# Step 4: WAIT FOR RECONNECT + RESTORE +# ------------------------------------------------------------------------------ + +if [[ "$RESUME" == 1 && -n "${STEP_RESTORE_AT:-}" ]]; then + info "[4/5] Restore übersprungen (resume)" +else + info "[4/5] Restore (warte auf Re-Verbindung, dann idevicebackup2 restore)" + + if [[ "$DRY_RUN" != 1 ]]; then + log "Warte auf iPhone-Reconnect (max 5 min)..." + for i in $(seq 1 60); do + if idevice_id -l 2>/dev/null | grep -q "$UDID"; then + ok "iPhone ist wieder verbunden" + break + fi + sleep 5 + [[ $i -eq 60 ]] && die "Timeout: iPhone nicht innerhalb 5 min wieder verbunden" + done + + # iOS-Setup-Assistant muss der User auf dem iPhone bis zum Punkt "Vom Backup wiederherstellen?" + # bringen — oder wir restoren direkt via libimobiledevice (was wir hier tun) + # VERIFY ON DEVICE: ob restore direkt nach Wipe geht (vor Setup-Assistant) oder + # erst nach Setup-Assistant + initial-activation + warn "iPhone zeigt jetzt 'Hallo'/Setup-Assistant." + warn "Folge der Anleitung in README.md → 'Post-Supervise iPhone-Setup'." + warn "Sobald iPhone die 'Mit Computer verbinden'-Stage erreicht: hier weiter." + confirm "iPhone ist im Recovery-Setup-Stadium und bereit für Restore?" \ + || die "Abgebrochen vom User" 0 + fi + + BACKUP_PASSWORD="$(cat "$BACKUP_PASSWORD_FILE")" + + # VERIFY ON DEVICE: '-i restore' nimmt $BACKUP_ROOT als positional arg + # Encryption-Passwort wird via stdin oder Env erwartet — idevicebackup2 v1.3+ unterstützt --password + run idevicebackup2 -u "$UDID" -i restore \ + --system --reboot \ + --password "$BACKUP_PASSWORD" \ + "$BACKUP_PATH" + + state_set "$UDID" STEP_RESTORE_AT "$(date -Iseconds)" + ok "Restore abgesetzt. iPhone reboots." +fi +log "" + +# ------------------------------------------------------------------------------ +# Step 5: INSTALL PROFIL + VERIFY +# ------------------------------------------------------------------------------ + +info "[5/5] Profil installieren + Verify" + +if [[ "$DRY_RUN" != 1 ]]; then + log "Warte auf iPhone-Reconnect post-restore (max 10 min)..." + for i in $(seq 1 120); do + if idevice_id -l 2>/dev/null | grep -q "$UDID"; then + ok "iPhone ist wieder verbunden" + break + fi + sleep 5 + [[ $i -eq 120 ]] && die "Timeout: iPhone nicht innerhalb 10 min wieder verbunden" + done + + warn "User muss iOS jetzt entsperren + Setup-Assistant abschließen falls noch nicht." + confirm "iPhone ist entsperrt und im Home-Screen?" || die "Abgebrochen vom User" 0 +fi + +# Profil installieren via cfgutil +# VERIFY ON DEVICE: 'cfgutil install-profile ' Syntax +run "$CFGUTIL" --foreach install-profile "$PROFILE_PATH" + +state_set "$UDID" STEP_PROFILE_AT "$(date -Iseconds)" + +# Verify supervised +if [[ "$DRY_RUN" != 1 ]]; then + IS_SUPERVISED="$("$CFGUTIL" --foreach get isSupervised 2>/dev/null || echo "?")" + log "Supervised-State (cfgutil get isSupervised): $IS_SUPERVISED" +fi + +log "" +log "${C_GREEN}═══════════════════════════════════════════════════${C_RESET}" +log "${C_GREEN} Bootstrap fertig${C_RESET}" +log "${C_GREEN}═══════════════════════════════════════════════════${C_RESET}" +log "" +log "Manueller Verify auf dem iPhone:" +log " 1. Settings → Allgemein → Info → 'Dieses iPhone wird beaufsichtigt' sichtbar?" +log " 2. Long-Press auf Rebreak-Icon → kein 'App löschen' mehr?" +log " 3. Settings → VPN → Rebreak → Toggle disabled?" +log " 4. Settings → Allgemein → VPN, DNS und Gerätemanagement → Profil 'Nicht entfernbar'?" +log "" +log "Backend-Enrollment (separater Step):" +log " → in der Rebreak-App: Profil-Tab → 'Schutz aktivieren' → QR scannen" +log "" +log "Logs: $LOG_FILE" +log "State: $(state_file_for "$UDID")" +log "Backup: $BACKUP_PATH" +log "Backup-Pass: $BACKUP_PASSWORD_FILE ${C_YELLOW}(sicher aufheben!)${C_RESET}" diff --git a/ops/mdm/profiles/rebreak-iphone-protection.DEV-removable.mobileconfig b/ops/mdm/profiles/rebreak-iphone-protection.DEV-removable.mobileconfig new file mode 100644 index 0000000..d981eb5 --- /dev/null +++ b/ops/mdm/profiles/rebreak-iphone-protection.DEV-removable.mobileconfig @@ -0,0 +1,89 @@ + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.DEV.20260524 + PayloadUUID + D1B2C3D4-E5F6-4789-ABCD-EF1234567899 + PayloadDisplayName + ReBreak Schutz (DEV-Test) + PayloadDescription + TEST-Profil — via Settings entfernbar. NICHT für Produktion. + PayloadOrganization + ReBreak DEV + PayloadRemovalDisallowed + + + PayloadContent + + + + PayloadType + com.apple.applicationaccess + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.DEV.restrictions + PayloadUUID + D2234567-ABCD-4F01-9345-67890ABCDED1 + PayloadDisplayName + ReBreak Restrictions (DEV) + + + allowAppRemoval + + allowUIConfigurationProfileInstallation + + + + + + + + + + PayloadType + com.apple.dnsSettings.managed + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.DEV.dns + PayloadUUID + D3234567-ABCD-4F01-9345-67890ABCDED2 + PayloadDisplayName + ReBreak DNS-Filter (DEV) + + DNSSettings + + DNSProtocol + HTTPS + ServerURL + https://dns.rebreak.org/dns-query + + + + + + diff --git a/ops/mdm/profiles/rebreak-iphone-protection.mobileconfig b/ops/mdm/profiles/rebreak-iphone-protection.mobileconfig new file mode 100644 index 0000000..28b9433 --- /dev/null +++ b/ops/mdm/profiles/rebreak-iphone-protection.mobileconfig @@ -0,0 +1,232 @@ + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.20260525 + PayloadUUID + A1B2C3D8-E5F6-4789-ABCD-EF1234567894 + PayloadDisplayName + ReBreak Schutz + PayloadDescription + Schützt dich vor Glücksspiel-Rückfall: Rebreak nicht entfernbar, DNS-Filter auf dns.rebreak.org gelocked, VPN-Toggle deaktiviert. Profil kann nicht selbst entfernt werden — Notfall-Entfernung via deinem Trustee. + PayloadOrganization + ReBreak + PayloadRemovalDisallowed + + ConsentText + + default + Du installierst hiermit das ReBreak-Schutz-Profil. Dieses Profil bindet dich freiwillig an folgende Einschränkungen: + +• Apps können während des Schutzes nicht über Long-Press gelöscht werden +• Das ReBreak-VPN kann nicht in den iPhone-Einstellungen deaktiviert werden +• System-DNS-Anfragen laufen über dns.rebreak.org (verschlüsselt via DoH) +• Du kannst dieses Profil nicht selbst entfernen + +Das ist gewollt. Der Schutz wirkt, weil er gegen deine impulsive Selbst-Override-Tendenz steht. Die Entfernung läuft über deinen Trustee oder deinen 7-Tage-Cooldown in der App. + +Bei Verlust deines iPhones: das Profil verschwindet mit Factory-Reset, das ist normal. + + + PayloadContent + + + + + PayloadType + com.apple.applicationaccess + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.restrictions + PayloadUUID + B3234567-ABCD-4F01-9345-67890ABCDEF6 + PayloadDisplayName + ReBreak Restrictions + PayloadDescription + Verhindert App-Löschung und VPN-Eingriffe. + + + allowAppRemoval + + + + allowEraseContentAndSettings + + + + allowUIConfigurationProfileInstallation + + + + + + + + PayloadType + com.apple.dnsSettings.managed + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.dns + PayloadUUID + C1234567-ABCD-4F01-9345-67890ABCDEF2 + PayloadDisplayName + ReBreak DNS-Filter + PayloadDescription + Leitet alle DNS-Anfragen verschlüsselt über dns.rebreak.org. Glücksspiel-Domains werden blockiert. + + DNSSettings + + DNSProtocol + HTTPS + ServerURL + https://dns.rebreak.org/dns-query + + + + + + PayloadType + com.apple.vpn.managed + PayloadVersion + 1 + PayloadIdentifier + org.rebreak.protection.iphone.vpn + PayloadUUID + D2234567-ABCD-4F01-9345-67890ABCDEF4 + PayloadDisplayName + ReBreak Schutz-VPN + PayloadDescription + Aktiviert den ReBreak-DNS-Filter als nicht-abschaltbaren VPN. Verhindert dass du dich impulsiv selbst aussperrst-vom-Schutz. + + UserDefinedName + ReBreak Schutz + VPNType + VPN + + VPNSubType + org.rebreak.app.PacketTunnelExtension + + VPN + + + RemoteAddress + ReBreak DNS-Filter (lokal) + AuthenticationMethod + Password + ProviderBundleIdentifier + org.rebreak.app.PacketTunnelExtension + ProviderType + packet-tunnel + DisconnectOnIdle + 0 + + OnDemandUserOverrideDisabled + + + + + VendorConfig + + + + OnDemandEnabled + 1 + OnDemandRules + + + Action + Connect + + + + + + +