chahinebrini 5a16cf771b feat(ios-protection): v1 NEPacketTunnelProvider DNS-Sinkhole als Layer-1
Neuer iOS-Layer-1-Filter: ein NEPacketTunnelProvider-DNS-Sinkhole — MDM-frei,
ab iOS 16, Parität zum Android-VPN-DNS-Filter. Ersetzt den Apple-seitig
blockierten NEURLFilter als Default. NEURLFilter-/PIR-Code bleibt inaktiv als
iOS-26-Upgrade-Pfad erhalten (User-Entscheidung).

Neues Extension-Target RebreakPacketTunnelExtension/:
- PacketTunnelProvider.swift — TUN-Setup (virtuelle DNS-IP 10.0.0.1, nur diese
  Route ins TUN), Read-Loop, NXDOMAIN-Sinkhole, Upstream-Forward via
  NWConnection zu 1.1.1.1, Blocklist-Reload via Darwin-Notification.
- DnsFilter.swift / HashList.swift / DomainHasher.swift — Swift-Ports der
  Android-DNS-Filter-Logik. blocklist.bin-Format (sortierte big-endian UInt64,
  SHA-256-Prefix) 1:1 beibehalten, mmap statt Heap-Load.

RebreakProtectionModule.swift:
- activateUrlFilter startet jetzt den Packet-Tunnel via NETunnelProviderManager
  (Default-Layer-1, On-Demand-Auto-Reconnect aktiv).
- NEURLFilter-Code in activateNeUrlFilter ausgelagert (inaktiv, behalten).
- getDeviceState/disable lesen bzw. stoppen den Tunnel-Status.

with-rebreak-protection-ios.js: zweites app_extension-Target, klassischer
Embed-Pfad (dstSubfolderSpec 13), packet-tunnel-provider-Entitlement + App-Group.
app.config.ts: zweites appExtensions-Target.

NICHT auf echtem Gerät verifiziert — NE-Packet-Tunnel laufen nicht im
Simulator. Ungetestete Annahmen im Code mit "UNGETESTETE ANNAHME" markiert.

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

359 lines
14 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.
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<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")
}
}