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>
This commit is contained in:
chahinebrini 2026-05-21 18:37:54 +02:00
parent a713070d25
commit 5a16cf771b
11 changed files with 1298 additions and 33 deletions

View File

@ -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: {

View File

@ -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

View File

@ -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<UInt8>, 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..<len {
label.append(bytes[pos + i])
}
labels.append(String(decoding: label, as: UTF8.self))
pos += len
}
let host = labels.joined(separator: ".")
return host.isEmpty ? nil : host
}
// NXDOMAIN-Response-Builder
/// Baut die synthetische NXDOMAIN-Response aus der Request-Kopie.
/// Logik 1:1 aus `DnsFilter.kt#buildNxDomainResponse`:
/// - Src-/Dst-IP tauschen, Src-/Dst-Port tauschen.
/// - DNS-Flags auf 0x8183 setzen (QR=1, RD=1, RA=1, RCODE=3 NXDOMAIN).
/// - Answer/Authority/Additional-Counts auf 0.
/// - IP-Checksum neu berechnen, UDP-Checksum auf 0 (optional in IPv4).
private static func buildNxDomainResponse(
_ bytes: UnsafeBufferPointer<UInt8>, length: Int, ihl: Int
) -> Data {
var resp = [UInt8](repeating: 0, count: length)
for i in 0..<length { resp[i] = bytes[i] }
// Src-IP (12..15) und Dst-IP (16..19) tauschen.
for i in 0..<4 {
let srcByte = bytes[12 + i]
let dstByte = bytes[16 + i]
resp[12 + i] = dstByte
resp[16 + i] = srcByte
}
// IP-Header-Checksum-Feld nullen (wird unten neu berechnet).
resp[10] = 0
resp[11] = 0
// UDP-Ports tauschen.
let udpStart = ihl
let srcPort0 = bytes[udpStart]
let srcPort1 = bytes[udpStart + 1]
let dstPort0 = bytes[udpStart + 2]
let dstPort1 = bytes[udpStart + 3]
resp[udpStart] = dstPort0
resp[udpStart + 1] = dstPort1
resp[udpStart + 2] = srcPort0
resp[udpStart + 3] = srcPort1
// UDP-Checksum auf 0 (in IPv4 erlaubt, Empfänger überspringt die Prüfung).
resp[udpStart + 6] = 0
resp[udpStart + 7] = 0
// DNS-Flags: 0x8183 = QR=1 OPCODE=0 AA=0 TC=0 RD=1 RA=1 RCODE=3 (NXDOMAIN).
let dnsStart = udpStart + 8
resp[dnsStart + 2] = 0x81
resp[dnsStart + 3] = 0x83
// ANCOUNT / NSCOUNT / ARCOUNT (Bytes 6..11 im DNS-Header) auf 0
// QDCOUNT (Bytes 4..5) bleibt unangetastet, die Frage spiegeln wir zurück.
for i in 6...11 {
resp[dnsStart + i] = 0
}
recomputeIpChecksum(&resp, ihl: ihl)
return Data(resp)
}
// Forward-Response-Builder
/// Verpackt die rohe DNS-Antwort des Upstream-Resolvers wieder als IPv4/UDP-
/// Paket, das ins TUN zurückgeschrieben werden kann. Logik aus
/// `DnsFilter.kt#forwardAndWrite` (der Paket-Bau-Teil).
///
/// - Parameters:
/// - request: die ursprüngliche Query (IPv4-Paket).
/// - dnsAnswer: die rohe DNS-Antwort (ab DNS-Header) vom Upstream.
/// - Returns: das fertige IPv4/UDP/DNS-Response-Paket, oder nil bei Fehler.
static func buildForwardResponse(request: Data, dnsAnswer: Data) -> 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..<ihl { resp[i] = req[i] }
// IP total-length aktualisieren (Bytes 2..3).
resp[2] = UInt8((totalLen >> 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..<length)
}
}
}

View File

@ -0,0 +1,59 @@
/*
DomainHasher Swift-Port von
`android/.../filter/DomainHasher.kt`.
Domain-Hashing IDENTISCH zu `server/utils/domainHash.ts` und der Android-
Implementierung. Produziert die ersten 8 Bytes des SHA-256 als big-endian
UInt64. Der Server liefert die Hash-Liste in dieser Form; iOS und Android
lesen sie 1:1.
Privacy: arbeitet rein lokal keine Klartext-Domain verlässt das Gerät.
*/
import Foundation
import CryptoKit
enum DomainHasher {
/// Normalisiert einen Hostname analog zu Server + Android.
/// - lowercase
/// - http(s):// strippen
/// - Pfad/Query/Anchor (alles ab erstem `/`) abschneiden
/// - leading "www." entfernen
///
/// Muss BIT-IDENTISCH zu `DomainHasher.kt#normalize` bleiben, sonst matchen
/// die Hashes nicht gegen die vom Server generierte `blocklist.bin`.
static func normalize(_ host: String) -> 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..<slash])
}
if h.hasPrefix("www.") {
h = String(h.dropFirst(4))
}
return h
}
/// SHA-256(normalize(host)).first(8) als big-endian UInt64.
///
/// Bit-Pattern identisch zu Kotlins `ByteBuffer.order(BIG_ENDIAN).long`
/// und zum Server. Die ersten 8 Bytes des Digest werden big-endian
/// interpretiert.
static func hash(_ host: String) -> 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
}
}

View File

@ -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)
}
}
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ReBreak DNS-Filter</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
</dict>
</plist>

View File

@ -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<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")
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.rebreak.app</string>
</array>
</dict>
</plist>

View File

@ -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

View File

@ -17,15 +17,32 @@ import type {
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
/**
* 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

View File

@ -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;
};