mirror of
https://github.com/torlando-tech/columba
synced 2026-08-12 18:07:15 -04:00
- 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>
244 lines
8.6 KiB
Python
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
|