chahinebrini 617312f367 fix(vpn): bypass own domains in DNS filter (rebreak.org, rebreak.app)
OAuth-Callbacks gehen an db-staging.rebreak.org — wenn der In-Flight-Cap
kurz erreicht wird, kriegt das SERVFAIL statt einer echten Antwort.
Eigene Infrastruktur-Domains explizit als Bypass deklariert: werden nie
aus der Blocklist geblockt und umgehen den In-Flight-Zähler nicht
(Forward läuft weiterhin normal, aber Block-Entscheidung wird übersprungen).

Gilt für iOS (PacketTunnelProvider) und Android (DnsFilter) gleichzeitig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 05:12:50 +02:00

546 lines
24 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
PacketTunnelProvider die ReBreak iOS-Layer-1-Schutzschicht (DNS-Sinkhole).
`NEPacketTunnelProvider`-Subclass das iOS-Pendant zu Androids
`RebreakVpnService`. Ersetzt den (Apple-seitig blockierten) NEURLFilter als
primären, lieferbaren iOS-Filter.
Architektur (1:1 zum Android-VpnService):
- TUN-Interface mit kleinem virtuellen Subnet (10.0.0.0/24).
- DNS-Server auf die virtuelle IP 10.0.0.1 gesetzt DNS-Queries laufen ins
TUN. Nur diese eine IP wird via `includedRoutes` geroutet sonstiger
Traffic geht direkt vorbei (batterieschonend, kein Voll-Tunnel).
- Read-Loop liest IPv4/UDP-Pakete; `DnsFilter` parst die QNAME.
- Treffer in der Blocklist synthetische NXDOMAIN-Response, sofort ins TUN
zurückgeschrieben.
- Miss Query an Upstream-Resolver (1.1.1.1:53) via `NWConnection`,
Response wieder als IPv4/UDP-Paket verpackt und ins TUN geschrieben.
- Upstream-Fehler (Timeout / recvError / leere Antwort / Connection failed)
oder In-Flight-Cap überschritten synthetische SERVFAIL-Response ins TUN,
damit der DNS-Client des Geräts schnell failt statt in seinen eigenen
mehrsekündigen Timeout zu laufen.
Blocklist: `blocklist.bin` aus dem App-Group-Container, mmap'd via `HashList`.
`syncBlocklist` (Haupt-App) postet die Darwin-Notification
`rebreak.blocklist.updated` die Extension re-mmap't ohne Neustart.
HONESTY / UNGETESTETE ANNAHMEN
NE-Packet-Tunnel laufen NICHT im iOS-Simulator. Diese Datei ist so korrekt
wie ohne echtes Gerät möglich implementiert, aber NICHT auf einem iPhone
verifiziert. Konkrete ungeprüfte Annahmen sind unten an Ort und Stelle mit
`UNGETESTETE ANNAHME` markiert.
*/
import Foundation
import NetworkExtension
import Network
import os
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
// Virtuelles Subnet identisch zur Android-Wahl (RebreakVpnService).
private let TUNNEL_LOCAL_ADDR = "10.0.0.2"
private let VIRTUAL_DNS_ADDR = "10.0.0.1"
private let TUNNEL_SUBNET_MASK = "255.255.255.0"
// Upstream-Resolver identisch zu Android (DnsFilter.UPSTREAM_DNS).
private let UPSTREAM_DNS_HOST = "1.1.1.1"
private let UPSTREAM_DNS_PORT: UInt16 = 53
// Hardcodierter Bypass: eigene Infrastruktur-Domains niemals blocken,
// unabhängig von Blocklist-Hash-Kollisionen oder In-Flight-Cap.
// Betrifft Supabase-Auth-Callbacks (db-staging.rebreak.org),
// Backend-API (staging.rebreak.org) und zukünftige prod-Domains.
private let BYPASS_DOMAIN_SUFFIXES = ["rebreak.org", "rebreak.app"]
// Extension-Log-Store
/// Schreibt in den geteilten App-Group-Log-Store (`url_filter_logs`), den die
/// Haupt-App via `getProtectionLogs()` ausliest. Die Extension läuft als
/// eigener Prozess das ist das einzige Debug-Fenster ohne Console.app.
/// Pattern 1:1 aus `RebreakURLFilterControlProvider.swift#ExtLog`.
enum ExtLog {
static func write(_ msg: String) {
NSLog("REBREAK_PKTTUN %@", msg)
guard let d = UserDefaults(suiteName: APP_GROUP) else { return }
let line = "[PKTTUN \(ISO8601DateFormatter().string(from: Date()))] \(msg)"
var logs = d.stringArray(forKey: "url_filter_logs") ?? []
logs.append(line)
if logs.count > 200 { logs.removeFirst(logs.count - 200) }
d.set(logs, forKey: "url_filter_logs")
}
}
// Provider
class PacketTunnelProvider: NEPacketTunnelProvider {
private let log = Logger(
subsystem: "org.rebreak.app.packettunnel", category: "provider")
/// Geteilte, gemmapte Blocklist.
private var hashList: HashList?
/// Eigene serielle Queue für Forward-Sockets hält den Read-Loop frei.
private let forwardQueue = DispatchQueue(
label: "org.rebreak.app.packettunnel.forward", qos: .userInitiated)
/// Ist der Read-Loop aktiv?
private var running = false
// In-Flight-Cap (H1)
//
// H1-Fix: Pro erlaubter Query wurde bisher eine frische `NWConnection`
// geöffnet (erst nach Antwort/Timeout bis 4 s gecancelt). Bei DNS-Bursts
// (App-Start, Webseite mit vielen Subresources) liefen so Dutzende
// gleichzeitige Connections + Timeout-WorkItems an NE-Packet-Tunnel-
// Extensions haben aber ein hartes Memory-Limit (~15 MB) Jetsam-Crash.
//
// Gewählte Variante: harter In-Flight-CAP statt Connection-Pooling.
// Begründung:
// - UDP-`NWConnection`-Pooling auf einen einzigen Socket ist fehleranfällig:
// ein gemeinsamer UDP-Socket vermischt Antworten mehrerer paralleler
// Queries korrektes Demultiplexing bräuchte ein eigenes Pending-Mapping
// über die DNS-Transaction-ID. Das ist mehr Zustand (= mehr Speicher) und
// mehr Crash-Fläche als das Problem rechtfertigt.
// - Ein simpler Zähler ist O(1)-Zustand und deckelt den Speicher hart.
// - DNS ist von sich aus retry-tolerant: wird der Cap überschritten,
// antworten wir der Query SOFORT mit SERVFAIL (K2-Pfad) der Client
// retryt dann von selbst, sobald der Burst abgeebbt ist. Besser ein
// schneller SERVFAIL als ein Extension-Crash, der den ganzen Tunnel
// (= den Schutz) reißt.
//
// UNGETESTETE ANNAHME: Der konkrete Cap-Wert (32) ist eine konservative
// Schätzung. Eine `NWConnection` + Timeout-WorkItem kostet grob einige KB;
// 32 gleichzeitige Verbindungen bleiben damit klar unter dem ~15-MB-Limit,
// decken aber typische Burst-Größen ab. Auf Gerät unter Last verifizieren
// (Instruments / Memory-Graph der Extension).
private static let maxInFlightUpstream = 32
/// Zähler aktuell offener Upstream-Connections. Nur auf `forwardQueue`
/// gelesen/geschrieben keine zusätzliche Synchronisation nötig.
private var inFlightUpstream = 0
// TUN-Netzwerk-Settings
/// Baut die NEPacketTunnelNetworkSettings für den DNS-Sinkhole.
/// Eine Quelle für `startTunnel` UND den Reconnect in `reloadBlocklist`
/// (`reapplyTunnelSettings`) sonst driften beide Konfigurationen ab.
private func buildTunnelSettings() -> NEPacketTunnelNetworkSettings {
// `tunnelRemoteAddress` ist eine Pflichtangabe, wird bei einem rein lokalen
// DNS-Sinkhole aber nie real kontaktiert wir setzen die virtuelle
// DNS-IP. (Pattern aus AdGuard/NextDNS/Lockdown.)
let settings = NEPacketTunnelNetworkSettings(
tunnelRemoteAddress: VIRTUAL_DNS_ADDR)
// IPv4: lokale TUN-Adresse + nur die virtuelle DNS-IP routen.
// exakt Androids `addRoute(VIRTUAL_DNS_ADDR, 32)`: NUR DNS-Traffic geht
// ins TUN, sonstiger Traffic läuft direkt.
let ipv4 = NEIPv4Settings(
addresses: [TUNNEL_LOCAL_ADDR], subnetMasks: [TUNNEL_SUBNET_MASK])
ipv4.includedRoutes = [
NEIPv4Route(destinationAddress: VIRTUAL_DNS_ADDR, subnetMask: "255.255.255.255")
]
settings.ipv4Settings = ipv4
// DNS-Server auf die virtuelle IP das System schickt seine DNS-Queries
// an 10.0.0.1, die durch unser TUN laufen.
let dns = NEDNSSettings(servers: [VIRTUAL_DNS_ADDR])
// matchDomains [""] = ALLE Domains werden über diesen DNS-Server aufgelöst.
dns.matchDomains = [""]
settings.dnsSettings = dns
// MTU defensiv DNS-Pakete sind klein, der Default reicht; explizit
// gesetzt, damit das Verhalten nicht von iOS-Defaults abhängt.
settings.mtu = 1500
return settings
}
// startTunnel
/// Vom System aufgerufen, sobald `connection.startVPNTunnel()` (aus dem
/// RN-Modul) den Tunnel hochfährt.
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
) {
ExtLog.write("startTunnel — Extension-Prozess gestartet")
// Blocklist mmap'en.
if let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) {
let fileURL = containerURL.appendingPathComponent(BLOCKLIST_FILENAME)
let list = HashList(fileURL: fileURL)
list.load()
hashList = list
ExtLog.write("blocklist geladen — \(list.count()) Hashes")
} else {
ExtLog.write("⚠️ App-Group-Container nicht erreichbar — Blocklist leer")
hashList = nil
}
// Darwin-Notification-Observer registrieren re-mmap nach syncBlocklist.
registerBlocklistObserver()
// TUN-Netzwerk-Settings
let settings = buildTunnelSettings()
setTunnelNetworkSettings(settings) { [weak self] error in
guard let self = self else {
completionHandler(
NSError(domain: "RebreakPacketTunnel", code: -1,
userInfo: [NSLocalizedDescriptionKey: "provider gone"]))
return
}
if let error = error {
ExtLog.write("❌ setTunnelNetworkSettings: \(error.localizedDescription)")
completionHandler(error)
return
}
ExtLog.write("✅ TUN-Settings gesetzt — Read-Loop startet")
self.running = true
self.readPackets()
// Self-Heal: war die Blocklist beim Start leer (Data-Protection bei
// gesperrtem Gerät), per Backoff erneut laden bis Hashes da sind.
self.scheduleBlocklistRetryIfEmpty(attempt: 0)
completionHandler(nil)
}
}
// stopTunnel
/// Vom System aufgerufen, wenn der Tunnel stoppt auch wenn der User den
/// VPN-Toggle in den iOS-Settings umlegt (das iOS-Pendant zu Androids
/// `onRevoke`). Wir spiegeln das in den App-Group-Flag, damit `getDeviceState`
/// den extern abgeschaltet"-Zustand erkennt JS-Phase `recoveringFromBypass`.
override func stopTunnel(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
ExtLog.write("stopTunnel — reason=\(reason.rawValue)")
running = false
unregisterBlocklistObserver()
// Flag spiegeln. `userInitiated` (= User-Toggle in Settings) ist der
// Android-`onRevoke`-Fall: explizit vom Nutzer abgeschaltet.
if reason == .userInitiated || reason == .userLogout {
if let d = UserDefaults(suiteName: APP_GROUP) {
d.set(false, forKey: "vpn_tunnel_running")
d.set(true, forKey: "vpn_tunnel_revoked_by_user")
}
ExtLog.write(" Tunnel vom User abgeschaltet (revoke-Flag gesetzt)")
} else {
// OS-Kill / Netzwerk-Event etc. kein User-Revoke. On-Demand fährt den
// Tunnel ggf. von selbst wieder hoch.
if let d = UserDefaults(suiteName: APP_GROUP) {
d.set(false, forKey: "vpn_tunnel_running")
}
}
completionHandler()
}
// Read-Loop
/// Liest fortlaufend Pakete vom TUN. `readPackets(completionHandler:)` ruft
/// den Handler einmal pro Batch wir re-armen ihn am Ende selbst (Apple-
/// Pattern; es gibt keinen Blocking-Read wie Androids `FileInputStream`).
private func readPackets() {
guard running else { return }
packetFlow.readPackets { [weak self] packets, protocols in
guard let self = self, self.running else { return }
for (index, packet) in packets.enumerated() {
// Nur IPv4 verarbeiten protocols[i] ist die AF_*-Familie.
// UNGETESTETE ANNAHME: `protocols` enthält `AF_INET` (Int 2) für
// IPv4-Pakete. Apple-Doku bestätigt das; auf Gerät verifizieren.
let proto = protocols[index].int32Value
guard proto == AF_INET else { continue }
self.handlePacket(packet)
}
// Read-Loop re-armen.
self.readPackets()
}
}
/// Verarbeitet ein einzelnes IPv4-Paket.
private func handlePacket(_ packet: Data) {
guard let hashList = hashList else {
// Keine Blocklist konservativ: einfach forwarden (kein Block).
forwardPacket(packet)
return
}
switch DnsFilter.classify(packet: packet, hashList: hashList) {
case .block(let response, let domain):
// Eigene Infrastruktur-Domains nie blocken auch wenn Hash-Kollision.
// Betrifft OAuth-Callbacks (db-staging.rebreak.org) und Backend-API.
let isBypass = BYPASS_DOMAIN_SUFFIXES.contains { domain == $0 || domain.hasSuffix(".\($0)") }
if isBypass {
ExtLog.write("BYPASS (own domain): \(domain)")
forwardPacket(packet)
} else {
ExtLog.write("BLOCKED: \(domain)")
writeToTun(response)
}
case .forward:
forwardPacket(packet)
case .drop:
// Kein DNS / IPv6 / Nicht-UDP verwerfen.
break
}
}
// Upstream-Forwarding
/// Forwarded eine erlaubte DNS-Query an den Upstream-Resolver und schreibt
/// die Antwort als IPv4/UDP-Paket zurück ins TUN.
///
/// Verwendet `NWConnection` (Network.framework) statt eines POSIX-Sockets.
/// Wichtig: die Verbindung läuft auf der iOS-Standard-Route, NICHT durch
/// unser eigenes TUN das ist auf iOS automatisch so, weil wir nur die
/// virtuelle DNS-IP `10.0.0.1` routen. Eine reale 1.1.1.1-Verbindung matcht
/// diese Route nicht und geht direkt raus. Damit entfällt das `protect()`,
/// das Android braucht (dort wäre ein DNS-Loop möglich).
private func forwardPacket(_ packet: Data) {
guard let query = DnsFilter.extractDnsPayload(packet: packet) else {
return
}
let host = NWEndpoint.Host(UPSTREAM_DNS_HOST)
guard let port = NWEndpoint.Port(rawValue: UPSTREAM_DNS_PORT) else {
return
}
// Gesamte Connection-Lebenszeit läuft auf `forwardQueue` damit ist auch
// der `inFlightUpstream`-Zähler ohne extra Lock konsistent.
forwardQueue.async { [weak self] in
guard let self = self, self.running else { return }
// H1: In-Flight-Cap
// Burst über dem Cap keine neue Connection öffnen, sondern die Query
// sofort mit SERVFAIL beantworten. Der Client retryt selbst; der
// Speicher der Extension bleibt gedeckelt.
if self.inFlightUpstream >= PacketTunnelProvider.maxInFlightUpstream {
ExtLog.write("⚠️ Upstream-Cap erreicht — SERVFAIL (in-flight=\(self.inFlightUpstream))")
self.replyServFail(for: packet)
return
}
let connection = NWConnection(host: host, port: port, using: .udp)
var didFinish = false
self.inFlightUpstream += 1
// Timeout-WorkItem vorab deklariert, damit `finish()` es referenzieren
// kann; die eigentliche Item-Closure wird gleich darunter zugewiesen.
var timeoutWork: DispatchWorkItem?
// Zentrale Abschluss-Logik: idempotent, dekrementiert den Cap-Zähler
// genau einmal und schreibt wenn keine echte Antwort kam eine
// SERVFAIL-Response ins TUN (K2). `gotAnswer = true` heißt: es wurde
// bereits eine valide Upstream-Antwort geschrieben.
func finish(gotAnswer: Bool) {
// Läuft immer auf `forwardQueue` (alle Aufrufer tun das).
if didFinish { return }
didFinish = true
timeoutWork?.cancel()
connection.cancel()
self.inFlightUpstream -= 1
if !gotAnswer {
// K2: Upstream lieferte nichts Brauchbares Client nicht hängen
// lassen, sondern schnell mit SERVFAIL failen.
self.replyServFail(for: packet)
}
}
// Timeout wie Androids `soTimeout = 4000`. Bei Ablauf: SERVFAIL.
let work = DispatchWorkItem {
ExtLog.write("Upstream-Timeout — SERVFAIL")
finish(gotAnswer: false)
}
timeoutWork = work
self.forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: work)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.send(content: query, completion: .contentProcessed { sendError in
if sendError != nil {
// Send fehlgeschlagen SERVFAIL.
self.forwardQueue.async { finish(gotAnswer: false) }
return
}
// Antwort empfangen.
connection.receiveMessage { [weak self] data, _, _, recvError in
guard let self = self else { return }
self.forwardQueue.async {
if didFinish { return }
guard recvError == nil, let answer = data, !answer.isEmpty,
let response = DnsFilter.buildForwardResponse(
request: packet, dnsAnswer: answer) else {
// recvError / leere / unverpackbare Antwort SERVFAIL (K2).
finish(gotAnswer: false)
return
}
// Echte Antwort als IPv4/UDP-Paket ins TUN schreiben.
self.writeToTun(response)
finish(gotAnswer: true)
}
}
})
case .failed:
// Connection endgültig fehlgeschlagen (z.B. kein Netz) SERVFAIL
// statt den Client hängen zu lassen (K2).
self.forwardQueue.async { finish(gotAnswer: false) }
case .cancelled:
// `cancelled` ist Folge eines bereits gelaufenen finish()
// finish() ist idempotent, hier ist nichts mehr zu tun.
break
case .waiting:
// `.waiting` (kein Pfad) ist NICHT zwingend final die Connection
// kann sich erholen, sobald Konnektivität zurückkommt. Wir brechen
// hier deshalb NICHT sofort ab, sondern überlassen es dem 4-s-
// Timeout (der dann SERVFAIL liefert). Ein vorzeitiger Abbruch bei
// kurzem Netz-Flackern wäre unnötig aggressiv.
break
default:
break
}
}
connection.start(queue: self.forwardQueue)
}
}
/// Synthetisiert eine SERVFAIL-Response für ein Request-Paket und schreibt
/// sie ins TUN. K2: gemeinsamer Fehler-Pfad für Timeout, recvError, leere
/// Antwort, Connection-`.failed` und den H1-Cap.
private func replyServFail(for requestPacket: Data) {
if let response = DnsFilter.buildServFailResponse(request: requestPacket) {
writeToTun(response)
}
}
// TUN-Write
/// Schreibt ein fertiges IPv4-Paket ins TUN zurück.
private func writeToTun(_ packet: Data) {
// `writePackets` ist thread-safe (Apple-Doku) kann aus dem Read-Loop
// und aus den Forward-Completion-Handlern aufgerufen werden.
packetFlow.writePackets([packet], withProtocols: [NSNumber(value: AF_INET)])
}
// Blocklist-Reload via Darwin-Notification
/// Registriert einen CFNotificationCenter-Observer auf `rebreak.blocklist.updated`.
/// Die Haupt-App postet diese Notification nach jedem erfolgreichen
/// `syncBlocklist` die Extension re-mmap't `blocklist.bin` ohne Neustart.
private func registerBlocklistObserver() {
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
observer,
{ (_, observer, _, _, _) in
guard let observer = observer else { return }
let provider = Unmanaged<PacketTunnelProvider>
.fromOpaque(observer).takeUnretainedValue()
provider.reloadBlocklist()
},
DARWIN_NOTIF as CFString,
nil,
.deliverImmediately
)
}
private func unregisterBlocklistObserver() {
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
observer,
CFNotificationName(DARWIN_NOTIF as CFString),
nil
)
}
/// Re-mmap't die Blocklist (nach `syncBlocklist`) UND erzwingt einen
/// DNS-Cache-Flush via Tunnel-Reconnect.
///
/// Warum der Reconnect: re-mmap allein lädt zwar die neuen Hashes, der
/// System-/Browser-DNS-Cache bleibt aber stehen. Eine gerade hinzugefügte
/// Custom-Domain, die der User vorher schon mal aufgerufen hat, würde dann
/// bis zum Ablauf der DNS-TTL über den Cache durchgereicht und NICHT durch
/// unseren Filter laufen "blockt nicht sofort".
/// Android macht denselben Flush implizit über `ACTION_RESTART`
/// (`stopVpn()` + `startVpn()`); hier ist das iOS-Pendant.
///
/// HYPOTHESE, am Gerät zu verifizieren: dass `setTunnelNetworkSettings(nil)`
/// + Re-Apply den System-DNS-Cache tatsächlich flusht (Netz-Rekonfiguration).
/// Stark erwartet, aber bis zum Live-Test auf dem iPhone nicht hart belegt.
private func reloadBlocklist() {
hashList?.load()
ExtLog.write("blocklist reloaded — \(hashList?.count() ?? 0) Hashes")
reapplyTunnelSettings()
}
/// Reißt die TUN-Netzwerk-Settings kurz ab (`nil`) und setzt sie neu
/// erzwingt eine Netz-Rekonfiguration durch iOS und damit einen
/// DNS-Cache-Flush. `reasserting` signalisiert dem System die kurze
/// Rekonfigurationsphase (UI bleibt "connected", kein VPN-Drop sichtbar).
private func reapplyTunnelSettings() {
guard running else { return }
reasserting = true
setTunnelNetworkSettings(nil) { [weak self] _ in
guard let self = self else { return }
self.setTunnelNetworkSettings(self.buildTunnelSettings()) { [weak self] error in
guard let self = self else { return }
self.reasserting = false
if let error = error {
ExtLog.write("❌ reapply TUN-Settings: \(error.localizedDescription)")
} else {
ExtLog.write("🔄 TUN-Settings re-applied — DNS-Cache-Flush")
}
}
}
}
/// Self-Heal: wenn `startTunnel` die Blocklist leer geladen hat (Datei wegen
/// iOS-Data-Protection noch nicht lesbar Tunnel-Start vor dem ersten
/// Entsperren seit Boot, oder eine alte mit `.complete` geschriebene Datei),
/// wird `load()` mit Backoff erneut versucht, bis Hashes da sind.
///
/// Ohne das bliebe Layer 1 still tot, bis die App das nächste Mal synct und
/// die Darwin-Notification feuert beobachtet in den Geräte-Logs als
/// ~30-Minuten-Fenster mit 0 Hashes. Der Retry läuft auf `forwardQueue`.
private func scheduleBlocklistRetryIfEmpty(attempt: Int) {
guard running else { return }
guard let list = hashList, list.count() == 0 else { return } // schon ok
let maxAttempts = 20
guard attempt < maxAttempts else {
ExtLog.write("⚠️ Blocklist nach \(maxAttempts) Retries leer — warte auf App-Sync")
return
}
// Backoff in Sekunden; ab Index 5 konstant 300 s.
let backoff: [Double] = [3, 10, 30, 60, 120]
let delay = attempt < backoff.count ? backoff[attempt] : 300
forwardQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self, self.running else { return }
self.hashList?.load()
let n = self.hashList?.count() ?? 0
if n > 0 {
ExtLog.write("✅ blocklist self-heal — \(n) Hashes (Versuch \(attempt + 1))")
} else {
self.scheduleBlocklistRetryIfEmpty(attempt: attempt + 1)
}
}
}
}