diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index 55a25e4d..4eba4a67 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies { refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, - awaitTorReady: { await TorManager.shared.awaitReady() }, + awaitTorReady: { await TorManager.shared.awaitEgressReady() }, makeFetchData: { let session = TorURLSession.shared.session return { request in diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 53d794fa..b420b851 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -86,7 +86,10 @@ private extension NostrRelayManagerDependencies { torIsForeground: { TorManager.shared.isForeground() }, awaitTorReady: { completion in Task.detached { - let ready = await TorManager.shared.awaitReady() + // Require both Tor bootstrap AND a positive egress self-check + // so relay sockets never open unless traffic is proven to + // route through Tor (fail-closed). + let ready = await TorManager.shared.awaitEgressReady() await MainActor.run { completion(ready) } diff --git a/bitchatTests/Nostr/TorEgressVerifierTests.swift b/bitchatTests/Nostr/TorEgressVerifierTests.swift new file mode 100644 index 00000000..65f65826 --- /dev/null +++ b/bitchatTests/Nostr/TorEgressVerifierTests.swift @@ -0,0 +1,137 @@ +// +// TorEgressVerifierTests.swift +// bitchatTests +// +// Unit tests for the runtime Tor-egress self-check policy/caching. The network +// probe is injected, so these tests are deterministic and offline. +// + +import Testing +import Foundation +@testable import bitchat +import Tor + +@Suite(.serialized) +struct TorEgressVerifierTests { + + /// Deterministic, controllable clock + probe. + private final class Harness: @unchecked Sendable { + private let lock = NSLock() + private var _now = Date(timeIntervalSince1970: 1_000_000) + private var _result: TorEgressVerifier.ProbeResult = .verifiedTor + private(set) var probeCount = 0 + + var now: Date { + lock.lock(); defer { lock.unlock() }; return _now + } + func advance(_ seconds: TimeInterval) { + lock.lock(); _now = _now.addingTimeInterval(seconds); lock.unlock() + } + func setResult(_ r: TorEgressVerifier.ProbeResult) { + lock.lock(); _result = r; lock.unlock() + } + func makeProbe() -> @Sendable () async -> TorEgressVerifier.ProbeResult { + return { [self] in + lock.lock(); probeCount += 1; let r = _result; lock.unlock() + return r + } + } + func nowProvider() -> @Sendable () -> Date { + return { [self] in self.now } + } + } + + private func makeVerifier(_ h: Harness, ttl: TimeInterval = 300, minRetry: TimeInterval = 5) -> TorEgressVerifier { + TorEgressVerifier(ttl: ttl, minRetryInterval: minRetry, now: h.nowProvider(), probe: h.makeProbe()) + } + + @Test("verifiedTor allows and is cached within TTL (single probe)") + func verifiedIsCached() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300) + + #expect(await v.verify() == true) + // Second call within TTL must not re-probe. + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + } + + @Test("cache expires after TTL and re-probes") + func cacheExpires() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300) + + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + + h.advance(301) + #expect(await v.verify() == true) + #expect(h.probeCount == 2) + } + + @Test("notTor refuses (leak detected) and is never cached as allowed") + func notTorRefuses() async { + let h = Harness() + h.setResult(.notTor) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + + #expect(await v.verify() == false) + // A subsequent success recovers. + h.setResult(.verifiedTor) + #expect(await v.verify() == true) + } + + @Test("unreachable allows (session stays fail-closed) but does not cache a Tor verification") + func unreachableAllowsButNotCached() async { + let h = Harness() + h.setResult(.unreachable("down")) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + + #expect(await v.verify() == true) + // Not a verified state: once it turns into a real leak, we must refuse. + h.setResult(.notTor) + #expect(await v.verify() == false) + } + + @Test("minRetryInterval throttles re-probing when not verified") + func throttleReprobe() async { + let h = Harness() + h.setResult(.unreachable("down")) + let v = makeVerifier(h, ttl: 300, minRetry: 5) + + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + // Within minRetry window: reuse last decision, no new probe. + h.advance(1) + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + // After the window: re-probe. + h.advance(5) + #expect(await v.verify() == true) + #expect(h.probeCount == 2) + } + + @Test("invalidate forces a fresh probe") + func invalidateForcesReprobe() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300) + + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + await v.invalidate() + #expect(await v.verify() == true) + #expect(h.probeCount == 2) + } + + @Test("lastProbeResult reflects the most recent outcome") + func lastResultTracked() async { + let h = Harness() + h.setResult(.notTor) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + _ = await v.verify() + #expect(await v.lastProbeResult() == .notTor) + } +} diff --git a/localPackages/Arti/Package.swift b/localPackages/Arti/Package.swift index 1fe587da..a2434edd 100644 --- a/localPackages/Arti/Package.swift +++ b/localPackages/Arti/Package.swift @@ -30,6 +30,7 @@ let package = Package( "TorManager.swift", "TorURLSession.swift", "TorNotifications.swift", + "TorEgressVerifier.swift", ], linkerSettings: [ .linkedLibrary("resolv"), diff --git a/localPackages/Arti/Sources/TorEgressVerifier.swift b/localPackages/Arti/Sources/TorEgressVerifier.swift new file mode 100644 index 00000000..501573c0 --- /dev/null +++ b/localPackages/Arti/Sources/TorEgressVerifier.swift @@ -0,0 +1,174 @@ +import BitLogger +import Foundation + +/// Runtime self-check that the proxied `URLSession` egress is *actually* routed +/// through Tor — defense-in-depth for the case where a platform silently ignores +/// `URLSessionConfiguration.connectionProxyDictionary` SOCKS settings and lets +/// traffic egress directly (leaking the real IP while Tor appears enabled). +/// +/// Runtime verification (see `scripts/tor-egress-verification/`) showed that +/// macOS and the iOS simulator DO honor the SOCKS proxy for both plain HTTPS and +/// `URLSessionWebSocketTask`, and that the proxied session is fail-closed (every +/// request errors when the SOCKS proxy is down). Apple does not officially +/// support SOCKS for URLSession on iOS, so on a physical device the behavior is +/// not contractually guaranteed. This verifier closes that gap: before relay +/// connections are opened under enforced Tor, it performs a canary request whose +/// response positively reports whether the egress hit the network via Tor. +/// +/// Policy (`verify()` return value): +/// - `.verifiedTor` → allow, and cache the positive result for `ttl`. +/// - `.notTor` → REFUSE. The canary reached the internet but the exit +/// is NOT a Tor node: a real leak. Never allow relays. +/// - `.unreachable` → allow-with-warning. The canary itself failed (endpoint +/// down / circuit not built). This does NOT egress direct: +/// the relay session is independently fail-closed, so the +/// worst case is that relay connections also fail. We do +/// not hard-block here to avoid taking Nostr fully offline +/// whenever the single canary host is unavailable. +/// +/// The probe is injectable so the policy/caching logic is unit-tested without a +/// live network (see `TorEgressVerifierTests`). +public actor TorEgressVerifier { + public enum ProbeResult: Equatable, Sendable { + /// Canary succeeded and the exit is a Tor node. + case verifiedTor + /// Canary succeeded but the exit is NOT Tor — a direct-egress leak. + case notTor + /// Canary could not complete (endpoint down, no circuit, parse error). + case unreachable(String) + } + + private let probe: @Sendable () async -> ProbeResult + private let now: @Sendable () -> Date + private let ttl: TimeInterval + /// Minimum spacing between probes when not currently verified, so a + /// persistent `.unreachable` cannot hammer the canary endpoint on every + /// reconnect burst. + private let minRetryInterval: TimeInterval + + private var lastVerifiedAt: Date? + private var lastProbeAt: Date? + private var lastResult: ProbeResult? + private var inFlight: Task? + + public init( + ttl: TimeInterval, + minRetryInterval: TimeInterval = 5.0, + now: @escaping @Sendable () -> Date = Date.init, + probe: @escaping @Sendable () async -> ProbeResult + ) { + self.ttl = ttl + self.minRetryInterval = minRetryInterval + self.now = now + self.probe = probe + } + + /// Drop any cached verification (e.g. after a Tor restart or when the + /// network path changes). The next `verify()` re-probes. + public func invalidate() { + lastVerifiedAt = nil + lastProbeAt = nil + lastResult = nil + } + + /// The most recent probe outcome, for diagnostics/tests. + public func lastProbeResult() -> ProbeResult? { lastResult } + + /// Returns `true` when it is safe to open relay connections through the + /// proxied session, `false` only when a non-Tor egress was positively + /// detected. See the type doc for the full policy. + public func verify() async -> Bool { + if isFreshlyVerified() { return true } + // Throttle re-probes when the last attempt did not verify. + if let last = lastProbeAt, + let result = lastResult, + now().timeIntervalSince(last) < minRetryInterval { + return decision(for: result) + } + if let inFlight { return await inFlight.value } + + let task = Task { await self.runProbe() } + inFlight = task + let allowed = await task.value + inFlight = nil + return allowed + } + + private func isFreshlyVerified() -> Bool { + guard let last = lastVerifiedAt else { return false } + return now().timeIntervalSince(last) < ttl + } + + private func decision(for result: ProbeResult) -> Bool { + switch result { + case .verifiedTor: return true + case .unreachable: return true + case .notTor: return false + } + } + + private func runProbe() async -> Bool { + let result = await probe() + lastProbeAt = now() + lastResult = result + switch result { + case .verifiedTor: + lastVerifiedAt = now() + return true + case .notTor: + lastVerifiedAt = nil + SecureLogger.error( + "🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)", + category: .session + ) + return false + case .unreachable(let why): + lastVerifiedAt = nil + SecureLogger.warning( + "🧅 Tor egress self-check could not complete (\(why)); relay session remains fail-closed via SOCKS proxy", + category: .session + ) + return true + } + } +} + +// MARK: - Live probe + +public extension TorEgressVerifier { + /// Default canary: fetch Tor Project's connectivity check API through the + /// shared proxied session and assert `IsTor == true`. Because the response + /// is served from the *exit's* vantage point, a silent direct egress is + /// caught here as `.notTor`. `check.torproject.org` is clearnet, so this + /// works without onion-service support. + /// + /// Follow-up (see PR): make the canary endpoint configurable and add an + /// onion-service canary so verification does not depend on a single host. + static func liveProbe( + endpoint: URL = URL(string: "https://check.torproject.org/api/ip")!, + timeout: TimeInterval = 20 + ) -> @Sendable () async -> ProbeResult { + return { + var request = URLRequest(url: endpoint) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + let session = TorURLSession.shared.session + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + return .unreachable("http status \((response as? HTTPURLResponse)?.statusCode ?? -1)") + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .unreachable("unparseable canary response") + } + if let isTor = json["IsTor"] as? Bool { + return isTor ? .verifiedTor : .notTor + } + return .unreachable("canary response missing IsTor") + } catch { + return .unreachable(error.localizedDescription) + } + } + } +} diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index cd831ba5..288fea35 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -59,6 +59,14 @@ public final class TorManager: ObservableObject { private var socksReady: Bool = false { didSet { recomputeReady() } } private var restarting: Bool = false + /// Runtime egress self-check: proves the proxied session actually exits via + /// Tor before relay connections are opened (defense-in-depth against a + /// platform silently ignoring the SOCKS proxy). Cached for a few minutes. + public let egressVerifier = TorEgressVerifier( + ttl: 300, + probe: TorEgressVerifier.liveProbe() + ) + // Whether the app must enforce Tor for all connections (fail-closed). public var torEnforced: Bool { #if BITCHAT_DEV_ALLOW_CLEARNET @@ -125,6 +133,22 @@ public final class TorManager: ObservableObject { return await MainActor.run(body: { self.networkPermitted }) } + /// Like `awaitReady`, but additionally requires that a canary request + /// through the proxied session positively verifies Tor egress. Returns + /// `false` if Tor never became ready, or if the egress self-check + /// positively detected a non-Tor (direct) egress. Callers must fail closed + /// on `false` — never fall back to a direct connection. + nonisolated + public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool { + let ready = await awaitReady(timeout: timeout) + guard ready else { return false } + // Clearnet dev builds don't route through Tor, so the canary would + // (correctly) report non-Tor; skip it there. + let enforced = await MainActor.run { self.torEnforced } + guard enforced else { return true } + return await egressVerifier.verify() + } + // MARK: - Filesystem func dataDirectoryURL() -> URL? { @@ -325,6 +349,8 @@ public final class TorManager: ObservableObject { self.socksReady = false self.isStarting = false } + // Force a fresh egress self-check once Tor comes back. + Task { await egressVerifier.invalidate() } } public func shutdownCompletely() { @@ -353,6 +379,7 @@ public final class TorManager: ObservableObject { // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded() // Clearing it here races with startup and defeats the grace period } + await self.egressVerifier.invalidate() } } @@ -368,6 +395,8 @@ public final class TorManager: ObservableObject { self.isDormant = false self.lastRestartAt = Date() } + // New Arti instance means new circuits; re-verify egress after restart. + await egressVerifier.invalidate() _ = arti_stop() diff --git a/scripts/tor-egress-verification/proxy_probe.swift b/scripts/tor-egress-verification/proxy_probe.swift new file mode 100644 index 00000000..e209464a --- /dev/null +++ b/scripts/tor-egress-verification/proxy_probe.swift @@ -0,0 +1,90 @@ +// Standalone harness: does Apple's URLSession honor connectionProxyDictionary +// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask? +// +// Build: swiftc -O proxy_probe.swift -o proxy_probe +// Usage: proxy_probe [targetURL] +// +// Prints a single RESULT line: RESULT +// The caller correlates this with the SOCKS proxy's connection log to decide +// whether the request was proxied. +#if canImport(CFNetwork) +import CFNetwork +#endif +import Foundation + +let args = CommandLine.arguments +guard args.count >= 4 else { + FileHandle.standardError.write(Data("usage: proxy_probe [targetURL]\n".utf8)) + exit(2) +} +let mode = args[1] +let keyStyle = args[2] +let proxyPort = Int(args[3]) ?? 19999 +let host = "127.0.0.1" + +func makeProxyDict() -> [AnyHashable: Any] { + switch keyStyle { +#if os(macOS) + case "cf": + // The exact constants the app uses on macOS. + return [ + kCFNetworkProxiesSOCKSEnable as String: 1, + kCFNetworkProxiesSOCKSProxy as String: host, + kCFNetworkProxiesSOCKSPort as String: proxyPort + ] +#endif + default: + // The exact raw string keys the app uses on iOS. + return [ + "SOCKSEnable": 1, + "SOCKSProxy": host, + "SOCKSPort": proxyPort + ] + } +} + +let cfg = URLSessionConfiguration.ephemeral +cfg.waitsForConnectivity = false +cfg.timeoutIntervalForRequest = 20 +cfg.connectionProxyDictionary = makeProxyDict() +let session = URLSession(configuration: cfg) + +func emit(_ outcome: String, _ detail: String) { + print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)") + exit(outcome == "ERROR" ? 1 : 0) +} + +let sem = DispatchSemaphore(value: 0) + +if mode == "http" { + let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! + let task = session.dataTask(with: target) { data, resp, err in + if let err = err { + emit("ERROR", "\(err.localizedDescription)") + } else if let http = resp as? HTTPURLResponse { + emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)") + } else { + emit("OK", "bytes=\(data?.count ?? 0)") + } + } + task.resume() +} else { + // WebSocket + let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")! + let ws = session.webSocketTask(with: target) + ws.resume() + // A successful ping proves the TLS+WS handshake completed end-to-end. + ws.sendPing { err in + if let err = err { + emit("ERROR", "\(err.localizedDescription)") + } else { + emit("OK", "ws-ping-ok") + } + } +} + +// Global watchdog so we never hang. +DispatchQueue.global().asyncAfter(deadline: .now() + 25) { + emit("ERROR", "timeout") +} +sem.wait() diff --git a/scripts/tor-egress-verification/run_verification.sh b/scripts/tor-egress-verification/run_verification.sh new file mode 100755 index 00000000..4f2b095a --- /dev/null +++ b/scripts/tor-egress-verification/run_verification.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Orchestrates the Tor-egress proxy-honoring verification on macOS. +# +# For each (request-type x key-style) it runs two experiments: +# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows) +# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED? +# If it succeeds with no proxy, egress went DIRECT (proxy ignored). +# If it fails, the proxy setting is being enforced (fail-closed). +# +# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down +# DIRECT = no connection at proxy OR succeeds when proxy down +set -u +DIR="$(cd "$(dirname "$0")" && pwd)" +PORT=19999 +DEADPORT=19998 # nothing listens here +LOG="$(mktemp -t sockslog)" +BIN="$(mktemp -t proxyprobe)" + +echo "== building swift probe ==" +swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; } + +echo "== starting SOCKS proxy on $PORT ==" +: > "$LOG" +python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 & +PROXY_PID=$! +trap 'kill $PROXY_PID 2>/dev/null' EXIT +# wait for READY +for _ in $(seq 1 50); do + grep -q READY /tmp/socksproxy.out 2>/dev/null && break + sleep 0.1 +done + +run_case() { + local mode="$1" key="$2" + # Experiment A: proxy up, watch log + local before after target + before=$(wc -l < "$LOG" | tr -d ' ') + local outA + outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output") + sleep 0.3 + after=$(wc -l < "$LOG" | tr -d ' ') + local proxied="NO" + if [ "$after" -gt "$before" ]; then proxied="YES"; fi + local newlines + newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|') + + # Experiment B: proxy down (dead port), same request + local outB + outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output") + + echo "----------------------------------------" + echo "CASE mode=$mode key=$key" + echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]" + echo " B(proxy down): $outB" + # verdict + local a_ok b_ok + a_ok=$(echo "$outA" | awk '{print $4}') + b_ok=$(echo "$outB" | awk '{print $4}') + local verdict="UNKNOWN" + if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi + if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi + if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi + if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi + echo " VERDICT: $verdict" +} + +for mode in http ws; do + for key in cf raw; do + run_case "$mode" "$key" + done +done +echo "========================================" +echo "raw proxy log:"; cat "$LOG" | tr '\t' ' ' diff --git a/scripts/tor-egress-verification/socks5_probe_proxy.py b/scripts/tor-egress-verification/socks5_probe_proxy.py new file mode 100755 index 00000000..dff6937f --- /dev/null +++ b/scripts/tor-egress-verification/socks5_probe_proxy.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's +URLSession actually honors `connectionProxyDictionary` SOCKS settings for +different request types (plain HTTPS vs URLSessionWebSocketTask). + +Behavior: + - Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays + bytes bidirectionally to the real destination. + - Every accepted CONNECT is appended to a log file as one line: + \tCONNECT\t: + - Any raw connection that is NOT valid SOCKS5 is logged as: + \tNON_SOCKS\t + (this catches the feared case where URLSession sends a raw TLS/HTTP + ClientHello straight at the proxy port instead of a SOCKS greeting). + +If a request egresses DIRECTLY (proxy ignored), nothing is logged at all. + +Usage: socks5_probe_proxy.py +""" +import selectors +import socket +import sys +import threading +from datetime import datetime, timezone + +LOG_LOCK = threading.Lock() + + +def log(logfile, kind, detail): + line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n" + with LOG_LOCK: + with open(logfile, "a") as f: + f.write(line) + sys.stderr.write("[proxy] " + line) + sys.stderr.flush() + + +def recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +def handle(client, logfile): + client.settimeout(15) + try: + # SOCKS5 greeting: VER=0x05, NMETHODS, METHODS... + head = recv_exact(client, 2) + if not head: + return + if head[0] != 0x05: + # Not SOCKS5 at all — this is the smoking gun for a direct egress + # that mistakenly hit the proxy port. Log the first bytes. + rest = b"" + try: + client.setblocking(False) + rest = client.recv(64) + except Exception: + pass + log(logfile, "NON_SOCKS", (head + rest).hex()) + return + nmethods = head[1] + if nmethods: + recv_exact(client, nmethods) + # Reply: no authentication required + client.sendall(b"\x05\x00") + + # Request: VER, CMD, RSV, ATYP, ADDR, PORT + req = recv_exact(client, 4) + if not req or req[1] != 0x01: # only CONNECT + client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00") + return + atyp = req[3] + if atyp == 0x01: # IPv4 + addr = socket.inet_ntoa(recv_exact(client, 4)) + elif atyp == 0x03: # domain + ln = recv_exact(client, 1)[0] + addr = recv_exact(client, ln).decode("ascii", errors="replace") + elif atyp == 0x04: # IPv6 + addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16)) + else: + client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00") + return + port = int.from_bytes(recv_exact(client, 2), "big") + + log(logfile, "CONNECT", f"{addr}:{port}") + + # Connect to the real destination and reply success. + try: + remote = socket.create_connection((addr, port), timeout=15) + except Exception as e: + log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}") + client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00") + return + client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + + relay(client, remote) + except Exception: + pass + finally: + try: + client.close() + except Exception: + pass + + +def relay(a, b): + a.setblocking(False) + b.setblocking(False) + sel = selectors.DefaultSelector() + sel.register(a, selectors.EVENT_READ, b) + sel.register(b, selectors.EVENT_READ, a) + try: + while True: + events = sel.select(timeout=30) + if not events: + break + for key, _ in events: + src = key.fileobj + dst = key.data + try: + data = src.recv(65536) + except (BlockingIOError, InterruptedError): + continue + except Exception: + return + if not data: + return + try: + dst.sendall(data) + except Exception: + return + finally: + sel.close() + for s in (a, b): + try: + s.close() + except Exception: + pass + + +def main(): + if len(sys.argv) != 3: + print("usage: socks5_probe_proxy.py ", file=sys.stderr) + sys.exit(2) + port = int(sys.argv[1]) + logfile = sys.argv[2] + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", port)) + srv.listen(64) + sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n") + sys.stderr.flush() + print("READY", flush=True) + while True: + client, _ = srv.accept() + threading.Thread(target=handle, args=(client, logfile), daemon=True).start() + + +if __name__ == "__main__": + main()