From 7f829dcbaa34f67b5dd778b283383686a7fa884a Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Sep 2025 13:45:03 +0200 Subject: [PATCH] iOS: deterministic Tor recovery + 100% gating; BLE-first; session rebuild - Restart/wake Tor on foreground via ControlPort (ACTIVE/SHUTDOWN), avoid restarts during bootstrap; add NWPathMonitor to trigger checks - Use NWConnection control polling for GETINFO; remove blocking CFStream readers to avoid QoS inversions; compute readiness from SOCKS + 100% - Rebuild TorURLSession on resume; reset Nostr connections to rebind - Gate all internet after full bootstrap; keep BLE mesh startup fast - Fix Swift 6 capture issues; hop UI updates to @MainActor - Remove Tor progress spam; persist initial "starting tor..." system message --- bitchat/BitchatApp.swift | 10 + bitchat/Services/Tor/TorManager.swift | 241 +++++++++++++++-------- bitchat/Services/Tor/TorURLSession.swift | 42 ++-- bitchat/ViewModels/ChatViewModel.swift | 49 +++-- 4 files changed, 221 insertions(+), 121 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index b4cd3784..80dbc7cd 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -43,6 +43,8 @@ struct BitchatApp: App { #elseif os(macOS) appDelegate.chatViewModel = chatViewModel #endif + // Spin up Tor early; all internet will gate on Tor 100% + TorManager.shared.startIfNeeded() // Check for shared content checkForSharedContent() } @@ -54,10 +56,18 @@ struct BitchatApp: App { switch newPhase { case .background: // Keep BLE mesh running in background; BLEService adapts scanning automatically + // Optionally nudge Tor to dormant to save power + TorManager.shared.goDormantOnBackground() break case .active: // Restart services when becoming active chatViewModel.meshService.startServices() + // Ensure Tor is healthy; restart if suspended; wake ACTIVE + TorManager.shared.ensureRunningOnForeground() + // Rebuild proxied sessions to bind to live Tor + TorURLSession.shared.rebuild() + // Reconnect Nostr via fresh sessions; will gate until Tor 100% + NostrRelayManager.shared.resetAllConnections() checkForSharedContent() case .inactive: break diff --git a/bitchat/Services/Tor/TorManager.swift b/bitchat/Services/Tor/TorManager.swift index 8bf6e776..59c99187 100644 --- a/bitchat/Services/Tor/TorManager.swift +++ b/bitchat/Services/Tor/TorManager.swift @@ -24,11 +24,16 @@ final class TorManager: ObservableObject { let controlPort: Int = 39051 // State + // True only when SOCKS is reachable AND bootstrap has reached 100%. @Published private(set) var isReady: Bool = false @Published private(set) var isStarting: Bool = false @Published private(set) var lastError: Error? @Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapSummary: String = "" + + // Internal readiness trackers + private var socksReady: Bool = false { didSet { recomputeReady() } } + private var restarting: Bool = false // Whether the app must enforce Tor for all connections (fail-closed). // This is the default. For local development, you may compile with @@ -50,6 +55,7 @@ final class TorManager: ObservableObject { private var didStart = false private var controlMonitorStarted = false + private var pathMonitor: NWPathMonitor? private init() {} @@ -62,6 +68,7 @@ final class TorManager: ObservableObject { lastError = nil ensureFilesystemLayout() startTor() + startPathMonitorIfNeeded() } /// Await Tor bootstrap to readiness. Returns true if network is permitted (Tor ready or dev bypass). @@ -257,20 +264,18 @@ final class TorManager: ObservableObject { // Start control-port monitor and probe readiness asynchronously startControlMonitorIfNeeded() - // Start control-port monitor and probe readiness asynchronously - startControlMonitorIfNeeded() - Task.detached(priority: .utility) { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } let ready = await self.waitForSocksReady(timeout: 60.0) await MainActor.run { - self.isReady = ready - self.isStarting = false + self.socksReady = ready if !ready { self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"]) SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) } else { SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) } + // isStarting will be cleared when bootstrap reaches 100% } } @@ -330,18 +335,18 @@ final class TorManager: ObservableObject { // Start control monitor early startControlMonitorIfNeeded() - Task.detached(priority: .utility) { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } let ready = await self.waitForSocksReady(timeout: 60.0) await MainActor.run { - self.isReady = ready - self.isStarting = false + self.socksReady = ready if ready { SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) } else { self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"]) SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) } + // isStarting will be cleared when bootstrap reaches 100% } } return true @@ -349,102 +354,170 @@ final class TorManager: ObservableObject { // MARK: - ControlPort monitoring (bootstrap progress) private func startControlMonitorIfNeeded() { - #if os(iOS) - // iOS: no-op; we skip ControlPort monitoring to keep startup lean. - return - #else guard !controlMonitorStarted else { return } controlMonitorStarted = true - Task.detached(priority: .background) { [weak self] in - guard let self else { return } - await self.controlMonitorLoop() + // Use a simple GETINFO poll on all platforms to avoid long-lived blocking streams + Task.detached(priority: .utility) { [weak self] in + await self?.bootstrapPollLoop() } - #endif } - private func controlMonitorLoop() async { + private func controlMonitorLoop() async {} + + private func tryControlSessionOnce() async -> Bool { false } + + // iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers. + private func bootstrapPollLoop() async { let deadline = Date().addingTimeInterval(75) while Date() < deadline { - if await self.tryControlSessionOnce() { return } + if let info = await controlGetBootstrapInfo() { + await MainActor.run { + self.bootstrapProgress = info.progress + self.bootstrapSummary = info.summary + if info.progress >= 100 { self.isStarting = false } + self.recomputeReady() + } + if info.progress >= 100 { break } + } try? await Task.sleep(nanoseconds: 1_000_000_000) } } - private func tryControlSessionOnce() async -> Bool { - guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie") else { return false } - guard let cookie = try? Data(contentsOf: cookiePath) else { return false } + private func controlGetBootstrapInfo() async -> (progress: Int, summary: String)? { + guard let text = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: 2.0) else { return nil } + var progress = self.bootstrapProgress + var summary = self.bootstrapSummary + // Search entire response for PROGRESS and SUMMARY tokens + // Typical: "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=75 TAG=... SUMMARY=\"...\"\r\n250 OK\r\n" + let tokens = text.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ").split(separator: " ") + for t in tokens { + if t.hasPrefix("PROGRESS=") { + progress = Int(t.split(separator: "=").last ?? "0") ?? progress + } else if t.hasPrefix("SUMMARY=") { + let raw = String(t.dropFirst("SUMMARY=".count)) + summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + return (progress, summary) + } + + // MARK: - Foreground recovery and control helpers + + func ensureRunningOnForeground() { + // If we can talk to ControlPort, wake Tor and verify bootstrap; else restart. + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + // Avoid restarts while starting/restarting + if await MainActor.run(body: { self.isStarting || self.restarting }) { return } + let ok = await self.controlPingBootstrap() + if ok { + _ = await self.controlSendSignal("ACTIVE") + return + } + // If Tor is still bootstrapping (SOCKS up but progress < 100), don't thrash; let monitor update + let stillBootstrapping = await MainActor.run(body: { self.socksReady && self.bootstrapProgress < 100 }) + if stillBootstrapping { return } + await self.restartTor() + } + } + + func goDormantOnBackground() { + Task.detached { [weak self] in + _ = await self?.controlSendSignal("DORMANT") + } + } + + private func restartTor() async { + await MainActor.run { self.restarting = true; self.isReady = false; self.socksReady = false; self.bootstrapProgress = 0; self.bootstrapSummary = ""; self.isStarting = true } + // Try graceful shutdown if control is reachable + _ = await controlSendSignal("SHUTDOWN") + // Wait for SOCKS to go down + let downDeadline = Date().addingTimeInterval(5) + while Date() < downDeadline { + if await !probeSocksOnce() { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + await MainActor.run { self.didStart = false } + await MainActor.run { self.startIfNeeded() } + await MainActor.run { self.restarting = false } + } + + private func recomputeReady() { + let ready = socksReady && bootstrapProgress >= 100 + if ready != isReady { + isReady = ready + } + } + + private func startPathMonitorIfNeeded() { + guard pathMonitor == nil else { return } + let monitor = NWPathMonitor() + pathMonitor = monitor + let queue = DispatchQueue(label: "TorPathMonitor") + monitor.pathUpdateHandler = { [weak self] _ in + // On any path change, poke Tor/recover (hop to main actor). + Task { @MainActor in + self?.ensureRunningOnForeground() + } + } + monitor.start(queue: queue) + } + + // Lightweight control: authenticate and GETINFO bootstrap-phase. + private func controlPingBootstrap(timeout: TimeInterval = 3.0) async -> Bool { + let data = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: timeout) + guard let text = data else { return false } + return text.contains("status/bootstrap-phase") + } + + private func controlSendSignal(_ signal: String, timeout: TimeInterval = 3.0) async -> Bool { + let text = await controlExchange(lines: ["SIGNAL \(signal)"], timeout: timeout) + return (text?.contains("250")) == true + } + + private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? { + guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"), + let cookie = try? Data(contentsOf: cookiePath) else { return nil } let cookieHex = cookie.map { String(format: "%02X", $0) }.joined() - var inStream: InputStream? - var outStream: OutputStream? - Stream.getStreamsToHost(withName: controlHost, port: controlPort, inputStream: &inStream, outputStream: &outStream) - guard let input = inStream, let output = outStream else { return false } - input.open(); output.open() + let queue = DispatchQueue(label: "TorControl", qos: .userInitiated) + let params = NWParameters.tcp + guard let port = NWEndpoint.Port(rawValue: UInt16(controlPort)) else { return nil } + let endpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port) + let conn = NWConnection(to: endpoint, using: params) - func send(_ s: String) { - let bytes = Array(s.utf8) - _ = bytes.withUnsafeBytes { raw -> Int in - guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return 0 } - return output.write(base, maxLength: bytes.count) - } + var resultText = "" + var completed = false + func send(_ text: String) { + let data = (text + "\r\n").data(using: .utf8) ?? Data() + conn.send(content: data, completion: .contentProcessed { _ in }) } - func readLine(timeout: TimeInterval = 3.0) -> String? { - var data = Data() - let start = Date() - var buf = [UInt8](repeating: 0, count: 1024) - while Date().timeIntervalSince(start) < timeout { - if input.hasBytesAvailable { - let n = input.read(&buf, maxLength: buf.count) - if n > 0 { - data.append(buf, count: n) - if let range = data.range(of: Data([13,10])) { // CRLF - let lineData = data.prefix(upTo: range.lowerBound) - let line = String(data: lineData, encoding: .utf8) - let rest = data.suffix(from: range.upperBound) - data = Data(rest) - return line + func receiveLoop(deadline: Date) async { + while Date() < deadline { + let ok: Bool = await withCheckedContinuation { cont in + conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in + if let data = data, !data.isEmpty, let s = String(data: data, encoding: .utf8) { + resultText.append(s) } - } else if n == 0 { - break + if isComplete || error != nil { completed = true } + cont.resume(returning: true) } } - usleep(20_000) - } - return nil - } - - // Greeting - _ = readLine() - send("AUTHENTICATE \(cookieHex)\r\n") - guard let auth = readLine(), auth.hasPrefix("250") else { - input.close(); output.close(); return false - } - send("SETEVENTS STATUS_CLIENT\r\n") - _ = readLine() // 250 OK - - while true { - if Task.isCancelled { break } - guard let line = readLine(timeout: 10.0) else { continue } - if line.hasPrefix("650 ") && line.contains("BOOTSTRAP") { - var progress = self.bootstrapProgress - var summary = self.bootstrapSummary - for part in line.split(separator: " ") { - if part.hasPrefix("PROGRESS=") { - progress = Int(part.split(separator: "=").last ?? "0") ?? progress - } else if part.hasPrefix("SUMMARY=") { - let raw = String(part.dropFirst("SUMMARY=".count)) - summary = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - await MainActor.run { - self.bootstrapProgress = progress - self.bootstrapSummary = summary - } - if progress >= 100 { break } + if !ok || completed { break } + // Small delay to avoid tight loop + try? await Task.sleep(nanoseconds: 20_000_000) } } - input.close(); output.close() - return true + conn.start(queue: queue) + // Send immediately; NWConnection will queue until ready + send("AUTHENTICATE \(cookieHex)") + // Send requested lines + for line in lines { send(line) } + // Ask tor to close + send("QUIT") + await receiveLoop(deadline: Date().addingTimeInterval(timeout)) + conn.cancel() + return resultText } } diff --git a/bitchat/Services/Tor/TorURLSession.swift b/bitchat/Services/Tor/TorURLSession.swift index a4c2a5d0..d2995d00 100644 --- a/bitchat/Services/Tor/TorURLSession.swift +++ b/bitchat/Services/Tor/TorURLSession.swift @@ -10,14 +10,38 @@ final class TorURLSession { static let shared = TorURLSession() // Default (no proxy) session for local development when dev bypass is enabled. - private lazy var defaultSession: URLSession = { + private var defaultSession: URLSession = { let cfg = URLSessionConfiguration.default cfg.waitsForConnectivity = true return URLSession(configuration: cfg) }() // Proxied (SOCKS5) session that routes through Tor. - private lazy var torSession: URLSession = { + private var torSession: URLSession = TorURLSession.makeTorSession() + + var session: URLSession { + #if BITCHAT_DEV_ALLOW_CLEARNET + // Dev bypass: use direct session. Call sites may still await Tor if desired. + return defaultSession + #else + // Production: always use the Tor-proxied session. Call sites ensure readiness. + return torSession + #endif + } + + // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart. + func rebuild() { + #if BITCHAT_DEV_ALLOW_CLEARNET + defaultSession = { + let cfg = URLSessionConfiguration.default + cfg.waitsForConnectivity = true + return URLSession(configuration: cfg) + }() + #endif + torSession = TorURLSession.makeTorSession() + } + + private static func makeTorSession() -> URLSession { let cfg = URLSessionConfiguration.ephemeral cfg.waitsForConnectivity = true // Keep in sync with TorManager defaults @@ -31,10 +55,6 @@ final class TorURLSession { ] #else // iOS: CFNetwork SOCKS proxy keys are unavailable at compile time. - // Using the documented string keys keeps the build green. On some iOS - // versions, URLSession may ignore per-session SOCKS; we still enforce - // Tor via fail-closed gating and can add platform-specific transport - // if needed. cfg.connectionProxyDictionary = [ "SOCKSEnable": 1, "SOCKSProxy": host, @@ -42,15 +62,5 @@ final class TorURLSession { ] #endif return URLSession(configuration: cfg) - }() - - var session: URLSession { - #if BITCHAT_DEV_ALLOW_CLEARNET - // Dev bypass: use direct session. Call sites may still await Tor if desired. - return defaultSession - #else - // Production: always use the Tor-proxied session. Call sites ensure readiness. - return torSession - #endif } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 309c68d0..f5f8f2a8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -533,30 +533,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Announce Tor status to the chat timeline as early as possible if TorManager.shared.torEnforced && !torStatusAnnounced { torStatusAnnounced = true - addPublicSystemMessage("Starting Tor…") - torProgressCancellable = TorManager.shared.$bootstrapProgress - .receive(on: DispatchQueue.main) - .sink { [weak self] p in - guard let self = self else { return } - if p == 0 { return } - if p == 100 || p / 10 > self.lastTorProgressAnnounced / 10 { - self.lastTorProgressAnnounced = p - let summary = TorManager.shared.bootstrapSummary - let msg = summary.isEmpty ? "Tor \(p)%" : "Tor \(p)% – \(summary)" - self.addPublicSystemMessage(msg) - } - } - Task.detached { [weak self] in + addPublicSystemMessage("starting tor...") + // Suppress incremental Tor progress messages; only show initial start and readiness. + torProgressCancellable = nil + Task.detached { let ready = await TorManager.shared.awaitReady() - await MainActor.run { - guard let strongSelf = self else { return } - if ready { strongSelf.addPublicSystemMessage("Tor ready. Routing all connections via Tor.") } - else { strongSelf.addPublicSystemMessage("Still waiting for Tor to start…") } + Task { @MainActor [weak self] in + guard let self else { return } + if ready { self.addPublicSystemMessage("tor started. routing all chats via tor for privacy.") } + else { self.addPublicSystemMessage("still waiting for tor to start...") } } } } else if !TorManager.shared.torEnforced && !torStatusAnnounced { torStatusAnnounced = true - addPublicSystemMessage("Development build: Tor bypass enabled.") + addPublicSystemMessage("development build: tor bypass enabled.") } // Initialize Nostr services @@ -4906,10 +4896,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate { messages.append(systemMessage) } - /// Public helper to add a system message to the public chat timeline + /// Public helper to add a system message to the public chat timeline. + /// Also persists the message into the active channel's backing store so it survives timeline rebinds. @MainActor func addPublicSystemMessage(_ content: String) { - addSystemMessage(content) + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + // Append to current visible messages + messages.append(systemMessage) + // Persist into the backing store for the active channel to survive rebinds + switch activeChannel { + case .mesh: + meshTimeline.append(systemMessage) + case .location(let ch): + var arr = geoTimelines[ch.geohash] ?? [] + arr.append(systemMessage) + geoTimelines[ch.geohash] = arr + } objectWillChange.send() } // Send a public message without adding a local user echo.