- 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.
588 lines
26 KiB
Swift
588 lines
26 KiB
Swift
/*
|
||
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"
|
||
private let EXTENSION_SECRET = "5067b8a065ef655a32631640d2e3f86cab8abbfaf6173aabb58598172c57c5fe"
|
||
|
||
// ─── Extension → Backend reporting ─────────────────────────────────────────────
|
||
|
||
private func reportProtectionEventToBackend(active: Bool, source: String, reason: String) {
|
||
guard let defaults = UserDefaults(suiteName: APP_GROUP),
|
||
let token = defaults.string(forKey: "rebreak_extension_token"), !token.isEmpty,
|
||
let deviceId = defaults.string(forKey: "rebreak_extension_device_id"), !deviceId.isEmpty,
|
||
let baseURL = defaults.string(forKey: "rebreak_extension_base_url"), !baseURL.isEmpty
|
||
else {
|
||
ExtLog.write("⚠️ reportProtectionEvent: keine Credentials in App-Group")
|
||
return
|
||
}
|
||
|
||
guard let url = URL(string: "\(baseURL)/api/protection/event") else { return }
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
request.setValue(EXTENSION_SECRET, forHTTPHeaderField: "x-extension-secret")
|
||
let body: [String: Any] = [
|
||
"active": active,
|
||
"source": source,
|
||
"deviceId": deviceId,
|
||
"reason": reason,
|
||
]
|
||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
|
||
ExtLog.write("📡 reportProtectionEvent active=\(active) source=\(source)")
|
||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||
if let error = error {
|
||
ExtLog.write("❌ reportProtectionEvent failed: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||
ExtLog.write("✅ reportProtectionEvent HTTP \(status)")
|
||
}
|
||
task.resume()
|
||
}
|
||
|
||
// 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
|
||
|
||
// Hardcodierter Bypass: eigene Infrastruktur-Domains — niemals blocken,
|
||
// unabhängig von Blocklist-Hash-Kollisionen oder In-Flight-Cap.
|
||
// Betrifft Supabase-Auth-Callbacks (db-staging.rebreak.org),
|
||
// Backend-API (staging.rebreak.org) und zukünftige prod-Domains.
|
||
private let BYPASS_DOMAIN_SUFFIXES = ["rebreak.org", "rebreak.app"]
|
||
|
||
// ─── 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
|
||
|
||
// ─── TUN-Netzwerk-Settings ──────────────────────────────────────────────────
|
||
|
||
/// Baut die NEPacketTunnelNetworkSettings für den DNS-Sinkhole.
|
||
/// Eine Quelle für `startTunnel` UND den Reconnect in `reloadBlocklist`
|
||
/// (`reapplyTunnelSettings`) — sonst driften beide Konfigurationen ab.
|
||
private func buildTunnelSettings() -> NEPacketTunnelNetworkSettings {
|
||
// `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
|
||
|
||
return settings
|
||
}
|
||
|
||
// ─── 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 ──
|
||
let settings = buildTunnelSettings()
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
reportProtectionEventToBackend(active: false, source: "vpn", reason: "PacketTunnel stopped (reason=\(reason.rawValue))")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
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):
|
||
// Eigene Infrastruktur-Domains nie blocken — auch wenn Hash-Kollision.
|
||
// Betrifft OAuth-Callbacks (db-staging.rebreak.org) und Backend-API.
|
||
let isBypass = BYPASS_DOMAIN_SUFFIXES.contains { domain == $0 || domain.hasSuffix(".\($0)") }
|
||
if isBypass {
|
||
ExtLog.write("BYPASS (own domain): \(domain)")
|
||
forwardPacket(packet)
|
||
} else {
|
||
ExtLog.write("BLOCKED: \(domain)")
|
||
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`) UND erzwingt einen
|
||
/// DNS-Cache-Flush via Tunnel-Reconnect.
|
||
///
|
||
/// Warum der Reconnect: re-mmap allein lädt zwar die neuen Hashes, der
|
||
/// System-/Browser-DNS-Cache bleibt aber stehen. Eine gerade hinzugefügte
|
||
/// Custom-Domain, die der User vorher schon mal aufgerufen hat, würde dann
|
||
/// bis zum Ablauf der DNS-TTL über den Cache durchgereicht und NICHT durch
|
||
/// unseren Filter laufen → "blockt nicht sofort".
|
||
/// Android macht denselben Flush implizit über `ACTION_RESTART`
|
||
/// (`stopVpn()` + `startVpn()`); hier ist das iOS-Pendant.
|
||
///
|
||
/// HYPOTHESE, am Gerät zu verifizieren: dass `setTunnelNetworkSettings(nil)`
|
||
/// + Re-Apply den System-DNS-Cache tatsächlich flusht (Netz-Rekonfiguration).
|
||
/// Stark erwartet, aber bis zum Live-Test auf dem iPhone nicht hart belegt.
|
||
private func reloadBlocklist() {
|
||
hashList?.load()
|
||
ExtLog.write("blocklist reloaded — \(hashList?.count() ?? 0) Hashes")
|
||
reapplyTunnelSettings()
|
||
}
|
||
|
||
/// Reißt die TUN-Netzwerk-Settings kurz ab (`nil`) und setzt sie neu —
|
||
/// erzwingt eine Netz-Rekonfiguration durch iOS und damit einen
|
||
/// DNS-Cache-Flush. `reasserting` signalisiert dem System die kurze
|
||
/// Rekonfigurationsphase (UI bleibt "connected", kein VPN-Drop sichtbar).
|
||
private func reapplyTunnelSettings() {
|
||
guard running else { return }
|
||
reasserting = true
|
||
setTunnelNetworkSettings(nil) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
self.setTunnelNetworkSettings(self.buildTunnelSettings()) { [weak self] error in
|
||
guard let self = self else { return }
|
||
self.reasserting = false
|
||
if let error = error {
|
||
ExtLog.write("❌ reapply TUN-Settings: \(error.localizedDescription)")
|
||
} else {
|
||
ExtLog.write("🔄 TUN-Settings re-applied — DNS-Cache-Flush")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|