/* PacketTunnelProvider — die ReBreak iOS-Layer-1-Schutzschicht (DNS-Sinkhole). `NEPacketTunnelProvider`-Subclass — das iOS-Pendant zu Androids `RebreakVpnService`. Ersetzt den (Apple-seitig blockierten) NEURLFilter als primären, lieferbaren iOS-Filter. Architektur (1:1 zum Android-VpnService): - TUN-Interface mit kleinem virtuellen Subnet (10.0.0.0/24). - DNS-Server auf die virtuelle IP 10.0.0.1 gesetzt → DNS-Queries laufen ins TUN. Nur diese eine IP wird via `includedRoutes` geroutet — sonstiger Traffic geht direkt vorbei (batterieschonend, kein Voll-Tunnel). - Read-Loop liest IPv4/UDP-Pakete; `DnsFilter` parst die QNAME. - Treffer in der Blocklist → synthetische NXDOMAIN-Response, sofort ins TUN zurückgeschrieben. - Miss → Query an Upstream-Resolver (1.1.1.1:53) via `NWConnection`, Response wieder als IPv4/UDP-Paket verpackt und ins TUN geschrieben. - Upstream-Fehler (Timeout / recvError / leere Antwort / Connection failed) oder In-Flight-Cap überschritten → synthetische SERVFAIL-Response ins TUN, damit der DNS-Client des Geräts schnell failt statt in seinen eigenen mehrsekündigen Timeout zu laufen. Blocklist: `blocklist.bin` aus dem App-Group-Container, mmap'd via `HashList`. `syncBlocklist` (Haupt-App) postet die Darwin-Notification `rebreak.blocklist.updated` → die Extension re-mmap't ohne Neustart. ── HONESTY / UNGETESTETE ANNAHMEN ─────────────────────────────────────────── NE-Packet-Tunnel laufen NICHT im iOS-Simulator. Diese Datei ist so korrekt wie ohne echtes Gerät möglich implementiert, aber NICHT auf einem iPhone verifiziert. Konkrete ungeprüfte Annahmen sind unten an Ort und Stelle mit `UNGETESTETE ANNAHME` markiert. */ import Foundation import NetworkExtension import Network import os // ─── Konstanten ─────────────────────────────────────────────────────────────── private let APP_GROUP = "group.org.rebreak.app" private let BLOCKLIST_FILENAME = "blocklist.bin" private let DARWIN_NOTIF = "rebreak.blocklist.updated" // Virtuelles Subnet — identisch zur Android-Wahl (RebreakVpnService). private let TUNNEL_LOCAL_ADDR = "10.0.0.2" private let VIRTUAL_DNS_ADDR = "10.0.0.1" private let TUNNEL_SUBNET_MASK = "255.255.255.0" // Upstream-Resolver — identisch zu Android (DnsFilter.UPSTREAM_DNS). private let UPSTREAM_DNS_HOST = "1.1.1.1" private let UPSTREAM_DNS_PORT: UInt16 = 53 // ─── Extension-Log-Store ────────────────────────────────────────────────────── /// Schreibt in den geteilten App-Group-Log-Store (`url_filter_logs`), den die /// Haupt-App via `getProtectionLogs()` ausliest. Die Extension läuft als /// eigener Prozess — das ist das einzige Debug-Fenster ohne Console.app. /// Pattern 1:1 aus `RebreakURLFilterControlProvider.swift#ExtLog`. enum ExtLog { static func write(_ msg: String) { NSLog("REBREAK_PKTTUN %@", msg) guard let d = UserDefaults(suiteName: APP_GROUP) else { return } let line = "[PKTTUN \(ISO8601DateFormatter().string(from: Date()))] \(msg)" var logs = d.stringArray(forKey: "url_filter_logs") ?? [] logs.append(line) if logs.count > 200 { logs.removeFirst(logs.count - 200) } d.set(logs, forKey: "url_filter_logs") } } // ─── Provider ───────────────────────────────────────────────────────────────── class PacketTunnelProvider: NEPacketTunnelProvider { private let log = Logger( subsystem: "org.rebreak.app.packettunnel", category: "provider") /// Geteilte, gemmapte Blocklist. private var hashList: HashList? /// Eigene serielle Queue für Forward-Sockets — hält den Read-Loop frei. private let forwardQueue = DispatchQueue( label: "org.rebreak.app.packettunnel.forward", qos: .userInitiated) /// Ist der Read-Loop aktiv? private var running = false // ─── In-Flight-Cap (H1) ───────────────────────────────────────────────────── // // H1-Fix: Pro erlaubter Query wurde bisher eine frische `NWConnection` // geöffnet (erst nach Antwort/Timeout bis 4 s gecancelt). Bei DNS-Bursts // (App-Start, Webseite mit vielen Subresources) liefen so Dutzende // gleichzeitige Connections + Timeout-WorkItems an — NE-Packet-Tunnel- // Extensions haben aber ein hartes Memory-Limit (~15 MB) → Jetsam-Crash. // // Gewählte Variante: harter In-Flight-CAP statt Connection-Pooling. // Begründung: // - UDP-`NWConnection`-Pooling auf einen einzigen Socket ist fehleranfällig: // ein gemeinsamer UDP-Socket vermischt Antworten mehrerer paralleler // Queries — korrektes Demultiplexing bräuchte ein eigenes Pending-Mapping // über die DNS-Transaction-ID. Das ist mehr Zustand (= mehr Speicher) und // mehr Crash-Fläche als das Problem rechtfertigt. // - Ein simpler Zähler ist O(1)-Zustand und deckelt den Speicher hart. // - DNS ist von sich aus retry-tolerant: wird der Cap überschritten, // antworten wir der Query SOFORT mit SERVFAIL (K2-Pfad) — der Client // retryt dann von selbst, sobald der Burst abgeebbt ist. Besser ein // schneller SERVFAIL als ein Extension-Crash, der den ganzen Tunnel // (= den Schutz) reißt. // // UNGETESTETE ANNAHME: Der konkrete Cap-Wert (32) ist eine konservative // Schätzung. Eine `NWConnection` + Timeout-WorkItem kostet grob einige KB; // 32 gleichzeitige Verbindungen bleiben damit klar unter dem ~15-MB-Limit, // decken aber typische Burst-Größen ab. Auf Gerät unter Last verifizieren // (Instruments / Memory-Graph der Extension). private static let maxInFlightUpstream = 32 /// Zähler aktuell offener Upstream-Connections. Nur auf `forwardQueue` /// gelesen/geschrieben → keine zusätzliche Synchronisation nötig. private var inFlightUpstream = 0 // ─── startTunnel ──────────────────────────────────────────────────────────── /// Vom System aufgerufen, sobald `connection.startVPNTunnel()` (aus dem /// RN-Modul) den Tunnel hochfährt. override func startTunnel( options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void ) { ExtLog.write("startTunnel — Extension-Prozess gestartet") // Blocklist mmap'en. if let containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) { let fileURL = containerURL.appendingPathComponent(BLOCKLIST_FILENAME) let list = HashList(fileURL: fileURL) list.load() hashList = list ExtLog.write("blocklist geladen — \(list.count()) Hashes") } else { ExtLog.write("⚠️ App-Group-Container nicht erreichbar — Blocklist leer") hashList = nil } // Darwin-Notification-Observer registrieren → re-mmap nach syncBlocklist. registerBlocklistObserver() // ── TUN-Netzwerk-Settings ── // // `tunnelRemoteAddress` ist eine Pflichtangabe, wird bei einem rein lokalen // DNS-Sinkhole aber nie real kontaktiert — wir setzen die virtuelle // DNS-IP. (Pattern aus AdGuard/NextDNS/Lockdown.) let settings = NEPacketTunnelNetworkSettings( tunnelRemoteAddress: VIRTUAL_DNS_ADDR) // IPv4: lokale TUN-Adresse + nur die virtuelle DNS-IP routen. // → exakt Androids `addRoute(VIRTUAL_DNS_ADDR, 32)`: NUR DNS-Traffic geht // ins TUN, sonstiger Traffic läuft direkt. let ipv4 = NEIPv4Settings( addresses: [TUNNEL_LOCAL_ADDR], subnetMasks: [TUNNEL_SUBNET_MASK]) ipv4.includedRoutes = [ NEIPv4Route(destinationAddress: VIRTUAL_DNS_ADDR, subnetMask: "255.255.255.255") ] settings.ipv4Settings = ipv4 // DNS-Server auf die virtuelle IP → das System schickt seine DNS-Queries // an 10.0.0.1, die durch unser TUN laufen. let dns = NEDNSSettings(servers: [VIRTUAL_DNS_ADDR]) // matchDomains [""] = ALLE Domains werden über diesen DNS-Server aufgelöst. dns.matchDomains = [""] settings.dnsSettings = dns // MTU defensiv — DNS-Pakete sind klein, der Default reicht; explizit // gesetzt, damit das Verhalten nicht von iOS-Defaults abhängt. settings.mtu = 1500 setTunnelNetworkSettings(settings) { [weak self] error in guard let self = self else { completionHandler( NSError(domain: "RebreakPacketTunnel", code: -1, userInfo: [NSLocalizedDescriptionKey: "provider gone"])) return } if let error = error { ExtLog.write("❌ setTunnelNetworkSettings: \(error.localizedDescription)") completionHandler(error) return } ExtLog.write("✅ TUN-Settings gesetzt — Read-Loop startet") self.running = true self.readPackets() // Self-Heal: war die Blocklist beim Start leer (Data-Protection bei // gesperrtem Gerät), per Backoff erneut laden bis Hashes da sind. self.scheduleBlocklistRetryIfEmpty(attempt: 0) completionHandler(nil) } } // ─── stopTunnel ───────────────────────────────────────────────────────────── /// Vom System aufgerufen, wenn der Tunnel stoppt — auch wenn der User den /// VPN-Toggle in den iOS-Settings umlegt (das iOS-Pendant zu Androids /// `onRevoke`). Wir spiegeln das in den App-Group-Flag, damit `getDeviceState` /// den „extern abgeschaltet"-Zustand erkennt → JS-Phase `recoveringFromBypass`. override func stopTunnel( with reason: NEProviderStopReason, completionHandler: @escaping () -> Void ) { ExtLog.write("stopTunnel — reason=\(reason.rawValue)") running = false unregisterBlocklistObserver() // Flag spiegeln. `userInitiated` (= User-Toggle in Settings) ist der // Android-`onRevoke`-Fall: explizit vom Nutzer abgeschaltet. if reason == .userInitiated || reason == .userLogout { if let d = UserDefaults(suiteName: APP_GROUP) { d.set(false, forKey: "vpn_tunnel_running") d.set(true, forKey: "vpn_tunnel_revoked_by_user") } ExtLog.write("ℹ️ Tunnel vom User abgeschaltet (revoke-Flag gesetzt)") } else { // OS-Kill / Netzwerk-Event etc. — kein User-Revoke. On-Demand fährt den // Tunnel ggf. von selbst wieder hoch. if let d = UserDefaults(suiteName: APP_GROUP) { d.set(false, forKey: "vpn_tunnel_running") } } completionHandler() } // ─── Read-Loop ────────────────────────────────────────────────────────────── /// Liest fortlaufend Pakete vom TUN. `readPackets(completionHandler:)` ruft /// den Handler einmal pro Batch — wir re-armen ihn am Ende selbst (Apple- /// Pattern; es gibt keinen Blocking-Read wie Androids `FileInputStream`). private func readPackets() { guard running else { return } packetFlow.readPackets { [weak self] packets, protocols in guard let self = self, self.running else { return } for (index, packet) in packets.enumerated() { // Nur IPv4 verarbeiten — protocols[i] ist die AF_*-Familie. // UNGETESTETE ANNAHME: `protocols` enthält `AF_INET` (Int 2) für // IPv4-Pakete. Apple-Doku bestätigt das; auf Gerät verifizieren. let proto = protocols[index].int32Value guard proto == AF_INET else { continue } self.handlePacket(packet) } // Read-Loop re-armen. self.readPackets() } } /// Verarbeitet ein einzelnes IPv4-Paket. private func handlePacket(_ packet: Data) { guard let hashList = hashList else { // Keine Blocklist → konservativ: einfach forwarden (kein Block). forwardPacket(packet) return } switch DnsFilter.classify(packet: packet, hashList: hashList) { case .block(let response, let domain): ExtLog.write("BLOCKED: \(domain)") // Synthetische NXDOMAIN-Response sofort zurück ins TUN. writeToTun(response) case .forward: forwardPacket(packet) case .drop: // Kein DNS / IPv6 / Nicht-UDP — verwerfen. break } } // ─── Upstream-Forwarding ──────────────────────────────────────────────────── /// Forwarded eine erlaubte DNS-Query an den Upstream-Resolver und schreibt /// die Antwort als IPv4/UDP-Paket zurück ins TUN. /// /// Verwendet `NWConnection` (Network.framework) statt eines POSIX-Sockets. /// Wichtig: die Verbindung läuft auf der iOS-Standard-Route, NICHT durch /// unser eigenes TUN — das ist auf iOS automatisch so, weil wir nur die /// virtuelle DNS-IP `10.0.0.1` routen. Eine reale 1.1.1.1-Verbindung matcht /// diese Route nicht und geht direkt raus. Damit entfällt das `protect()`, /// das Android braucht (dort wäre ein DNS-Loop möglich). private func forwardPacket(_ packet: Data) { guard let query = DnsFilter.extractDnsPayload(packet: packet) else { return } let host = NWEndpoint.Host(UPSTREAM_DNS_HOST) guard let port = NWEndpoint.Port(rawValue: UPSTREAM_DNS_PORT) else { return } // Gesamte Connection-Lebenszeit läuft auf `forwardQueue` — damit ist auch // der `inFlightUpstream`-Zähler ohne extra Lock konsistent. forwardQueue.async { [weak self] in guard let self = self, self.running else { return } // ── H1: In-Flight-Cap ── // Burst über dem Cap → keine neue Connection öffnen, sondern die Query // sofort mit SERVFAIL beantworten. Der Client retryt selbst; der // Speicher der Extension bleibt gedeckelt. if self.inFlightUpstream >= PacketTunnelProvider.maxInFlightUpstream { ExtLog.write("⚠️ Upstream-Cap erreicht — SERVFAIL (in-flight=\(self.inFlightUpstream))") self.replyServFail(for: packet) return } let connection = NWConnection(host: host, port: port, using: .udp) var didFinish = false self.inFlightUpstream += 1 // Timeout-WorkItem — vorab deklariert, damit `finish()` es referenzieren // kann; die eigentliche Item-Closure wird gleich darunter zugewiesen. var timeoutWork: DispatchWorkItem? // Zentrale Abschluss-Logik: idempotent, dekrementiert den Cap-Zähler // genau einmal und schreibt — wenn keine echte Antwort kam — eine // SERVFAIL-Response ins TUN (K2). `gotAnswer = true` heißt: es wurde // bereits eine valide Upstream-Antwort geschrieben. func finish(gotAnswer: Bool) { // Läuft immer auf `forwardQueue` (alle Aufrufer tun das). if didFinish { return } didFinish = true timeoutWork?.cancel() connection.cancel() self.inFlightUpstream -= 1 if !gotAnswer { // K2: Upstream lieferte nichts Brauchbares → Client nicht hängen // lassen, sondern schnell mit SERVFAIL failen. self.replyServFail(for: packet) } } // Timeout — wie Androids `soTimeout = 4000`. Bei Ablauf: SERVFAIL. let work = DispatchWorkItem { ExtLog.write("Upstream-Timeout — SERVFAIL") finish(gotAnswer: false) } timeoutWork = work self.forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: work) connection.stateUpdateHandler = { state in switch state { case .ready: connection.send(content: query, completion: .contentProcessed { sendError in if sendError != nil { // Send fehlgeschlagen → SERVFAIL. self.forwardQueue.async { finish(gotAnswer: false) } return } // Antwort empfangen. connection.receiveMessage { [weak self] data, _, _, recvError in guard let self = self else { return } self.forwardQueue.async { if didFinish { return } guard recvError == nil, let answer = data, !answer.isEmpty, let response = DnsFilter.buildForwardResponse( request: packet, dnsAnswer: answer) else { // recvError / leere / unverpackbare Antwort → SERVFAIL (K2). finish(gotAnswer: false) return } // Echte Antwort als IPv4/UDP-Paket ins TUN schreiben. self.writeToTun(response) finish(gotAnswer: true) } } }) case .failed: // Connection endgültig fehlgeschlagen (z.B. kein Netz) → SERVFAIL // statt den Client hängen zu lassen (K2). self.forwardQueue.async { finish(gotAnswer: false) } case .cancelled: // `cancelled` ist Folge eines bereits gelaufenen finish() — // finish() ist idempotent, hier ist nichts mehr zu tun. break case .waiting: // `.waiting` (kein Pfad) ist NICHT zwingend final — die Connection // kann sich erholen, sobald Konnektivität zurückkommt. Wir brechen // hier deshalb NICHT sofort ab, sondern überlassen es dem 4-s- // Timeout (der dann SERVFAIL liefert). Ein vorzeitiger Abbruch bei // kurzem Netz-Flackern wäre unnötig aggressiv. break default: break } } connection.start(queue: self.forwardQueue) } } /// Synthetisiert eine SERVFAIL-Response für ein Request-Paket und schreibt /// sie ins TUN. K2: gemeinsamer Fehler-Pfad für Timeout, recvError, leere /// Antwort, Connection-`.failed` und den H1-Cap. private func replyServFail(for requestPacket: Data) { if let response = DnsFilter.buildServFailResponse(request: requestPacket) { writeToTun(response) } } // ─── TUN-Write ────────────────────────────────────────────────────────────── /// Schreibt ein fertiges IPv4-Paket ins TUN zurück. private func writeToTun(_ packet: Data) { // `writePackets` ist thread-safe (Apple-Doku) — kann aus dem Read-Loop // und aus den Forward-Completion-Handlern aufgerufen werden. packetFlow.writePackets([packet], withProtocols: [NSNumber(value: AF_INET)]) } // ─── Blocklist-Reload via Darwin-Notification ─────────────────────────────── /// Registriert einen CFNotificationCenter-Observer auf `rebreak.blocklist.updated`. /// Die Haupt-App postet diese Notification nach jedem erfolgreichen /// `syncBlocklist` → die Extension re-mmap't `blocklist.bin` ohne Neustart. private func registerBlocklistObserver() { let observer = Unmanaged.passUnretained(self).toOpaque() CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), observer, { (_, observer, _, _, _) in guard let observer = observer else { return } let provider = Unmanaged .fromOpaque(observer).takeUnretainedValue() provider.reloadBlocklist() }, DARWIN_NOTIF as CFString, nil, .deliverImmediately ) } private func unregisterBlocklistObserver() { let observer = Unmanaged.passUnretained(self).toOpaque() CFNotificationCenterRemoveObserver( CFNotificationCenterGetDarwinNotifyCenter(), observer, CFNotificationName(DARWIN_NOTIF as CFString), nil ) } /// Re-mmap't die Blocklist (nach `syncBlocklist`). private func reloadBlocklist() { hashList?.load() ExtLog.write("blocklist reloaded — \(hashList?.count() ?? 0) Hashes") } /// Self-Heal: wenn `startTunnel` die Blocklist leer geladen hat (Datei wegen /// iOS-Data-Protection noch nicht lesbar — Tunnel-Start vor dem ersten /// Entsperren seit Boot, oder eine alte mit `.complete` geschriebene Datei), /// wird `load()` mit Backoff erneut versucht, bis Hashes da sind. /// /// Ohne das bliebe Layer 1 still tot, bis die App das nächste Mal synct und /// die Darwin-Notification feuert — beobachtet in den Geräte-Logs als /// ~30-Minuten-Fenster mit 0 Hashes. Der Retry läuft auf `forwardQueue`. private func scheduleBlocklistRetryIfEmpty(attempt: Int) { guard running else { return } guard let list = hashList, list.count() == 0 else { return } // schon ok let maxAttempts = 20 guard attempt < maxAttempts else { ExtLog.write("⚠️ Blocklist nach \(maxAttempts) Retries leer — warte auf App-Sync") return } // Backoff in Sekunden; ab Index 5 konstant 300 s. let backoff: [Double] = [3, 10, 30, 60, 120] let delay = attempt < backoff.count ? backoff[attempt] : 300 forwardQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self, self.running else { return } self.hashList?.load() let n = self.hashList?.count() ?? 0 if n > 0 { ExtLog.write("✅ blocklist self-heal — \(n) Hashes (Versuch \(attempt + 1))") } else { self.scheduleBlocklistRetryIfEmpty(attempt: attempt + 1) } } } }