chahinebrini 1aa86c2c0c fix(ios-vpn): K1/K2/H1 — Upstream-Fehler, In-Flight-Cap, Response-Layout
K2: forwardPacket schreibt jetzt bei jedem Upstream-Fehler eine synthetische
SERVFAIL ins TUN (zentrale finish(gotAnswer:)) — DNS-Client haengt nicht mehr.
H1: harter In-Flight-Cap (32) statt Connection-pro-Query — ueber dem Cap sofort
SERVFAIL; deckelt den NE-Memory-Footprint.
K1: buildErrorResponse + dnsQuestionEnd schneiden das Paket hinter der Question-
Section ab (EDNS-OPT-Muell weg), IP/UDP-Length neu berechnet.
H3: Compression-Pointer-QNAME → fail-open (.forward) statt stillem Drop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:22:22 +02:00

452 lines
20 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.

/*
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()
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<PacketTunnelProvider>
.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")
}
}