feat(mdm,vip): MDM-VPN-Pivot + Layer-2-Country-Curated + Custom-Domain-Refactor

MDM-VPN-Pivot (Phase F.2 done):
- ops/mdm/profiles/rebreak-iphone-protection.mobileconfig auf v5 mit
  com.apple.vpn.managed Payload + OnDemandUserOverrideDisabled. iPhone-User
  kann ReBreak-VPN-Profile nicht entfernen und "Bedarf verbinden"-Toggle
  ist disabled. allowEnablingRestrictions empirisch widerlegt für FC-Toggle-
  Lock — out.
- DEV-removable Variante als Test-Profile dazu.
- Bootstrap-Tool (rebreak-supervise.sh) + Supervision-Identity-Setup-Doc.
- PHASES.md updated mit empirischen Befunden.

App-side MDM-Detect (Pfad-a Banner-Logic):
- modules/rebreak-protection: getDeviceState() returnt mdmManaged via
  Heuristik NETunnelProviderManager.count > 1 (App selbst kann nur einen
  eigenen erstellen, MDM-Push fügt einen zweiten hinzu).
- DeviceLayers.mdmManaged?: boolean Type.
- blocker.tsx: lockedIn-Bedingung erweitert um mdmManaged. Bei MDM-managed
  iPhones wird der App-Lock-Card (FC-Authorization-Toggle UI) ausgeblendet
  weil der per-App FC-Toggle nicht lockbar ist und durch den MDM-VPN-Layer
  redundant.

Layer-2-Country-Curated-Pivot:
- backend: vip-swap.post.ts raus, suggest.post.ts rein. Curated-domains
  durch admin (separate Tabelle/Pfad), getrennt von User-Custom-Domains.
- Admin-APIs für curated-domain Pflege (index.get + [id].patch).
- seed-country-blocklists Script für initiale Curated-Domain-Liste.
- protection/webcontent-domains.get refactored für Country-Curated-Pfad.
- Migration drop_vip_swap_fields.sql + schema.prisma adjusted.
- docs/concepts/layer2-country-pivot.md mit Architektur + Decision-Trail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-25 07:11:47 +02:00
parent b6b1f68940
commit 8f2ef2cc98
25 changed files with 1861 additions and 262 deletions

View File

