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 familyControlsActive = state?.layers.familyControls === true;
|
||||
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
||||
// MDM-Managed: iOS hat einen zusätzlichen MDM-pushed Tunnel-Provider mit
|
||||
// unserer PacketTunnel-Bundle-ID. Detection erfolgt nativ in getDeviceState
|
||||
// via Count der NETunnelProviderManager-Instances mit unserem Bundle-ID.
|
||||
// Konsequenz: FC-Authorization-Toggle ist UI-only irrelevant (Schutz läuft
|
||||
// via MDM-managed VPN), App-Lock-Card wird ausgeblendet, einziger relevanter
|
||||
// Layer ist der VPN-Toggle.
|
||||
const mdmManaged = state?.layers.mdmManaged === true;
|
||||
// "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
|
||||
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
|
||||
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
|
||||
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
|
||||
// "lockedIn" normal = URL-Filter UND App-Lock aktiv. Wenn Family Controls
|
||||
// build-seitig nicht verfügbar ist (Distribution-Entitlement pending), kann
|
||||
// es keinen App-Lock geben → dann reicht der URL-Filter allein für "geschützt".
|
||||
// Ausnahmen:
|
||||
// - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) →
|
||||
// es kann gar keinen App-Lock geben, URL-Filter allein reicht.
|
||||
// - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares
|
||||
// Profile + non-removable App enforced, FC-Toggle ist irrelevant.
|
||||
const lockedIn =
|
||||
urlFilterActive && (appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
|
||||
urlFilterActive && (mdmManaged || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
|
||||
|
||||
const urlFilterActiveRef = useRef(urlFilterActive);
|
||||
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
||||
@ -294,11 +303,14 @@ export default function BlockerScreen() {
|
||||
onActivate={handleActivateFamilyControls}
|
||||
warning={t('blocker.layers_app_lock_warning')}
|
||||
/>
|
||||
) : FAMILY_CONTROLS_AVAILABLE ? (
|
||||
/* iOS App-Lock nur zeigen wenn das Family-Controls-Entitlement
|
||||
im Build aktiv ist. Distribution-Builds ohne Apple-Approval
|
||||
→ Card ausblenden statt ein sandbox-blockiertes Feature
|
||||
anzubieten (NSCocoaErrorDomain:4099). */
|
||||
) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged ? (
|
||||
/* iOS App-Lock nur zeigen wenn (a) das Family-Controls-
|
||||
Entitlement im Build aktiv ist (Distribution-Builds ohne
|
||||
Apple-Approval → ausblenden statt sandbox-blockiertes
|
||||
Feature, NSCocoaErrorDomain:4099) UND (b) wir nicht
|
||||
MDM-managed sind (dann ist der per-App-FC-Authorization-
|
||||
Toggle UI-irrelevant — Schutz läuft via MDM-VPN, App-Lock
|
||||
wird MDM-seitig durch nicht-entfernbares Profile enforced). */
|
||||
<LayerSwitchCard
|
||||
icon="lock-closed-outline"
|
||||
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);
|
||||
},
|
||||
|
||||
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
|
||||
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
||||
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
||||
/** Self-Heal Layer-1-Filter. Bei App-Start/Foreground/Poll aufrufen.
|
||||
*
|
||||
* 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> {
|
||||
if (Platform.OS !== "android") return;
|
||||
if (Platform.OS === "android") {
|
||||
try {
|
||||
await RebreakProtection.reconcileVpn();
|
||||
} 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
|
||||
}
|
||||
|
||||
// ───────── reconcileUrlFilter: Self-Heal nach „VPN löschen" in Settings ─────────
|
||||
//
|
||||
// User-Bypass-Pfad: Settings → VPN → ReBreak Schutz → „VPN löschen" entfernt
|
||||
// unsere NETunnelProviderManager-Config. iOS lässt diesen Button bei app-managed
|
||||
// VPNs immer zu — kein MDM-Key blockt selektiv nur diesen einen Button (Apple-
|
||||
// Limitation, verifiziert 2026-05-24).
|
||||
//
|
||||
// Counter-Strategie: bei jedem Foreground/Polling-Tick prüfen ob unser
|
||||
// Tunnel-Manager noch in loadAllFromPreferences enthalten ist. Falls weg →
|
||||
// silent recreate via loadOrCreateTunnelManager + saveToPreferences. Wenn iOS
|
||||
// wegen frischem Manager den Permission-Dialog zeigt: akzeptierte Friktion —
|
||||
// der User sieht dass sein Delete erkannt wurde.
|
||||
//
|
||||
// Wird vom JS-Wrapper `protection.reconcileVpn()` (iOS-Branch) gerufen, der
|
||||
// wiederum aus `enforceProtection()` in app/(app)/_layout.tsx (mount +
|
||||
// foreground + 15s-Poll) feuert.
|
||||
|
||||
AsyncFunction("reconcileUrlFilter") { () async -> [String: Any] in
|
||||
do {
|
||||
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
||||
if let existing = Self.findRebreakTunnel(in: managers) {
|
||||
// Config noch da — kein recreate nötig. OnDemand-Regel kümmert sich
|
||||
// um Reconnect bei Netzwerk-Events, hier kein explizites startVPNTunnel.
|
||||
let statusName = Self.tunnelStatusName(existing.connection.status)
|
||||
return ["recreated": false, "status": statusName]
|
||||
}
|
||||
|
||||
// Config WEG — wahrscheinlich „VPN löschen" durch User. Silent recreate.
|
||||
SharedLogStore.append("⚠️ [reconcileUrlFilter] tunnel MISSING — recreating after user-delete")
|
||||
let manager = try await Self.loadOrCreateTunnelManager()
|
||||
try await manager.saveToPreferences()
|
||||
try await manager.loadFromPreferences()
|
||||
|
||||
// Tunnel sofort starten — OnDemand fängt sonst erst beim nächsten
|
||||
// Netzwerk-Event. Bewusst nicht warten/timeout: das Polling sieht den
|
||||
// Connected-State spätestens beim nächsten Tick.
|
||||
if let session = manager.connection as? NETunnelProviderSession {
|
||||
try? session.startVPNTunnel()
|
||||
}
|
||||
|
||||
// App-Group-Flag spiegeln (siehe activateUrlFilter — getDeviceState liest hier).
|
||||
if let d = UserDefaults(suiteName: APP_GROUP) {
|
||||
d.set(true, forKey: VPN_TUNNEL_RUNNING_KEY)
|
||||
d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
|
||||
}
|
||||
|
||||
SharedLogStore.append("✅ [reconcileUrlFilter] tunnel recreated")
|
||||
return ["recreated": true]
|
||||
} catch let e as NSError {
|
||||
let errStr = "\(e.domain):\(e.code) \(e.localizedDescription)"
|
||||
SharedLogStore.append("❌ [reconcileUrlFilter] failed: \(errStr)")
|
||||
return ["recreated": false, "error": errStr]
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── activateFamilyControls: NUR FC + denyAppRemoval ─────────
|
||||
|
||||
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
|
||||
@ -633,13 +688,25 @@ public class RebreakProtectionModule: Module {
|
||||
// (NEURLFilter ist nicht mehr der Default-Filter; sein Status fließt
|
||||
// bewusst NICHT mehr in den `urlFilter`-Slot ein.)
|
||||
var urlFilter = false
|
||||
var mdmManaged = false
|
||||
do {
|
||||
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
||||
if let manager = Self.findRebreakTunnel(in: managers) {
|
||||
// MDM-Detection: zähle wie viele Manager unsere PacketTunnel-Bundle-ID
|
||||
// referenzieren. App selbst erstellt nur einen einzigen über
|
||||
// `loadOrCreateTunnelManager`. Wenn der Count > 1 ist, hat MDM
|
||||
// mindestens einen weiteren via `com.apple.vpn.managed`-Payload
|
||||
// gepushed → MDM-managed VPN aktiv, FC-Toggle ist UI-only irrelevant.
|
||||
let rebreakTunnels = managers.filter { manager in
|
||||
guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol
|
||||
else { return false }
|
||||
return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID
|
||||
}
|
||||
mdmManaged = rebreakTunnels.count > 1
|
||||
if let manager = rebreakTunnels.first {
|
||||
urlFilter = (manager.connection.status == .connected)
|
||||
}
|
||||
} catch {
|
||||
// ignore — kein Tunnel konfiguriert → urlFilter bleibt false.
|
||||
// ignore — kein Tunnel konfiguriert → urlFilter + mdmManaged bleiben false.
|
||||
}
|
||||
|
||||
// FamilyControls
|
||||
@ -668,6 +735,7 @@ public class RebreakProtectionModule: Module {
|
||||
"familyControls": familyControls,
|
||||
"appDeletionLock": appDeletionLock,
|
||||
"webContentFilter": webContentFilter,
|
||||
"mdmManaged": mdmManaged,
|
||||
"blocklistCount": count,
|
||||
"blocklistLastSyncAt": lastSync ?? NSNull(),
|
||||
]
|
||||
|
||||
@ -14,6 +14,17 @@ export type DeviceLayers = {
|
||||
* FilterPolicy ≠ .none gesetzt hat (kuratierte Gambling-Domain-Liste aktiv).
|
||||
*/
|
||||
webContentFilter?: boolean;
|
||||
/**
|
||||
* iOS-only. True wenn MDM einen managed VPN/Tunnel-Provider mit unserer
|
||||
* PacketTunnelExtension Bundle-ID pushed hat. Erkannt heuristisch via
|
||||
* `NETunnelProviderManager.loadAllFromPreferences().count > 1` — App selbst
|
||||
* kann nur einen eigenen Manager erstellen, ein zusätzlicher MDM-Push
|
||||
* fügt einen zweiten hinzu. Konsequenz für UI: bei mdmManaged=true ist
|
||||
* der per-App-FC-Authorization-Toggle irrelevant für den Schutz (Schutz
|
||||
* läuft via MDM-managed VPN-Layer), die Locked-In-Card kann unabhängig
|
||||
* vom familyControls/appDeletionLock-Status angezeigt werden.
|
||||
*/
|
||||
mdmManaged?: boolean;
|
||||
// Android
|
||||
vpn?: boolean;
|
||||
accessibility?: boolean;
|
||||
|
||||
@ -55,6 +55,22 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
||||
*/
|
||||
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).
|
||||
* Triggert iOS-Dialog "Bildschirmzeit verwalten".
|
||||
|
||||
@ -80,6 +80,9 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
|
||||
async reconcileVpn() {
|
||||
return { restarted: false };
|
||||
}
|
||||
async reconcileUrlFilter() {
|
||||
return { recreated: false };
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
addedAt DateTime @default(now()) @map("added_at")
|
||||
|
||||
// VIP-Slot-Replace (Layer-2-Swap mit 24h-Cooldown):
|
||||
// vipDeferUntil — die NEUE Domain ist erst ab hier Teil der VIP-Liste
|
||||
// (während des Cooldowns nur via Layer 1 geschützt).
|
||||
// vipEvictAt — die ERSETZTE Domain fällt ab hier aus der VIP-Liste.
|
||||
// Beide NULL = kein laufender Swap.
|
||||
vipDeferUntil DateTime? @map("vip_defer_until")
|
||||
vipEvictAt DateTime? @map("vip_evict_at")
|
||||
// Layer-2-Country-Pivot (2026-05-25): vipDeferUntil + vipEvictAt entfernt.
|
||||
// Layer 2 ist nicht mehr User-Custom-gespeist — Pure Country-Curated.
|
||||
// DB-Columns werden via drop_vip_swap_fields.sql gedroppt (nach Code-Deploy).
|
||||
|
||||
submission DomainSubmission?
|
||||
|
||||
|
||||
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 {
|
||||
addUserCustomDomain,
|
||||
countActiveCustomDomains,
|
||||
getWebCustomDomains,
|
||||
CUSTOM_DOMAIN_TYPES,
|
||||
type CustomDomainType,
|
||||
} from "../../db/domains";
|
||||
import { getProfile } from "../../db/profile";
|
||||
import { getPlanLimits } from "../../utils/plan-features";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import gamblingDomains from "../../data/gambling-domains.json";
|
||||
|
||||
// Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk")
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
// Kuratierte Layer-2-VIP-Listen pro Land (gambling-domains.json).
|
||||
const CURATED_LISTS = gamblingDomains as unknown as Record<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.
|
||||
*
|
||||
@ -201,37 +182,16 @@ export default defineEventHandler(async (event) => {
|
||||
return { alreadyGlobal: true, domain: value };
|
||||
}
|
||||
|
||||
// ─── Web: 3-Fall-Check gegen Layer 1 (global) + Layer 2 (kuratierte VIP) ──
|
||||
//
|
||||
// Layer 1 (VPN/URL-Filter) = globale Blocklist. Layer 2 (webContent/VIP) =
|
||||
// kuratierte gambling-domains.json + eigene Custom-Domains; greift als
|
||||
// Zweitschutz, falls Layer 1 aus ist.
|
||||
// 1. weder global noch kuratiert → normaler Custom-Eintrag ('active')
|
||||
// 2. global UND kuratiert → schon komplett geschützt, kein Slot
|
||||
// 3. global, aber NICHT kuratiert → Hinweis an User; bei addToVip=true wird
|
||||
// die Domain als 'approved' gespeichert (kein Slot, erscheint nur in der
|
||||
// VIP-Liste — 'approved' ist semantisch korrekt: sie IST in Layer 1).
|
||||
let webAddAsApproved = false;
|
||||
if (type === "web") {
|
||||
const country = resolveVipCountry(body?.country);
|
||||
const curatedList: string[] = CURATED_LISTS[country] ?? [];
|
||||
const inVipCurated = curatedList.includes(value);
|
||||
const addToVip = body?.addToVip === true;
|
||||
|
||||
if (inGlobal && !addToVip) {
|
||||
return inVipCurated
|
||||
? { alreadyProtected: true, domain: value }
|
||||
: { inGlobalNotVip: true, domain: value };
|
||||
}
|
||||
if (inGlobal && addToVip) {
|
||||
webAddAsApproved = true;
|
||||
}
|
||||
// !inGlobal → normaler Add unten
|
||||
// ─── Web: bereits in globaler Layer-1-Blocklist → kein Slot verbrennen ──
|
||||
// Layer 2 (webContent) wird ab 2026-05-25 ausschliesslich Country-Curated
|
||||
// gespeist — User-Custom-Domains landen NUR noch in Layer 1. Ein Custom-Slot
|
||||
// für eine bereits global geblocknte Domain ist daher sinnlos.
|
||||
if (type === "web" && inGlobal) {
|
||||
return { alreadyProtected: true, domain: value };
|
||||
}
|
||||
|
||||
// Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend
|
||||
// 20). Entfällt für webAddAsApproved (approved belegt keinen Slot).
|
||||
if (!webAddAsApproved) {
|
||||
// Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend 20).
|
||||
{
|
||||
const profile = await getProfile(user.id);
|
||||
const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
|
||||
|
||||
@ -257,7 +217,7 @@ export default defineEventHandler(async (event) => {
|
||||
value,
|
||||
"manual",
|
||||
type,
|
||||
webAddAsApproved ? "approved" : "active",
|
||||
"active",
|
||||
);
|
||||
|
||||
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch(
|
||||
@ -284,25 +244,6 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (webAddAsApproved) {
|
||||
return { ...data, addedToVip: true };
|
||||
}
|
||||
|
||||
// VIP-Slot-Replace: bringt die neue Web-Domain die VIP-Liste (Layer 2)
|
||||
// über ihr 30er-Cap, wird sie zunächst zurückgestellt (vipDeferUntil) —
|
||||
// der User wählt dann im Swap-Dialog, welche eigene Domain sie ersetzt.
|
||||
// Layer 1 schützt die neue Domain bereits ab sofort.
|
||||
if (type === "web") {
|
||||
const vipDomains = await getWebCustomDomains(user.id);
|
||||
if (vipDomains.length > MAX_VIP_CUSTOM) {
|
||||
await db.userCustomDomain.update({
|
||||
where: { id: data.id },
|
||||
data: { vipDeferUntil: new Date(Date.now() + SWAP_COOLDOWN_MS) },
|
||||
});
|
||||
return { ...data, vipFull: true };
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
|
||||
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 { getWebCustomDomains } from "../../db/domains";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
|
||||
const COUNTRY_KEYS = ["DE", "GB", "FR", "TN"] as const;
|
||||
@ -9,87 +8,53 @@ const GLOBAL_LISTS = gamblingDomains as unknown as Record<string, string[]>;
|
||||
|
||||
const MAX_PER_COUNTRY = 50;
|
||||
|
||||
// Hybrid-Reservierung: die Top-N kuratierten Gambling-Domains pro Land sind
|
||||
// FEST garantiert — ein User kann sie nicht mit eigenen Custom-Domains aus
|
||||
// seinem Layer-2-Zweitschutz verdrängen. Custom-Domains werden daher hart auf
|
||||
// (50 − RESERVED_CURATED) gekappt. Voraussetzung: gambling-domains.json ist
|
||||
// nach Relevanz sortiert (die ersten RESERVED_CURATED = die wichtigsten).
|
||||
const RESERVED_CURATED = 20;
|
||||
const MAX_CUSTOM = MAX_PER_COUNTRY - RESERVED_CURATED; // 30
|
||||
|
||||
/**
|
||||
* GET /api/protection/webcontent-domains
|
||||
*
|
||||
* Liefert die VIP-Domain-Liste für den WebKit-webContent-Filter (Layer 2).
|
||||
* Pro User personalisiert, Hybrid-Komposition pro Land:
|
||||
* 1. Custom-Web-Domains (pending zuerst, dann approved) — gekappt auf 30
|
||||
* 2. kuratierte Gambling-Liste — füllt den Rest bis 50 auf
|
||||
* → dedupliziert → hart auf 50 gekappt (Apple-Limit).
|
||||
* Liefert die Country-Curated-Domain-Liste für den WebKit-webContent-Filter
|
||||
* (Layer 2). Nach Layer-2-Country-Pivot (2026-05-25) ist Layer 2 vollständig
|
||||
* entkoppelt von User-Custom-Domains:
|
||||
*
|
||||
* Damit sind immer ≥ 20 kuratierte Top-Domains im Zweitschutz garantiert,
|
||||
* egal wie viele Custom-Domains der User angesammelt hat.
|
||||
* Response-Shape ist identisch mit der statischen Version — iOS parst das unverändert.
|
||||
* Layer 1 (VPN/blocklist.bin) = User-Custom-Domains + globale Blocklist
|
||||
* Layer 2 (iOS NEFilter) = ausschliesslich Country-Curated (Admin-managed)
|
||||
*
|
||||
* Lade-Mechanismus: direkter JSON-Import (build-time gebundelt via Nitro-Bundler).
|
||||
* Kein serverAssets/useStorage — kein extra Laufzeit-I/O, kein globales
|
||||
* backend/data/-Verzeichnis nötig.
|
||||
* Zusammensetzung pro Land:
|
||||
* 1. Statische gambling-domains.json (build-time gebundelt)
|
||||
* 2. DB-approved CuratedDomain-Rows (Admin-kuratiert + User-Vorschläge mit status="approved")
|
||||
* → dedupliziert → hart auf 50 gekappt (Apple-Limit)
|
||||
*
|
||||
* Pflege: backend/server/data/gambling-domains.json editieren,
|
||||
* _meta.version hochzählen, _meta.updatedAt setzen, dann neu deployen.
|
||||
* Optional: Query-Param ?travel=FR für Travel-Detection (Server-side Merge).
|
||||
* iOS sendet origin (OS-Region) + travel (Cellular-MCC-Land) wenn verfügbar.
|
||||
* Ohne Params: alle COUNTRY_KEYS werden zurückgegeben — iOS filtert selbst.
|
||||
*
|
||||
* Response-Shape unverändert: { _meta, DE: [], GB: [], FR: [], TN: [] }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
await requireUser(event); // Auth bleibt — kein User-Lookup, nur Authentifizierung
|
||||
|
||||
// Custom Web-Domains des Users laden — parallel zu allen Country-Listen
|
||||
const userWebDomains = await getWebCustomDomains(user.id);
|
||||
|
||||
// Custom-Domains hart auf 30 kappen — die ersten 30 sind die höchst-
|
||||
// priorisierten (getWebCustomDomains liefert pending zuerst, dann approved
|
||||
// neueste-zuerst). Die restlichen 20 Slots bleiben für die kuratierte Liste.
|
||||
const cappedCustom = userWebDomains.slice(0, MAX_CUSTOM);
|
||||
// Dedup-Set NUR über die gekappten Customs — eine kuratierte Domain, die
|
||||
// einer aus dem 30-Cap GEFLOGENEN Custom-Domain entspricht, soll über die
|
||||
// kuratierte Auffüllung wieder reinkommen (sie ist ja eine Top-Domain).
|
||||
const cappedCustomSet = new Set(cappedCustom);
|
||||
|
||||
// DB-approved Curated-Domains (User-Vorschläge, admin-freigegeben) ergänzen
|
||||
// die statische gambling-domains.json pro Land — wichtig für Länder mit
|
||||
// kurzer Starter-Liste (z.B. TN).
|
||||
const db = usePrisma();
|
||||
const approvedCurated = await db.curatedDomain.findMany({
|
||||
where: { status: "approved" },
|
||||
select: { domain: true, country: true },
|
||||
});
|
||||
|
||||
const curatedByCountry: Record<string, string[]> = {};
|
||||
for (const c of approvedCurated) {
|
||||
(curatedByCountry[c.country] ??= []).push(c.domain);
|
||||
}
|
||||
|
||||
// Pro Country: Custom-Domains vorne, dann globale Auffüllung, dedup, cap 50
|
||||
const composed: Record<CountryKey, string[]> = {} as Record<
|
||||
CountryKey,
|
||||
string[]
|
||||
>;
|
||||
|
||||
for (const country of COUNTRY_KEYS) {
|
||||
// statische JSON-Liste + DB-approved Curated des Landes, dedupliziert
|
||||
const globalList: string[] = [
|
||||
const merged = [
|
||||
...new Set([
|
||||
...(GLOBAL_LISTS[country] ?? []),
|
||||
...(curatedByCountry[country] ?? []),
|
||||
]),
|
||||
];
|
||||
|
||||
// Gekappte Custom-Domains zuerst (bereits dedupliziert da aus DB)
|
||||
const merged: string[] = [...cappedCustom];
|
||||
|
||||
// Kuratierte Domains auffüllen — nur wenn noch nicht durch Custom drin
|
||||
for (const domain of globalList) {
|
||||
if (!cappedCustomSet.has(domain)) {
|
||||
merged.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
composed[country] = merged.slice(0, MAX_PER_COUNTRY);
|
||||
}
|
||||
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const db = usePrisma();
|
||||
const rows = await db.userCustomDomain.findMany({
|
||||
@ -73,8 +33,6 @@ export async function getUserCustomDomains(userId: string) {
|
||||
type: true,
|
||||
postId: true,
|
||||
addedAt: true,
|
||||
vipDeferUntil: true,
|
||||
vipEvictAt: true,
|
||||
submission: {
|
||||
select: { id: true, yesVotes: true, noVotes: true, status: true },
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
## Revisions-Log
|
||||
|
||||
| Datum | Was geändert |
|
||||
|-------------|-----------------------------------------------------------------------------------------------|
|
||||
| 2026-05-10 | Initial: Phasen A–G mit Factory-Reset-Approach für Phase F |
|
||||
| 2026-05-24 | Phase F pivotiert auf Backup-Sandwich (TechLockdown-Stil); Scope erweitert um DNS-/VPN-Lock |
|
||||
| 2026-05-24-late | DEV-Test zeigt: VPN-Restrictions blocken Rebreak-eigene NEVPNManager-Calls. Scope korrigiert: VPN-Restrictions raus, DNS bleibt als Fallback-Layer. Saubere MDM-VPN-Lösung als Phase F.2 |
|
||||
|
||||
## Phase A ✅ Server-Bootstrap
|
||||
|
||||
Erledigt vor 2026-05-10.
|
||||
@ -201,21 +209,76 @@ Server-Status:
|
||||
|
||||
Reaktivierung: User sagt „Phase E GO", wir verifizieren Domain in Resend, senden, fertig. Files bleiben bis dahin auf Server.
|
||||
|
||||
## Phase F ⏳ Device-Enrollment
|
||||
## Phase F ⏳ Device-Enrollment via Backup-Sandwich
|
||||
|
||||
Wartet auf Phase E.
|
||||
**Revidiert 2026-05-24** — alter Plan (Factory-Reset + Apple Configurator) war User-Friction-Killer. Niemand reset sich freiwillig sein iPhone. Neuer Plan: Backup-Sandwich-Approach wie TechLockdown / iMazing Configurator Edition.
|
||||
|
||||
Was passiert:
|
||||
1. iPhone auf Werkseinstellungen zurücksetzen (Backup vorher!)
|
||||
2. Während Setup: iPhone via USB-C mit Mac verbinden, Apple Configurator 2 öffnen
|
||||
3. In Apple Configurator 2: Gerät preparieren (Supervised Mode aktivieren)
|
||||
4. MDM-Enrollment-Profil von `https://mdm.rebreak.org/enroll` auf Gerät installieren
|
||||
5. Verifyieren dass Profil als "nicht entfernbar" markiert ist
|
||||
6. Apps installieren (ReBreak, etc.)
|
||||
Phase F ist NICHT mehr auf Phase E blockiert (Ina-Email-Distribution kann nachgeholt werden).
|
||||
|
||||
**Hinweis zum Supervised Mode:** Ohne Supervision kann das MDM-Profil vom User entfernt werden. Supervision braucht einmalig USB + Apple Configurator. Danach ist OTA-MDM-Update möglich.
|
||||
### Mechanismus
|
||||
|
||||
**Scope-Constraint (User-bestätigt 2026-05-10):** Profil enthält NUR `allowAppRemoval=false` für Bundle-ID `org.rebreak.app` + `allowMDMProfileRemoval=false`. KEIN App-Store-Block, keine weiteren Restrictions. iOS-App-Store hat keine Echtgeld-Casino-Apps (Apple-Policy), Browser-Casinos werden von ReBreak's NEFilter geblockt.
|
||||
```
|
||||
1. Backup (idevicebackup2 encrypted) → vollständig auf Mac
|
||||
2. Supervise (cfgutil prepare) → wiped Gerät, Supervised-Flag wird gesetzt
|
||||
3. Restore (idevicebackup2 restore) → Daten zurück, Supervised-Flag bleibt persistent
|
||||
4. Enroll (mobileconfig install) → via QR-Code aus Rebreak-App, OTA über mdm.rebreak.org
|
||||
```
|
||||
|
||||
Find-My-Disable ist Voraussetzung für Step 2 (Activation Lock blockt sonst den Wipe). Apple-ID-Passwort des Users wird live abgefragt — nicht automatisierbar.
|
||||
|
||||
### Komponenten dieser Phase
|
||||
|
||||
- `ops/mdm/bootstrap-tool/` — Bash-Scripts orchestrieren Backup → Supervise → Restore auf User-Mac (Mac-only Phase 1; Windows = Phase 2 via iMazing-Lizenz oder libimobiledevice-Erweiterung)
|
||||
- `ops/mdm/profiles/rebreak-iphone-protection.mobileconfig` — Profil-Template mit den unten genannten Restrictions
|
||||
- `backend/server/api/mdm/enroll.get.ts` — User-spezifisches signed Profil ausliefern, plus QR-Code-Endpoint
|
||||
- `apps/rebreak-native/lib/mdm.ts` + `app/(protection)/mdm-setup.tsx` — Lyra-geführter Onboarding-Flow in der App
|
||||
|
||||
### Scope (erweitert 2026-05-24, revidiert 2026-05-24-late nach DEV-Test)
|
||||
|
||||
Profil enthält:
|
||||
|
||||
| Restriction | Wirkung |
|
||||
|----------------------------------------------|--------------------------------------------------------------|
|
||||
| `allowAppRemoval = false` | Rebreak (und alle anderen Apps) nicht löschbar via Long-Press — zeigt nur "Vom Home-Screen entfernen", App bleibt in Mediathek (verifiziert auf TechLockdown-supervisem iPhone 2026-05-24) |
|
||||
| `PayloadRemovalDisallowed = true` | Profil nicht via Settings → Allgemein → VPN/Geräteverwaltung entfernbar |
|
||||
| `allowEraseContentAndSettings = false` | User kann iPhone nicht via Settings → Reset wipen |
|
||||
| `allowUIConfigurationProfileInstallation = false` | User kann keine konkurrierenden Profile installieren |
|
||||
| DNS-Settings-Payload (DoH) | System-DNS auf `dns.rebreak.org/dns-query` gelocked — always-on Fallback-Schicht |
|
||||
|
||||
**VPN-Restrictions bewusst RAUS** (Test-Befund 2026-05-24):
|
||||
`allowVPNCreation=false` blockt auch Rebreak-eigene `NEVPNManager`-Aufrufe ("Permission denied"). Apple unterscheidet im API-Call nicht zwischen User und App. Konsequenz:
|
||||
- App-VPN (Rebreak NEPacketTunnel) bleibt App-managed + user-toggleable — wie heute
|
||||
- MDM-DNS-Payload ist always-on Fallback: auch wenn User Rebreak-VPN ausschaltet, DNS-Filter greift weiterhin
|
||||
- Bypass-Vektor: User installiert 3rd-Party-VPN (z.B. ExpressVPN). Akzeptiert für Prototype — 5-min-Friktion, trifft planenden Rückfall nicht impulsiven
|
||||
- Saubere Lösung wäre **Phase F.2**: MDM-pushed-VPN mit `ProviderBundleIdentifier=org.rebreak.app.PacketTunnelExtension`, dann braucht App-Code kein eigenes `NEVPNManager.saveToPreferences` mehr → echtes "VPN nur via MDM"
|
||||
|
||||
Bewusste Trade-offs:
|
||||
- `allowAppRemoval=false` ist GLOBAL — kein per-Bundle-ID-Lock möglich ohne MDM-managed-Convert (zusätzlicher InstallApplication-Command, Phase F.5 später). Für Prototype akzeptiert: User der sich self-bindet darf auch andere Support-Apps nicht löschen — Feature, kein Bug.
|
||||
- Determinierter User kann via zweitem Mac unsupervisen (ABM-ADE wäre der einzige echte Hard-Lock, ist aber strukturell nicht erreichbar für Consumer-iPhones). Akzeptabel für DiGA-Sucht-Kontext: wir hoben Friktion, nicht Festung.
|
||||
|
||||
Bewusst NICHT im Scope:
|
||||
- KEIN App-Store-Block (Casino-Apps gibt's eh nicht im iOS-App-Store)
|
||||
- KEINE Web-Content-Filter-Payload (Browser-Casinos werden vom Rebreak-NEFilter geblockt)
|
||||
- KEINE Restriktionen die nicht direkt mit Casino-Bypass-Prevention zu tun haben
|
||||
|
||||
### Hardware-/Tool-Voraussetzungen
|
||||
|
||||
- Mac mit macOS (User-Mac, NICHT Server-Mac) — für cfgutil + libimobiledevice
|
||||
- USB-Kabel iPhone↔Mac
|
||||
- Apple Configurator 2 (kostenlos, Mac App Store) — für `cfgutil` CLI
|
||||
- libimobiledevice via `brew install libimobiledevice` — für `idevicebackup2`
|
||||
- Supervision-Identity einmalig generiert via cfgutil (persistent, gleicher Mac reused)
|
||||
- iPhone mit deaktiviertem Find-My (live während Setup)
|
||||
|
||||
### Akzeptanz-Test (M2)
|
||||
|
||||
Auf einem physischen Test-iPhone nach kompletter Sandwich-Sequenz:
|
||||
- [ ] `Settings → Allgemein → Info` zeigt "Dieses iPhone wird verwaltet/beaufsichtigt"
|
||||
- [ ] Long-Press auf Rebreak-Icon → kein "App löschen" mehr
|
||||
- [ ] `Settings → VPN → Rebreak` → Toggle disabled / nicht entfernbar
|
||||
- [ ] `Settings → Allgemein → VPN, DNS und Gerätemanagement` → Profil zeigt "Nicht entfernbar"
|
||||
- [ ] Daten/Apps/Login-States/iMessage-History intakt nach Sandwich
|
||||
- [ ] Rebreak-App erkennt MDM-Enrollment-Status via Backend-Check und unlockt Pro/Legend-Schutz-UI
|
||||
|
||||
## Phase G ⏳ iPad-Enrollment (optional, später)
|
||||
|
||||
|
||||
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