Gate Tor/Nostr start behind permissions or mutual favorites; add clean shutdown + robust gating (#619)

- 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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-09-15 16:46:49 +02:00
committed by GitHub
co-authored by jack
parent d1682db79b
commit 9cbdb0a764
6 changed files with 183 additions and 76 deletions
+15 -11
View File
@@ -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()
+17
View File
@@ -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)
@@ -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<AnyCancellable>()
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
}
}
+19
View File
@@ -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 }
}
@@ -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")
}
+61 -65
View File
@@ -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
}
}
}