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
This commit is contained in:
jack
2025-09-10 13:45:03 +02:00
parent 37d655d065
commit 7f829dcbaa
4 changed files with 221 additions and 121 deletions
+10
View File
@@ -43,6 +43,8 @@ struct BitchatApp: App {
#elseif os(macOS) #elseif os(macOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#endif #endif
// Spin up Tor early; all internet will gate on Tor 100%
TorManager.shared.startIfNeeded()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -54,10 +56,18 @@ struct BitchatApp: App {
switch newPhase { switch newPhase {
case .background: case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically // Keep BLE mesh running in background; BLEService adapts scanning automatically
// Optionally nudge Tor to dormant to save power
TorManager.shared.goDormantOnBackground()
break break
case .active: case .active:
// Restart services when becoming active // Restart services when becoming active
chatViewModel.meshService.startServices() 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() checkForSharedContent()
case .inactive: case .inactive:
break break
+157 -84
View File
@@ -24,11 +24,16 @@ final class TorManager: ObservableObject {
let controlPort: Int = 39051 let controlPort: Int = 39051
// State // State
// True only when SOCKS is reachable AND bootstrap has reached 100%.
@Published private(set) var isReady: Bool = false @Published private(set) var isReady: Bool = false
@Published private(set) var isStarting: Bool = false @Published private(set) var isStarting: Bool = false
@Published private(set) var lastError: Error? @Published private(set) var lastError: Error?
@Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapProgress: Int = 0
@Published private(set) var bootstrapSummary: String = "" @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). // Whether the app must enforce Tor for all connections (fail-closed).
// This is the default. For local development, you may compile with // 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 didStart = false
private var controlMonitorStarted = false private var controlMonitorStarted = false
private var pathMonitor: NWPathMonitor?
private init() {} private init() {}
@@ -62,6 +68,7 @@ final class TorManager: ObservableObject {
lastError = nil lastError = nil
ensureFilesystemLayout() ensureFilesystemLayout()
startTor() startTor()
startPathMonitorIfNeeded()
} }
/// Await Tor bootstrap to readiness. Returns true if network is permitted (Tor ready or dev bypass). /// 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 // Start control-port monitor and probe readiness asynchronously
startControlMonitorIfNeeded() startControlMonitorIfNeeded()
// Start control-port monitor and probe readiness asynchronously Task.detached(priority: .userInitiated) { [weak self] in
startControlMonitorIfNeeded()
Task.detached(priority: .utility) { [weak self] in
guard let self else { return } guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0) let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run { await MainActor.run {
self.isReady = ready self.socksReady = ready
self.isStarting = false
if !ready { if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"]) 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) SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
} else { } else {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) 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 // Start control monitor early
startControlMonitorIfNeeded() startControlMonitorIfNeeded()
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return } guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0) let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run { await MainActor.run {
self.isReady = ready self.socksReady = ready
self.isStarting = false
if ready { if ready {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info)
} else { } else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"]) 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) SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error)
} }
// isStarting will be cleared when bootstrap reaches 100%
} }
} }
return true return true
@@ -349,102 +354,170 @@ final class TorManager: ObservableObject {
// MARK: - ControlPort monitoring (bootstrap progress) // MARK: - ControlPort monitoring (bootstrap progress)
private func startControlMonitorIfNeeded() { private func startControlMonitorIfNeeded() {
#if os(iOS)
// iOS: no-op; we skip ControlPort monitoring to keep startup lean.
return
#else
guard !controlMonitorStarted else { return } guard !controlMonitorStarted else { return }
controlMonitorStarted = true controlMonitorStarted = true
Task.detached(priority: .background) { [weak self] in // Use a simple GETINFO poll on all platforms to avoid long-lived blocking streams
guard let self else { return } Task.detached(priority: .utility) { [weak self] in
await self.controlMonitorLoop() 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) let deadline = Date().addingTimeInterval(75)
while Date() < deadline { 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) try? await Task.sleep(nanoseconds: 1_000_000_000)
} }
} }
private func tryControlSessionOnce() async -> Bool { private func controlGetBootstrapInfo() async -> (progress: Int, summary: String)? {
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie") else { return false } guard let text = await controlExchange(lines: ["GETINFO status/bootstrap-phase"], timeout: 2.0) else { return nil }
guard let cookie = try? Data(contentsOf: cookiePath) else { return false } 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() let cookieHex = cookie.map { String(format: "%02X", $0) }.joined()
var inStream: InputStream? let queue = DispatchQueue(label: "TorControl", qos: .userInitiated)
var outStream: OutputStream? let params = NWParameters.tcp
Stream.getStreamsToHost(withName: controlHost, port: controlPort, inputStream: &inStream, outputStream: &outStream) guard let port = NWEndpoint.Port(rawValue: UInt16(controlPort)) else { return nil }
guard let input = inStream, let output = outStream else { return false } let endpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port)
input.open(); output.open() let conn = NWConnection(to: endpoint, using: params)
func send(_ s: String) { var resultText = ""
let bytes = Array(s.utf8) var completed = false
_ = bytes.withUnsafeBytes { raw -> Int in func send(_ text: String) {
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return 0 } let data = (text + "\r\n").data(using: .utf8) ?? Data()
return output.write(base, maxLength: bytes.count) conn.send(content: data, completion: .contentProcessed { _ in })
}
} }
func readLine(timeout: TimeInterval = 3.0) -> String? { func receiveLoop(deadline: Date) async {
var data = Data() while Date() < deadline {
let start = Date() let ok: Bool = await withCheckedContinuation { cont in
var buf = [UInt8](repeating: 0, count: 1024) conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
while Date().timeIntervalSince(start) < timeout { if let data = data, !data.isEmpty, let s = String(data: data, encoding: .utf8) {
if input.hasBytesAvailable { resultText.append(s)
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
} }
} else if n == 0 { if isComplete || error != nil { completed = true }
break cont.resume(returning: true)
} }
} }
usleep(20_000) if !ok || completed { break }
} // Small delay to avoid tight loop
return nil try? await Task.sleep(nanoseconds: 20_000_000)
}
// 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 }
} }
} }
input.close(); output.close() conn.start(queue: queue)
return true // 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
} }
} }
+26 -16
View File
@@ -10,14 +10,38 @@ final class TorURLSession {
static let shared = TorURLSession() static let shared = TorURLSession()
// Default (no proxy) session for local development when dev bypass is enabled. // 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 let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true cfg.waitsForConnectivity = true
return URLSession(configuration: cfg) return URLSession(configuration: cfg)
}() }()
// Proxied (SOCKS5) session that routes through Tor. // 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 let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = true cfg.waitsForConnectivity = true
// Keep in sync with TorManager defaults // Keep in sync with TorManager defaults
@@ -31,10 +55,6 @@ final class TorURLSession {
] ]
#else #else
// iOS: CFNetwork SOCKS proxy keys are unavailable at compile time. // 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 = [ cfg.connectionProxyDictionary = [
"SOCKSEnable": 1, "SOCKSEnable": 1,
"SOCKSProxy": host, "SOCKSProxy": host,
@@ -42,15 +62,5 @@ final class TorURLSession {
] ]
#endif #endif
return URLSession(configuration: cfg) 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
} }
} }
+28 -21
View File
@@ -533,30 +533,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Announce Tor status to the chat timeline as early as possible // Announce Tor status to the chat timeline as early as possible
if TorManager.shared.torEnforced && !torStatusAnnounced { if TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true torStatusAnnounced = true
addPublicSystemMessage("Starting Tor") addPublicSystemMessage("starting tor...")
torProgressCancellable = TorManager.shared.$bootstrapProgress // Suppress incremental Tor progress messages; only show initial start and readiness.
.receive(on: DispatchQueue.main) torProgressCancellable = nil
.sink { [weak self] p in Task.detached {
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
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
await MainActor.run { Task { @MainActor [weak self] in
guard let strongSelf = self else { return } guard let self else { return }
if ready { strongSelf.addPublicSystemMessage("Tor ready. Routing all connections via Tor.") } if ready { self.addPublicSystemMessage("tor started. routing all chats via tor for privacy.") }
else { strongSelf.addPublicSystemMessage("Still waiting for Tor to start") } else { self.addPublicSystemMessage("still waiting for tor to start...") }
} }
} }
} else if !TorManager.shared.torEnforced && !torStatusAnnounced { } else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true torStatusAnnounced = true
addPublicSystemMessage("Development build: Tor bypass enabled.") addPublicSystemMessage("development build: tor bypass enabled.")
} }
// Initialize Nostr services // Initialize Nostr services
@@ -4906,10 +4896,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messages.append(systemMessage) 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 @MainActor
func addPublicSystemMessage(_ content: String) { 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() objectWillChange.send()
} }
// Send a public message without adding a local user echo. // Send a public message without adding a local user echo.