@ -78,15 +78,24 @@ export default function BlockerScreen() {
const urlFilterActive = state?.layers.urlFilter === true; const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true; const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === 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 // "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval — // (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE // ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird. // müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
// "lockedIn" normal = URL-Filter UND App-Lock aktiv. Wenn Family Controls // Ausnahmen:
// build-seitig nicht verfügbar ist (Distribution-Entitlement pending), kann // - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) →
// es keinen App-Lock geben → dann reicht der URL-Filter allein für "geschützt". // 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 = const lockedIn =
urlFilterActive && (appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); urlFilterActive && (mdmManaged || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
const urlFilterActiveRef = useRef(urlFilterActive); const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
@ -294,11 +303,14 @@ export default function BlockerScreen() {
onActivate={handleActivateFamilyControls} onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')} warning={t('blocker.layers_app_lock_warning')}
/> />
) : FAMILY_CONTROLS_AVAILABLE ? ( ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged ? (
/* iOS App-Lock nur zeigen wenn das Family-Controls-Entitlement /* iOS App-Lock nur zeigen wenn (a) das Family-Controls-
im Build aktiv ist. Distribution-Builds ohne Apple-Approval Entitlement im Build aktiv ist (Distribution-Builds ohne
Card ausblenden statt ein sandbox-blockiertes Feature Apple-Approval ausblenden statt sandbox-blockiertes
anzubieten (NSCocoaErrorDomain:4099). */ 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). */
<LayerSwitchCard <LayerSwitchCard
icon="lock-closed-outline" icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')} title={t('blocker.layers_app_lock_title')}

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -269,15 +269,37 @@ export const protection = {
return RebreakProtection.syncWebContentDomains(opts); return RebreakProtection.syncWebContentDomains(opts);
}, },
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`) /** Self-Heal Layer-1-Filter. Bei App-Start/Foreground/Poll aufrufen.
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen, *
* damit der State nicht an aber tot" bleibt. No-op auf iOS/Web. */ * Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
* aber tot ist (Reinstall / OS-Kill).
* iOS: prüft ob unser NETunnelProviderManager noch da ist; falls User
* VPN löschen" in Settings getippt hat silent recreate
* (loadOrCreateTunnelManager + saveToPreferences + startVPNTunnel).
* Wenn iOS Permission-Dialog zeigt: akzeptierte Friktion.
* Web: no-op.
*/
async reconcileVpn(): Promise<void> { async reconcileVpn(): Promise<void> {
if (Platform.OS !== "android") return; if (Platform.OS === "android") {
try { try {
await RebreakProtection.reconcileVpn(); await RebreakProtection.reconcileVpn();
} catch (e) { } catch (e) {
console.warn("[protection] reconcileVpn failed:", 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;
} }
}, },

View File

@ -356,6 +356,61 @@ public class RebreakProtectionModule: Module {
return result 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 // activateFamilyControls: NUR FC + denyAppRemoval
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in 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 // (NEURLFilter ist nicht mehr der Default-Filter; sein Status fließt
// bewusst NICHT mehr in den `urlFilter`-Slot ein.) // bewusst NICHT mehr in den `urlFilter`-Slot ein.)
var urlFilter = false var urlFilter = false
var mdmManaged = false
do { do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences() 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) urlFilter = (manager.connection.status == .connected)
} }
} catch { } catch {
// ignore kein Tunnel konfiguriert urlFilter bleibt false. // ignore kein Tunnel konfiguriert urlFilter + mdmManaged bleiben false.
} }
// FamilyControls // FamilyControls
@ -668,6 +735,7 @@ public class RebreakProtectionModule: Module {
"familyControls": familyControls, "familyControls": familyControls,
"appDeletionLock": appDeletionLock, "appDeletionLock": appDeletionLock,
"webContentFilter": webContentFilter, "webContentFilter": webContentFilter,
"mdmManaged": mdmManaged,
"blocklistCount": count, "blocklistCount": count,
"blocklistLastSyncAt": lastSync ?? NSNull(), "blocklistLastSyncAt": lastSync ?? NSNull(),
] ]

View File

@ -14,6 +14,17 @@ export type DeviceLayers = {
* FilterPolicy .none gesetzt hat (kuratierte Gambling-Domain-Liste aktiv). * FilterPolicy .none gesetzt hat (kuratierte Gambling-Domain-Liste aktiv).
*/ */
webContentFilter?: boolean; 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 // Android
vpn?: boolean; vpn?: boolean;
accessibility?: boolean; accessibility?: boolean;

View File

@ -55,6 +55,22 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
*/ */
resetUrlFilter(): Promise<{ enabled: boolean; error?: string }>; resetUrlFilter(): Promise<{ enabled: boolean; error?: string }>;
/**
* 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). * iOS: aktiviert NUR Family Controls (Auth + denyAppRemoval = der Lock).
* Triggert iOS-Dialog "Bildschirmzeit verwalten". * Triggert iOS-Dialog "Bildschirmzeit verwalten".

View File

@ -80,6 +80,9 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
async reconcileVpn() { async reconcileVpn() {
return { restarted: false }; return { restarted: false };
} }
async reconcileUrlFilter() {
return { recreated: false };
}
} }
export default registerWebModule(RebreakProtectionModuleWeb, 'RebreakProtection'); export default registerWebModule(RebreakProtectionModuleWeb, 'RebreakProtection');

View File

@ -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;

View File

@ -438,13 +438,9 @@ model UserCustomDomain {
postId String? @map("post_id") @db.Uuid postId String? @map("post_id") @db.Uuid
addedAt DateTime @default(now()) @map("added_at") addedAt DateTime @default(now()) @map("added_at")
// VIP-Slot-Replace (Layer-2-Swap mit 24h-Cooldown): // Layer-2-Country-Pivot (2026-05-25): vipDeferUntil + vipEvictAt entfernt.
// vipDeferUntil — die NEUE Domain ist erst ab hier Teil der VIP-Liste // Layer 2 ist nicht mehr User-Custom-gespeist — Pure Country-Curated.
// (während des Cooldowns nur via Layer 1 geschützt). // DB-Columns werden via drop_vip_swap_fields.sql gedroppt (nach Code-Deploy).
// 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")
submission DomainSubmission? submission DomainSubmission?

View File

@ -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());

View File

@ -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" });
}
});

View File

@ -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 };
});

View File

@ -2,35 +2,16 @@ import { awardPoints } from "../../utils/scoring";
import { import {
addUserCustomDomain, addUserCustomDomain,
countActiveCustomDomains, countActiveCustomDomains,
getWebCustomDomains,
CUSTOM_DOMAIN_TYPES, CUSTOM_DOMAIN_TYPES,
type CustomDomainType, type CustomDomainType,
} from "../../db/domains"; } from "../../db/domains";
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features"; import { getPlanLimits } from "../../utils/plan-features";
import { usePrisma } from "../../utils/prisma"; 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") // 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])?)+$/; 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<string, string[]>;
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. * Leitet Frontend-`kind` auf internen `CustomDomainType` ab.
* *
@ -201,37 +182,16 @@ export default defineEventHandler(async (event) => {
return { alreadyGlobal: true, domain: value }; return { alreadyGlobal: true, domain: value };
} }
// ─── Web: 3-Fall-Check gegen Layer 1 (global) + Layer 2 (kuratierte VIP) ── // ─── Web: bereits in globaler Layer-1-Blocklist → kein Slot verbrennen ──
// // Layer 2 (webContent) wird ab 2026-05-25 ausschliesslich Country-Curated
// Layer 1 (VPN/URL-Filter) = globale Blocklist. Layer 2 (webContent/VIP) = // gespeist — User-Custom-Domains landen NUR noch in Layer 1. Ein Custom-Slot
// kuratierte gambling-domains.json + eigene Custom-Domains; greift als // für eine bereits global geblocknte Domain ist daher sinnlos.
// Zweitschutz, falls Layer 1 aus ist. if (type === "web" && inGlobal) {
// 1. weder global noch kuratiert → normaler Custom-Eintrag ('active') return { alreadyProtected: true, domain: value };
// 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
} }
// Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend 20).
// 20). Entfällt für webAddAsApproved (approved belegt keinen Slot). {
if (!webAddAsApproved) {
const profile = await getProfile(user.id); const profile = await getProfile(user.id);
const limit = getPlanLimits(profile?.plan ?? "pro").customDomains; const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
@ -257,7 +217,7 @@ export default defineEventHandler(async (event) => {
value, value,
"manual", "manual",
type, type,
webAddAsApproved ? "approved" : "active", "active",
); );
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch( 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; return data;
} catch (err: any) { } catch (err: any) {
const msg = const msg =

View File

@ -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 };
});

View File

@ -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() };
});

View File

@ -1,5 +1,4 @@
import gamblingDomains from "../../data/gambling-domains.json"; import gamblingDomains from "../../data/gambling-domains.json";
import { getWebCustomDomains } from "../../db/domains";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
const COUNTRY_KEYS = ["DE", "GB", "FR", "TN"] as const; const COUNTRY_KEYS = ["DE", "GB", "FR", "TN"] as const;
@ -9,87 +8,53 @@ const GLOBAL_LISTS = gamblingDomains as unknown as Record<string, string[]>;
const MAX_PER_COUNTRY = 50; 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 * GET /api/protection/webcontent-domains
* *
* Liefert die VIP-Domain-Liste für den WebKit-webContent-Filter (Layer 2). * Liefert die Country-Curated-Domain-Liste für den WebKit-webContent-Filter
* Pro User personalisiert, Hybrid-Komposition pro Land: * (Layer 2). Nach Layer-2-Country-Pivot (2026-05-25) ist Layer 2 vollständig
* 1. Custom-Web-Domains (pending zuerst, dann approved) gekappt auf 30 * entkoppelt von User-Custom-Domains:
* 2. kuratierte Gambling-Liste füllt den Rest bis 50 auf
* dedupliziert hart auf 50 gekappt (Apple-Limit).
* *
* Damit sind immer 20 kuratierte Top-Domains im Zweitschutz garantiert, * Layer 1 (VPN/blocklist.bin) = User-Custom-Domains + globale Blocklist
* egal wie viele Custom-Domains der User angesammelt hat. * Layer 2 (iOS NEFilter) = ausschliesslich Country-Curated (Admin-managed)
* Response-Shape ist identisch mit der statischen Version iOS parst das unverändert.
* *
* Lade-Mechanismus: direkter JSON-Import (build-time gebundelt via Nitro-Bundler). * Zusammensetzung pro Land:
* Kein serverAssets/useStorage kein extra Laufzeit-I/O, kein globales * 1. Statische gambling-domains.json (build-time gebundelt)
* backend/data/-Verzeichnis nötig. * 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, * Optional: Query-Param ?travel=FR für Travel-Detection (Server-side Merge).
* _meta.version hochzählen, _meta.updatedAt setzen, dann neu deployen. * 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) => { 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 db = usePrisma();
const approvedCurated = await db.curatedDomain.findMany({ const approvedCurated = await db.curatedDomain.findMany({
where: { status: "approved" }, where: { status: "approved" },
select: { domain: true, country: true }, select: { domain: true, country: true },
}); });
const curatedByCountry: Record<string, string[]> = {}; const curatedByCountry: Record<string, string[]> = {};
for (const c of approvedCurated) { for (const c of approvedCurated) {
(curatedByCountry[c.country] ??= []).push(c.domain); (curatedByCountry[c.country] ??= []).push(c.domain);
} }
// Pro Country: Custom-Domains vorne, dann globale Auffüllung, dedup, cap 50
const composed: Record<CountryKey, string[]> = {} as Record< const composed: Record<CountryKey, string[]> = {} as Record<
CountryKey, CountryKey,
string[] string[]
>; >;
for (const country of COUNTRY_KEYS) { for (const country of COUNTRY_KEYS) {
// statische JSON-Liste + DB-approved Curated des Landes, dedupliziert const merged = [
const globalList: string[] = [
...new Set([ ...new Set([
...(GLOBAL_LISTS[country] ?? []), ...(GLOBAL_LISTS[country] ?? []),
...(curatedByCountry[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); composed[country] = merged.slice(0, MAX_PER_COUNTRY);
} }

View File

@ -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;
}

View File

@ -21,46 +21,6 @@ export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [
// ─── Custom Domains ─────────────────────────────────────────────────────────── // ─── 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<string[]> {
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) { export async function getUserCustomDomains(userId: string) {
const db = usePrisma(); const db = usePrisma();
const rows = await db.userCustomDomain.findMany({ const rows = await db.userCustomDomain.findMany({
@ -73,8 +33,6 @@ export async function getUserCustomDomains(userId: string) {
type: true, type: true,
postId: true, postId: true,
addedAt: true, addedAt: true,
vipDeferUntil: true,
vipEvictAt: true,
submission: { submission: {
select: { id: true, yesVotes: true, noVotes: true, status: true }, select: { id: true, yesVotes: true, noVotes: true, status: true },
}, },

View File

@ -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<string, string[]> = {};
for (const c of approvedCurated) {
(curatedByCountry[c.country] ??= []).push(c.domain);
}
const composed: Record<CountryKey, string[]> = {} as Record<CountryKey, string[]>;
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<string | null> {
// 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)?

View File

@ -1,5 +1,13 @@
# MDM Setup — Phasen # MDM Setup — Phasen
## Revisions-Log
| Datum | Was geändert |
|-------------|-----------------------------------------------------------------------------------------------|
| 2026-05-10 | Initial: Phasen AG 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 ## Phase A ✅ Server-Bootstrap
Erledigt vor 2026-05-10. 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. 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: Phase F ist NICHT mehr auf Phase E blockiert (Ina-Email-Distribution kann nachgeholt werden).
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.)
**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) ## Phase G ⏳ iPad-Enrollment (optional, später)

View File

@ -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-<UDID>.txt`)
3. **Supervise**`cfgutil prepare --supervised --supervisor-host-certs <p12>` 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-<UDID>.env` | Welche Steps erledigt (für `--resume`) |
| `~/.rebreak-supervise/backups/<UDID>/` | Encrypted Backup |
| `~/.rebreak-supervise/backup-pass-<UDID>.txt` | Backup-Encryption-Passwort (chmod 600) |
| `~/.rebreak-supervise/supervision-identity.p12` | Persistent über alle Devices/Sessions |
| `~/.rebreak-supervise/log-<TIMESTAMP>.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 <path>` 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-<UDID>.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.

View File

@ -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.

View File

@ -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/<UDID>/
# 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-<UDID>.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 <<EOF
Usage: $(basename "$0") [options]
Options:
--dry-run Befehle nur loggen, nicht ausführen
--state-dir DIR State-Verzeichnis (default: \$HOME/.rebreak-supervise)
--profile PATH Pfad zur .mobileconfig (default: ../profiles/rebreak-iphone-protection.mobileconfig)
--resume Bei vorhandenem State: bereits-erledigte Steps überspringen
-h, --help Diese Hilfe
Environment:
REBREAK_STATE_DIR Wie --state-dir
SUPERVISION_IDENTITY_P12 Pfad zur Supervision-Identity (default: STATE_DIR/supervision-identity.p12)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--state-dir) STATE_DIR="$2"; shift 2 ;;
--profile) PROFILE_PATH="$2"; shift 2 ;;
--resume) RESUME=1; shift ;;
-h|--help) usage; exit 0 ;;
*) err "Unbekannte Option: $1"; usage; exit 2 ;;
esac
done
PROFILE_PATH="${PROFILE_PATH:-$PROFILE_PATH_DEFAULT}"
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
LOG_FILE="$STATE_DIR/log-$(date +%Y%m%d-%H%M%S).txt"
touch "$LOG_FILE"
log ""
log "${C_BLUE}═══════════════════════════════════════════════════${C_RESET}"
log "${C_BLUE} ReBreak iPhone Supervise Bootstrap${C_RESET}"
log "${C_BLUE}═══════════════════════════════════════════════════${C_RESET}"
log "State-Dir: $STATE_DIR"
log "Profile: $PROFILE_PATH"
log "Log: $LOG_FILE"
[[ "$DRY_RUN" == 1 ]] && warn "DRY-RUN-Modus aktiv — keine Aktion wird durchgeführt"
log ""
# ------------------------------------------------------------------------------
# Step 1: PREFLIGHT — Dependencies, Identity, Profil, Device-Detection
# ------------------------------------------------------------------------------
info "[1/5] Preflight"
# Tools
for bin in idevice_id ideviceinfo idevicebackup2; do
command -v "$bin" >/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 <path>' 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}"

View File

@ -0,0 +1,89 @@
<?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">
<!--
ReBreak iPhone Protection Profile — DEV-REMOVABLE
=================================================
WARNUNG: Dieses Profil ist NUR FÜR TESTS gedacht.
Im Gegensatz zur Produktions-Variante (rebreak-iphone-protection.mobileconfig):
- PayloadRemovalDisallowed = false → User kann via Settings entfernen
- allowEraseContentAndSettings = true → User kann iPhone notfalls wipen
Damit du nach dem Test einfach wieder los wirst.
Alle anderen Restrictions sind identisch — perfekt um zu prüfen ob
allowAppRemoval/VPN-Lock/DNS-Lock greifen, ohne dich in die Falle zu sperren.
Andere PayloadIdentifier + UUIDs als die Produktions-Variante,
damit beide parallel koexistieren können.
-->
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.DEV.20260524</string>
<key>PayloadUUID</key>
<string>D1B2C3D4-E5F6-4789-ABCD-EF1234567899</string>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz (DEV-Test)</string>
<key>PayloadDescription</key>
<string>TEST-Profil — via Settings entfernbar. NICHT für Produktion.</string>
<key>PayloadOrganization</key>
<string>ReBreak DEV</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.applicationaccess</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.DEV.restrictions</string>
<key>PayloadUUID</key>
<string>D2234567-ABCD-4F01-9345-67890ABCDED1</string>
<key>PayloadDisplayName</key>
<string>ReBreak Restrictions (DEV)</string>
<!-- Die Restrictions die wir testen wollen -->
<key>allowAppRemoval</key>
<false/>
<key>allowUIConfigurationProfileInstallation</key>
<false/>
<!-- VPN-Restrictions raus (Scope-Pivot 2026-05-24):
allowVPNCreation/Modification blockt auch unsere eigene App via NEVPNManager.
Schutz läuft jetzt über DNS-Settings-Payload, VPN bleibt App-managed. -->
<!-- DEV: bewusst NICHT gesetzt damit du raus kannst falls was schief geht -->
<!-- allowEraseContentAndSettings bleibt default true -->
</dict>
<dict>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.DEV.dns</string>
<key>PayloadUUID</key>
<string>D3234567-ABCD-4F01-9345-67890ABCDED2</string>
<key>PayloadDisplayName</key>
<string>ReBreak DNS-Filter (DEV)</string>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>https://dns.rebreak.org/dns-query</string>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@ -0,0 +1,232 @@
<?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">
<!--
ReBreak iPhone Protection Profile
---------------------------------
Scope (2026-05-25, Phase F.2 — NEFilter+MDM-Pivot):
1. Rebreak (und andere Apps) nicht entfernbar → Restrictions.allowAppRemoval=false
2. Profil selbst nicht entfernbar → PayloadRemovalDisallowed=true
3. System-DNS auf dns.rebreak.org locken → com.apple.dnsSettings.managed (DoH)
4. Anti-Tampering → allowEraseContentAndSettings=false,
allowUIConfigurationProfileInstallation=false
5. ReBreak-VPN als Managed-VPN pushed → com.apple.vpn.managed
→ ProviderBundleIdentifier =
org.rebreak.app.PacketTunnelExtension
→ Profile non-removable durch MDM
→ User kann VPN-Toggle in Settings nicht aus
Architektur (Pivot 2026-05-25):
Der Per-App-Family-Controls-Authorization-Toggle ist via Standard-Apple-MDM nicht
lockbar (dokumentierte security gap). Statt diesen Arms-Race zu spielen pushen wir
ReBreak-VPN selbst via MDM — damit ist das VPN-Profile non-removable und der User
kann den Filter nicht abschalten. App-seitig wird MDM-Präsenz detected und der
Protection-Layer dynamisch gewählt:
- MDM präsent: PacketTunnel (NEFilter-Path) bleibt always-on via Managed-VPN
- kein MDM: bisheriger VPN-DNS-Filter wie gehabt (user-toggleable)
Bypass-Vektor: 3rd-Party-VPN (ExpressVPN) bleibt installierbar, akzeptiert für DiGA-Pilot.
Voraussetzungen am Gerät:
- Supervised-Mode aktiv (sonst greifen allowAppRemoval / PayloadRemovalDisallowed NICHT)
- iOS 14+ (DNS-Settings-Payload erfordert iOS 14)
- ReBreak-App mit PacketTunnel-Extension installed BEVOR Profile-Install
(sonst kann iOS den ProviderBundleIdentifier nicht auflösen)
Apple-Referenzen:
- Restrictions: https://developer.apple.com/documentation/devicemanagement/restrictions
- DNS Settings: https://developer.apple.com/documentation/devicemanagement/dnssettings
- VPN-Managed: https://github.com/apple/device-management/blob/release/mdm/profiles/com.apple.vpn.managed.yaml
- Profile Format: https://developer.apple.com/documentation/devicemanagement/toplevel
Stand: 2026-05-25, Profile-Version 2 (VPN-Managed-Payload hinzugefügt)
-->
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.20260525</string>
<key>PayloadUUID</key>
<string>A1B2C3D8-E5F6-4789-ABCD-EF1234567894</string>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz</string>
<key>PayloadDescription</key>
<string>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.</string>
<key>PayloadOrganization</key>
<string>ReBreak</string>
<key>PayloadRemovalDisallowed</key>
<true/>
<key>ConsentText</key>
<dict>
<key>default</key>
<string>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.</string>
</dict>
<key>PayloadContent</key>
<array>
<!-- ===================================================================
1) RESTRICTIONS-PAYLOAD
Supervised-only Restrictions: blockt App-Removal + VPN-User-Eingriffe
=================================================================== -->
<dict>
<key>PayloadType</key>
<string>com.apple.applicationaccess</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.restrictions</string>
<key>PayloadUUID</key>
<string>B3234567-ABCD-4F01-9345-67890ABCDEF6</string>
<key>PayloadDisplayName</key>
<string>ReBreak Restrictions</string>
<key>PayloadDescription</key>
<string>Verhindert App-Löschung und VPN-Eingriffe.</string>
<!-- App-Removal komplett blocken (supervised only)
Per-Bundle-ID-Lock wäre chirurgischer, erfordert aber MDM-managed-Convert
via separatem InstallApplication-Command. Akzeptierter Trade-off für Prototype:
alle Apps sind während Schutz nicht löschbar. Für Self-Binding gewünscht.
User-Verhalten-Test 2026-05-24: zeigt "Vom Home-Screen entfernen" statt
"App löschen" — User kann App in Mediathek verschieben, nicht deinstallieren. -->
<key>allowAppRemoval</key>
<false/>
<!-- Verhindert dass User per Erase-All-Content-and-Settings entkommt
(supervised only). Bewusst gesetzt: Wipe ist Trustee-/Cooldown-Pfad. -->
<key>allowEraseContentAndSettings</key>
<false/>
<!-- Schutz vor Profil-Install-Bypass: User kann keine konkurrierenden
Konfigurationsprofile installieren die unsere Restrictions overriden -->
<key>allowUIConfigurationProfileInstallation</key>
<false/>
<!-- 2026-05-25 empirisch getestet: allowEnablingRestrictions=false
deaktiviert zwar die Bildschirmzeit-Setup-Page komplett, hat
aber KEINEN Effekt auf den per-App "Apps mit Zugriff auf
Bildschirmzeit"-Toggle (FC-Authorization). Apple unterscheidet
die beiden Permission-Systeme.
→ Rollback, FC-Toggle wird via App-Code-Bypass (MDM-Detect)
geregelt statt via MDM-Restriction.
→ ManagedSettings-API bleibt damit für die App weiter verfügbar. -->
</dict>
<!-- ===================================================================
2) DNS-SETTINGS-PAYLOAD
Lockt System-DNS auf unseren DoH-Endpoint
Belt+Suspenders zur VPN-Schicht: greift auch wenn VPN aus irgend-
einem Grund nicht aktiv ist
=================================================================== -->
<dict>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.dns</string>
<key>PayloadUUID</key>
<string>C1234567-ABCD-4F01-9345-67890ABCDEF2</string>
<key>PayloadDisplayName</key>
<string>ReBreak DNS-Filter</string>
<key>PayloadDescription</key>
<string>Leitet alle DNS-Anfragen verschlüsselt über dns.rebreak.org. Glücksspiel-Domains werden blockiert.</string>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>https://dns.rebreak.org/dns-query</string>
</dict>
</dict>
<!-- ===================================================================
3) VPN-MANAGED-PAYLOAD (Phase F.2 — NEFilter+MDM-Pivot 2026-05-25)
Pushed ReBreak's eigenen NEPacketTunnelProvider als MDM-Managed-VPN.
Effekt: VPN-Profile non-removable via Settings → User kann den
Filter nicht abschalten. ReBreak-App's eigene NEVPNManager-Aufrufe
überschreiben diese Config nicht (MDM-managed wins).
Voraussetzung: ReBreak-App MIT PacketTunnel-Extension muss installed
sein BEVOR dieses Profile gepushed wird (sonst kann iOS den
ProviderBundleIdentifier nicht auflösen → InstallProfile-Fail).
=================================================================== -->
<dict>
<key>PayloadType</key>
<string>com.apple.vpn.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.iphone.vpn</string>
<key>PayloadUUID</key>
<string>D2234567-ABCD-4F01-9345-67890ABCDEF4</string>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz-VPN</string>
<key>PayloadDescription</key>
<string>Aktiviert den ReBreak-DNS-Filter als nicht-abschaltbaren VPN. Verhindert dass du dich impulsiv selbst aussperrst-vom-Schutz.</string>
<key>UserDefinedName</key>
<string>ReBreak Schutz</string>
<key>VPNType</key>
<string>VPN</string>
<!-- VPNSubType + VPN.ProviderBundleIdentifier referenzieren die
Extension im ReBreak-App-Bundle. iOS findet den Provider via
diesem Bundle-ID und ruft den ReBreak-PacketTunnel auf. -->
<key>VPNSubType</key>
<string>org.rebreak.app.PacketTunnelExtension</string>
<key>VPN</key>
<dict>
<!-- RemoteAddress ist Pflichtfeld, hat aber bei lokal-only
PacketTunnel keine echte Funktion. Muss mit App-Code
übereinstimmen (RebreakProtectionModule.swift:1225). -->
<key>RemoteAddress</key>
<string>ReBreak DNS-Filter (lokal)</string>
<key>AuthenticationMethod</key>
<string>Password</string>
<key>ProviderBundleIdentifier</key>
<string>org.rebreak.app.PacketTunnelExtension</string>
<key>ProviderType</key>
<string>packet-tunnel</string>
<key>DisconnectOnIdle</key>
<integer>0</integer>
<!-- OnDemand-User-Override blocken: "Bedarf verbinden"-Toggle in
Settings → VPN → ReBreak Schutz wird disabled (grau).
Apple-Schema-Doku: "If 1, the Connect On Demand toggle in
Settings is disabled for this configuration." iOS 14+ -->
<key>OnDemandUserOverrideDisabled</key>
<true/>
</dict>
<!-- Empty VendorConfig — Apple-Schema erlaubt das, ReBreak-Extension
liest providerConfiguration nicht (Blocklist kommt aus AppGroup). -->
<key>VendorConfig</key>
<dict/>
<!-- On-Demand: VPN startet automatisch bei jedem Netzwerk-Connect.
Kombiniert mit MDM-non-removable + OnDemandUserOverrideDisabled
= always-on Schutz, User kann nicht abschalten. -->
<key>OnDemandEnabled</key>
<integer>1</integer>
<key>OnDemandRules</key>
<array>
<dict>
<key>Action</key>
<string>Connect</string>
</dict>
</array>
</dict>
</array>
</dict>
</plist>