1// TorManager.swift
2// SophaxChat
3//
4// Manages embedded Tor lifecycle. No Orbot required.
5// Provides a local SOCKS5 proxy on 127.0.0.1:9050 once ready.
6
7import Foundation
8import Network
9
10#if !targetEnvironment(macCatalyst)
11@MainActor
12final class TorManager: ObservableObject {
13
14 static let shared = TorManager()
15
16 // MARK: - State
17
18 enum TorState: Equatable {
19 case stopped
20 case starting
21 case ready
22 case failed(String)
23 }
24
25 @Published private(set) var state: TorState = .stopped
26 @Published private(set) var bootstrapProgress: Int = 0
27 /// Hostname of our v3 hidden service (without port), set once Tor reaches 100% bootstrap.
28 /// Equals `OnionAddress.from(ed25519PublicKey:)` when started with an identity seed.
29 @Published private(set) var hiddenServiceHostname: String? = nil
30
31 /// SOCKS5 proxy string to pass to TCPTransport when ready.
32 static let socksProxy = "127.0.0.1:9050"
33
34 // MARK: - Private
35
36 private var thread: TorThread?
37 private var controller: TorController?
38 private var statusObserver: Any?
39
40 private var torDataDir: URL {
41 let app = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
42 return app.appendingPathComponent("tor_data", isDirectory: true)
43 }
44
45 private var hiddenServiceDir: URL {
46 torDataDir.appendingPathComponent("hidden_service", isDirectory: true)
47 }
48
49 private var controlSocketURL: URL {
50 torDataDir.appendingPathComponent("control.socket")
51 }
52
53 // MARK: - Lifecycle
54
55 private init() {}
56
57 /// Writes the deterministic hidden service key file from the user's Ed25519 identity seed.
58 /// Call once after identity is loaded — safe to call repeatedly (idempotent write).
59 /// If not called before `start()`, Tor generates a random hidden service key on first run.
60 func configureHiddenService(ed25519PrivateKeySeed seed: Data) {
61 try? FileManager.default.createDirectory(at: torDataDir, withIntermediateDirectories: true)
62 try? HiddenServiceKeyWriter.write(to: hiddenServiceDir, seed: seed)
63 }
64
65 /// Starts embedded Tor. Safe to call multiple times — no-op if already starting/ready.
66 func start() {
67 guard state == .stopped else { return }
68 state = .starting
69 bootstrapProgress = 0
70 hiddenServiceHostname = nil
71
72 // Create data dir
73 try? FileManager.default.createDirectory(at: torDataDir, withIntermediateDirectories: true)
74
75 // Configure Tor
76 let config = TorConfiguration()
77 config.dataDirectory = torDataDir
78 config.cacheDirectory = torDataDir
79 config.controlSocket = controlSocketURL
80 config.cookieAuthentication = true
81 config.avoidDiskWrites = false
82 config.socksPort = 9050
83 config.ignoreMissingTorrc = true
84 // Hidden service: serve on port 25519, forward to local TCP listener.
85 config.hiddenServiceDirectory = hiddenServiceDir
86 config.options["HiddenServicePort"] = "25519 127.0.0.1:25519"
87 config.options["HiddenServiceVersion"] = "3"
88
89 // Start Tor thread
90 let t = TorThread(configuration: config)
91 t.start()
92 thread = t
93
94 // Wait for control socket to appear, then connect controller
95 connectController(config: config, attempts: 0)
96 }
97
98 func stop() {
99 statusObserver.map { controller?.removeObserver($0) }
100 controller = nil
101 thread?.cancel()
102 thread = nil
103 state = .stopped
104 bootstrapProgress = 0
105 }
106
107 // MARK: - Controller connection
108
109 private func connectController(config: TorConfiguration, attempts: Int) {
110 guard attempts < 30 else {
111 state = .failed("Control socket timeout")
112 return
113 }
114
115 DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 1) { [weak self] in
116 guard let self else { return }
117 guard FileManager.default.fileExists(atPath: self.controlSocketURL.path) else {
118 Task { @MainActor in self.connectController(config: config, attempts: attempts + 1) }
119 return
120 }
121 Task { @MainActor in self.authenticate(config: config) }
122 }
123 }
124
125 private func authenticate(config: TorConfiguration) {
126 let ctrl = TorController(socketURL: controlSocketURL)
127 controller = ctrl
128
129 DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.5) { [weak self] in
130 guard let self, let cookie = config.cookie else {
131 Task { @MainActor in self?.state = .failed("No auth cookie") }
132 return
133 }
134 ctrl.authenticate(with: cookie) { [weak self] success, error in
135 guard let self else { return }
136 Task { @MainActor in
137 if success {
138 self.observeBootstrap(ctrl: ctrl)
139 } else {
140 self.state = .failed(error?.localizedDescription ?? "Auth failed")
141 }
142 }
143 }
144 }
145 }
146
147 private func observeBootstrap(ctrl: TorController) {
148 // Subscribe to STATUS_CLIENT BOOTSTRAP events
149 statusObserver = ctrl.addObserver(forStatusEvents: { [weak self] type, _, action, args in
150 guard type == "STATUS_CLIENT", action == "BOOTSTRAP",
151 let progressStr = args?["PROGRESS"],
152 let progress = Int(progressStr) else { return true }
153 Task { @MainActor [weak self] in
154 guard let self else { return }
155 self.bootstrapProgress = progress
156 if progress >= 100 {
157 self.readHiddenServiceHostname()
158 self.state = .ready
159 }
160 }
161 return true
162 })
163
164 // Also poll once in case we missed early events
165 ctrl.getInfoForKeys(["status/bootstrap-phase"]) { [weak self] values in
166 guard let phase = values.first else { return }
167 let progress = Self.parseProgress(phase)
168 Task { @MainActor [weak self] in
169 guard let self else { return }
170 self.bootstrapProgress = max(self.bootstrapProgress, progress)
171 if progress >= 100 {
172 self.readHiddenServiceHostname()
173 self.state = .ready
174 }
175 }
176 }
177 }
178
179 /// Reads the `.onion` hostname from the hidden service directory once Tor has written it.
180 private func readHiddenServiceHostname() {
181 let hostnameURL = hiddenServiceDir.appendingPathComponent("hostname")
182 guard let raw = try? String(contentsOf: hostnameURL, encoding: .utf8) else { return }
183 let hostname = raw.trimmingCharacters(in: .whitespacesAndNewlines)
184 guard hostname.hasSuffix(".onion"), hostname.count == 62 else { return }
185 hiddenServiceHostname = hostname
186 }
187
188 // MARK: - Helpers
189
190 private static func parseProgress(_ phase: String) -> Int {
191 guard let range = phase.range(of: "PROGRESS=") else { return 0 }
192 let after = phase[range.upperBound...]
193 return Int(after.prefix(3).filter(\.isNumber)) ?? 0
194 }
195}
196#endif // !targetEnvironment(macCatalyst)