diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 11af878..bb91881 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -115,6 +115,26 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { appExtensions: [ { + // Layer 1 (NEU, Default) — Packet-Tunnel-DNS-Filter. + // Bundle-ID + Entitlements müssen exakt zu + // plugins/with-rebreak-protection-ios.js (PT_BUNDLE_SUFFIX) + // und modules/rebreak-protection/ios/RebreakPacketTunnelExtension/ + // passen, sonst kippt der EAS-Build mit + // "No profiles for 'org.rebreak.app.PacketTunnelExtension'". + targetName: "RebreakPacketTunnelExtension", + bundleIdentifier: "org.rebreak.app.PacketTunnelExtension", + entitlements: { + "com.apple.developer.networking.networkextension": [ + "packet-tunnel-provider", + ], + "com.apple.security.application-groups": [ + "group.org.rebreak.app", + ], + }, + }, + { + // Layer 1-alt — NEURLFilter (iOS 26). INAKTIV, behalten als + // optionaler Upgrade-Pfad. EAS muss das Target trotzdem kennen. targetName: "RebreakURLFilterExtension", bundleIdentifier: "org.rebreak.app.URLFilterExtension", entitlements: { diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index c339ad1..5aca364 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -116,14 +116,13 @@ export const protection = { const enabled = !r.missingLayers.includes("vpn"); res = enabled ? { enabled: true } : { enabled: false, error: r.errors?.[0] }; } else { - // iOS: NEURLFilter braucht die PIR-Server-Config. Token kommt aus - // app.config.ts extra (Build-Env PIR_AUTH_TOKEN) — nie im Repo. + // 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) ?? ""; - // Diagnose: zeigt in der Metro-Konsole ob der Token im Manifest ankommt. - console.log( - `[protection] PIR-Config — serverURL=${pirServerURL} tokenLen=${pirAuthToken.length}`, - ); res = await RebreakProtection.activateUrlFilter({ pirServerURL, pirAuthToken }); } // Diagnose: Fehler-String + nativer Log-Tail (inkl. der [EXT ...]-Zeilen diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift new file mode 100644 index 0000000..3255096 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/DnsFilter.swift @@ -0,0 +1,283 @@ +/* + DnsFilter — Swift-Port von `android/.../filter/DnsFilter.kt`. + + IPv4 + UDP + DNS Packet-Parsing und Response-Builder. + + Verarbeitungsmodell (identisch zu Android): + - Block-Query → synthetische NXDOMAIN-Response (RCODE=3), synchron gebaut. + - Allow-Query → an Upstream-Resolver (1.1.1.1:53) forwarden, Response wird + wieder als IPv4/UDP-Paket verpackt zurückgegeben. + - Drop (kein DNS / IPv6 / Nicht-UDP) → nil, das Paket wird verworfen. + + Unterschied zu Android: Android schreibt selbst ins TUN (FileOutputStream). + Auf iOS schreibt der `NEPacketTunnelProvider` über `packetFlow.writePackets`. + Daher liefert dieser Filter das fertige Response-Paket nur ZURÜCK; der + PacketTunnelProvider übernimmt das Schreiben (synchron für Block, via + Completion-Handler für Forward). + + ── UNGETESTETE ANNAHME ────────────────────────────────────────────────────── + Das exakte Byte-Layout der NXDOMAIN-Response und der forwardeten Antwort ist + 1:1 aus dem produktiv erprobten Android-`DnsFilter.kt` übernommen. Es konnte + NICHT auf einem echten iPhone verifiziert werden (NE-Extensions laufen nicht + im Simulator). Die Annahme: `packetFlow` liefert/erwartet rohe IPv4-Pakete + inkl. IP-Header (wie Androids TUN-FD). Apple-Doku bestätigt das für + `NEPacketTunnelFlow` — `protocols` enthält `AF_INET`/`AF_INET6`. +*/ + +import Foundation +import Network +import os + +/// Ergebnis der Paket-Verarbeitung. +enum DnsFilterDecision { + /// Domain ist geblockt — synthetische NXDOMAIN-Response, sofort schreiben. + case block(response: Data, domain: String) + /// Domain ist erlaubt — Query muss an Upstream forwardet werden. + /// `domain` nur fürs Logging. + case forward(domain: String) + /// Paket ist irrelevant (kein IPv4/UDP/DNS) → verwerfen. + case drop +} + +enum DnsFilter { + + private static let log = Logger( + subsystem: "org.rebreak.app.packettunnel", category: "dnsfilter") + + private static let dnsPort: UInt16 = 53 + + // ─── Entry: ein IPv4-Paket vom TUN klassifizieren ────────────────────────── + + /// Parst ein IPv4-Paket und entscheidet block / forward / drop. + /// + /// - Parameter packet: rohes IPv4-Paket inkl. IP-Header (wie vom TUN gelesen). + /// - Parameter hashList: die geladene Blocklist. + static func classify(packet: Data, hashList: HashList) -> DnsFilterDecision { + let length = packet.count + // min IPv4(20) + UDP(8) + guard length >= 28 else { return .drop } + + return packet.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> DnsFilterDecision in + let bytes = raw.bindMemory(to: UInt8.self) + + // IPv4-Check (höchste 4 Bit der Version/IHL-Byte). + let version = (Int(bytes[0]) >> 4) & 0xF + guard version == 4 else { return .drop } + + // IHL = Internet Header Length in 32-bit-Words → in Bytes. + let ihl = (Int(bytes[0]) & 0xF) * 4 + guard ihl >= 20, length >= ihl + 8 else { return .drop } + + // UDP only (IP-Protocol 17). + let proto = Int(bytes[9]) & 0xFF + guard proto == 17 else { return .drop } + + // UDP-Header. + let udpStart = ihl + let dstPort = (UInt16(bytes[udpStart + 2]) << 8) | UInt16(bytes[udpStart + 3]) + guard dstPort == dnsPort else { return .drop } + + let dnsStart = udpStart + 8 + let dnsLen = length - dnsStart + guard dnsLen >= 12 else { return .drop } + + // QNAME parsen — beginnt nach dem 12-Byte-DNS-Header. + guard let domain = parseQname(bytes, start: dnsStart + 12, end: length) else { + return .drop + } + + if hashList.matchesAnySuffix(domain) { + let response = buildNxDomainResponse(bytes, length: length, ihl: ihl) + return .block(response: response, domain: domain) + } + return .forward(domain: domain) + } + } + + // ─── QNAME-Parser ────────────────────────────────────────────────────────── + + /// Parst den QNAME ab `start`. Gibt `nil` zurück bei DNS-Compression-Pointer + /// (0xC0-Bit gesetzt) oder Out-of-Bounds — identisch zu Androids `parseQname`. + private static func parseQname( + _ bytes: UnsafeBufferPointer, start: Int, end: Int + ) -> String? { + var labels: [String] = [] + var pos = start + while pos < end { + let len = Int(bytes[pos]) & 0xFF + if len == 0 { break } + // Compression-Pointer (zwei höchste Bits) — in einer Query unüblich, + // wir behandeln ihn defensiv als nicht-parsebar. + if (len & 0xC0) != 0 { return nil } + pos += 1 + guard pos + len <= end else { return nil } + var label = [UInt8]() + label.reserveCapacity(len) + for i in 0.., length: Int, ihl: Int + ) -> Data { + var resp = [UInt8](repeating: 0, count: length) + for i in 0.. Data? { + let reqLen = request.count + guard reqLen >= 28 else { return nil } + + return request.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Data? in + let req = raw.bindMemory(to: UInt8.self) + let ihl = (Int(req[0]) & 0xF) * 4 + guard ihl >= 20, reqLen >= ihl + 8 else { return nil } + + let udpStart = ihl + let totalLen = ihl + 8 + dnsAnswer.count + // IPv4 total-length ist ein 16-bit-Feld. + guard totalLen <= 0xFFFF else { return nil } + + var resp = [UInt8](repeating: 0, count: totalLen) + // IP-Header aus der Request übernehmen. + for i in 0..> 8) & 0xFF) + resp[3] = UInt8(totalLen & 0xFF) + + // Src-/Dst-IP tauschen. + for i in 0..<4 { + let srcByte = req[12 + i] + let dstByte = req[16 + i] + resp[12 + i] = dstByte + resp[16 + i] = srcByte + } + resp[10] = 0 + resp[11] = 0 + + // UDP-Ports tauschen. + resp[udpStart] = req[udpStart + 2] + resp[udpStart + 1] = req[udpStart + 3] + resp[udpStart + 2] = req[udpStart] + resp[udpStart + 3] = req[udpStart + 1] + // UDP-Length = 8 (Header) + Payload. + let udpLen = 8 + dnsAnswer.count + resp[udpStart + 4] = UInt8((udpLen >> 8) & 0xFF) + resp[udpStart + 5] = UInt8(udpLen & 0xFF) + resp[udpStart + 6] = 0 + resp[udpStart + 7] = 0 + + // DNS-Antwort hinter den UDP-Header kopieren. + let dnsStart = udpStart + 8 + for (i, byte) in dnsAnswer.enumerated() { + resp[dnsStart + i] = byte + } + + recomputeIpChecksum(&resp, ihl: ihl) + return Data(resp) + } + } + + // ─── IP-Header-Checksum ──────────────────────────────────────────────────── + + /// Berechnet die IPv4-Header-Checksum neu (RFC 791). Identisch zu + /// `DnsFilter.kt#recomputeIpChecksum` — 16-bit-Einerkomplement-Summe über + /// den IP-Header, Checksum-Feld zuvor auf 0. + private static func recomputeIpChecksum(_ packet: inout [UInt8], ihl: Int) { + packet[10] = 0 + packet[11] = 0 + var sum: UInt32 = 0 + var i = 0 + while i < ihl { + let word = (UInt32(packet[i]) << 8) | UInt32(packet[i + 1]) + sum += word + i += 2 + } + while (sum >> 16) != 0 { + sum = (sum & 0xFFFF) + (sum >> 16) + } + let checksum = ~sum & 0xFFFF + packet[10] = UInt8((checksum >> 8) & 0xFF) + packet[11] = UInt8(checksum & 0xFF) + } + + // ─── DNS-Payload-Extraktion (für Forward) ────────────────────────────────── + + /// Extrahiert die rohe DNS-Query (ab DNS-Header) aus einem IPv4/UDP-Paket — + /// das ist exakt das, was an den Upstream-Resolver gesendet werden muss. + static func extractDnsPayload(packet: Data) -> Data? { + let length = packet.count + guard length >= 28 else { return nil } + return packet.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Data? in + let bytes = raw.bindMemory(to: UInt8.self) + let ihl = (Int(bytes[0]) & 0xF) * 4 + guard ihl >= 20, length >= ihl + 8 else { return nil } + let dnsStart = ihl + 8 + guard dnsStart < length else { return nil } + return packet.subdata(in: dnsStart.. String { + var h = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if h.hasPrefix("https://") { + h = String(h.dropFirst(8)) + } else if h.hasPrefix("http://") { + h = String(h.dropFirst(7)) + } + if let slash = h.firstIndex(of: "/") { + h = String(h[h.startIndex.. UInt64 { + let normalized = normalize(host) + let digest = SHA256.hash(data: Data(normalized.utf8)) + var result: UInt64 = 0 + var i = 0 + for byte in digest { + result = (result << 8) | UInt64(byte) + i += 1 + if i == 8 { break } + } + return result + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/HashList.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/HashList.swift new file mode 100644 index 0000000..f68ff28 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/HashList.swift @@ -0,0 +1,176 @@ +/* + HashList — Swift-Port von `android/.../filter/HashList.kt`. + + Memory-mapped binäre Hash-Liste — sortierte 64-bit big-endian UInt64s. + + Datei-Format identisch zu Android und Server: jeder Hash 8 Bytes, sortiert. + Reader macht Binary-Search → O(log n) Lookup (~18 Vergleiche bei 208k Hashes). + + Reload-Strategie: bei jedem `load()` wird die Datei neu gemmapt. Aufrufer muss + `load()` triggern (z.B. nach `syncBlocklist` via Darwin-Notification). + + Speicher: `mmap` lädt nur angefragte Pages — die ganze 1,6-MB-Datei wird NIE + in den Heap geladen. Working-Set bleibt im KB-Bereich. Kritisch für das + enge Memory-Limit von Network-Extensions. + + Thread-safety: ein `os_unfair_lock` schützt das Snapshot-Swapping. Lookups + lesen ein konsistentes Snapshot (Pointer + count) unter dem Lock — der + mmap-Bereich selbst ist read-only und damit nebenläufig lesbar. +*/ + +import Foundation +import os + +final class HashList { + + /// Ein konsistenter Snapshot des aktuell gemmapten Files. + private struct Snapshot { + let base: UnsafeRawPointer? + let byteCount: Int + let count: Int // Anzahl 8-Byte-Hashes + } + + private let fileURL: URL + private var snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + private var lock = os_unfair_lock() + + init(fileURL: URL) { + self.fileURL = fileURL + } + + deinit { + unmapCurrent() + } + + /// Lädt `blocklist.bin` neu (mmap). Idempotent — kann zur Laufzeit mehrfach + /// aufgerufen werden; die alte Map wird per `munmap` freigegeben. + func load() { + os_unfair_lock_lock(&lock) + defer { os_unfair_lock_unlock(&lock) } + + // Alte Map freigeben bevor wir neu mappen. + unmapCurrentLocked() + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + return + } + + let fd = open(fileURL.path, O_RDONLY) + guard fd >= 0 else { + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + return + } + defer { close(fd) } // mmap überlebt den geschlossenen fd. + + var st = stat() + guard fstat(fd, &st) == 0 else { + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + return + } + let size = Int(st.st_size) + // Datei muss ein Vielfaches von 8 Bytes sein, sonst ist sie korrupt. + guard size > 0, size % 8 == 0 else { + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + return + } + + guard let mapped = mmap(nil, size, PROT_READ, MAP_PRIVATE, fd, 0), + mapped != MAP_FAILED else { + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + return + } + + snapshot = Snapshot( + base: UnsafeRawPointer(mapped), + byteCount: size, + count: size / 8 + ) + } + + /// Aktuelle Anzahl der Hashes (zum Logging). + func count() -> Int { + os_unfair_lock_lock(&lock) + defer { os_unfair_lock_unlock(&lock) } + return snapshot.count + } + + /// True wenn die Datei aktuell als Blocklist gemmapt ist. + func isLoaded() -> Bool { + os_unfair_lock_lock(&lock) + defer { os_unfair_lock_unlock(&lock) } + return snapshot.base != nil + } + + /// Binary-Search auf den sortierten 64-bit-Hashes. O(log n). + /// + /// Die Datei ist als UNSIGNED 64-bit big-endian sortiert; Swifts `UInt64`- + /// Vergleich ist nativ unsigned, daher entfällt das `compareUnsigned` aus dem + /// Kotlin-Code. Jeder 8-Byte-Block wird big-endian aus dem mmap gelesen. + func contains(_ hash: UInt64) -> Bool { + os_unfair_lock_lock(&lock) + let snap = snapshot + os_unfair_lock_unlock(&lock) + + guard let base = snap.base, snap.count > 0 else { return false } + + var lo = 0 + var hi = snap.count - 1 + while lo <= hi { + let mid = (lo + hi) >> 1 + let value = readBigEndianUInt64(base, offset: mid * 8) + if value == hash { + return true + } else if value < hash { + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return false + } + + /// Subdomain-Match (max 5 Iterationen — identisch zu Android & Server). + /// Für `evil.shop.bet365.com` wird der Reihe nach getestet: + /// evil.shop.bet365.com → shop.bet365.com → bet365.com + /// (eine TLD ohne Punkt wird nie getestet → kein false-positive auf `com`). + func matchesAnySuffix(_ host: String) -> Bool { + var current = DomainHasher.normalize(host) + var iter = 0 + while iter < 5 { + if contains(DomainHasher.hash(current)) { + return true + } + guard let dot = current.firstIndex(of: ".") else { return false } + current = String(current[current.index(after: dot)...]) + if !current.contains(".") { return false } + iter += 1 + } + return false + } + + // ─── Private ────────────────────────────────────────────────────────────── + + /// Liest 8 Bytes ab `offset` als big-endian UInt64 aus dem mmap-Bereich. + /// `loadUnaligned` deckt den Fall ab, dass `base + offset` nicht 8-aligned + /// ist (mmap'd Pages sind page-aligned, Offsets sind 8er-Vielfache → in der + /// Praxis aligned, aber `loadUnaligned` ist defensiv korrekt). + private func readBigEndianUInt64(_ base: UnsafeRawPointer, offset: Int) -> UInt64 { + let raw = base.load(fromByteOffset: offset, as: UInt64.self) + return UInt64(bigEndian: raw) + } + + private func unmapCurrent() { + os_unfair_lock_lock(&lock) + unmapCurrentLocked() + snapshot = Snapshot(base: nil, byteCount: 0, count: 0) + os_unfair_lock_unlock(&lock) + } + + /// Muss unter gehaltenem `lock` aufgerufen werden. + private func unmapCurrentLocked() { + if let base = snapshot.base, snapshot.byteCount > 0 { + munmap(UnsafeMutableRawPointer(mutating: base), snapshot.byteCount) + } + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist new file mode 100644 index 0000000..470599b --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ReBreak DNS-Filter + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift new file mode 100644 index 0000000..04dd096 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift @@ -0,0 +1,358 @@ +/* + 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. + + 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 + + // ─── 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() + 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 + } + + let connection = NWConnection(host: host, port: port, using: .udp) + var didFinish = false + + // Timeout — wie Androids `soTimeout = 4000`. + let timeoutWork = DispatchWorkItem { + if !didFinish { + didFinish = true + connection.cancel() + } + } + forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: timeoutWork) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.send(content: query, completion: .contentProcessed { sendError in + if sendError != nil { + if !didFinish { + didFinish = true + timeoutWork.cancel() + connection.cancel() + } + return + } + // Antwort empfangen. + connection.receiveMessage { [weak self] data, _, _, recvError in + if didFinish { return } + didFinish = true + timeoutWork.cancel() + defer { connection.cancel() } + guard recvError == nil, let answer = data, !answer.isEmpty else { + return + } + // Antwort als IPv4/UDP-Paket verpacken und ins TUN schreiben. + if let response = DnsFilter.buildForwardResponse( + request: packet, dnsAnswer: answer) { + self?.writeToTun(response) + } + } + }) + case .failed, .cancelled: + if !didFinish { + didFinish = true + timeoutWork.cancel() + } + default: + break + } + } + connection.start(queue: forwardQueue) + } + + // ─── 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") + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/RebreakPacketTunnelExtension.entitlements b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/RebreakPacketTunnelExtension.entitlements new file mode 100644 index 0000000..cb52246 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/RebreakPacketTunnelExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.org.rebreak.app + + + diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index 19cfe98..1cc2dab 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -30,6 +30,18 @@ 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 { @@ -80,9 +92,89 @@ public class RebreakProtectionModule: Module { // ───────── activate: Family Controls + NEFilter + denyAppRemoval ───────── - // ───────── activateUrlFilter: NUR NEFilter ───────── + // ───────── activateUrlFilter: Layer 1 = Packet-Tunnel-DNS-Filter ───────── + // + // NEU (2026-05-21): Default-Layer-1 ist der NEPacketTunnelProvider-DNS- + // Sinkhole — MDM-frei, ab iOS 16, Parität zum Android-VPN-Filter. + // NEURLFilter (iOS 26) bleibt als Code erhalten (siehe `activateNeUrlFilter` + // unten), wird aber NICHT mehr der Default — Apple hat den Stack blockiert. + // + // WICHTIG: nie zwei Layer-1-Filter gleichzeitig. `activateUrlFilter` startet + // ausschließlich den Packet-Tunnel. - AsyncFunction("activateUrlFilter") { (opts: [String: String]) async -> [String: Any] in + AsyncFunction("activateUrlFilter") { (_: [String: String]) async -> [String: Any] in + 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 stabilen Status warten (max ~3,2s) — direkt nach dem Start + // steht er auf .connecting. + var status = manager.connection.status + var waited = 0 + while (status == .connecting || status == .reasserting) && waited < 8 { + 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" @@ -406,6 +498,29 @@ public class RebreakProtectionModule: Module { // 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) + } + + // 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 @@ -510,18 +625,18 @@ public class RebreakProtectionModule: Module { // ───────── getDeviceState: aktueller Status aller Layer ───────── AsyncFunction("getDeviceState") { () async -> [String: Any] in - // NEURLFilter + // Layer 1 = Packet-Tunnel-DNS-Filter. Wahrheit ist der Runtime-Status + // des NETunnelProviderManager — nur .connected heißt „filtert wirklich". + // (NEURLFilter ist nicht mehr der Default-Filter; sein Status fließt + // bewusst NICHT mehr in den `urlFilter`-Slot ein.) var urlFilter = false - if #available(iOS 26.0, *) { - do { - let manager = NEURLFilterManager.shared - try await manager.loadFromPreferences() - // Wahrheit ist der Runtime-Status: nur .running heißt „filtert - // wirklich". isEnabled bleibt true auch bei einer „Ungültig"-Config. - urlFilter = (await manager.status == .running) - } catch { - // ignore + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + if let manager = Self.findRebreakTunnel(in: managers) { + urlFilter = (manager.connection.status == .connected) } + } catch { + // ignore — kein Tunnel konfiguriert → urlFilter bleibt false. } // FamilyControls @@ -996,6 +1111,62 @@ public class RebreakProtectionModule: Module { 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 diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index e107f99..2101ac9 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -17,15 +17,32 @@ import type { declare class RebreakProtectionModule extends NativeModule { /** - * iOS: aktiviert den NEURLFilter (URL-Filter Layer) via `NEURLFilterManager` - * mit dem PIR-Server. Braucht `pirServerURL` + `pirAuthToken` (aus - * `app.config.ts` extra → Build-Env). iOS 26+. + * iOS: aktiviert Layer 1 = den Packet-Tunnel-DNS-Filter + * (`NEPacketTunnelProvider`). Startet/konfiguriert den Tunnel via + * `NETunnelProviderManager` — beim ersten Aufruf erscheint der iOS-VPN- + * System-Permission-Dialog. MDM-frei, ab iOS 16. Das ist der neue + * Default-Layer-1 (ersetzt NEURLFilter, der Apple-seitig blockiert ist). + * + * `opts` wird auf iOS NICHT mehr ausgewertet (der Packet-Tunnel braucht + * keine PIR-Config) — bleibt für API-Kompatibilität in der Signatur. */ activateUrlFilter(opts: { pirServerURL: string; pirAuthToken: string; }): Promise<{ enabled: boolean; error?: string }>; + /** + * iOS: aktiviert den NEURLFilter (iOS 26) via `NEURLFilterManager` mit dem + * PIR-Server. INAKTIV — NICHT der Default-Filter. Behalten als optionaler + * iOS-26-Upgrade-Pfad, falls Apple den NEURLFilter-DTS-Bug fixt. Der + * Default-Layer-1 ist der Packet-Tunnel (`activateUrlFilter`). + * Braucht `pirServerURL` + `pirAuthToken`. iOS 26+. + */ + activateNeUrlFilter(opts: { + pirServerURL: string; + pirAuthToken: string; + }): Promise<{ enabled: boolean; error?: string }>; + /** * iOS: nach "Nicht erlauben" beim NEFilter-Permission-Dialog hat iOS den * Denied-State gecached und zeigt beim erneuten activateUrlFilter() den diff --git a/apps/rebreak-native/plugins/with-rebreak-protection-ios.js b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js index 7d1ebdd..998eb18 100644 --- a/apps/rebreak-native/plugins/with-rebreak-protection-ios.js +++ b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js @@ -50,14 +50,41 @@ const SWIFT_SOURCES = [ // Bundle-Resources. const RESOURCES = ['bloom_filter.plist']; +// ─── Packet-Tunnel-Extension (Layer 1 — Default-Filter) ────────────────────── +// Neues Target: der NEPacketTunnelProvider-DNS-Sinkhole. Ersetzt NEURLFilter +// als primären, lieferbaren iOS-Filter. KLASSISCHE PluginKit-App-Extension — +// normaler `app_extension`-Pfad, normale „Embed App Extensions"-Phase +// (dstSubfolderSpec 13). KEIN ExtensionKit-Sonderweg. +const PT_TARGET_NAME = 'RebreakPacketTunnelExtension'; +const PT_BUNDLE_SUFFIX = 'PacketTunnelExtension'; // → org.rebreak.app.PacketTunnelExtension +const PT_MODULE_DIR = path.join( + __dirname, + '..', + 'modules', + 'rebreak-protection', + 'ios', + PT_TARGET_NAME, +); +// Swift-Quellen (Provider + Filter-Logik, aus Android nach Swift portiert). +const PT_SWIFT_SOURCES = [ + 'PacketTunnelProvider.swift', + 'DnsFilter.swift', + 'HashList.swift', + 'DomainHasher.swift', +]; + // ─── 1) Haupt-App-Entitlements ────────────────────────────────────────────── function withMainAppEntitlements(config) { return withEntitlementsPlist(config, (cfg) => { - // NEURLFilter-Entitlement (ersetzt das alte content-filter-provider). - // Dev-Builds laufen ohne Apple-Freigabe; TestFlight/Store brauchen die - // genehmigte Capability (Developer-Portal → Capability Requests). + // NetworkExtension-Entitlement. Das Array darf mehrere Provider-Typen + // halten — beide gleichzeitig ist technisch erlaubt: + // - `packet-tunnel-provider`: der NEU aktive Layer-1-Filter (DNS-Sinkhole, + // MDM-frei, kein Apple-Capability-Request nötig — direkt in Xcode/Portal). + // - `url-filter-provider`: NEURLFilter, behalten als iOS-26-Upgrade-Pfad + // (inaktiv; User-Entscheidung NICHT zu löschen). cfg.modResults['com.apple.developer.networking.networkextension'] = [ + 'packet-tunnel-provider', 'url-filter-provider', ]; // Family Controls = Kern-Funktion, Apple-Distribution-Entitlement freigegeben @@ -81,15 +108,21 @@ function withCopyExtensionSources(config) { return withDangerousMod(config, [ 'ios', async (cfg) => { - const dest = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME); - if (!fs.existsSync(MODULE_DIR)) { - throw new Error( - `[with-rebreak-protection-ios] Extension source dir missing: ${MODULE_DIR}`, - ); - } - if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); - for (const file of fs.readdirSync(MODULE_DIR)) { - fs.copyFileSync(path.join(MODULE_DIR, file), path.join(dest, file)); + // Beide Extension-Verzeichnisse nach ios/ kopieren. + for (const [target, srcDir] of [ + [TARGET_NAME, MODULE_DIR], + [PT_TARGET_NAME, PT_MODULE_DIR], + ]) { + const dest = path.join(cfg.modRequest.platformProjectRoot, target); + if (!fs.existsSync(srcDir)) { + throw new Error( + `[with-rebreak-protection-ios] Extension source dir missing: ${srcDir}`, + ); + } + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + for (const file of fs.readdirSync(srcDir)) { + fs.copyFileSync(path.join(srcDir, file), path.join(dest, file)); + } } return cfg; }, @@ -206,11 +239,115 @@ function withExtensionTarget(config) { }); } +// ─── 4) Packet-Tunnel-Xcode-Target hinzufügen ──────────────────────────────── +// +// KLASSISCHE App-Extension — anders als das NEURLFilter-Target: +// - normaler `app_extension`-Produkttyp, +// - normale „Embed App Extensions"-Phase (dstSubfolderSpec 13, PlugIns/), +// - KEIN ExtensionKit-Sonderweg (kein dstSubfolderSpec 16, kein +// EXAppExtensionAttributes). Der `addTarget`-Default ist hier schon korrekt +// — wir biegen die Embed-Phase NICHT um. +// - Deployment-Target = Main-App (iOS 16), KEIN 26.0-Gate. + +function withPacketTunnelTarget(config) { + return withXcodeProject(config, async (cfg) => { + const proj = cfg.modResults; + + // Idempotenz: skip wenn Target schon angelegt. + if (proj.pbxTargetByName(PT_TARGET_NAME)) { + return cfg; + } + + const mainBundleId = cfg.ios?.bundleIdentifier; + if (!mainBundleId) { + throw new Error('[with-rebreak-protection-ios] ios.bundleIdentifier fehlt in app.config'); + } + const extBundleId = `${mainBundleId}.${PT_BUNDLE_SUFFIX}`; + + // ── Target anlegen (klassischer app_extension-Produkttyp) ── + const target = proj.addTarget( + PT_TARGET_NAME, + 'app_extension', + PT_TARGET_NAME, + extBundleId, + ); + + // ── Build-Phasen ── + proj.addBuildPhase( + PT_SWIFT_SOURCES, + 'PBXSourcesBuildPhase', + 'Sources', + target.uuid, + ); + proj.addBuildPhase( + ['NetworkExtension.framework'], + 'PBXFrameworksBuildPhase', + 'Frameworks', + target.uuid, + ); + + // ── PBXGroup für die Extension-Dateien ── + const pbxGroup = proj.addPbxGroup( + [...PT_SWIFT_SOURCES, 'Info.plist', `${PT_TARGET_NAME}.entitlements`], + PT_TARGET_NAME, + PT_TARGET_NAME, + ); + const groups = proj.hash.project.objects.PBXGroup; + Object.keys(groups).forEach((key) => { + if ( + groups[key].name === 'CustomTemplate' || + (groups[key].name === undefined && groups[key].path === undefined) + ) { + proj.addToPbxGroup(pbxGroup.uuid, key); + } + }); + + // ── Build-Settings auf der Target-Configuration ── + const configurations = proj.pbxXCBuildConfigurationSection(); + Object.keys(configurations) + .filter((k) => typeof configurations[k] === 'object') + .forEach((k) => { + const buildSettingsObj = configurations[k].buildSettings; + if ( + buildSettingsObj && + buildSettingsObj.PRODUCT_NAME && + buildSettingsObj.PRODUCT_NAME.replace(/"/g, '') === PT_TARGET_NAME + ) { + buildSettingsObj.INFOPLIST_FILE = `"${PT_TARGET_NAME}/Info.plist"`; + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${PT_TARGET_NAME}/${PT_TARGET_NAME}.entitlements"`; + // Packet-Tunnel läuft ab iOS 16 — Deployment-Target = Main-App. + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '16.0'; + buildSettingsObj.SWIFT_VERSION = '5.0'; + buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"'; + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'; + // EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App + // → Extension braucht expliziten Team-Wert. + buildSettingsObj.DEVELOPMENT_TEAM = DEVELOPMENT_TEAM; + } + }); + + // ── Embed-Phase: BEWUSST NICHT umbiegen ── + // proj.addTarget() hat bereits eine „Copy Files"-PBXCopyFilesBuildPhase + // (dstSubfolderSpec 13 = PlugIns/) im Haupt-Target angelegt, die unsere + // .appex einbettet. Für eine KLASSISCHE PluginKit-App-Extension (unser + // NEPacketTunnelProvider hat ein NSExtension-Dict in der Info.plist) ist + // genau das korrekt — wir lassen die Phase, wie addTarget sie anlegt. + // KEIN dstSubfolderSpec-16-Umbau wie beim NEURLFilter-Target. + + // ── Target-Dependency: Haupt-App baut die Extension vorher ── + const mainTargetUuid = proj.getFirstTarget().uuid; + proj.addTargetDependency(mainTargetUuid, [target.uuid]); + + return cfg; + }); +} + // ─── Composition ──────────────────────────────────────────────────────────── module.exports = function withRebreakProtectionIos(config) { config = withMainAppEntitlements(config); config = withCopyExtensionSources(config); config = withExtensionTarget(config); + config = withPacketTunnelTarget(config); return config; };