chahinebrini c477b300ad
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
feat(ios): Extensions melden Protection-State ans Backend
- RebreakProtectionModule.setExtensionCredentials() speichert Token,
  deviceId + baseURL in App-Group Shared UserDefaults.
- Auth-Store ruft setExtensionCredentials bei Session-Änderungen auf.
- ContentFilter-Extension (FilterDataProvider) sendet bei stopFilter()
  /api/protection/event active=false mit x-extension-secret.
- PacketTunnel-Extension (PacketTunnelProvider) sendet bei stopTunnel()
  /api/protection/event active=false mit x-extension-secret.
2026-06-18 09:42:18 +02:00

1630 lines
73 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
private let LAST_SYNC_KEY = "blocklist_last_sync_at"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
private let MS_STORE_NAME = "rebreak.shield"
// Layer 2 webContent-Filter (ManagedSettings)
private let WEBCONTENT_BUNDLE = "RebreakProtectionResources"
private let WEBCONTENT_DOMAINS_FILE = "gambling-domains"
// Apple-Hartlimit: ManagedSettings blockt max. 50 Domains pro FilterPolicy.
// Wird hier defensiv durchgesetzt nie mehr als 50 an blockedByFilter geben.
private let WEBCONTENT_MAX_DOMAINS = 50
private let WEBCONTENT_FALLBACK_REGION = "DE"
// Runtime-Sync-Cache: die vom Backend geholte Domain-Liste landet als
// webcontent-domains.json im App-Group-Container. loadWebContentDomains liest
// cache-first; die gebündelte gambling-domains.json bleibt Offline-Seed/Fallback.
private let WEBCONTENT_CACHE_FILENAME = "webcontent-domains.json"
private let WEBCONTENT_ETAG_KEY = "webcontent_domains_etag"
private let WEBCONTENT_LAST_SYNC_KEY = "webcontent_domains_last_sync_at"
// Layer 1 (NEU) Packet-Tunnel-DNS-Filter (NEPacketTunnelProvider).
// Ersetzt NEURLFilter als primären, lieferbaren iOS-Filter (NEURLFilter ist
// Apple-seitig blockiert; der Code bleibt als iOS-26-Upgrade-Pfad erhalten).
// Bundle-ID MUSS exakt zu plugins/with-rebreak-protection-ios.js + app.config.ts
// (appExtensions) passen.
private let PACKET_TUNNEL_BUNDLE_ID = "org.rebreak.app.PacketTunnelExtension"
// Lesbarer Name für den iOS-VPN-Settings-Eintrag.
private let PACKET_TUNNEL_DESCRIPTION = "ReBreak Schutz"
// App-Group-Flags, die die Packet-Tunnel-Extension spiegelt (Tunnel-Lifecycle).
private let VPN_TUNNEL_RUNNING_KEY = "vpn_tunnel_running"
private let VPN_TUNNEL_REVOKED_KEY = "vpn_tunnel_revoked_by_user"
// Shared Log-Store
fileprivate enum SharedLogStore {
static let logKey = "url_filter_logs"
static let maxEntries = 200
static func append(_ message: String) {
NSLog("REBREAK_PROTECTION %@", message)
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
var logs = defaults.stringArray(forKey: logKey) ?? []
logs.append(entry)
if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
defaults.set(logs, forKey: logKey)
}
/// Letzte `n` Einträge für Inline-Diagnose im activateUrlFilter-Ergebnis
/// (enthält auch die `[EXT ...]`-Zeilen der Control-Provider-Extension).
static func tail(_ n: Int) -> [String] {
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
return Array((defaults.stringArray(forKey: logKey) ?? []).suffix(n))
}
}
// Module
/// Vereinheitlichtes ReBreak-Schutz-Modul.
///
/// Wraps:
/// - NEFilterDataProvider Konfiguration (URL-Filter Layer)
/// - AuthorizationCenter + ManagedSettings (Family Controls denyAppRemoval)
/// - Blocklist-Sync mit Retry-Backoff
/// - End-to-End Health-Probe via hidden WKWebView
///
/// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt
/// JS-seitig (`lib/protection.ts` Backend-API).
public class RebreakProtectionModule: Module {
// Health-Probe State (UI-Thread)
private var probeWebView: WKWebView?
private var probeDelegate: HealthProbeDelegate?
public func definition() -> ModuleDefinition {
Name("RebreakProtection")
Events("onLayerChange")
// setExtensionCredentials: Auth-Token + deviceId an Extensions
// Die Network-Extensions laufen als eigene Prozesse und haben keinen Zugriff
// auf das Supabase-SDK. Damit sie bei stopFilter()/stopTunnel() selbst den
// Protection-State ans Backend melden können, schreibt die Haupt-App Token
// + deviceId in die App-Group Shared UserDefaults.
AsyncFunction("setExtensionCredentials") { (token: String, deviceId: String, baseURL: String) in
guard let defaults = UserDefaults(suiteName: APP_GROUP) else {
SharedLogStore.append("⚠️ setExtensionCredentials: App-Group nicht erreichbar")
return
}
defaults.set(token, forKey: "rebreak_extension_token")
defaults.set(deviceId, forKey: "rebreak_extension_device_id")
defaults.set(baseURL, forKey: "rebreak_extension_base_url")
SharedLogStore.append("🔐 Extension-Credentials gespeichert (deviceId len=\(deviceId.count))")
}
// activate: Family Controls + NEFilter + denyAppRemoval
// probeContentFilter: Try NEFilter, retourniert ob das Device es
// erlaubt. Pure Detection kein persistenter State, kein User-Toggle-Touch.
//
// iOS-Verhalten 2026: NEFilter wird auf nicht-MDM-managed Geräten silent
// gecuttet (kein Permission-Dialog, isEnabled bleibt false). Wenn die App
// nach saveToPreferences() ein isEnabled=true sieht, ist das Device entweder
// MDM-managed ODER der User hat dem Dialog explizit zugestimmt beide
// Bedeutungen "NEFilter ist gestartet". Wenn isEnabled=false bleibt
// Device ist nicht-MDM und kann NEFilter nicht.
//
// Aufgerufen vom Settings-"Auto-Detect"-Button setzt JS-Toggle. Cleanup
// erfolgt NICHT automatisch wenn die Probe positiv ist, läuft NEFilter
// weiter als Schutz-Layer (gut so). Wenn negativ, removeFromPreferences
// räumt die halbe Config auf.
AsyncFunction("probeContentFilter") { () async -> [String: Any] in
var enabled = false
var error: String? = nil
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("🔍 [probeContentFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
// CACHED-DENIED-CLEAR: Apple cached den "User hat Nicht erlauben gewählt"
// State permanent ein simples saveToPreferences würde silent Error 5
// bringen, OHNE Dialog. removeFromPreferences löscht den cached state,
// sodass das nächste save als frischer Permission-Request gilt + Dialog
// zeigt (auf non-MDM) ODER silent durchgeht (auf MDM-enrolled).
SharedLogStore.append("🔍 [probeContentFilter] removeFromPreferences (clear cached denied)...")
do {
try await manager.removeFromPreferences()
} catch {
SharedLogStore.append(" [probeContentFilter] removeFromPreferences skip: \(error.localizedDescription)")
}
// NEAgent braucht ~800ms damit der remove propagiert bevor save als
// frisch behandelt wird. Empirisch aus resetUrlFilter (iOS 17/18).
try? await Task.sleep(nanoseconds: 800_000_000)
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "ReBreak Schutz"
manager.isEnabled = true
SharedLogStore.append("🔍 [probeContentFilter] saveToPreferences (Dialog auf non-MDM, silent auf MDM)...")
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("🔍 [probeContentFilter] post-save isEnabled=\(enabled)")
if !enabled {
// Cleanup halbe Config nicht stehen lassen
try? await manager.removeFromPreferences()
SharedLogStore.append("🔍 [probeContentFilter] not enabled — removed config")
}
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ [probeContentFilter] failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
result["log"] = SharedLogStore.tail(30)
return result
}
// activateUrlFilter: Layer 1 Branch supervised vs unsupervised
//
// Branch entscheidet die JS-Schicht via `supervised: Bool` aus dem
// User-Setting (AsyncStorage `protection:device-mdm-supervised`).
//
// Supervised=true (User-Toggle ON oder Auto-Detect bestätigt):
// klassisches NEFilterDataProvider kein VPN-Toggle in Settings,
// kein User-Bypass. Funktioniert nur auf MDM-managed Devices (iOS-Wall).
// Supervised=false (Default):
// NEPacketTunnelProvider DNS-Sinkhole VPN-Eintrag in Settings sichtbar,
// MDM-frei, ab iOS 16, Android-Parität.
AsyncFunction("activateUrlFilter") { (opts: [String: Any]) async -> [String: Any] in
// Expo's RN-Bridge schickt JS-Booleans als NSNumber durch direktes
// `as? Bool`-Cast retourniert nil. Doppel-Cast (Bool NSNumber.boolValue)
// damit's robust ist gegen beide Wege.
let supervisedAny = opts["supervised"]
let supervised: Bool
if let b = supervisedAny as? Bool {
supervised = b
} else if let n = supervisedAny as? NSNumber {
supervised = n.boolValue
} else {
supervised = false
}
SharedLogStore.append("📥 [activateUrlFilter] opts.supervised raw=\(String(describing: supervisedAny))\(supervised)\(supervised ? "NEFilter (content-filter)" : "PacketTunnel (VPN)")")
if supervised {
return await Self.activateContentFilter()
}
var error: String? = nil
var enabled = false
var statusName = "n/a"
do {
SharedLogStore.append("📥 [activateUrlFilter] Packet-Tunnel — loadAllFromPreferences...")
let manager = try await Self.loadOrCreateTunnelManager()
// Tunnel starten. saveToPreferences() löst beim allerersten Mal den
// iOS-VPN-System-Permission-Dialog aus.
try await manager.saveToPreferences()
// Nach saveToPreferences muss neu geladen werden, sonst wirft
// startVPNTunnel() NEVPNError.configurationStale.
try await manager.loadFromPreferences()
guard let session = manager.connection as? NETunnelProviderSession else {
error = "tunnel_session_unavailable"
SharedLogStore.append("❌ [activateUrlFilter] keine NETunnelProviderSession")
var r: [String: Any] = ["enabled": false, "status": "n/a"]
r["error"] = error
r["log"] = SharedLogStore.tail(30)
return r
}
SharedLogStore.append("🚀 [activateUrlFilter] startVPNTunnel...")
try session.startVPNTunnel()
// Auf .connected warten. Direkt nach startVPNTunnel() steht der Status
// oft noch kurz auf .disconnected, BEVOR er auf .connecting .connected
// wechselt. Daher pollen bis .connected ODER Timeout (6s) NICHT nur
// solange .connecting (sonst false-negative tunnel_not_connected in der
// frühen .disconnected-Phase). .invalid = Konfig-Fehler sofort raus.
var status = manager.connection.status
var waited = 0
while status != .connected && status != .invalid && waited < 15 {
try? await Task.sleep(nanoseconds: 400_000_000)
status = manager.connection.status
waited += 1
}
statusName = Self.tunnelStatusName(status)
enabled = (status == .connected)
// App-Group-Flags spiegeln (die Extension setzt sie ebenfalls; hier
// setzen wir sie optimistisch, damit getDeviceState konsistent ist).
if let d = UserDefaults(suiteName: APP_GROUP) {
d.set(enabled, forKey: VPN_TUNNEL_RUNNING_KEY)
d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
}
if enabled {
SharedLogStore.append("✅ Packet-Tunnel connected")
} else {
error = "tunnel_not_connected status=\(statusName)"
SharedLogStore.append("❌ Packet-Tunnel nicht aktiv: \(error!)")
}
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ [activateUrlFilter] Packet-Tunnel failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled, "status": statusName]
if let error = error { result["error"] = error }
result["log"] = SharedLogStore.tail(30)
return result
}
// activateNeUrlFilter: NEURLFilter (iOS 26) INAKTIV
//
// Behalten als optionaler iOS-26-Upgrade-Pfad (User-Entscheidung). Wird
// NICHT mehr vom Default-Flow aufgerufen `activateUrlFilter` startet
// stattdessen den Packet-Tunnel. Sobald Apple den NEURLFilter-DTS-Bug
// fixt, kann dieser Pfad als privacy-besserer" Filter (PIR) reaktiviert
// werden. Funktion bleibt registriert, damit der Code build-bar bleibt.
AsyncFunction("activateNeUrlFilter") { (opts: [String: String]) async -> [String: Any] in
var error: String? = nil
var enabled = false
var statusName = "n/a"
var disconnectName: String? = nil
if #available(iOS 26.0, *) {
let pirAuthToken = opts["pirAuthToken"] ?? ""
guard let pirServerURL = URL(string: opts["pirServerURL"] ?? ""), !pirAuthToken.isEmpty else {
SharedLogStore.append("❌ [activateUrlFilter] fehlende pirServerURL/pirAuthToken")
return ["enabled": false, "error": "missing_pir_config"]
}
func name(_ s: NEURLFilterManager.Status) -> String {
switch s {
case .invalid: return "invalid"
case .stopped: return "stopped"
case .starting: return "starting"
case .running: return "running"
case .stopping: return "stopping"
@unknown default: return "unknown(\(s.rawValue))"
}
}
var phase = "loadFromPreferences"
do {
let manager = NEURLFilterManager.shared
SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
phase = "setConfiguration"
SharedLogStore.append(
"⚙️ [activateUrlFilter] setConfiguration (server=\(pirServerURL.absoluteString) tokenLen=\(pirAuthToken.count))..."
)
// pirPrivacyPassIssuerURL == pirServerURL PIRService bedient beides.
try manager.setConfiguration(
pirServerURL: pirServerURL,
pirPrivacyPassIssuerURL: pirServerURL,
pirAuthenticationToken: pirAuthToken,
controlProviderBundleIdentifier: "org.rebreak.app.URLFilterExtension"
)
// WWDC2025-NEURLFilter-Sample setzt zusätzlich `localizedDescription` +
// `prefilterFetchInterval` die hatten wir bisher NICHT. Eine fehlende
// `localizedDescription` ist ein Kandidat für das `configurationInvalid`
// bei `saveToPreferences()`.
manager.localizedDescription = "ReBreak URL-Filter"
manager.prefilterFetchInterval = 86400 // 1 Tag (wie WWDC-Sample)
manager.isEnabled = true
manager.shouldFailClosed = true
phase = "saveToPreferences"
SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences...")
try await manager.saveToPreferences()
phase = "status"
// saveToPreferences wirft NICHT, wenn iOS die Config zwar speichert,
// aber als ungültig ablehnt ("Ungültig" in Settings). Der echte
// Status ist erst nach kurzer Zeit stabil direkt nach dem Save
// steht er auf .starting. Auf stabilen Status warten (max ~3s).
var status = await manager.status
var waited = 0
while status == .starting && waited < 8 {
try? await Task.sleep(nanoseconds: 400_000_000)
status = await manager.status
waited += 1
}
statusName = name(status)
if let d = await manager.lastDisconnectError {
disconnectName = "\(d)"
SharedLogStore.append("⚠️ NEURLFilter lastDisconnectError: \(d) (rawValue=\(d.rawValue))")
}
// Wahrheit ist der Status, NICHT isEnabled (das bleibt true auch bei
// einer abgelehnten Config).
enabled = (status == .running)
SharedLogStore.append(
" NEURLFilter post-save: status=\(statusName) isEnabled=\(manager.isEnabled) disconnectError=\(disconnectName ?? "nil")"
)
if !enabled {
error = "config_invalid status=\(statusName) disconnectError=\(disconnectName ?? "none")"
SharedLogStore.append("❌ NEURLFilter nicht aktiv: \(error!)")
} else {
SharedLogStore.append("✅ NEURLFilter running")
}
} catch let e as NSError {
// lastDisconnectError auch im Throw-Fall lesen gibt oft den echten
// Grund hinter einem generischen configurationInvalid.
var disc = "n/a"
if let d = await NEURLFilterManager.shared.lastDisconnectError {
disc = "\(d)"
}
error = "[\(phase)] \(e.domain):\(e.code) \(e.localizedDescription) | lastDisconnectError=\(disc)"
SharedLogStore.append("❌ NEURLFilter enable failed: \(error!)")
}
} else {
error = "iOS 26+ erforderlich für NEURLFilter"
SharedLogStore.append("\(error!)")
}
var result: [String: Any] = ["enabled": enabled, "status": statusName]
if let d = disconnectName { result["disconnectError"] = d }
if let error = error { result["error"] = error }
// Tail des geteilten Log-Stores mitgeben enthält die [EXT ...]-Zeilen
// der Control-Provider-Extension (separater Prozess, sonst unsichtbar).
result["log"] = SharedLogStore.tail(30)
return result
}
// resetUrlFilter: cached "denied" State löschen + frischer Dialog
//
// Apple's NEFilterManager cached den "permission denied"-State wenn User einmal
// "Nicht erlauben" getippt hat danach zeigt iOS den System-Dialog beim erneuten
// saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences
// löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences
// als brandneuer Permission-Request behandelt wird frischer System-Dialog.
//
// Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht
// zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery.
AsyncFunction("resetUrlFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
// Best-effort remove schlägt fehl wenn nichts existiert, das ist OK.
SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...")
do {
try await manager.removeFromPreferences()
} catch {
SharedLogStore.append(" removeFromPreferences ignored: \(error.localizedDescription)")
}
// NEAgent braucht Zeit den remove zu propagieren bevor ein neuer save
// als "frischer Permission-Request" behandelt wird. Ohne Pause feuert
// iOS oft direkt wieder NEFilterErrorDomain:5 (cached denied).
// 800ms aus empirischen Tests (iOS 17/18).
try? await Task.sleep(nanoseconds: 800_000_000)
// Frische Config setzen + neu speichern iOS zeigt jetzt frischen Dialog.
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
// Retry-Loop: bis 3 Versuche mit exponentiellem Backoff. Apple hat
// seit iOS 17 den denied-cache hardened manchmal braucht's mehrere
// saves bis der System-Dialog wirklich aufpoppt.
var lastError: NSError? = nil
for attempt in 1...3 {
SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences attempt \(attempt)/3...")
do {
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))")
lastError = nil
break
} catch let e as NSError {
lastError = e
SharedLogStore.append("⚠️ saveToPreferences attempt \(attempt) failed: \(e.domain):\(e.code)")
// Wenn code != 5 (denied): nicht retry, das ist anderer Fehler.
if !(e.domain == "NEFilterErrorDomain" && e.code == 5) {
throw e
}
// Sonst: kurzes Wait + nochmal versuchen
try? await Task.sleep(nanoseconds: UInt64(attempt) * 600_000_000)
}
}
if let lastError = lastError {
throw lastError
}
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ resetUrlFilter failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
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
var error: String? = nil
var enabled = false
var webContentCount = 0
if #available(iOS 16.0, *) {
// Retry-Loop: FamilyControls XPC-Daemon kann auf den ersten Call
// mit NSCocoaErrorDomain:4099 antworten (Communication-Failure, oft
// direkt nach App-Start oder nach NEFilter-Activation). 3 Versuche
// mit Backoff lösen das in der Praxis.
var lastError: NSError? = nil
for attempt in 1...3 {
do {
SharedLogStore.append("🔐 [activateFamilyControls] requestAuthorization attempt \(attempt)/3...")
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
let authorized = AuthorizationCenter.shared.authorizationStatus == .approved
SharedLogStore.append("✅ FamilyControls authorized (\(authorized))")
lastError = nil
if authorized {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.application.denyAppRemoval = true
store.application.denyAppInstallation = false
let lockActive = (store.application.denyAppRemoval as? Bool) == true
enabled = lockActive
SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)")
if !lockActive {
error = "denyAppRemoval_not_active"
}
// Layer 2 der webContent-Filter ist Teil des FC-Schutzes:
// greift mit, sobald FC aktiv ist (stilles Sicherheitsnetz für
// den Fall, dass der User Layer 1 / VPN abschaltet). Best-effort
// ein Fehlschlag hier kippt die FC-Aktivierung NICHT.
let wc = Self.applyWebContentLayer()
webContentCount = wc.count
} else {
enabled = false
}
break
} catch let e as NSError {
lastError = e
SharedLogStore.append("⚠️ FamilyControls attempt \(attempt) failed: \(e.domain):\(e.code) \(e.localizedDescription)")
// Nur retryen wenn 4099 (XPC-Daemon nicht erreichbar). Sonst sofort fail.
if !(e.domain == "NSCocoaErrorDomain" && e.code == 4099) {
break
}
try? await Task.sleep(nanoseconds: UInt64(attempt) * 700_000_000)
}
}
if let lastError = lastError, error == nil {
error = "\(lastError.domain):\(lastError.code) \(lastError.localizedDescription)"
SharedLogStore.append("❌ FamilyControls auth failed (all retries): \(error!)")
}
} else {
error = "iOS 16+ required for FamilyControls"
}
var result: [String: Any] = ["enabled": enabled, "webContentDomains": webContentCount]
if let error = error { result["error"] = error }
return result
}
// activate (legacy, alle Layer in einem Call)
AsyncFunction("activate") { () async -> [String: Any] in
var missingLayers: [String] = []
var errors: [String] = []
// 1) Family Controls Authorization (iOS 16+)
var familyControlsApproved = false
if #available(iOS 16.0, *) {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved
SharedLogStore.append("✅ FamilyControls authorized")
} catch let error as NSError {
let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
} else {
errors.append("iOS 16+ required for FamilyControls")
}
if !familyControlsApproved {
missingLayers.append("familyControls")
}
// 2) NEFilter aktivieren
var urlFilterOn = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activate] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...")
try await manager.saveToPreferences()
urlFilterOn = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))")
} catch let error as NSError {
let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
if !urlFilterOn {
missingLayers.append("urlFilter")
}
// 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved)
if #available(iOS 16.0, *), familyControlsApproved {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.application.denyAppRemoval = true
store.application.denyAppInstallation = false
SharedLogStore.append("🔒 denyAppRemoval = true")
// Layer 2 webContent-Filter als Teil des FC-Schutzes mit-aktivieren.
_ = Self.applyWebContentLayer()
}
return [
"allLayersOn": missingLayers.isEmpty,
"missingLayers": missingLayers,
// Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt
"errors": errors,
]
}
// disable: NUR aufrufen wenn JS-Cooldown abgelaufen!
AsyncFunction("disable") { () async -> [String: Any] in
// NEFilter robuster Disable-Path:
// 1. loadFromPreferences (current config + isEnabled state lesen)
// 2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen +
// Settings.app-UI flippt sofort auf "deaktiviert")
// 3. removeFromPreferences (Config-Eintrag aus Settings entfernen)
//
// Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen
// (insb. iOS 18+) unzuverlässig Settings-UI zeigt "Läuft..." obwohl der
// Provider beendet sein sollte. Erst isEnabled=false + save bringt das
// System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config
// löschen. Pattern aus Apple-Developer-Forums + eigene Empirie.
// Layer 1 = Packet-Tunnel-DNS-Filter stoppen + Config entfernen.
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let manager = Self.findRebreakTunnel(in: managers) {
if let session = manager.connection as? NETunnelProviderSession {
session.stopVPNTunnel()
}
try await manager.removeFromPreferences()
SharedLogStore.append("✅ Packet-Tunnel stopped + removed from preferences")
} else {
SharedLogStore.append(" Packet-Tunnel disable: keine Config vorhanden")
}
} catch {
SharedLogStore.append("⚠️ Packet-Tunnel disable: \(error.localizedDescription)")
}
// App-Group-Tunnel-Flags zurücksetzen.
if let d = UserDefaults(suiteName: APP_GROUP) {
d.removeObject(forKey: VPN_TUNNEL_RUNNING_KEY)
d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
}
// NEFilter (klassisch, content-filter) defensiv ebenfalls deaktivieren
// falls vorher der supervised-Pfad lief. Idempotent: macht nichts wenn
// keine NEFilter-Config existiert.
await Self.disableContentFilter()
// NEURLFilter (iOS 26) defensiv ebenfalls deaktivieren falls ein
// früherer Build NEURLFilter aktiviert hatte. Bleibt als Code erhalten.
if #available(iOS 26.0, *) {
do {
let manager = NEURLFilterManager.shared
try await manager.loadFromPreferences()
manager.isEnabled = false
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEURLFilter disabled + removed from preferences")
} catch {
SharedLogStore.append("⚠️ NEURLFilter disable: \(error.localizedDescription)")
}
}
// ManagedSettings (löst denyAppRemoval UND Layer-2-webContent-Filter
// clearAllSettings() setzt den gesamten Store inkl. webContent zurück).
if #available(iOS 16.0, *) {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.clearAllSettings()
SharedLogStore.append("🔓 ManagedSettings cleared (denyAppRemoval + webContent)")
}
// Blocklist-Datei löschen
Self.clearBlocklistFile()
return ["allLayersOff": true]
}
// Layer 2: webContent-Filter (ManagedSettings)
//
// WebKit-interner Content-Filter via ManagedSettingsStore().webContent.
// Stilles Sicherheitsnetz: blockt eine kuratierte, länderabhängige Top-
// Gambling-Domain-Liste (gebündeltes JSON, 50 Domains pro Land Apple-
// Hartlimit) plus systemseitig Adult-Content gratis mit (.auto-Variante).
//
// Voraussetzung: gültige Family-Controls-Authorization (wie denyAppRemoval).
// Kein MDM, kein neues Entitlement. Wirkt nur, solange FC authorisiert ist
// bei FC-Widerruf fällt der Filter lautlos weg (Apple-Verhalten, kein
// Callback).
//
// TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die
// Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1
// deaktiviert ist + während ein Cooldown läuft, sonst AUS") ist bewusst
// NICHT implementiert 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 ruft die
// JS-Schicht applyWebContentFilter/clearWebContentFilter explizit auf.
// applyWebContentFilter: Land Domains blockedByFilter
AsyncFunction("applyWebContentFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
var appliedCount = 0
var resolvedRegion = WEBCONTENT_FALLBACK_REGION
if #available(iOS 16.0, *) {
// Family Controls muss authorisiert sein, sonst ist webContent stumm.
guard AuthorizationCenter.shared.authorizationStatus == .approved else {
SharedLogStore.append("⚠️ [applyWebContentFilter] FamilyControls nicht authorisiert — abort")
return [
"enabled": false,
"appliedCount": 0,
"region": resolvedRegion,
"error": "family_controls_not_authorized",
]
}
// Gemeinsame Layer-2-Logik (applyWebContentLayer) exakt dieselbe,
// die auch bei der FC-Aktivierung mitläuft.
let wc = Self.applyWebContentLayer()
enabled = wc.enabled
appliedCount = wc.count
resolvedRegion = wc.region
if !enabled { error = "no_domains_for_region" }
} else {
error = "iOS 16+ required for webContent filter"
SharedLogStore.append("❌ [applyWebContentFilter] \(error!)")
}
var result: [String: Any] = [
"enabled": enabled,
"appliedCount": appliedCount,
"region": resolvedRegion,
]
if let error = error { result["error"] = error }
return result
}
// clearWebContentFilter: Filter zurücksetzen
AsyncFunction("clearWebContentFilter") { () async -> [String: Any] in
if #available(iOS 16.0, *) {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
// Nur den webContent-Layer zurücksetzen NICHT clearAllSettings(),
// damit denyAppRemoval (Layer App-Lock") unangetastet bleibt.
store.webContent.blockedByFilter = .none
SharedLogStore.append("🔓 [clearWebContentFilter] webContent.blockedByFilter=.none")
return ["cleared": true]
}
return ["cleared": false, "error": "iOS 16+ required"]
}
// isNeFilterActive: System-NEFilter via Profile detecten
//
// Build 19 (2026-05-26): Apple's webcontent-filter Profile (Sideload non-removable)
// aktiviert die ContentFilter-Extension autonom App-Code aktiviert NICHT
// mehr selbst (würde gegen MDM-managed-Filter konfligieren, plus Apple-Wall
// bei Distribution-Cert). App liest nur den State um UI all-green zu zeigen.
//
// NEFilterManager.shared().loadFromPreferences() returns die aktive Config
// (kann via App-Code ODER via webcontent-filter Profile gesetzt sein
// egal, wir betrachten beides als aktiv").
AsyncFunction("isNeFilterActive") { () async -> [String: Any] in
var enabled = false
var localizedDescription: String? = nil
var error: String? = nil
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
enabled = manager.isEnabled
localizedDescription = manager.localizedDescription
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
}
var result: [String: Any] = ["enabled": enabled]
if let ld = localizedDescription { result["localizedDescription"] = ld }
if let error = error { result["error"] = error }
return result
}
// getDeviceState: aktueller Status aller Layer
AsyncFunction("getDeviceState") { () async -> [String: Any] in
// Layer 1 = Packet-Tunnel-DNS-Filter (alter VPN-Pfad, unsupervised devices)
// ODER NEFilter via webcontent-filter Profile (Sideload non-removable, MDM-mode).
// UI muss beide State-Quellen kennen `nefilterActive` (neu) hat höhere Prio.
var urlFilter = false
var mdmManaged = false
do {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
// Legacy MDM-Detection via VPN-Tunnel-Count (heute irrelevant wir nutzen
// jetzt nefilterActive als primary MDM-Indikator, da der Sideload-Pfad
// den MDM-VPN-Push überholt hat).
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 + mdmManaged bleiben false.
}
// NEFilter (klassisches Content-Filter via Sideload-Profile)
var nefilterActive = false
var nefilterDescription: String? = nil
do {
let nefManager = NEFilterManager.shared()
try await nefManager.loadFromPreferences()
nefilterActive = nefManager.isEnabled
nefilterDescription = nefManager.localizedDescription
} catch {
// ignore kein NEFilter konfiguriert nefilterActive bleibt false
}
// FamilyControls
var familyControls = false
var appDeletionLock = false
var webContentFilter = false
if #available(iOS 16.0, *) {
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
// Layer 2 gilt als an", wenn eine FilterPolicy .none gesetzt ist.
if let policy = store.webContent.blockedByFilter {
if case .none = policy {
webContentFilter = false
} else {
webContentFilter = true
}
}
}
let count = Self.currentHashCount()
let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY)
var result: [String: Any] = [
"urlFilter": urlFilter,
"familyControls": familyControls,
"appDeletionLock": appDeletionLock,
"webContentFilter": webContentFilter,
"mdmManaged": mdmManaged,
"nefilterActive": nefilterActive,
"blocklistCount": count,
"blocklistLastSyncAt": lastSync ?? NSNull(),
]
if let nd = nefilterDescription { result["nefilterDescription"] = nd }
return result
}
// syncBlocklist: download + atomic write + DarwinNotif
AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
)
}
guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
)
}
// Retry mit Backoff (1s, 2s) fängt iOS NECP-Race nach saveToPreferences
let maxAttempts = 3
var attempt = 1
var lastError: Error?
while attempt <= maxAttempts {
do {
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) {
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
)
}
if httpResponse.statusCode == 304 {
SharedLogStore.append("📡 sync 304 (cached)")
return ["updated": false, "count": Self.currentHashCount()]
}
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "RebreakProtection", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
guard let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
)
}
let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME)
let tmpURL = finalURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
// DiGA-Hardening: Datei verschlüsselt at-rest. WICHTIG:
// `.completeUntilFirstUserAuthentication`, NICHT `.complete`
// die PacketTunnel-Extension muss `blocklist.bin` auch bei
// GESPERRTEM Gerät lesen können. `startTunnel` läuft OS-getrieben
// (on-demand) auch während Lock-Phasen; `.complete` macht die Datei
// dann unlesbar `HashList.load()` liefert 0 Hashes Layer 1
// blockt nichts, bis die App das nächste Mal synct.
// `.completeUntilFirstUserAuthentication` bleibt at-rest
// verschlüsselt, ist aber nach dem ersten Entsperren seit Boot
// lesbar die korrekte Klasse für eine 24/7-NE-Datei.
try? (tmpURL as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
var mut = tmpURL
var rv = URLResourceValues()
rv.isExcludedFromBackup = true
try? mut.setResourceValues(rv)
// Atomic replace
_ = try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
// ETag + lastSync persistieren
let defaults = UserDefaults(suiteName: APP_GROUP)
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
defaults?.set(newEtag, forKey: ETAG_KEY)
}
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY)
// Extension benachrichtigen (cross-process)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
let count = data.count / 8
let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? ""
SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))")
var result: [String: Any] = ["updated": true, "count": count]
if !plan.isEmpty { result["plan"] = plan }
return result
} catch {
lastError = error
let nsError = error as NSError
let isTransient = nsError.domain == NSURLErrorDomain && (
nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotConnectToHost ||
nsError.code == NSURLErrorTimedOut ||
nsError.code == NSURLErrorNetworkConnectionLost ||
nsError.code == NSURLErrorCannotFindHost
)
if isTransient && attempt < maxAttempts {
let delaySec = UInt64(attempt)
SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s")
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
attempt += 1
continue
}
SharedLogStore.append("❌ sync failed: \(error.localizedDescription)")
throw error
}
}
throw lastError ?? NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
)
}
// syncWebContentDomains: Layer-2-Domain-Liste vom Backend
//
// Gespiegelt von `syncBlocklist` (oben). Holt die kuratierte Gambling-
// Domain-Liste (`GET /api/protection/webcontent-domains`) und schreibt das
// JSON atomar in den App-Group-Container als `webcontent-domains.json`.
// `loadWebContentDomains` liest danach cache-first aus dieser Datei; die
// gebündelte `gambling-domains.json` bleibt nur noch Offline-Seed/Fallback.
//
// URL-/Auth-Handling exakt wie `syncBlocklist`: baseURL + authToken kommen
// aus JS (`opts`), Bearer-Auth, ETag/If-None-Match, Retry mit Backoff.
//
// Nach erfolgreichem Sync wird wenn Family Controls authorisiert ist
// `applyWebContentLayer()` erneut aufgerufen, damit die neue Liste sofort
// greift. Solange der Backend-Endpoint nicht deployed ist, schlägt der
// Fetch fehl `loadWebContentDomains` fällt sauber auf die gebündelte
// JSON zurück (App funktioniert offline weiter).
AsyncFunction("syncWebContentDomains") { (opts: [String: String]) async throws -> [String: Any] in
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
)
}
guard let endpoint = URL(string: "\(baseURL)/api/protection/webcontent-domains") else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
)
}
// Retry mit Backoff (1s, 2s) fängt transiente Netzwerk-Fehler.
let maxAttempts = 3
var attempt = 1
var lastError: Error?
while attempt <= maxAttempts {
do {
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: WEBCONTENT_ETAG_KEY) {
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
)
}
if httpResponse.statusCode == 304 {
SharedLogStore.append("📡 [webContent] sync 304 (cached)")
return ["updated": false]
}
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "RebreakProtection", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
// Vor dem Schreiben validieren: muss parsebares JSON mit Country-Keys
// sein (gleiche Struktur wie die gebündelte gambling-domains.json).
// Eine kaputte Response NICHT in den Cache schreiben sonst killt
// sie den Fallback.
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(
domain: "RebreakProtection", code: 422,
userInfo: [NSLocalizedDescriptionKey: "response is not valid JSON object"]
)
}
// Mindestens ein Country-Key mit einem String-Array erwartet.
let countryKeyCount = root.keys.filter { !$0.hasPrefix("_") }.count
guard countryKeyCount > 0 else {
throw NSError(
domain: "RebreakProtection", code: 422,
userInfo: [NSLocalizedDescriptionKey: "response has no country keys"]
)
}
guard let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
)
}
let finalURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
let tmpURL = finalURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
// DiGA-Hardening (gespiegelt von syncBlocklist)
// `.completeUntilFirstUserAuthentication`, damit die Datei auch aus
// Hintergrund-Kontexten bei gesperrtem Gerät lesbar bleibt.
try? (tmpURL as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
var mut = tmpURL
var rv = URLResourceValues()
rv.isExcludedFromBackup = true
try? mut.setResourceValues(rv)
// Atomic replace
_ = try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
// ETag + lastSync persistieren
let defaults = UserDefaults(suiteName: APP_GROUP)
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
defaults?.set(newEtag, forKey: WEBCONTENT_ETAG_KEY)
}
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: WEBCONTENT_LAST_SYNC_KEY)
let version = ((root["_meta"] as? [String: Any])?["version"] as? Int) ?? 0
SharedLogStore.append(
"📡 [webContent] sync ok: \(countryKeyCount) Länder (version=\(version))"
)
// Reapply: wenn Family Controls authorisiert ist, die neue Liste
// sofort wirksam machen. Best-effort ein Fehlschlag hier kippt den
// erfolgreichen Sync NICHT.
var reapplied = false
if #available(iOS 16.0, *),
AuthorizationCenter.shared.authorizationStatus == .approved {
let wc = Self.applyWebContentLayer()
reapplied = wc.enabled
SharedLogStore.append(
"🛡️ [webContent] reapply nach sync: enabled=\(wc.enabled) count=\(wc.count) region=\(wc.region)"
)
}
return [
"updated": true,
"countries": countryKeyCount,
"version": version,
"reapplied": reapplied,
]
} catch {
lastError = error
let nsError = error as NSError
let isTransient = nsError.domain == NSURLErrorDomain && (
nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotConnectToHost ||
nsError.code == NSURLErrorTimedOut ||
nsError.code == NSURLErrorNetworkConnectionLost ||
nsError.code == NSURLErrorCannotFindHost
)
if isTransient && attempt < maxAttempts {
let delaySec = UInt64(attempt)
SharedLogStore.append("⏳ [webContent] sync transient \(nsError.code), retry in \(delaySec)s")
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
attempt += 1
continue
}
SharedLogStore.append("❌ [webContent] sync failed: \(error.localizedDescription)")
throw error
}
}
throw lastError ?? NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
)
}
// runHealthProbe: hidden WKWebView gegen bet365
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
let target = (opts?["target"] as? String) ?? "https://bet365.com"
let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0
guard let url = URL(string: target) else {
return [
"outcome": "offline",
"reason": "invalid_target",
"durationMs": 0,
"target": target,
]
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async { [weak self] in
guard let self = self else {
continuation.resume(returning: [
"outcome": "offline",
"reason": "module_gone",
"durationMs": 0,
"target": target,
])
return
}
// Vorherige Probe abbrechen
self.probeWebView?.stopLoading()
self.probeWebView?.navigationDelegate = nil
self.probeWebView = nil
self.probeDelegate = nil
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
configuration: config
)
let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in
self?.probeWebView?.stopLoading()
self?.probeWebView?.navigationDelegate = nil
self?.probeWebView = nil
self?.probeDelegate = nil
switch result {
case .blocked(let reason, let durationMs):
SharedLogStore.append("🛡️ probe BLOCKED: \(target)\(reason)\(durationMs)ms")
continuation.resume(returning: [
"outcome": "blocked", "reason": reason,
"durationMs": durationMs, "target": target,
])
case .loaded(let durationMs):
SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms")
continuation.resume(returning: [
"outcome": "loaded", "reason": "page_loaded",
"durationMs": durationMs, "target": target,
])
case .offline(let reason):
continuation.resume(returning: [
"outcome": "offline", "reason": reason,
"durationMs": 0, "target": target,
])
case .timeout:
continuation.resume(returning: [
"outcome": "timeout", "reason": "no_resolution",
"durationMs": Int(timeoutSec * 1000), "target": target,
])
}
}
self.probeWebView = webView
self.probeDelegate = delegate
webView.navigationDelegate = delegate
var request = URLRequest(url: url)
request.timeoutInterval = timeoutSec
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
webView.load(request)
}
}
}
// openSystemSettings
AsyncFunction("openSystemSettings") { (target: String?) -> Void in
// Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische
// Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per
// URL-Scheme Apple deny'd das seit iOS 13. Wir öffnen die App-Settings
// unserer App; der User navigiert von dort weiter.
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
// getProtectionLogs / clearProtectionLogs
//
// SharedLogStore (NEFilter/FamilyControls native flow) schreibt nach
// UserDefaults(APP_GROUP) key "url_filter_logs". Die App hat bisher KEINEN
// Weg die auszulesen TestFlight-Debugging war auf Console.app angewiesen.
// Dieser Export macht die nativen Logs in der Debug-Page sichtbar.
AsyncFunction("getProtectionLogs") { () -> [String] in
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
return defaults.stringArray(forKey: SharedLogStore.logKey) ?? []
}
AsyncFunction("clearProtectionLogs") { () -> Void in
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
defaults.removeObject(forKey: SharedLogStore.logKey)
}
}
// Helpers
/// Wendet den Layer-2-webContent-Filter an: Land bestimmen Domain-Liste
/// laden `webContent.blockedByFilter = .auto`. Gemeinsame Logik für die
/// explizite `applyWebContentFilter`-Function UND die FC-Aktivierung
/// (`activateFamilyControls`/`activate`) Layer 2 ist Teil des FC-Schutzes
/// und läuft mit, sobald Family Controls aktiv ist. Voraussetzung: FC
/// authorisiert (der Aufrufer prüft das).
@available(iOS 16.0, *)
private static func applyWebContentLayer() -> (enabled: Bool, count: Int, region: String) {
var resolvedRegion = WEBCONTENT_FALLBACK_REGION
if let region = Locale.current.region?.identifier, !region.isEmpty {
resolvedRegion = region.uppercased()
} else if let region = Locale.current.regionCode, !region.isEmpty {
resolvedRegion = region.uppercased()
}
let domains = loadWebContentDomains(forRegion: resolvedRegion)
guard !domains.isEmpty else {
SharedLogStore.append("⚠️ [webContent] keine Domains für \(resolvedRegion)")
return (false, 0, resolvedRegion)
}
// .auto(_, except:) blockt die gelisteten Domains PLUS systemseitig
// Adult-Content. Hartlimit 50 Domains.
let webDomains = Set(domains.prefix(WEBCONTENT_MAX_DOMAINS).map { WebDomain(domain: $0) })
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.webContent.blockedByFilter = .auto(webDomains, except: [])
SharedLogStore.append("🛡️ [webContent] blockedByFilter=.auto — \(webDomains.count) Domains (\(resolvedRegion))")
return (true, webDomains.count, resolvedRegion)
}
// Packet-Tunnel (Layer 1)
/// Lesbarer Name eines NEVPNStatus fürs Logging und das JS-Ergebnis.
private static func tunnelStatusName(_ s: NEVPNStatus) -> String {
switch s {
case .invalid: return "invalid"
case .disconnected: return "disconnected"
case .connecting: return "connecting"
case .connected: return "connected"
case .reasserting: return "reasserting"
case .disconnecting: return "disconnecting"
@unknown default: return "unknown(\(s.rawValue))"
}
}
/// Findet unter allen geladenen Tunnel-Managern den ReBreak-Tunnel
/// (identifiziert über die `providerBundleIdentifier`).
private static func findRebreakTunnel(
in managers: [NETunnelProviderManager]
) -> NETunnelProviderManager? {
return managers.first { manager in
guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol
else { return false }
return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID
}
}
/// Lädt den bestehenden ReBreak-Tunnel-Manager oder erstellt einen neuen,
/// vollständig konfigurierten Manager (Protocol + On-Demand-Regel).
///
/// On-Demand (`isOnDemandEnabled`): der Tunnel fährt nach Netzwerk-Wechseln
/// automatisch wieder hoch (Self-Healing-Friction KEIN Hard-Lock; der User
/// kann On-Demand in den iOS-Settings abschalten). User-Entscheidung: an.
private static func loadOrCreateTunnelManager() async throws -> NETunnelProviderManager {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
let manager = findRebreakTunnel(in: managers) ?? NETunnelProviderManager()
// Protocol-Konfiguration (idempotent bei jedem Aufruf frisch gesetzt).
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = PACKET_TUNNEL_BUNDLE_ID
// `serverAddress` ist ein Pflichtfeld, wird bei einem rein lokalen
// DNS-Sinkhole aber nie real kontaktiert nur als Settings-UI-Label.
proto.serverAddress = "ReBreak DNS-Filter (lokal)"
manager.protocolConfiguration = proto
manager.localizedDescription = PACKET_TUNNEL_DESCRIPTION
manager.isEnabled = true
// On-Demand: Tunnel nach Netzwerk-Events selbst hochfahren.
let connectRule = NEOnDemandRuleConnect()
connectRule.interfaceTypeMatch = .any
manager.onDemandRules = [connectRule]
manager.isOnDemandEnabled = true
return manager
}
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land **cache-first**.
///
/// 1. Zuerst die per `syncWebContentDomains` vom Backend geholte
/// `webcontent-domains.json` aus dem App-Group-Container lesen.
/// 2. Nur wenn die fehlt / nicht parsebar ist / das Land nicht enthält
/// Fallback auf die gebündelte `gambling-domains.json` im
/// RebreakProtectionResources.bundle (Offline-Seed).
///
/// Beide Quellen haben dieselbe Struktur (`{ "DE": ["..."], ... }` plus
/// `_comment`/`_meta`-Felder, die ignoriert werden) und werden über
/// `parseDomainsJSON` identisch geparst. Liefert [] wenn keine der beiden
/// Quellen das Land listet der Aufrufer behandelt das als
/// no_domains_for_region.
private static func loadWebContentDomains(forRegion region: String) -> [String] {
// 1) Cache-first: App-Group-webcontent-domains.json
if let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) {
let cacheURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
if let domains = parseDomainsJSON(at: cacheURL, forRegion: region) {
SharedLogStore.append(
"🛡️ [loadWebContentDomains] cache-hit (\(domains.count) Domains, \(region.uppercased()))")
return domains
}
}
// 2) Fallback: gebündelte gambling-domains.json (Offline-Seed)
if let url = bundledWebContentDomainsURL(),
let domains = parseDomainsJSON(at: url, forRegion: region) {
SharedLogStore.append(
"🛡️ [loadWebContentDomains] bundle-fallback (\(domains.count) Domains, \(region.uppercased()))")
return domains
}
SharedLogStore.append(
"❌ [loadWebContentDomains] keine Domains für \(region.uppercased()) (cache+bundle leer)")
return []
}
/// Löst die URL der gebündelten `gambling-domains.json` auf. Das JSON wird
/// von der Podspec als RebreakProtectionResources.bundle ins App-Bundle
/// gepackt; je nach Build-Konfig liegt es im Pod-Bundle oder flach im
/// Main-Bundle.
private static func bundledWebContentDomainsURL() -> URL? {
let moduleBundle = Bundle(for: RebreakProtectionModule.self)
var jsonURL: URL? = moduleBundle.url(
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
if jsonURL == nil,
let resourceBundleURL = moduleBundle.url(
forResource: WEBCONTENT_BUNDLE, withExtension: "bundle"),
let resourceBundle = Bundle(url: resourceBundleURL) {
jsonURL = resourceBundle.url(
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
}
// Fallback: manche Build-Konfigs flachen Resource-Bundles ins Main-Bundle.
if jsonURL == nil {
jsonURL = Bundle.main.url(
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
}
return jsonURL
}
/// Parst eine Domain-Liste-JSON-Datei (Cache ODER Bundle identische
/// Struktur) und gibt die normalisierte Domain-Liste für `region` zurück.
/// Liefert `nil` wenn die Datei fehlt, nicht parsebar ist oder das Land
/// nicht enthält der Aufrufer behandelt `nil` als diese Quelle leer,
/// nächste versuchen".
private static func parseDomainsJSON(at url: URL, forRegion region: String) -> [String]? {
guard FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let domains = root[region.uppercased()] as? [String]
else {
return nil
}
// Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert,
// hart auf das Apple-Limit (WEBCONTENT_MAX_DOMAINS) gekappt.
var seen = Set<String>()
var cleaned: [String] = []
for raw in domains {
let d = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if d.isEmpty || seen.contains(d) { continue }
seen.insert(d)
cleaned.append(d)
if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
}
// Ein leeres Array ist eine gültige Antwort dieser Quelle (Land gelistet,
// aber 0 Domains) der Aufrufer entscheidet dann no_domains_for_region.
return cleaned
}
private static func currentHashCount() -> Int {
guard let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
.appendingPathComponent(BLOCKLIST_FILENAME),
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int
else { return 0 }
return size / 8
}
private static func clearBlocklistFile() {
if let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
.appendingPathComponent(BLOCKLIST_FILENAME) {
try? FileManager.default.removeItem(at: url)
}
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY)
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
}
// Supervised-Pfad: NEFilterDataProvider (content-filter)
//
// Klassisches NEFilter-Setup. Filter-Logik (Hash-Lookup gegen App-Group
// blocklist.bin) lebt in RebreakContentFilter/FilterDataProvider.swift.
// Permission-Dialog: einmaliger System-Prompt beim ersten saveToPreferences.
// Kein VPN-Eintrag in iOS-Settings, kein User-Toggle (Bypass nur via Profile-
// Entfernung die wir via Sideload-Protect-Profile blockieren).
fileprivate static func activateContentFilter() async -> [String: Any] {
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activateContentFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "ReBreak Schutz"
manager.isEnabled = true
SharedLogStore.append("💾 [activateContentFilter] saveToPreferences (System-Dialog möglich)...")
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))")
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ [activateContentFilter] NEFilter enable failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled, "status": enabled ? "running" : "stopped"]
if let error = error { result["error"] = error }
result["log"] = SharedLogStore.tail(30)
return result
}
fileprivate static func disableContentFilter() async {
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
if manager.isEnabled {
manager.isEnabled = false
try await manager.saveToPreferences()
SharedLogStore.append("✅ NEFilter disabled (isEnabled=false saved)")
}
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEFilter removed from preferences")
} catch {
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
}
}
}
// HealthProbeDelegate
private final class HealthProbeDelegate: NSObject, WKNavigationDelegate {
enum Outcome {
case blocked(reason: String, durationMs: Int)
case loaded(durationMs: Int)
case offline(reason: String)
case timeout
}
private let onComplete: (Outcome) -> Void
private let startTime: Date
private var resolved = false
private var timeoutItem: DispatchWorkItem?
init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) {
self.onComplete = onComplete
self.startTime = Date()
super.init()
let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) }
self.timeoutItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item)
}
private func resolveOnce(_ outcome: Outcome) {
if resolved { return }
resolved = true
timeoutItem?.cancel()
timeoutItem = nil
onComplete(outcome)
}
private func durationMs() -> Int {
return Int(Date().timeIntervalSince(startTime) * 1000)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
resolveOnce(.loaded(durationMs: durationMs()))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain &&
(nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotFindHost ||
nsError.code == NSURLErrorTimedOut) {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
}