From 9cbdb0a76459da88af96d5380ce72d1b28ca95a1 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:46:49 +0200 Subject: [PATCH] Gate Tor/Nostr start behind permissions or mutual favorites; add clean shutdown + robust gating (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NetworkActivationService to permit Tor/Nostr only when location is authorized OR at least one mutual favorite exists. - Gate TorManager.startIfNeeded/ensureRunningOnForeground behind a global allowAutoStart flag. - Always stop Tor on background for deterministic restarts; rebuild sessions on foreground when allowed. - NostrRelayManager respects the gate in connect/ensureConnections/subscribe/send/connectToRelay and skips reconnection when disallowed. - Symmetric shutdown when conditions become disallowed: disconnect relays and stop Tor. - Fix double-start by avoiding restart if Tor is already ready; prevent background thrash. - Improve UX: post "starting tor…" via TorWillStart, and "tor started…" on initial ready; keep existing restart messages. Rationale: Avoid starting Tor/relays when the user has no location permission and no mutual favorites, and ensure a clean, predictable lifecycle (no stale sockets, no double starts). Co-authored-by: jack --- bitchat/BitchatApp.swift | 26 ++-- bitchat/Nostr/NostrRelayManager.swift | 17 +++ .../Services/NetworkActivationService.swift | 70 ++++++++++ bitchat/Services/Tor/TorManager.swift | 19 +++ bitchat/Services/Tor/TorNotifications.swift | 1 + bitchat/ViewModels/ChatViewModel.swift | 126 +++++++++--------- 6 files changed, 183 insertions(+), 76 deletions(-) create mode 100644 bitchat/Services/NetworkActivationService.swift diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 75b7b907..99a1adf0 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -57,8 +57,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() + // Initialize network activation policy; will start Tor/Nostr only when allowed + NetworkActivationService.shared.start() // Check for shared content checkForSharedContent() } @@ -70,7 +70,7 @@ 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 + // Always send Tor to dormant on background for a clean restart later. TorManager.shared.setAppForeground(false) TorManager.shared.goDormantOnBackground() // Stop geohash sampling while backgrounded @@ -87,18 +87,22 @@ struct BitchatApp: App { // On initial cold launch, Tor was just started in onAppear. // Skip the deterministic restart the first time we become active. if didHandleInitialActive && didEnterBackground { - TorManager.shared.ensureRunningOnForeground() + if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { + TorManager.shared.ensureRunningOnForeground() + } } else { didHandleInitialActive = true } didEnterBackground = false - Task.detached { - let _ = await TorManager.shared.awaitReady(timeout: 60) - await MainActor.run { - // Rebuild proxied sessions to bind to the live Tor after readiness - TorURLSession.shared.rebuild() - // Reconnect Nostr via fresh sessions; will gate until Tor 100% - NostrRelayManager.shared.resetAllConnections() + if TorManager.shared.isAutoStartAllowed() { + Task.detached { + let _ = await TorManager.shared.awaitReady(timeout: 60) + await MainActor.run { + // Rebuild proxied sessions to bind to the live Tor after readiness + TorURLSession.shared.rebuild() + // Reconnect Nostr via fresh sessions; will gate until Tor 100% + NostrRelayManager.shared.resetAllConnections() + } } } checkForSharedContent() diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 8ce0e1d6..654b85de 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -86,6 +86,8 @@ final class NostrRelayManager: ObservableObject { /// Connect to all configured relays func connect() { + // Global network policy gate + if !TorManager.shared.isAutoStartAllowed() { return } // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. Task.detached { let ready = await TorManager.shared.awaitReady() @@ -117,6 +119,8 @@ final class NostrRelayManager: ObservableObject { /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { + // Global network policy gate + if !TorManager.shared.isAutoStartAllowed() { return } if TorManager.shared.torEnforced && !TorManager.shared.isReady { // Defer until Tor is fully ready; avoid queuing connection attempts early Task.detached { [weak self] in @@ -139,6 +143,8 @@ final class NostrRelayManager: ObservableObject { /// Send an event to specified relays (or all if none specified) func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { + // Global network policy gate + if !TorManager.shared.isAutoStartAllowed() { return } if TorManager.shared.torEnforced && !TorManager.shared.isReady { // Defer sends until Tor is ready to avoid premature queueing Task.detached { [weak self] in @@ -213,6 +219,8 @@ final class NostrRelayManager: ObservableObject { handler: @escaping (NostrEvent) -> Void, onEOSE: (() -> Void)? = nil ) { + // Global network policy gate + if !TorManager.shared.isAutoStartAllowed() { return } // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = Date() if messageHandlers[id] != nil { @@ -317,6 +325,8 @@ final class NostrRelayManager: ObservableObject { // MARK: - Private Methods private func connectToRelay(_ urlString: String) { + // Global network policy gate + if !TorManager.shared.isAutoStartAllowed() { return } guard let url = URL(string: urlString) else { SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) return @@ -523,6 +533,13 @@ final class NostrRelayManager: ObservableObject { } private func handleDisconnection(relayUrl: String, error: Error) { + // If networking is disallowed, do not schedule reconnection + if !TorManager.shared.isAutoStartAllowed() { + connections.removeValue(forKey: relayUrl) + subscriptions.removeValue(forKey: relayUrl) + updateRelayStatus(relayUrl, isConnected: false, error: error) + return + } connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift new file mode 100644 index 00000000..5281e97e --- /dev/null +++ b/bitchat/Services/NetworkActivationService.swift @@ -0,0 +1,70 @@ +import Foundation +import BitLogger +import Combine + +/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. +/// Policy: permit start when either location permissions are authorized OR +/// there exists at least one mutual favorite. Otherwise, do not start. +@MainActor +final class NetworkActivationService: ObservableObject { + static let shared = NetworkActivationService() + + @Published private(set) var activationAllowed: Bool = false + + private var cancellables = Set() + private var started = false + + private init() {} + + func start() { + guard !started else { return } + started = true + + // Initial compute + activationAllowed = Self.computeAllowed() + TorManager.shared.setAutoStartAllowed(activationAllowed) + + // React to location permission changes + LocationChannelManager.shared.$permissionState + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reevaluate() + } + .store(in: &cancellables) + + // React to mutual favorites changes + FavoritesPersistenceService.shared.$mutualFavorites + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reevaluate() + } + .store(in: &cancellables) + } + + private func reevaluate() { + let allowed = Self.computeAllowed() + if allowed != activationAllowed { + SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session) + activationAllowed = allowed + TorManager.shared.setAutoStartAllowed(allowed) + if allowed { + // Kick Tor + relays if we're now permitted + TorManager.shared.startIfNeeded() + // If app is in foreground, begin relay connections + if TorManager.shared.isForeground() { + NostrRelayManager.shared.connect() + } + } else { + // Transitioned to disallowed: disconnect relays and shut down Tor + NostrRelayManager.shared.disconnect() + TorManager.shared.goDormantOnBackground() + } + } + } + + private static func computeAllowed() -> Bool { + let permOK = LocationChannelManager.shared.permissionState == .authorized + let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty + return permOK || hasMutual + } +} diff --git a/bitchat/Services/Tor/TorManager.swift b/bitchat/Services/Tor/TorManager.swift index b899c7d0..8945a6d6 100644 --- a/bitchat/Services/Tor/TorManager.swift +++ b/bitchat/Services/Tor/TorManager.swift @@ -69,18 +69,24 @@ final class TorManager: ObservableObject { private var pathMonitor: NWPathMonitor? private var isAppForeground: Bool = true private var lastRestartAt: Date? = nil + // Global policy gate: only allow Tor to start when true + private(set) var allowAutoStart: Bool = false private init() {} // MARK: - Public API func startIfNeeded() { + // Respect global start policy + guard allowAutoStart else { return } // Do not start in background; caller should wait for foreground guard isAppForeground else { return } guard !didStart else { return } didStart = true isStarting = true lastError = nil + // Announce initial start so UI can show a status message + NotificationCenter.default.post(name: .TorWillStart, object: nil) ensureFilesystemLayout() startTor() startPathMonitorIfNeeded() @@ -473,6 +479,8 @@ final class TorManager: ObservableObject { // MARK: - Foreground recovery and control helpers func ensureRunningOnForeground() { + // Respect global start policy + if !allowAutoStart { return } // iOS can suspend Tor harshly; the most reliable approach for // embedding is to restart Tor every time we become active. Task.detached(priority: .userInitiated) { [weak self] in @@ -636,3 +644,14 @@ final class TorManager: ObservableObject { return resultText } } + +// MARK: - Start policy configuration +extension TorManager { + @MainActor + func setAutoStartAllowed(_ allow: Bool) { + allowAutoStart = allow + } + + @MainActor + func isAutoStartAllowed() -> Bool { allowAutoStart } +} diff --git a/bitchat/Services/Tor/TorNotifications.swift b/bitchat/Services/Tor/TorNotifications.swift index e14e5929..6cff19a2 100644 --- a/bitchat/Services/Tor/TorNotifications.swift +++ b/bitchat/Services/Tor/TorNotifications.swift @@ -3,4 +3,5 @@ import Foundation extension Notification.Name { static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorWillRestart = Notification.Name("TorWillRestart") + static let TorWillStart = Notification.Name("TorWillStart") } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c5d7b741..15636afa 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -482,6 +482,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Track app startup phase to prevent marking old messages as unread private var isStartupPhase = true + // Announce Tor initial readiness once per launch to avoid duplicates + private var torInitialReadyAnnounced: Bool = false // Track Nostr pubkey mappings for unknown senders private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey @@ -548,81 +550,51 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Start mesh service immediately meshService.startServices() - // Announce Tor status (geohash-only; do not show in mesh chat) - if TorManager.shared.torEnforced && !torStatusAnnounced { + // Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed. + if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() { torStatusAnnounced = true addGeohashOnlySystemMessage("starting tor...") - // Suppress incremental Tor progress messages; only show initial start and readiness. + // Suppress incremental Tor progress messages torProgressCancellable = nil - Task.detached { - let ready = await TorManager.shared.awaitReady() - Task { @MainActor [weak self] in - guard let self else { return } - if ready { self.addGeohashOnlySystemMessage("tor started. routing all chats via tor for privacy.") } - else { self.addGeohashOnlySystemMessage("still waiting for tor to start...") } - } - } } else if !TorManager.shared.torEnforced && !torStatusAnnounced { torStatusAnnounced = true addGeohashOnlySystemMessage("development build: tor bypass enabled.") } - // Initialize Nostr services only after Tor is fully ready, to avoid - // interleaving setup logs during Tor startup. + // Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere + nostrRelayManager = NostrRelayManager.shared + // Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately) + messageRouter.flushAllOutbox() + // End startup phase after a short delay Task { @MainActor in - // Wait for Tor readiness before Nostr init - let ready = await TorManager.shared.awaitReady(timeout: 60) - guard ready else { - SecureLogger.error("Nostr init skipped: Tor not ready", category: .session) - return - } - nostrRelayManager = NostrRelayManager.shared - SecureLogger.debug("Initializing Nostr relay connections", category: .session) - // Connect is managed centrally on scene activation; avoid duplicate connects here - - // Attempt to flush any queued outbox after Nostr comes online - messageRouter.flushAllOutbox() - - // End startup phase after 2 seconds - // During startup phase, we: - // 1. Skip cleanup of read receipts - // 2. Only block OLD messages from being marked as unread - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) // 2 seconds - self.isStartupPhase = false - } - - // Bind unified peer service's peer list to our published property - let cancellable = unifiedPeerService.$peers - .receive(on: DispatchQueue.main) - .sink { [weak self] peers in - guard let self = self else { return } - // Update peers directly; @Published drives UI updates - self.allPeers = peers - // Update peer index for O(1) lookups - // Deduplicate peers by ID to prevent crash from duplicate keys - var uniquePeers: [String: BitchatPeer] = [:] - for peer in peers { - // Keep the first occurrence of each peer ID - if uniquePeers[peer.id] == nil { - uniquePeers[peer.id] = peer - } else { - SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", category: .session) - } - } - self.peerIndex = uniquePeers - // Schedule UI update if peers changed - if peers.count > 0 || self.allPeers.count > 0 { - // UI will update automatically - } - - // Update private chat peer ID if needed when peers change - if self.selectedPrivateChatFingerprint != nil { - self.updatePrivateChatPeerIfNeeded() + try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) + self.isStartupPhase = false + } + // Bind unified peer service's peer list to our published property + let peersCancellable = unifiedPeerService.$peers + .receive(on: DispatchQueue.main) + .sink { [weak self] peers in + guard let self = self else { return } + // Update peers directly; @Published drives UI updates + self.allPeers = peers + // Update peer index for O(1) lookups + // Deduplicate peers by ID to prevent crash from duplicate keys + var uniquePeers: [String: BitchatPeer] = [:] + for peer in peers { + // Keep the first occurrence of each peer ID + if uniquePeers[peer.id] == nil { + uniquePeers[peer.id] = peer + } else { + SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", category: .session) } } - - self.cancellables.insert(cancellable) + self.peerIndex = uniquePeers + // Update private chat peer ID if needed when peers change + if self.selectedPrivateChatFingerprint != nil { + self.updatePrivateChatPeerIfNeeded() + } + } + self.cancellables.insert(peersCancellable) // Resubscribe geohash on relay reconnect if let relayMgr = self.nostrRelayManager { @@ -652,7 +624,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } .store(in: &self.cancellables) } - } // Set up Noise encryption callbacks setupNoiseCallbacks() @@ -801,6 +772,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { name: .TorDidBecomeReady, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillStart), + name: .TorWillStart, + object: nil + ) #else NotificationCenter.default.addObserver( self, @@ -845,6 +822,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { name: .TorDidBecomeReady, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillStart), + name: .TorWillStart, + object: nil + ) #endif } @@ -855,6 +838,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // MARK: - Tor notifications + @objc private func handleTorWillStart() { + Task { @MainActor in + if !self.torStatusAnnounced && TorManager.shared.torEnforced { + self.torStatusAnnounced = true + // Post only in geohash channels (queue if not active) + self.addGeohashOnlySystemMessage("starting tor...") + } + } + } @objc private func handleTorWillRestart() { Task { @MainActor in self.torRestartPending = true @@ -870,6 +862,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage("tor restarted. network routing restored.") self.torRestartPending = false + } else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced { + // Initial start completed + self.addGeohashOnlySystemMessage("tor started. routing all chats via tor for privacy.") + self.torInitialReadyAnnounced = true } } }