columba/python/TorClientInterface.py
torlando-tech e64026e01d Address review feedback: deployment robustness, premature online, .onion UX
- Use shutil.copy2 (sibling file via __file__) as primary deployment
  method for TorClientInterface.py, with pkgutil.get_data as fallback
- Remove premature self.online = True before timeout configuration
- Prevent disabling SOCKS proxy toggle when .onion address is entered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:53:40 -05:00

244 lines
8.6 KiB
Python

"""TorClientInterface - SOCKS5 proxy support for Reticulum TCP connections.
Subclasses TCPClientInterface to route connections through a SOCKS5 proxy
(e.g., Orbot for Tor .onion address support).
Uses SOCKS5 Username/Password authentication for Tor stream isolation —
each interface gets its own Tor circuit via unique username.
Deployed as an RNS external interface module to {configdir}/interfaces/.
Configuration:
[[Tor Hidden Service]]
type = TorClientInterface
enabled = yes
target_host = abcdef1234567890.onion
target_port = 4242
proxy_host = 127.0.0.1
proxy_port = 9050
"""
import struct
import socket
import platform
import RNS
from RNS.Interfaces.Interface import Interface
from RNS.Interfaces.TCPInterface import TCPClientInterface
class TorClientInterface(TCPClientInterface):
INITIAL_CONNECT_TIMEOUT = 30 # Tor circuits are slower than direct TCP
# Tor-appropriate keepalive settings (modeled on I2P values in upstream RNS)
TOR_USER_TIMEOUT = 45
TOR_PROBE_AFTER = 10
TOR_PROBE_INTERVAL = 9
TOR_PROBES = 5
def __init__(self, owner, configuration, connected_socket=None):
# Extract proxy params BEFORE super().__init__ triggers connect()
c = Interface.get_config_obj(configuration)
self.proxy_host = c.get('proxy_host', '127.0.0.1')
self.proxy_port = int(c.get('proxy_port', 9050))
super().__init__(owner, configuration, connected_socket)
def connect(self, initial=False):
try:
if initial:
RNS.log(
f"Establishing Tor/SOCKS5 connection for {self} "
f"via {self.proxy_host}:{self.proxy_port}...",
RNS.LOG_DEBUG
)
# Resolve the proxy address (supports both IPv4 and IPv6 proxies)
proxy_info = socket.getaddrinfo(
self.proxy_host, self.proxy_port, proto=socket.IPPROTO_TCP
)[0]
proxy_family = proxy_info[0]
proxy_address = proxy_info[4]
self.socket = socket.socket(proxy_family, socket.SOCK_STREAM)
self.socket.settimeout(TorClientInterface.INITIAL_CONNECT_TIMEOUT)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect(proxy_address)
# SOCKS5 handshake — after this, socket is tunneled to target
self._socks5_handshake()
self.socket.settimeout(None)
if initial:
RNS.log(
f"Tor/SOCKS5 connection for {self} established "
f"to {self.target_ip}:{self.target_port}",
RNS.LOG_DEBUG
)
except Exception as e:
if self.socket is not None:
try:
self.socket.close()
except Exception:
pass
self.socket = None
if initial:
RNS.log(
f"Initial Tor/SOCKS5 connection for {self} "
f"could not be established: {e}",
RNS.LOG_ERROR
)
RNS.log(
f"Leaving unconnected and retrying connection in "
f"{TCPClientInterface.RECONNECT_WAIT} seconds.",
RNS.LOG_ERROR
)
return False
else:
raise e
if platform.system() == "Linux":
self.set_timeouts_linux()
elif platform.system() == "Darwin":
self.set_timeouts_osx()
self.online = True
self.writing = False
self.never_connected = False
return True
def _recv_exact(self, n):
"""Read exactly n bytes from the socket, raising on premature close."""
buf = b''
while len(buf) < n:
chunk = self.socket.recv(n - len(buf))
if not chunk:
raise ConnectionError(
f"SOCKS5 proxy closed connection "
f"(received {len(buf)}/{n} bytes)"
)
buf += chunk
return buf
def _socks5_handshake(self):
"""SOCKS5 handshake with username auth for Tor stream isolation.
Uses Username/Password auth (RFC 1929) so Tor assigns a unique
circuit per interface — different usernames = different circuits.
"""
# Greeting: version 5, 1 method, Username/Password (0x02)
self.socket.sendall(b'\x05\x01\x02')
resp = self._recv_exact(2)
if resp != b'\x05\x02':
raise ConnectionError(
f"SOCKS5 proxy rejected Username/Password auth "
f"(response: {resp.hex()}). "
f"Ensure proxy supports method 0x02."
)
# Username/Password sub-negotiation (RFC 1929)
# Encode target as username for Tor stream isolation
username = f"{self.target_ip}:{self.target_port}".encode('utf-8')[:255]
password = b'x' # Tor ignores the password
self.socket.sendall(
b'\x01'
+ bytes([len(username)]) + username
+ bytes([len(password)]) + password
)
resp = self._recv_exact(2)
if resp[1] != 0x00:
raise ConnectionError("SOCKS5 username/password auth failed")
# Connect request with domain name (ATYP=0x03)
# Proxy resolves the hostname — required for .onion addresses
dest_host_bytes = self.target_ip.encode('utf-8')
if len(dest_host_bytes) > 255:
raise ConnectionError(
f"Target hostname too long for SOCKS5 "
f"({len(dest_host_bytes)} bytes, max 255)"
)
dest_port_bytes = struct.pack('!H', self.target_port)
connect_req = (
b'\x05\x01\x00\x03'
+ bytes([len(dest_host_bytes)])
+ dest_host_bytes
+ dest_port_bytes
)
self.socket.sendall(connect_req)
# Read connect response header (VER, REP, RSV, ATYP)
resp = self._recv_exact(4)
if resp[1] != 0x00:
error_codes = {
0x01: "general SOCKS server failure",
0x02: "connection not allowed by ruleset",
0x03: "network unreachable",
0x04: "host unreachable",
0x05: "connection refused",
0x06: "TTL expired",
0x07: "command not supported",
0x08: "address type not supported",
}
error_msg = error_codes.get(
resp[1], f"unknown error (0x{resp[1]:02x})"
)
raise ConnectionError(
f"SOCKS5 connect to {self.target_ip}:{self.target_port} "
f"failed: {error_msg}"
)
# Consume bind address based on ATYP
atyp = resp[3]
if atyp == 0x01: # IPv4: 4 addr + 2 port
self._recv_exact(6)
elif atyp == 0x03: # Domain: 1 len + domain + 2 port
domain_len = self._recv_exact(1)[0]
self._recv_exact(domain_len + 2)
elif atyp == 0x04: # IPv6: 16 addr + 2 port
self._recv_exact(18)
else:
raise ConnectionError(
f"SOCKS5 proxy returned unsupported address type: 0x{atyp:02x}"
)
# Socket is now tunneled through SOCKS5 to the target
def set_timeouts_linux(self):
"""Tor-appropriate TCP keepalive timeouts for Linux/Android."""
self.socket.setsockopt(
socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT,
int(TorClientInterface.TOR_USER_TIMEOUT * 1000)
)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(
socket.IPPROTO_TCP, socket.TCP_KEEPIDLE,
int(TorClientInterface.TOR_PROBE_AFTER)
)
self.socket.setsockopt(
socket.IPPROTO_TCP, socket.TCP_KEEPINTVL,
int(TorClientInterface.TOR_PROBE_INTERVAL)
)
self.socket.setsockopt(
socket.IPPROTO_TCP, socket.TCP_KEEPCNT,
int(TorClientInterface.TOR_PROBES)
)
def set_timeouts_osx(self):
"""Tor-appropriate TCP keepalive timeouts for macOS."""
TCP_KEEPIDLE = 0x10
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
self.socket.setsockopt(
socket.IPPROTO_TCP, TCP_KEEPIDLE,
int(TorClientInterface.TOR_PROBE_AFTER)
)
def __str__(self):
return f"TorClientInterface[{self.name}]"
# RNS external interface module convention
interface_class = TorClientInterface