mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Runtime-verified whether Apple's URLSession honors connectionProxyDictionary SOCKS settings for Nostr relay traffic (plain HTTPS + URLSessionWebSocketTask), since the app routes all Nostr traffic through Arti's local SOCKS5 proxy solely via those keys and Apple does not officially support SOCKS for URLSession on iOS. Verification harness (scripts/tor-egress-verification/): a minimal SOCKS5 CONNECT proxy that logs arriving connections, plus a Swift probe that runs an HTTPS GET and a WebSocket ping through a URLSession configured with the proxy dict. Result: on macOS (kCFNetworkProxiesSOCKS* constants) and the iOS simulator (raw "SOCKSProxy"/"SOCKSPort" string keys, the exact app path) BOTH HTTP and WebSocket are proxied, do remote DNS through the proxy, and the proxied session is fail-closed (every request errors when the SOCKS proxy is down). The feared direct-egress leak did not reproduce on the tested platforms. Defense-in-depth for the platform Apple does not guarantee (physical iOS): add TorEgressVerifier, which performs a canary request through the proxied session and asserts the exit is a Tor node (check.torproject.org IsTor==true), positively detecting a direct egress even if the OS silently ignored the proxy. Policy: verifiedTor allows (cached for a TTL); notTor refuses (leak detected, never open relays); unreachable allows-with-warning (session stays fail-closed by construction). Wired into TorManager.awaitEgressReady() and required by both NostrRelayManager and GeoRelayDirectory before opening connections. Cache is invalidated on Tor restart/dormant/shutdown. Never falls back to a direct connection. Probe is injected so the policy/caching is unit-tested offline; the live-network harness lives under scripts/ and is not run by CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
5.1 KiB
Python
Executable File
167 lines
5.1 KiB
Python
Executable File
#!/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:
|
|
<iso8601>\tCONNECT\t<host>:<port>
|
|
- Any raw connection that is NOT valid SOCKS5 is logged as:
|
|
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
|
|
(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 <listen_port> <log_file>
|
|
"""
|
|
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 <port> <logfile>", 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()
|