columba/python/TorClientInterface.py
torlando-tech ac8e484ff1 Add SOCKS5 proxy UI fields to TCP Client interface config
Wire up the existing TorClientInterface backend to the UI by adding
SOCKS5 proxy toggle, host, and port fields under TCP Client advanced
options. Also fix socket resource leak during reconnect cycles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:47:17 -05:00

252 lines
8.8 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
)
# Close previous socket to prevent resource leak during reconnect
if self.socket is not None:
try:
self.socket.close()
except Exception:
pass
self.socket = None
# 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