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:
parent
a713070d25
commit
5a16cf771b
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user