chahinebrini 8f2ef2cc98 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>
2026-05-25 07:11:47 +02:00

490 lines
19 KiB
TypeScript

/**
* Protection orchestration layer (JS-side).
*
* Verbindet das native rebreak-protection-Modul (Device-Layer-State) mit
* dem Backend-Cooldown-API (`/api/cooldown/*` + `/api/protection/state`).
*
* Cooldown ist Backend-driven (JWT mit `cooldown_ends_at`-Claim, server-time
* = single source of truth gegen lokale-Uhr-Manipulation). Native-Modul
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
*/
import { Platform } from "react-native";
import Constants from "expo-constants";
import AsyncStorage from "@react-native-async-storage/async-storage";
import RebreakProtection from "../modules/rebreak-protection";
import type {
ActivateResult,
DeviceLayers,
HealthProbeOpts,
HealthProbeResult,
SyncBlocklistOpts,
SyncBlocklistResult,
SyncWebContentDomainsOpts,
SyncWebContentDomainsResult,
SystemSettingsTarget,
WebContentFilterResult,
} from "../modules/rebreak-protection";
import { apiFetch } from "./api";
// ─── Feature Flags ─────────────────────────────────────────────────────────
/**
* True only in dev/dev-client builds where Apple's FamilyControls Development
* entitlement is active. False in preview/production (Distribution entitlement
* pending Apple approval) — showing the App-Lock toggle in those builds would
* throw NSCocoaErrorDomain:4099.
*/
export const FAMILY_CONTROLS_AVAILABLE =
Constants.expoConfig?.extra?.familyControlsEnabled === true;
// ─── Public Types ──────────────────────────────────────────────────────────
export type ProtectionPhase =
| "inactive"
| "activating"
| "active"
| "cooldownPending"
| "cooldownActive"
| "recoveringFromBypass";
export type CooldownState = {
active: boolean;
endsAt: string | null;
remainingSeconds: number;
reason: string | null;
};
export type ProtectionState = {
phase: ProtectionPhase;
layers: DeviceLayers;
cooldown: CooldownState;
blocklistCount: number;
plan: "free" | "pro" | "legend";
};
// ─── Backend Response-Types ────────────────────────────────────────────────
type BackendCooldownStatus = {
active: boolean;
remainingSeconds: number;
cooldownEndsAt: string | null;
token: string | null;
canDisableProtection: boolean;
reason?: string;
};
// Matches actual response from `apps/rebreak/server/api/protection/state.get.ts`
// (apiFetch unwrapt das `data`-Feld bereits, daher hier nur die Inner-Shape).
type BackendProtectionState = {
protectionShouldBeActive: boolean;
cooldown: {
active: boolean;
remainingSeconds: number;
cooldownEndsAt: string | null;
};
plan: "free" | "pro" | "legend";
};
// ─── Dev Helpers ───────────────────────────────────────────────────────────
const DEV_COOLDOWN_TESTMODE_KEY = "dev:cooldown-testmode";
export async function setCooldownTestMode(on: boolean): Promise<void> {
await AsyncStorage.setItem(DEV_COOLDOWN_TESTMODE_KEY, on ? "1" : "0");
}
export async function getCooldownTestMode(): Promise<boolean> {
const val = await AsyncStorage.getItem(DEV_COOLDOWN_TESTMODE_KEY);
return val === "1";
}
// ─── Public API ────────────────────────────────────────────────────────────
export const protection = {
// ─── Native-Calls (Device-Layer) ─────────────────────────────────────────
activate(): Promise<ActivateResult> {
return RebreakProtection.activate();
},
async activateUrlFilter(): Promise<{ enabled: boolean; error?: string }> {
let res: { enabled: boolean; error?: string };
if (Platform.OS === "android") {
// Android Layer-1 = VpnService (DNS-Filter). iOS-API erwartet hier
// {enabled, error?}, also Native-`activate()`-Result re-shapen.
const r = await RebreakProtection.activate();
const enabled = !r.missingLayers.includes("vpn");
res = enabled ? { enabled: true } : { enabled: false, error: r.errors?.[0] };
} else {
// iOS Layer-1 = Packet-Tunnel-DNS-Filter (NEPacketTunnelProvider).
// Startet/konfiguriert den Tunnel via NETunnelProviderManager — beim
// ersten Mal erscheint der iOS-VPN-System-Permission-Dialog.
// Der Tunnel braucht KEINE PIR-Config; die Felder werden nativ
// ignoriert und nur aus API-Kompatibilität weitergereicht.
const pirServerURL = (Constants.expoConfig?.extra?.pirServerURL as string) ?? "";
const pirAuthToken = (Constants.expoConfig?.extra?.pirAuthToken as string) ?? "";
res = await RebreakProtection.activateUrlFilter({ pirServerURL, pirAuthToken });
}
// Diagnose: Fehler-String + nativer Log-Tail (inkl. der [EXT ...]-Zeilen
// der Control-Provider-Extension) zeilenweise in Metro.
{
const resAny = res as Record<string, unknown>;
const nativeLog = resAny.log;
delete resAny.log;
console.log(`[protection] activateUrlFilter → ${JSON.stringify(res)}`);
if (Array.isArray(nativeLog)) {
for (const l of nativeLog) console.log(` [native] ${l}`);
}
}
// Bei erfolgreicher Reaktivierung: Backend-Flag clearen (sonst bleibt
// protectionShouldBeActive=false und Bypass-Detection feuert nicht mehr).
// Best-effort — wenn das Backend nicht erreichbar ist, lokal nicht blocken.
if (res.enabled) {
apiFetch("/api/protection/mark-active", { method: "POST" }).catch(() => {});
}
return res;
},
/**
* iOS-only Workaround: User hat "Nicht erlauben" beim NEFilter-System-Dialog
* getippt → iOS cached den Denied-State + zeigt den Dialog beim erneuten
* activateUrlFilter NICHT mehr (NEFilterErrorDomain code 5 silent).
* resetUrlFilter macht removeFromPreferences VOR saveToPreferences — iOS
* behandelt das als frischen Request → System-Dialog kommt erneut.
*
* Auf Android no-op (gibt einfach activateUrlFilter zurück, dort kein Cache).
*/
async resetUrlFilter(): Promise<{ enabled: boolean; error?: string }> {
if (Platform.OS === "android") {
return this.activateUrlFilter();
}
return RebreakProtection.resetUrlFilter();
},
async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> {
if (Platform.OS === "android") {
// Android "App-Lock" = AccessibilityService als reiner Tamper-Lock (KEIN
// Browser-Filter mehr — Glücksspielseiten blockt der VpnService DNS-Filter).
// Der a11y-Service verhindert nur, dass schutz-relevante Settings geöffnet
// werden (VPN abschalten / App deinstallieren / a11y-Service abschalten).
// Two-step UX:
// (1) A11y nicht aktiv → Settings öffnen, return {enabled:false} mit
// Marker-Error. UI fragt nach Return den State neu ab und tappt
// erneut auf "App lock" → wir landen in Step (2).
// (2) A11y aktiv → tamperLock armen → return {enabled:true}.
const a11y = await RebreakProtection.isAccessibilityEnabled();
if (!a11y.enabled) {
await RebreakProtection.openAccessibilitySettings();
return { enabled: false, error: "accessibility_pending" };
}
try {
await RebreakProtection.armTamperLock();
return { enabled: true };
} catch (e: any) {
return {
enabled: false,
error: e?.message ?? "tamper_lock_failed",
};
}
}
return RebreakProtection.activateFamilyControls();
},
/** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
async forceDisable() {
console.log("[protection] forceDisable() — disarm tamper + native disable");
// Tamper-Lock ZUERST disarmen — sonst setzt der AccessibilityService den Schutz
// nach dem Cooldown weiter durch (blockt z.B. das Ausschalten des a11y-Service in den
// System-Settings) → der User kommt nicht aus dem Schutz raus, obwohl der Cooldown
// abgelaufen ist. (Android-Bug-Fix.)
try {
await RebreakProtection.disarmTamperLock();
} catch (e) {
console.warn("[protection] disarmTamperLock failed:", e);
}
const res = await RebreakProtection.disable();
console.log("[protection] native disable returned:", res);
return res;
},
getDeviceState(): Promise<DeviceLayers> {
return RebreakProtection.getDeviceState();
},
// ─── iOS Layer 2 — webContent-Filter (ManagedSettings) ───────────────────
//
// Stilles WebKit-Sicherheitsnetz: blockt eine kuratierte, länderabhängige
// Top-Gambling-Domain-Liste (≤50 Domains — Apple-Hartlimit) via
// ManagedSettings. Braucht NUR Family Controls (kein MDM, kein neues
// Entitlement). Auf Android/Web no-op.
//
// TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die
// Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1
// aus ist + ein Cooldown läuft, sonst AUS") ist bewusst NICHT gebaut — sie
// hängt an einer zuverlässigen NEURLFilter-Status-Erkennung (die aktuell
// selbst nicht stabil funktioniert) und ist eine offene Produkt-/Design-
// Entscheidung des Users. Bis dahin müssen Aufrufer applyWebContentFilter /
// clearWebContentFilter explizit triggern.
/**
* Aktiviert Layer 2 — der webContent-Filter blockt die gebündelte Top-
* Gambling-Domain-Liste des Geräte-Landes. Setzt eine aktive Family-Controls-
* Authorization voraus. No-op auf Android/Web (gibt enabled:false zurück).
*/
async applyWebContentFilter(): Promise<WebContentFilterResult> {
if (Platform.OS !== "ios") {
return { enabled: false, appliedCount: 0, region: "", error: "ios_only" };
}
return RebreakProtection.applyWebContentFilter();
},
/**
* Setzt Layer 2 zurück (blockedByFilter = .none). Rührt den App-Lock
* (denyAppRemoval) NICHT an. No-op auf Android/Web.
*/
async clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }> {
if (Platform.OS !== "ios") {
return { cleared: false, error: "ios_only" };
}
return RebreakProtection.clearWebContentFilter();
},
/**
* Synct die kuratierte Layer-2-Gambling-Domain-Liste vom Backend in den
* App-Group-Cache (`webcontent-domains.json`). Die gebündelte JSON bleibt
* Offline-Seed/Fallback. Nach erfolgreichem Sync wird der webContent-Filter
* — wenn Family Controls authorisiert ist — nativ sofort reapplied.
*
* No-op auf Android/Web (Layer 2 ist iOS-only) → gibt updated:false zurück.
* Best-effort: solange der Backend-Endpoint nicht deployed ist, schlägt der
* Fetch fehl — der native loadWebContentDomains fällt dann sauber auf die
* gebündelte JSON zurück. Der Aufrufer behandelt einen Fehler als nicht-fatal.
*/
async syncWebContentDomains(
opts: SyncWebContentDomainsOpts,
): Promise<SyncWebContentDomainsResult> {
if (Platform.OS !== "ios") {
return { updated: false };
}
return RebreakProtection.syncWebContentDomains(opts);
},
/** 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") {
try {
await RebreakProtection.reconcileVpn();
} catch (e) {
console.warn("[protection] reconcileVpn (android) failed:", e);
}
return;
}
if (Platform.OS === "ios") {
try {
const res = await RebreakProtection.reconcileUrlFilter();
if (res?.recreated) {
console.log("[protection] iOS Packet-Tunnel auto-recreated nach VPN-Delete");
} else if (res?.error) {
console.warn(`[protection] reconcileUrlFilter (ios) error: ${res.error}`);
}
} catch (e) {
console.warn("[protection] reconcileUrlFilter (ios) failed:", e);
}
return;
}
},
syncBlocklist(opts: SyncBlocklistOpts): Promise<SyncBlocklistResult> {
return RebreakProtection.syncBlocklist(opts);
},
runHealthProbe(opts?: HealthProbeOpts): Promise<HealthProbeResult> {
return RebreakProtection.runHealthProbe(opts);
},
openSystemSettings(target?: SystemSettingsTarget): Promise<void> {
return RebreakProtection.openSystemSettings(target);
},
/** iOS: native Protection-Logs (NEFilter/FamilyControls flow) für Debug-Page.
* Auf Android/Web no-op → leeres Array. */
async getProtectionLogs(): Promise<string[]> {
if (Platform.OS !== "ios") return [];
try {
return await RebreakProtection.getProtectionLogs();
} catch {
return [];
}
},
/** iOS: leert die nativen Protection-Logs. No-op auf Android/Web. */
async clearProtectionLogs(): Promise<void> {
if (Platform.OS !== "ios") return;
try {
await RebreakProtection.clearProtectionLogs();
} catch {
// ignore
}
},
addLayerChangeListener(cb: (layers: DeviceLayers) => void) {
return RebreakProtection.addListener("onLayerChange", cb);
},
// ─── Backend-Cooldown ────────────────────────────────────────────────────
/** Startet 24h Cooldown (oder 40s bei aktivem __DEV__-testMode). Schutz BLEIBT aktiv. */
async requestDeactivation(
reason?: string,
): Promise<{ cooldownEndsAt: string }> {
const testMode = __DEV__ ? await getCooldownTestMode() : false;
const body: Record<string, unknown> = { reason };
if (testMode) body.testMode = true;
const res = await apiFetch<{
cooldownEndsAt: string;
token: string;
remainingSeconds: number;
}>("/api/cooldown/request", { method: "POST", body });
return { cooldownEndsAt: res.cooldownEndsAt };
},
/** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */
async cancelDeactivation(): Promise<{ cancelled: boolean }> {
const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", {
method: "POST",
body: {},
});
return res;
},
async getCooldownStatus(): Promise<CooldownState> {
try {
const res = await apiFetch<BackendCooldownStatus>("/api/cooldown/status");
return {
active: res.active,
endsAt: res.cooldownEndsAt,
remainingSeconds: res.remainingSeconds,
reason: res.reason ?? null,
};
} catch {
// Offline / Backend down → konservativ: kein Cooldown angenommen
return { active: false, endsAt: null, remainingSeconds: 0, reason: null };
}
},
async getBackendProtectionState(): Promise<BackendProtectionState | null> {
try {
return await apiFetch<BackendProtectionState>("/api/protection/state");
} catch {
return null;
}
},
// ─── Combined State (für UI) ─────────────────────────────────────────────
/**
* Holt nativen Device-State + Backend-Cooldown parallel und merged.
* Phase-Berechnung folgt der State-Machine im Plan.
*/
async getCombinedState(): Promise<ProtectionState> {
const [rawLayers, cooldown, backend] = await Promise.all([
this.getDeviceState(),
this.getCooldownStatus(),
this.getBackendProtectionState(),
]);
// Android's native module reports {vpn, accessibility, tamperLock}; the UI
// (blocker.tsx, isAllLayersOn) reads the iOS-shaped names {urlFilter,
// familyControls, appDeletionLock}. Alias them so consumers are platform-
// agnostic. Android "App-Lock" = AccessibilityService + armed tamper-lock,
// so the lock-state maps to `tamperLock`.
const layers: DeviceLayers =
Platform.OS === "android" && rawLayers.urlFilter === undefined
? ({
...rawLayers,
urlFilter: rawLayers.vpn,
familyControls: rawLayers.tamperLock,
appDeletionLock: rawLayers.tamperLock,
} as DeviceLayers)
: rawLayers;
// "Aktiv" = der eigentliche Schutz (URL-/DNS-Filter) läuft. Der App-Lock
// (familyControls/tamperLock) ist optionales Hardening — er macht den Schutz
// schwerer abschaltbar, ist aber keine Voraussetzung für "geschützt". Er wird
// nur beim ersten Aktivieren eingerichtet; eine Reaktivierung setzt nur den
// Filter wieder. → "recoveringFromBypass" heißt deshalb: Filter ist aus,
// obwohl das Backend sagt er sollte an sein (= jemand hat den VPN extern aus).
const phase: ProtectionPhase = cooldown.active
? "cooldownActive"
: backend?.protectionShouldBeActive === true && layers.urlFilter !== true
? "recoveringFromBypass"
: layers.urlFilter === true
? "active"
: "inactive";
return {
phase,
layers,
cooldown,
blocklistCount: layers.blocklistCount,
plan: backend?.plan ?? "free",
};
},
/**
* Wenn ein Cooldown TATSÄCHLICH gelaufen ist und jetzt elapsed → native disable.
*
* Defensiv: prüft `cooldownEndsAt` (heißt es gab einen Cooldown) UND
* `remainingSeconds <= 0` (heißt er ist abgelaufen) UND `canDisableProtection`.
* Backend kann `canDisableProtection: true` auch im initial-state geben;
* der `cooldownEndsAt`-Check verhindert dann False-Positives.
*/
async applyCooldownDisableIfElapsed(): Promise<boolean> {
const status = await apiFetch<BackendCooldownStatus>(
"/api/cooldown/status",
).catch(() => null);
if (!status) return false;
if (!status.canDisableProtection) return false;
if (!status.cooldownEndsAt) return false; // nie ein Cooldown gewesen
if (status.remainingSeconds > 0) return false; // Cooldown noch nicht abgelaufen
await this.forceDisable();
return true;
},
};
// ─── Helpers ───────────────────────────────────────────────────────────────
export function isAllLayersOn(layers: DeviceLayers): boolean {
// iOS: urlFilter + appDeletionLock (fallback: familyControls für ältere Builds).
// Android: vpn + accessibility (+ tamperLock optional).
if (
layers.urlFilter !== undefined ||
layers.familyControls !== undefined ||
layers.appDeletionLock !== undefined
) {
const lockLayer = layers.appDeletionLock ?? layers.familyControls;
return layers.urlFilter === true && lockLayer === true;
}
if (layers.vpn !== undefined || layers.accessibility !== undefined) {
return layers.vpn === true && layers.accessibility === true;
}
return false;
}
export function formatCooldownRemaining(seconds: number): string {
if (seconds <= 0) return "00:00:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}