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:
parent
b6b1f68940
commit
8f2ef2cc98
@ -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')}
|
||||||
|
|||||||
BIN
apps/rebreak-native/assets/onboarding/de/vpn_permission.jpeg
Normal file
BIN
apps/rebreak-native/assets/onboarding/de/vpn_permission.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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".
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
17
backend/prisma/migrations/drop_vip_swap_fields.sql
Normal file
17
backend/prisma/migrations/drop_vip_swap_fields.sql
Normal 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;
|
||||||
@ -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?
|
||||||
|
|
||||||
|
|||||||
113
backend/scripts/seed-country-blocklists.ts
Normal file
113
backend/scripts/seed-country-blocklists.ts
Normal 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());
|
||||||
50
backend/server/api/admin/curated-domains/[id].patch.ts
Normal file
50
backend/server/api/admin/curated-domains/[id].patch.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
36
backend/server/api/admin/curated-domains/index.get.ts
Normal file
36
backend/server/api/admin/curated-domains/index.get.ts
Normal 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 };
|
||||||
|
});
|
||||||
@ -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 =
|
||||||
|
|||||||
53
backend/server/api/custom-domains/suggest.post.ts
Normal file
53
backend/server/api/custom-domains/suggest.post.ts
Normal 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 };
|
||||||
|
});
|
||||||
@ -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() };
|
|
||||||
});
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
backend/server/db/curatedDomains.ts
Normal file
107
backend/server/db/curatedDomains.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
},
|
},
|
||||||
|
|||||||
321
docs/concepts/layer2-country-pivot.md
Normal file
321
docs/concepts/layer2-country-pivot.md
Normal 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)?
|
||||||
@ -1,5 +1,13 @@
|
|||||||
# MDM Setup — Phasen
|
# MDM Setup — Phasen
|
||||||
|
|
||||||
|
## Revisions-Log
|
||||||
|
|
||||||
|
| Datum | Was geändert |
|
||||||
|
|-------------|-----------------------------------------------------------------------------------------------|
|
||||||
|
| 2026-05-10 | Initial: Phasen A–G mit Factory-Reset-Approach für Phase F |
|
||||||
|
| 2026-05-24 | Phase F pivotiert auf Backup-Sandwich (TechLockdown-Stil); Scope erweitert um DNS-/VPN-Lock |
|
||||||
|
| 2026-05-24-late | DEV-Test zeigt: VPN-Restrictions blocken Rebreak-eigene NEVPNManager-Calls. Scope korrigiert: VPN-Restrictions raus, DNS bleibt als Fallback-Layer. Saubere MDM-VPN-Lösung als Phase F.2 |
|
||||||
|
|
||||||
## Phase A ✅ Server-Bootstrap
|
## 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)
|
||||||
|
|
||||||
|
|||||||
101
ops/mdm/bootstrap-tool/README.md
Normal file
101
ops/mdm/bootstrap-tool/README.md
Normal 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.
|
||||||
81
ops/mdm/bootstrap-tool/SUPERVISION-IDENTITY-SETUP.md
Normal file
81
ops/mdm/bootstrap-tool/SUPERVISION-IDENTITY-SETUP.md
Normal 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.
|
||||||
407
ops/mdm/bootstrap-tool/rebreak-supervise.sh
Executable file
407
ops/mdm/bootstrap-tool/rebreak-supervise.sh
Executable 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}"
|
||||||
@ -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>
|
||||||
232
ops/mdm/profiles/rebreak-iphone-protection.mobileconfig
Normal file
232
ops/mdm/profiles/rebreak-iphone-protection.mobileconfig
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user