diff --git a/.gitignore b/.gitignore index 573a982..627e9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ vps.env reference/ dist/ -*.ipk \ No newline at end of file +*.ipk + +sim-results/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d7caf..1fdde89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 can now use `host:port` with either IP addresses or DNS hostnames (e.g., `"peer1.example.com:2121"`). DNS resolution with 60-second cache for UDP, one-shot resolution at connect time for TCP. +- Tor transport with three operating modes: + - `socks5` mode: outbound connections to .onion, clearnet IP, and + clearnet hostname addresses via SOCKS5 proxy with per-destination + circuit isolation (IsolateSOCKSAuth) + - `directory` mode: inbound via Tor-managed `HiddenServiceDir` onion + service, enables Tor Sandbox 1 (seccomp-bpf) + - `control_port` mode: Tor daemon monitoring via async control port client + with cookie and password authentication + - Optional control port monitoring in directory mode when `control_addr` + is configured + - Docker integration tests for SOCKS5 outbound and directory-mode inbound +- Tor operator visibility: + - Background monitoring task polling Tor daemon status every 10s + - `show_transports` query exposes `tor_mode`, `onion_address`, and + `tor_monitoring` (bootstrap, circuits, liveness, traffic, version) + - fipstop Tor transport detail view with daemon status, connection stats, + and truncated onion address in table view + - Bootstrap milestone logging (25/50/75/100%), stall warning, network + liveness transitions, dormant mode alerts ## [0.1.0] - 2026-03-12 diff --git a/Cargo.lock b/Cargo.lock index b41d380..4288312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-socks", "tracing", "tracing-subscriber", "tun", @@ -2422,6 +2423,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index b64e322..c91079f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "t futures = "0.3" simple-dns = "0.11.2" socket2 = { version = "0.6.2", features = ["all"] } +tokio-socks = "0.5" [package.metadata.deb] maintainer = "Johnathan Corgan " diff --git a/README.md b/README.md index 396e5c5..7e81bf3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # FIPS: Free Internetworking Peering System + ![banner](docs/logos/fips_banner.png) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/) @@ -241,7 +242,7 @@ testing/ Docker-based integration test harnesses ## Status & Roadmap FIPS is at **v0.1.0 (alpha)**. The core protocol works end-to-end over -UDP, TCP, and Ethernet but has not been tested beyond small meshes. +UDP, TCP, Ethernet, and Tor but has not been tested beyond small meshes. ### What works today @@ -254,14 +255,14 @@ UDP, TCP, and Ethernet but has not been tested beyond small meshes. - Static hostname mapping (`/etc/fips/hosts`) with auto-reload - Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation - ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection) -- UDP, TCP, and Ethernet transports +- UDP, TCP, Ethernet, and Tor transports (SOCKS5 outbound + directory-mode onion service inbound) - Runtime inspection via `fipsctl` and `fipstop` - Docker-based integration and chaos testing ### Near-term priorities - Peer discovery via Nostr relays (bootstrap without static peer lists) -- Additional transports (Bluetooth, Tor) +- Additional transports (Bluetooth) - Improved routing resilience under churn - Security audit of cryptographic protocols diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index f93150d..e3d4251 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -373,6 +373,111 @@ transports: max_inbound_connections: 64 ``` +### Tor (`transports.tor.*`) + +Tor transport routes FIPS traffic through the Tor network for anonymity. +Requires an external Tor daemon providing a SOCKS5 proxy. Three modes: +`socks5` for outbound-only, `control_port` for outbound + monitoring, +`directory` for outbound + inbound via Tor-managed onion service. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `transports.tor.mode` | string | `"socks5"` | Tor access mode: `socks5` (outbound only), `control_port` (outbound + monitoring), or `directory` (outbound + inbound onion service) | +| `transports.tor.socks5_addr` | string | `"127.0.0.1:9050"` | SOCKS5 proxy address (host:port) | +| `transports.tor.connect_timeout_ms` | u64 | `120000` | Connect timeout in milliseconds. Tor circuits take 10–60s. | +| `transports.tor.mtu` | u16 | `1400` | Default MTU | +| `transports.tor.control_addr` | string | `"/run/tor/control"` | Tor control port address: Unix socket path or host:port. Used in `control_port` mode; optional in `directory` mode for monitoring. | +| `transports.tor.control_auth` | string | `"cookie"` | Control port authentication: `"cookie"`, `"cookie:/path/to/cookie"`, or `"password:"`. | +| `transports.tor.cookie_path` | string | `"/var/run/tor/control.authcookie"` | Path to Tor control cookie file. Used when `control_auth` is `"cookie"`. | +| `transports.tor.max_inbound_connections` | usize | `64` | Maximum inbound connections via onion service. | +| `transports.tor.directory_service.hostname_file` | string | `"/var/lib/tor/fips_onion_service/hostname"` | Path to Tor-managed hostname file containing the `.onion` address. | +| `transports.tor.directory_service.bind_addr` | string | `"127.0.0.1:8443"` | Local bind address for the listener that Tor forwards inbound connections to. Must match `HiddenServicePort` target in `torrc`. | + +**Named instances.** Like other transports, multiple Tor instances can +be configured with named sub-keys for different SOCKS5 proxy endpoints. + +**Directory mode** (recommended for production). Tor manages the onion +service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion` +address from the hostname file and binds a local TCP listener. This +enables Tor's `Sandbox 1` (seccomp-bpf). If `control_addr` is also +set, the transport connects to the control port for daemon monitoring +(non-fatal on failure). + +**Control port mode.** Connects to the Tor daemon's control port for +monitoring only (bootstrap status, circuit health, traffic stats). +No inbound connections. Both `control_addr` and `control_auth` are +required. + +### UDP + Tor Bridge Example + +A node bridging clearnet (UDP) and anonymous (Tor) portions of the mesh: + +```yaml +node: + identity: + persistent: true + +tun: + enabled: true + +transports: + udp: + bind_addr: "0.0.0.0:2121" + mtu: 1472 + tor: + socks5_addr: "127.0.0.1:9050" + +peers: + - npub: "npub1abc..." + alias: "clearnet-peer" + addresses: + - transport: udp + addr: "203.0.113.5:2121" + - npub: "npub1def..." + alias: "anonymous-peer" + addresses: + - transport: tor + addr: "abc123...xyz.onion:2121" +``` + +### Tor Directory Mode Example + +A node accepting inbound connections via Tor-managed onion service +(recommended for production — enables Sandbox 1): + +```yaml +node: + identity: + persistent: true + +tun: + enabled: true + +transports: + tor: + mode: "directory" + socks5_addr: "127.0.0.1:9050" + control_addr: "/run/tor/control" # optional, for monitoring + control_auth: "cookie" + directory_service: + hostname_file: "/var/lib/tor/fips/hostname" + bind_addr: "127.0.0.1:8444" + +peers: + - npub: "npub1abc..." + alias: "tor-peer" + addresses: + - transport: tor + addr: "abcdef...xyz.onion:8443" +``` + +Requires a corresponding `torrc`: + +```text +HiddenServiceDir /var/lib/tor/fips +HiddenServicePort 8443 127.0.0.1:8444 +``` + ## Peers (`peers[]`) Static peer list. Each entry defines a peer to connect to. @@ -381,8 +486,8 @@ Static peer list. Each entry defines a peer to connect to. |-----------|------|---------|-------------| | `peers[].npub` | string | *(required)* | Peer's Nostr public key (npub-encoded) | | `peers[].alias` | string | *(none)* | Human-readable name for logging | -| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, or `ethernet` | -| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`) | +| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, `ethernet`, or `tor` | +| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`). Tor: `".onion:port"` or `"host:port"` | | `peers[].addresses[].priority` | u8 | `100` | Address priority (lower = preferred) | | `peers[].connect_policy` | string | `"auto_connect"` | Connection policy: `auto_connect`, `on_demand`, or `manual` | | `peers[].auto_reconnect` | bool | `true` | Automatically reconnect after MMP link-dead removal (exponential backoff, unlimited retries) | @@ -589,6 +694,20 @@ transports: # recv_buf_size: 2097152 # 2 MB # send_buf_size: 2097152 # 2 MB # max_inbound_connections: 256 # resource protection limit + # tor: # uncomment to enable Tor transport + # mode: "socks5" # "socks5", "control_port", or "directory" + # socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address + # connect_timeout_ms: 120000 # connect timeout (120s for Tor circuits) + # mtu: 1400 # default MTU + # # monitoring (control_port mode, or optional in directory mode): + # # control_addr: "/run/tor/control" # Unix socket or host:port + # # control_auth: "cookie" # "cookie" or "password:" + # # cookie_path: "/var/run/tor/control.authcookie" + # # directory mode (inbound via Tor-managed onion service): + # # directory_service: + # # hostname_file: "/var/lib/tor/fips/hostname" + # # bind_addr: "127.0.0.1:8444" + # # max_inbound_connections: 64 peers: # static peer list # - npub: "npub1..." diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index a2a7c3d..4ddaba1 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -524,8 +524,9 @@ forwarding, a publicly addressed peer, or relay through other mesh nodes. UDP hole punching and relay-assisted NAT traversal are potential future mechanisms but are not part of the current design. -> **Implementation status**: UDP/IP, TCP/IP, and Ethernet transports are -> implemented. All others are future directions. +> **Implementation status**: UDP/IP, TCP/IP, Ethernet, and Tor +> (SOCKS5 outbound + directory-mode inbound via onion service) +> transports are implemented. All others are future directions. See [fips-transport-layer.md](fips-transport-layer.md) for the full transport layer specification. diff --git a/docs/design/fips-transport-layer.md b/docs/design/fips-transport-layer.md index 591811f..e5b1711 100644 --- a/docs/design/fips-transport-layer.md +++ b/docs/design/fips-transport-layer.md @@ -442,6 +442,225 @@ If `bind_addr` is configured, the transport accepts inbound connections. Without it, the transport operates in outbound-only mode (no listener socket is created). +## Tor: The Anonymity Transport + +The Tor transport routes FIPS traffic through the Tor network, hiding +a node's IP address from its peers. A node behind Tor connects outbound +through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit +node's IP, not the initiator's. After the Noise IK handshake, the remote +peer knows the initiator's FIPS identity (npub) but not its network +location. + +Like TCP, Tor is connection-oriented and reliable. The same TCP-over-TCP +considerations apply — MMP correctly measures the elevated latency and +cost-based parent selection naturally deprioritizes Tor links. + +### Architecture + +The Tor transport is a separate `TorTransport` implementation, not a TCP +variant, because it manages SOCKS5 proxy negotiation, has different +address semantics (.onion vs IP:port), and has significantly different +latency characteristics. It reuses the FMP header-based stream reader +(`tcp/stream.rs`) for packet framing on the underlying TCP connection. + +The transport maintains two pools (same pattern as TCP): a +`ConnectingPool` for background SOCKS5 connection attempts, and an +established pool of `TorConnection` entries. Each `TorConnection` holds +a write half, a per-connection receive task, the negotiated MTU, and +a connection timestamp. + +| Property | Value | +| -------- | ----- | +| Addressing | .onion:port or IP:port | +| Default MTU | 1400 bytes | +| Framing | FMP header-based (shared with TCP) | +| Connection model | Non-blocking connect, outbound SOCKS5 + inbound via onion service | +| Platform | Cross-platform (requires external Tor daemon) | + +### Address Types + +The Tor transport accepts three address formats, parsed into a `TorAddr` +enum: + +- **Onion**: `.onion:port` — connects to a Tor hidden service. Both + sides anonymous. (e.g., `abcdef...xyz.onion:8443`) +- **Clearnet IP**: `IP:port` — connects through a Tor exit node to a + remote TCP listener. Hides the initiator's IP; the remote peer sees + the exit node's IP. +- **Clearnet Hostname**: `hostname:port` — hostname is passed through + SOCKS5 for Tor-side DNS resolution, avoiding local DNS leaks. Compatible + with SafeSocks 1. (e.g., `fips.example.com:8443`) + +All address types are routed through the same SOCKS5 proxy. + +### Connection Establishment + +Connection setup follows the same non-blocking pattern as TCP. When FMP +needs to reach a peer, the node calls `connect(addr)` on the transport. +The transport spawns a background tokio task that: + +1. Opens a SOCKS5 connection through the local Tor proxy +2. Configures the socket: `TCP_NODELAY`, keepalive (30s) +3. Returns the connected stream + +The call returns immediately. `connection_state(addr)` reports progress. +Tor circuit establishment typically takes 10–60 seconds (vs milliseconds +for TCP), making non-blocking connect essential — a blocking connect +would stall the entire FMP event loop. + +The connect timeout defaults to 120 seconds (vs 5 seconds for TCP), +accounting for Tor circuit setup time. As a fallback, `send(addr, data)` +performs synchronous connect-on-send if no connection exists. + +### Inbound via Onion Service (Directory Mode) + +In `directory` mode (recommended for production), Tor manages the onion +service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion` address +from the hostname file at startup and binds a local TCP listener that the +Tor daemon forwards inbound connections to. + +This mode enables Tor's `Sandbox 1` (seccomp-bpf) — the strongest single +hardening option — because no control port interaction is required for +onion service management. Tor handles key generation and persistence +directly through the `HiddenServiceDir`. + +The inbound accept loop mirrors the TCP transport's pattern: accept +connection, configure socket (TCP_NODELAY, keepalive), spawn a +per-connection receive loop using the shared FMP stream reader. Inbound +connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer +identity is resolved during the Noise IK handshake, not from the transport +address. + +Configuration requires coordinating `torrc` and `fips.yaml`: + +```text +# torrc +HiddenServiceDir /var/lib/tor/fips +HiddenServicePort 8443 127.0.0.1:8444 + +# fips.yaml tor section +mode: "directory" +directory_service: + hostname_file: "/var/lib/tor/fips/hostname" + bind_addr: "127.0.0.1:8444" +``` + +The `HiddenServicePort` external port (8443) is what peers connect to. +The bind_addr must match the `HiddenServicePort` target address. + +### Session Independence + +Same as TCP: Tor connection loss does **not** tear down the FIPS peer. +Noise keys, MMP state, and FSP sessions survive reconnection. + +### Bridge Node Pattern + +A node running both Tor and UDP transports acts as a bridge between +anonymous and clearnet portions of the mesh: + +```text +[Anonymous node] --tor--> [Bridge node] --udp--> [Clearnet node] +``` + +No special code is needed — FIPS multi-transport routing handles it. +Anonymous nodes connect to the bridge via Tor; the bridge forwards +traffic to clearnet peers over UDP. Clearnet peers never see the +anonymous node's IP. + +### Latency Characteristics + +Tor adds 200ms–2s RTT per circuit. First-packet latency after connection +is higher (~2.8s) due to circuit warm-up. MMP measures this elevated +latency, and cost-based parent selection penalizes Tor links (high SRTT +→ high link cost). ETX is 1.0 since TCP handles retransmission. + +Tor throughput is typically 1–5 Mbps — adequate for control plane and +moderate data transfer, not for bulk transfer. + +### Monitoring + +In `control_port` mode and optionally in `directory` mode (when +`control_addr` is configured), the transport spawns a background +monitoring task that polls the Tor daemon every 10 seconds via the +control port. The cached monitoring data is exposed through the +`show_transports` control socket query and displayed in fipstop. + +Monitoring data includes: + +- **Bootstrap progress** (0–100%) with INFO logging at milestones + (25/50/75/100%) and WARN if stalled >60s +- **Circuit status** (whether Tor has a working circuit) +- **Network liveness** (up/down) with WARN on transitions +- **Dormant mode** detection with WARN on entry +- **Tor daemon version** and **traffic counters** (bytes read/written) + +The control port connection uses cookie authentication by default +(reading from `/var/run/tor/control.authcookie`). Unix socket +connections (`/run/tor/control`) are preferred over TCP for security. + +### Configuration + +```yaml +transports: + tor: + mode: "socks5" # "socks5", "control_port", or "directory" + socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address + connect_timeout_ms: 120000 # Connect timeout (120s for Tor circuits) + mtu: 1400 # Default MTU + # control_port mode: monitoring via Tor control port (no inbound) + # control_addr: "/run/tor/control" # Unix socket (preferred) or host:port + # control_auth: "cookie" # "cookie" or "password:" + # cookie_path: "/var/run/tor/control.authcookie" + # directory mode: inbound via Tor-managed HiddenServiceDir + # directory_service: + # hostname_file: "/var/lib/tor/fips/hostname" + # bind_addr: "127.0.0.1:8444" + # max_inbound_connections: 64 +``` + +Three modes are available: + +- **`socks5`** (default): Outbound-only through a SOCKS5 proxy. No + control port, no inbound connections. +- **`control_port`**: Outbound via SOCKS5 plus control port connection + for Tor daemon monitoring. No inbound connections. +- **`directory`** (recommended for inbound): Outbound via SOCKS5 plus + inbound via Tor-managed `HiddenServiceDir` onion service. Optionally + connects to the control port for monitoring when `control_addr` is set. + Enables Tor's `Sandbox 1` for maximum security. + +The Tor transport requires an external Tor daemon. Named instances are +supported for multiple proxy endpoints. + +### Implementation Roadmap + +- Outbound SOCKS5 connections to .onion, clearnet IP, and clearnet + hostname addresses *(implemented)* +- Inbound connections via Tor onion service using `HiddenServiceDir` + directory mode *(implemented)* +- Operator visibility: cached monitoring snapshot, control socket + exposure, fipstop display, bootstrap/liveness logging *(implemented)* +- Embedded `arti` (Rust Tor implementation) for self-contained operation + without an external Tor daemon *(future)* + +### Statistics + +The transport tracks per-instance statistics: + +| Counter | Description | +| ------- | ----------- | +| `packets_sent` / `bytes_sent` | Successful sends | +| `packets_recv` / `bytes_recv` | Successful receives | +| `send_errors` / `recv_errors` | Send/receive failures | +| `connections_established` | Successful SOCKS5 connections | +| `connect_timeouts` | Connection timeout count | +| `connect_refused` | Connection refused count | +| `socks5_errors` | SOCKS5 protocol errors | +| `mtu_exceeded` | Packets rejected for MTU violation | +| `connections_accepted` | Accepted inbound connections via onion service | +| `connections_rejected` | Rejected inbound connections (limit exceeded) | +| `control_errors` | Tor control port errors | + ## Discovery Discovery determines that a FIPS-capable endpoint is reachable at a given @@ -450,7 +669,7 @@ detection — a new TCP connection or UDP packet from an unknown source is not discovery; a FIPS-specific announcement or response is. Discovery is an optional transport capability. Transports that don't support -it (configured UDP endpoints, TCP) simply don't provide discovery events. +it (configured UDP endpoints, TCP, Tor) simply don't provide discovery events. FMP handles both cases uniformly: with discovery, it waits for events then initiates link setup; without discovery, it initiates link setup directly to configured addresses. @@ -499,13 +718,13 @@ Key properties: ### Current State -> **Implemented**: UDP and TCP peers are configured via YAML. Ethernet -> peers are discovered via beacon broadcast — the `discover()` trait -> method returns newly seen endpoints, and per-transport `auto_connect()` -> / `accept_connections()` policies control whether discovered peers are -> connected automatically or require explicit configuration. TCP has no -> discovery mechanism (peers are configured). Nostr relay discovery is -> not yet implemented. +> **Implemented**: UDP, TCP, Tor, and Ethernet peers can be configured +> statically via YAML. Ethernet peers can also be discovered via beacon +> broadcast — the `discover()` trait method returns newly seen endpoints, +> and per-transport `auto_connect()` / `accept_connections()` policies +> control whether discovered peers are connected automatically or require +> explicit configuration. TCP and Tor have no discovery mechanism. +> Nostr relay discovery is not yet implemented. ## Transport Interface @@ -582,8 +801,9 @@ on all forwarded datagrams. | Transport | Congestion Source | Mechanism | | --------- | ----------------- | --------- | | UDP | `SO_RXQ_OVFL` kernel drop counter | `recvmsg()` ancillary data on every packet | -| TCP | Not yet implemented | Returns `None` (TCP handles congestion internally) | -| Ethernet | Not yet implemented | Returns `None` | +| TCP | Not implemented | Returns `None` (TCP handles congestion internally) | +| Tor | Not implemented | Returns `None` (TCP handles congestion internally) | +| Ethernet | Not implemented | Returns `None` | ### Transport Addresses @@ -611,7 +831,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to | TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU | | Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only | | WiFi | Future direction | Infrastructure mode = Ethernet driver | -| Tor | Future direction | High latency, .onion addressing | +| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing | | BLE | Future direction | ATT_MTU negotiation, per-link MTU | | Radio | Future direction | Constrained MTU (51–222 bytes) | | Serial | Future direction | SLIP/COBS framing, point-to-point | diff --git a/docs/design/fips-wire-formats.md b/docs/design/fips-wire-formats.md index 1aa39b0..41ab5f2 100644 --- a/docs/design/fips-wire-formats.md +++ b/docs/design/fips-wire-formats.md @@ -21,7 +21,8 @@ Datagram-oriented transports (UDP, raw Ethernet, radio) preserve natural packet boundaries and require no additional framing. Stream-oriented transports (TCP, WebSocket, Tor) must delineate FIPS packets within the byte stream; the common prefix `payload_len` field provides this -framing directly. +framing directly. TCP and Tor share a common stream reader +(`tcp/stream.rs`) that implements this framing. **Ethernet data frame header.** The Ethernet transport prepends a 3-byte header before the FMP payload on data frames: a 1-byte frame type diff --git a/packaging/torrc.fips b/packaging/torrc.fips new file mode 100644 index 0000000..04a6b23 --- /dev/null +++ b/packaging/torrc.fips @@ -0,0 +1,83 @@ +### FIPS-specific Tor configuration ### +### +### Reference torrc for running Tor alongside the FIPS daemon. +### Uses HiddenServiceDir (directory mode) for the onion service, +### which enables Sandbox mode — the strongest single hardening option. +### +### Install: cp torrc.fips /etc/tor/torrc +### Verify: tor --verify-config -f /etc/tor/torrc +### +### The corresponding fips.yaml transport section should be: +### +### transports: +### tor: +### mode: "directory" +### directory_service: +### hostname_file: "/var/lib/tor/fips_onion_service/hostname" +### bind_addr: "127.0.0.1:8443" + +## Identity +DataDirectory /var/lib/tor +User debian-tor + +## SOCKS proxy (for outbound peer connections) +## IsolateSOCKSAuth: username/password fields serve as circuit isolation +## keys — each FIPS peer destination gets its own Tor circuit. +## IsolateDestAddr + IsolateDestPort: additional isolation by destination. +## IsolateSOCKSAuth: required — FIPS uses SOCKS5 username/password fields +## as per-destination circuit isolation keys (not real authentication). +## +## SafeSocks 1 rejects SOCKS5 CONNECT to raw IP addresses. FIPS supports +## DNS hostnames in peer addresses — when clearnet peers use hostnames, +## they are passed through SOCKS5 for Tor-side resolution (no local DNS +## leak). Enable SafeSocks 1 when all clearnet peers use hostnames or +## when running .onion-only. Not enabled by default for compatibility +## with IP-addressed peers. +SocksPort 127.0.0.1:9050 IsolateSOCKSAuth +#TestSocks 1 # Enable during development only + +## Control access (Unix socket, not TCP — filesystem permission control) +## Only needed if fips uses control_port mode. In directory mode, the +## control socket is not required but can be useful for monitoring. +ControlSocket /run/tor/control GroupWritable RelaxDirModeCheck +ControlSocketsGroupWritable 1 +## Do NOT use ControlPort 9051 — TCP control ports are accessible to any +## local process that authenticates. Use Unix socket only. + +## Authentication +CookieAuthentication 1 +CookieAuthFileGroupReadable 1 +CookieAuthFile /run/tor/control.authcookie + +## Onion service (persistent, filesystem-managed by Tor) +## Tor manages the key in HiddenServiceDir. FIPS reads the .onion +## address from the hostname file at startup. +HiddenServiceDir /var/lib/tor/fips_onion_service +HiddenServicePort 8443 127.0.0.1:8443 + +## Onion service hardening +HiddenServiceMaxStreams 100 +HiddenServiceMaxStreamsCloseCircuit 1 + +## Onion service DoS protection (Tor 0.4.8+) +## Intro point rate limiting — prevents introduction cell floods. +HiddenServiceEnableIntroDoSDefense 1 +HiddenServiceEnableIntroDoSRatePerSec 25 +HiddenServiceEnableIntroDoSBurstPerSec 200 + +## Proof-of-work defense — auto-scaling computational puzzle for clients. +## Minimal cost to legitimate peers, expensive for flood attacks. +HiddenServicePoWDefensesEnabled 1 +HiddenServicePoWQueueRate 250 +HiddenServicePoWQueueBurst 2500 + +## Security +ExitRelay 0 +ExitPolicy reject *:* +Sandbox 1 +VanguardsLiteEnabled 1 +ConnectionPadding 1 +SafeLogging 1 + +## Logging +Log notice syslog diff --git a/src/bin/fipstop/ui/transports.rs b/src/bin/fipstop/ui/transports.rs index 30e5125..0eaf805 100644 --- a/src/bin/fipstop/ui/transports.rs +++ b/src/bin/fipstop/ui/transports.rs @@ -160,6 +160,24 @@ fn draw_table( let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or(""); let label = if !name.is_empty() { format!("{indicator}{typ} {name}") + } else if typ == "tor" { + let mode = t + .get("tor_mode") + .and_then(|v| v.as_str()) + .unwrap_or("socks5"); + let onion_hint = t + .get("onion_address") + .and_then(|v| v.as_str()) + .map(|a| { + let short = if a.len() > 16 { + &a[..16] + } else { + a + }; + format!(" {short}..") + }) + .unwrap_or_default(); + format!("{indicator}tor({mode}){onion_hint}") } else if !addr.is_empty() { format!("{indicator}{typ} {addr}") } else { @@ -328,6 +346,14 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso lines.push(helpers::kv_line("Local Addr", addr)); } + // Tor-specific info + if let Some(mode) = t.get("tor_mode").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Tor Mode", mode)); + } + if let Some(onion) = t.get("onion_address").and_then(|v| v.as_str()) { + lines.push(helpers::kv_line("Onion Address", onion)); + } + // Transport stats if let Some(stats) = t.get("stats") { let typ = helpers::str_field(t, "type"); @@ -424,6 +450,42 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso &helpers::nested_u64(t, "stats", "connect_refused"), )); } + "tor" => { + lines.push(helpers::kv_line( + "MTU Exceeded", + &helpers::nested_u64(t, "stats", "mtu_exceeded"), + )); + lines.push(helpers::kv_line( + "SOCKS5 Errors", + &helpers::nested_u64(t, "stats", "socks5_errors"), + )); + lines.push(helpers::kv_line( + "Control Errors", + &helpers::nested_u64(t, "stats", "control_errors"), + )); + lines.push(Line::from("")); + lines.push(helpers::section_header("Connections")); + lines.push(helpers::kv_line( + "Established", + &helpers::nested_u64(t, "stats", "connections_established"), + )); + lines.push(helpers::kv_line( + "Accepted", + &helpers::nested_u64(t, "stats", "connections_accepted"), + )); + lines.push(helpers::kv_line( + "Rejected", + &helpers::nested_u64(t, "stats", "connections_rejected"), + )); + lines.push(helpers::kv_line( + "Timeouts", + &helpers::nested_u64(t, "stats", "connect_timeouts"), + )); + lines.push(helpers::kv_line( + "Refused", + &helpers::nested_u64(t, "stats", "connect_refused"), + )); + } "ethernet" => { lines.push(Line::from("")); lines.push(helpers::section_header("Beacons")); @@ -448,6 +510,54 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso } _ => {} } + + // Tor daemon monitoring (when control port data is available) + if let Some(mon) = t.get("tor_monitoring") { + lines.push(Line::from("")); + lines.push(helpers::section_header("Tor Daemon")); + lines.push(helpers::kv_line( + "Bootstrap", + &format!("{}%", helpers::nested_u64(t, "tor_monitoring", "bootstrap")), + )); + lines.push(helpers::kv_line( + "Circuit", + if mon + .get("circuit_established") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + "established" + } else { + "none" + }, + )); + lines.push(helpers::kv_line( + "Version", + &helpers::nested_str(t, "tor_monitoring", "version"), + )); + lines.push(helpers::kv_line( + "Network", + &helpers::nested_str(t, "tor_monitoring", "network_liveness"), + )); + lines.push(helpers::kv_line("Dormant", helpers::bool_field(mon, "dormant"))); + + let tor_read = mon + .get("traffic_read") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let tor_written = mon + .get("traffic_written") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + lines.push(helpers::kv_line( + "Tor Read", + &helpers::format_bytes(tor_read), + )); + lines.push(helpers::kv_line( + "Tor Written", + &helpers::format_bytes(tor_written), + )); + } } let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0); diff --git a/src/config/mod.rs b/src/config/mod.rs index d9d5feb..700123c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,7 +34,7 @@ pub use node::{ TreeConfig, }; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; -pub use transport::{EthernetConfig, TcpConfig, TransportInstances, TransportsConfig, UdpConfig}; +pub use transport::{DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; /// Default config filename. const CONFIG_FILENAME: &str = "fips.yaml"; diff --git a/src/config/transport.rs b/src/config/transport.rs index 01d786c..c7ad5b1 100644 --- a/src/config/transport.rs +++ b/src/config/transport.rs @@ -293,10 +293,6 @@ pub struct TcpConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub send_buf_size: Option, - /// SOCKS5 proxy for outbound connections (placeholder; not yet implemented). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub socks5_proxy: Option, - /// Maximum simultaneous inbound connections. Defaults to 256. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_inbound_connections: Option, @@ -339,6 +335,169 @@ impl TcpConfig { } } +// ============================================================================ +// Tor Transport Configuration +// ============================================================================ + +/// Default Tor SOCKS5 proxy address. +const DEFAULT_TOR_SOCKS5_ADDR: &str = "127.0.0.1:9050"; + +/// Default Tor control port address. +const DEFAULT_TOR_CONTROL_ADDR: &str = "/run/tor/control"; + +/// Default Tor control cookie file path (Debian standard location). +const DEFAULT_TOR_COOKIE_PATH: &str = "/var/run/tor/control.authcookie"; + +/// Default Tor connect timeout in milliseconds (120s — Tor circuit +/// establishment can take 30-60s on first connect, plus SOCKS5 handshake). +const DEFAULT_TOR_CONNECT_TIMEOUT_MS: u64 = 120_000; + +/// Default Tor MTU (same as TCP). +const DEFAULT_TOR_MTU: u16 = 1400; + +/// Default max inbound connections via onion service. +const DEFAULT_TOR_MAX_INBOUND: usize = 64; + +/// Default HiddenServiceDir hostname file path. +const DEFAULT_HOSTNAME_FILE: &str = "/var/lib/tor/fips_onion_service/hostname"; + +/// Default directory mode bind address. +const DEFAULT_DIRECTORY_BIND_ADDR: &str = "127.0.0.1:8443"; + +/// Tor transport instance configuration. +/// +/// Supports three modes: +/// - `socks5`: Outbound-only connections through a Tor SOCKS5 proxy. +/// - `control_port`: Full bidirectional support — outbound via SOCKS5 +/// plus inbound via Tor onion service managed through the control port. +/// - `directory`: Full bidirectional support — outbound via SOCKS5, +/// inbound via a Tor-managed `HiddenServiceDir` onion service. No +/// control port needed. Enables Tor `Sandbox 1` mode. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TorConfig { + /// Tor access mode: "socks5", "control_port", or "directory". + /// Default: "socks5". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + + /// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:9050". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub socks5_addr: Option, + + /// Outbound connect timeout in milliseconds. Defaults to 120000 (120s). + /// Tor circuit establishment can take 30-60s, so this must be generous. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_timeout_ms: Option, + + /// Default MTU for Tor connections. Defaults to 1400. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtu: Option, + + /// Control port address: a Unix socket path (`/run/tor/control`) or + /// TCP address (`host:port`). Unix sockets are preferred for security. + /// Defaults to "/run/tor/control". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_addr: Option, + + /// Control port authentication method: + /// `"cookie"` (read from default path), + /// `"cookie:/path/to/cookie"` (read from specified path), or + /// `"password:secret"` (password auth). Default: `"cookie"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_auth: Option, + + /// Path to the Tor control cookie file. Used when control_auth is "cookie". + /// Defaults to "/var/run/tor/control.authcookie". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie_path: Option, + + /// Maximum number of inbound connections via onion service. Default: 64. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_inbound_connections: Option, + + /// Directory-mode onion service configuration. Only valid in + /// "directory" mode. Tor manages the onion service via HiddenServiceDir + /// in torrc; fips reads the .onion hostname from a file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory_service: Option, +} + +/// Directory-mode onion service configuration. +/// +/// In `directory` mode, Tor manages the onion service via `HiddenServiceDir` +/// in torrc. FIPS reads the `.onion` address from the hostname file and +/// binds a local TCP listener for Tor to forward inbound connections to. +/// This mode requires no control port and enables Tor's `Sandbox 1`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DirectoryServiceConfig { + /// Path to the Tor-managed hostname file containing the .onion address. + /// Defaults to "/var/lib/tor/fips_onion_service/hostname". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname_file: Option, + + /// Local bind address for the listener that Tor forwards inbound + /// connections to. Must match the target in torrc's `HiddenServicePort`. + /// Defaults to "127.0.0.1:8443". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, +} + +impl DirectoryServiceConfig { + /// Path to the hostname file. Default: "/var/lib/tor/fips_onion_service/hostname". + pub fn hostname_file(&self) -> &str { + self.hostname_file.as_deref().unwrap_or(DEFAULT_HOSTNAME_FILE) + } + + /// Local bind address for the listener. Default: "127.0.0.1:8443". + pub fn bind_addr(&self) -> &str { + self.bind_addr.as_deref().unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR) + } +} + +impl TorConfig { + /// Get the access mode. Default: "socks5". + pub fn mode(&self) -> &str { + self.mode.as_deref().unwrap_or("socks5") + } + + /// Get the SOCKS5 proxy address. Default: "127.0.0.1:9050". + pub fn socks5_addr(&self) -> &str { + self.socks5_addr.as_deref().unwrap_or(DEFAULT_TOR_SOCKS5_ADDR) + } + + /// Get the control port address. Default: "/run/tor/control". + pub fn control_addr(&self) -> &str { + self.control_addr.as_deref().unwrap_or(DEFAULT_TOR_CONTROL_ADDR) + } + + /// Get the control auth string. Default: "cookie". + pub fn control_auth(&self) -> &str { + self.control_auth.as_deref().unwrap_or("cookie") + } + + /// Get the cookie file path. Default: "/var/run/tor/control.authcookie". + pub fn cookie_path(&self) -> &str { + self.cookie_path.as_deref().unwrap_or(DEFAULT_TOR_COOKIE_PATH) + } + + /// Get the connect timeout in milliseconds. Default: 120000. + pub fn connect_timeout_ms(&self) -> u64 { + self.connect_timeout_ms.unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS) + } + + /// Get the default MTU. Default: 1400. + pub fn mtu(&self) -> u16 { + self.mtu.unwrap_or(DEFAULT_TOR_MTU) + } + + /// Get the max inbound connections. Default: 64. + pub fn max_inbound_connections(&self) -> usize { + self.max_inbound_connections.unwrap_or(DEFAULT_TOR_MAX_INBOUND) + } +} + // ============================================================================ // TransportsConfig // ============================================================================ @@ -360,6 +519,10 @@ pub struct TransportsConfig { /// TCP transport instances. #[serde(default, skip_serializing_if = "is_transport_empty")] pub tcp: TransportInstances, + + /// Tor transport instances. + #[serde(default, skip_serializing_if = "is_transport_empty")] + pub tor: TransportInstances, } /// Helper for skip_serializing_if on TransportInstances. @@ -370,7 +533,7 @@ fn is_transport_empty(instances: &TransportInstances) -> bool { impl TransportsConfig { /// Check if any transports are configured. pub fn is_empty(&self) -> bool { - self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty() + self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty() && self.tor.is_empty() } /// Merge another TransportsConfig into this one. @@ -386,5 +549,8 @@ impl TransportsConfig { if !other.tcp.is_empty() { self.tcp = other.tcp; } + if !other.tor.is_empty() { + self.tor = other.tor; + } } } diff --git a/src/control/queries.rs b/src/control/queries.rs index 9db51ac..a2d8d92 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -490,6 +490,18 @@ pub fn show_transports(node: &Node) -> Value { t_json["local_addr"] = json!(format!("{}", addr)); } + // Tor-specific fields + if let Some(mode) = handle.tor_mode() { + t_json["tor_mode"] = json!(mode); + } + if let Some(onion) = handle.onion_address() { + t_json["onion_address"] = json!(onion); + } + if let Some(monitoring) = handle.tor_monitoring() { + t_json["tor_monitoring"] = + serde_json::to_value(&monitoring).unwrap_or_default(); + } + t_json["stats"] = handle.transport_stats(); t_json diff --git a/src/lib.rs b/src/lib.rs index 7273f8e..ed44c07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ pub use identity::{ }; // Re-export config types -pub use config::{Config, ConfigError, IdentityConfig, UdpConfig}; +pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig}; pub use upper::config::{DnsConfig, TunConfig}; // Re-export tree types diff --git a/src/node/mod.rs b/src/node/mod.rs index 3a9b93f..514a9d4 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -30,6 +30,7 @@ use crate::transport::{ }; use crate::transport::udp::UdpTransport; use crate::transport::tcp::TcpTransport; +use crate::transport::tor::TorTransport; #[cfg(target_os = "linux")] use crate::transport::ethernet::EthernetTransport; use crate::tree::TreeState; @@ -704,6 +705,21 @@ impl Node { transports.push(TransportHandle::Tcp(tcp)); } + // Create Tor transport instances + let tor_instances: Vec<_> = self + .config + .transports + .tor + .iter() + .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) + .collect(); + + for (name, tor_config) in tor_instances { + let transport_id = self.allocate_transport_id(); + let tor = TorTransport::new(transport_id, name, tor_config, packet_tx.clone()); + transports.push(TransportHandle::Tor(tor)); + } + transports } diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 143843f..ccbfb77 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -6,6 +6,7 @@ pub mod udp; pub mod tcp; +pub mod tor; #[cfg(target_os = "linux")] pub mod ethernet; @@ -13,6 +14,8 @@ pub mod ethernet; use secp256k1::XOnlyPublicKey; use udp::UdpTransport; use tcp::TcpTransport; +use tor::control::TorMonitoringInfo; +use tor::TorTransport; #[cfg(target_os = "linux")] use ethernet::EthernetTransport; use std::fmt; @@ -842,6 +845,8 @@ pub enum TransportHandle { Ethernet(EthernetTransport), /// TCP/IP transport. Tcp(TcpTransport), + /// Tor transport (via SOCKS5). + Tor(TorTransport), } impl TransportHandle { @@ -852,6 +857,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await, + TransportHandle::Tor(t) => t.start_async().await, } } @@ -862,6 +868,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await, + TransportHandle::Tor(t) => t.stop_async().await, } } @@ -872,6 +879,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Tcp(t) => t.send_async(addr, data).await, + TransportHandle::Tor(t) => t.send_async(addr, data).await, } } @@ -882,6 +890,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(), + TransportHandle::Tor(t) => t.transport_id(), } } @@ -892,6 +901,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.name(), TransportHandle::Tcp(t) => t.name(), + TransportHandle::Tor(t) => t.name(), } } @@ -902,6 +912,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(), + TransportHandle::Tor(t) => t.transport_type(), } } @@ -912,6 +923,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.state(), TransportHandle::Tcp(t) => t.state(), + TransportHandle::Tor(t) => t.state(), } } @@ -922,6 +934,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(), + TransportHandle::Tor(t) => t.mtu(), } } @@ -935,16 +948,18 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr), + TransportHandle::Tor(t) => t.link_mtu(addr), } } - /// Get the local bound address (UDP only, returns None for other transports). + /// Get the local bound address (UDP/TCP only, returns None for other transports). pub fn local_addr(&self) -> Option { match self { TransportHandle::Udp(t) => t.local_addr(), #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => None, TransportHandle::Tcp(t) => t.local_addr(), + TransportHandle::Tor(_) => None, } } @@ -955,6 +970,31 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Tcp(_) => None, + TransportHandle::Tor(_) => None, + } + } + + /// Get the onion service address (Tor only, returns None for other transports). + pub fn onion_address(&self) -> Option<&str> { + match self { + TransportHandle::Tor(t) => t.onion_address(), + _ => None, + } + } + + /// Get cached Tor daemon monitoring info (Tor only). + pub fn tor_monitoring(&self) -> Option { + match self { + TransportHandle::Tor(t) => t.cached_monitoring(), + _ => None, + } + } + + /// Get the Tor transport mode (Tor only). + pub fn tor_mode(&self) -> Option<&str> { + match self { + TransportHandle::Tor(t) => Some(t.mode()), + _ => None, } } @@ -965,6 +1005,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(), + TransportHandle::Tor(t) => t.discover(), } } @@ -975,6 +1016,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(), + TransportHandle::Tor(t) => t.auto_connect(), } } @@ -985,6 +1027,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(), + TransportHandle::Tor(t) => t.accept_connections(), } } @@ -1001,6 +1044,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Tcp(t) => t.connect_async(addr).await, + TransportHandle::Tor(t) => t.connect_async(addr).await, } } @@ -1015,12 +1059,13 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Tcp(t) => t.connection_state_sync(addr), + TransportHandle::Tor(t) => t.connection_state_sync(addr), } } /// Close a specific connection on this transport. /// - /// No-op for connectionless transports. For TCP, removes the + /// No-op for connectionless transports. For TCP/Tor, removes the /// connection from the pool and drops the stream. pub async fn close_connection(&self, addr: &TransportAddr) { match self { @@ -1028,6 +1073,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Tcp(t) => t.close_connection_async(addr).await, + TransportHandle::Tor(t) => t.close_connection_async(addr).await, } } @@ -1047,6 +1093,7 @@ impl TransportHandle { #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(), + TransportHandle::Tor(_) => TransportCongestion::default(), } } @@ -1077,6 +1124,9 @@ impl TransportHandle { TransportHandle::Tcp(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() } + TransportHandle::Tor(t) => { + serde_json::to_value(t.stats().snapshot()).unwrap_or_default() + } } } } diff --git a/src/transport/tor/control.rs b/src/transport/tor/control.rs new file mode 100644 index 0000000..3514dae --- /dev/null +++ b/src/transport/tor/control.rs @@ -0,0 +1,776 @@ +//! Tor control port client. +//! +//! Minimal async client for the Tor control protocol (control-spec). +//! Implements AUTHENTICATE and GETINFO for monitoring the Tor daemon. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +#[cfg(unix)] +use tokio::net::UnixStream; +use serde::Serialize; +use tracing::debug; + +// ============================================================================ +// Error Type +// ============================================================================ + +/// Errors from the Tor control port client. +#[derive(Debug)] +pub enum TorControlError { + /// Failed to connect to the control port. + ConnectionFailed(String), + /// Authentication failed. + AuthFailed(String), + /// Protocol-level error (unexpected response format). + ProtocolError(String), + /// I/O error. + Io(std::io::Error), +} + +impl fmt::Display for TorControlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConnectionFailed(msg) => write!(f, "control port connection failed: {}", msg), + Self::AuthFailed(msg) => write!(f, "control port auth failed: {}", msg), + Self::ProtocolError(msg) => write!(f, "control protocol error: {}", msg), + Self::Io(e) => write!(f, "control port I/O error: {}", e), + } + } +} + +impl std::error::Error for TorControlError {} + +impl From for TorControlError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +// ============================================================================ +// Authentication +// ============================================================================ + +/// Control port authentication method. +#[derive(Debug, Clone)] +pub enum ControlAuth { + /// Cookie authentication — reads 32-byte cookie from file, sends as hex. + Cookie(PathBuf), + /// Password authentication — sends AUTHENTICATE "password". + Password(String), +} + +impl ControlAuth { + /// Parse a control_auth config string into a ControlAuth value. + /// + /// - `"cookie"` or `"cookie:/path/to/cookie"` → Cookie auth + /// - `"password:secret"` → Password auth + pub fn from_config( + auth_str: &str, + default_cookie_path: &str, + ) -> Result { + if auth_str == "cookie" { + Ok(Self::Cookie(PathBuf::from(default_cookie_path))) + } else if let Some(path) = auth_str.strip_prefix("cookie:") { + Ok(Self::Cookie(PathBuf::from(path))) + } else if let Some(password) = auth_str.strip_prefix("password:") { + Ok(Self::Password(password.to_string())) + } else { + Err(TorControlError::AuthFailed(format!( + "unknown control_auth format '{}': expected 'cookie', 'cookie:/path', or 'password:secret'", + auth_str + ))) + } + } +} + +// ============================================================================ +// Monitoring Info +// ============================================================================ + +/// Snapshot of Tor daemon status collected via control port GETINFO queries. +#[derive(Debug, Clone, Serialize)] +pub struct TorMonitoringInfo { + /// Bootstrap progress (0-100). + pub bootstrap: u8, + /// Whether Tor has at least one working circuit. + pub circuit_established: bool, + /// Total bytes read by Tor since startup. + pub traffic_read: u64, + /// Total bytes written by Tor since startup. + pub traffic_written: u64, + /// Network liveness: "up" or "down". + pub network_liveness: String, + /// Tor daemon version string. + pub version: String, + /// Whether Tor is in dormant mode (no recent activity). + pub dormant: bool, +} + +// ============================================================================ +// Client +// ============================================================================ + +/// Async Tor control port client. +/// +/// Maintains a persistent connection to the Tor daemon's control port. +/// Supports both TCP (`host:port`) and Unix socket (`/path/to/socket`) +/// connections. The connection must stay alive for the lifetime of +/// ephemeral onion services (unless created with detach=true). +pub struct TorControlClient { + reader: BufReader>, + writer: Box, +} + +impl fmt::Debug for TorControlClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TorControlClient").finish_non_exhaustive() + } +} + +impl TorControlClient { + /// Connect to a Tor control port. + /// + /// The address can be either: + /// - A TCP address (`host:port` or `IP:port`) for TCP connections + /// - A filesystem path (starting with `/` or `./`) for Unix socket connections + /// + /// Unix sockets are preferred for security: they provide filesystem + /// permission-based access control and are not reachable from containers + /// unless explicitly mounted. The Debian default is `/run/tor/control`. + pub async fn connect(addr: &str) -> Result { + if is_unix_socket_path(addr) { + Self::connect_unix(addr).await + } else { + Self::connect_tcp(addr).await + } + } + + /// Connect via TCP to a control port at `host:port`. + async fn connect_tcp(addr: &str) -> Result { + let stream = TcpStream::connect(addr).await.map_err(|e| { + TorControlError::ConnectionFailed(format!( + "failed to connect to control port {}: {}", + addr, e + )) + })?; + + let (read_half, write_half) = stream.into_split(); + + debug!(addr = %addr, transport = "tcp", "Connected to Tor control port"); + + Ok(Self { + reader: BufReader::new(Box::new(read_half)), + writer: Box::new(write_half), + }) + } + + /// Connect via Unix socket to a control port at the given path. + #[cfg(unix)] + async fn connect_unix(path: &str) -> Result { + let stream = UnixStream::connect(path).await.map_err(|e| { + TorControlError::ConnectionFailed(format!( + "failed to connect to control socket {}: {}", + path, e + )) + })?; + + let (read_half, write_half) = stream.into_split(); + + debug!(path = %path, transport = "unix", "Connected to Tor control port"); + + Ok(Self { + reader: BufReader::new(Box::new(read_half)), + writer: Box::new(write_half), + }) + } + + #[cfg(not(unix))] + async fn connect_unix(path: &str) -> Result { + Err(TorControlError::ConnectionFailed(format!( + "Unix sockets not supported on this platform: {}", + path + ))) + } + + /// Authenticate with the Tor daemon. + pub async fn authenticate(&mut self, auth: &ControlAuth) -> Result<(), TorControlError> { + let command = match auth { + ControlAuth::Cookie(path) => { + let cookie = read_cookie_file(path)?; + format!("AUTHENTICATE {}\r\n", hex::encode(cookie)) + } + ControlAuth::Password(password) => { + // Escape quotes in password + let escaped = password.replace('\\', "\\\\").replace('"', "\\\""); + format!("AUTHENTICATE \"{}\"\r\n", escaped) + } + }; + + self.send_command(&command).await?; + let response = self.read_response().await?; + + if response.code != 250 { + return Err(TorControlError::AuthFailed(format!( + "AUTHENTICATE failed: {} {}", + response.code, response.message + ))); + } + + debug!("Authenticated with Tor control port"); + Ok(()) + } + + // ======================================================================== + // Monitoring Queries + // ======================================================================== + + /// Issue a GETINFO query and return the value for the given key. + /// + /// Tor responds with `250-key=value` data lines. This extracts the + /// value for the requested key. + async fn getinfo(&mut self, key: &str) -> Result { + let command = format!("GETINFO {}\r\n", key); + self.send_command(&command).await?; + let response = self.read_response().await?; + + if response.code != 250 { + return Err(TorControlError::ProtocolError(format!( + "GETINFO {} failed: {} {}", + key, response.code, response.message + ))); + } + + let prefix = format!("{}=", key); + for line in &response.data_lines { + if let Some(value) = line.strip_prefix(&prefix) { + return Ok(value.to_string()); + } + } + + Err(TorControlError::ProtocolError(format!( + "GETINFO response missing key '{}'", + key + ))) + } + + /// Query Tor's bootstrap progress (0-100). + pub async fn get_bootstrap_phase(&mut self) -> Result { + let raw = self.getinfo("status/bootstrap-phase").await?; + + // Value looks like: NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="Done" + if let Some(progress_start) = raw.find("PROGRESS=") { + let after = &raw[progress_start + 9..]; + let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(progress) = digits.parse::() { + return Ok(progress); + } + } + + Err(TorControlError::ProtocolError( + "could not parse bootstrap progress".into(), + )) + } + + /// Check whether Tor has established circuits (health check). + /// + /// Returns true if Tor has at least one working circuit, false otherwise. + pub async fn is_circuit_established(&mut self) -> Result { + let value = self.getinfo("status/circuit-established").await?; + Ok(value.trim() == "1") + } + + /// Query total bytes read by Tor since startup. + pub async fn traffic_read(&mut self) -> Result { + let value = self.getinfo("traffic/read").await?; + value.trim().parse::().map_err(|_| { + TorControlError::ProtocolError(format!( + "invalid traffic/read value: '{}'", + value + )) + }) + } + + /// Query total bytes written by Tor since startup. + pub async fn traffic_written(&mut self) -> Result { + let value = self.getinfo("traffic/written").await?; + value.trim().parse::().map_err(|_| { + TorControlError::ProtocolError(format!( + "invalid traffic/written value: '{}'", + value + )) + }) + } + + /// Query whether Tor considers the network reachable. + /// + /// Returns `"up"` or `"down"`. + pub async fn network_liveness(&mut self) -> Result { + self.getinfo("network-liveness").await + } + + /// Query the Tor daemon version string. + pub async fn version(&mut self) -> Result { + self.getinfo("version").await + } + + /// Query whether Tor is in dormant mode (no recent activity). + pub async fn is_dormant(&mut self) -> Result { + let value = self.getinfo("dormant").await?; + Ok(value.trim() == "1") + } + + /// Query Tor's SOCKS listener addresses. + /// + /// Returns a list of addresses Tor is listening on for SOCKS connections. + pub async fn socks_listeners(&mut self) -> Result, TorControlError> { + let value = self.getinfo("net/listeners/socks").await?; + Ok(value.split_whitespace().map(|s| s.trim_matches('"').to_string()).collect()) + } + + /// Collect all monitoring info in a single batch of queries. + pub async fn monitoring_snapshot(&mut self) -> Result { + let bootstrap = self.get_bootstrap_phase().await.unwrap_or(0); + let circuit_established = self.is_circuit_established().await.unwrap_or(false); + let traffic_read = self.traffic_read().await.unwrap_or(0); + let traffic_written = self.traffic_written().await.unwrap_or(0); + let network_liveness = self.network_liveness().await.unwrap_or_else(|_| "unknown".into()); + let version = self.version().await.unwrap_or_else(|_| "unknown".into()); + let dormant = self.is_dormant().await.unwrap_or(false); + + Ok(TorMonitoringInfo { + bootstrap, + circuit_established, + traffic_read, + traffic_written, + network_liveness, + version, + dormant, + }) + } + + // ======================================================================== + // Protocol Helpers + // ======================================================================== + + /// Send a raw command string to the control port. + async fn send_command(&mut self, command: &str) -> Result<(), TorControlError> { + self.writer.write_all(command.as_bytes()).await?; + self.writer.flush().await?; + Ok(()) + } + + /// Read a complete response from the control port. + /// + /// Tor responses are line-based: + /// - `250-key=value` — mid-reply data line (more lines follow) + /// - `250 OK` — final line of a successful reply + /// - `5xx message` — error + /// + /// Returns the status code and collected data lines. + async fn read_response(&mut self) -> Result { + let mut data_lines = Vec::new(); + let mut line_buf = String::new(); + + loop { + line_buf.clear(); + let n = self.reader.read_line(&mut line_buf).await?; + if n == 0 { + return Err(TorControlError::ProtocolError( + "control port connection closed".into(), + )); + } + + let line = line_buf.trim_end_matches(['\r', '\n']); + + if line.len() < 4 { + return Err(TorControlError::ProtocolError(format!( + "response line too short: '{}'", + line + ))); + } + + let code: u16 = line[..3].parse().map_err(|_| { + TorControlError::ProtocolError(format!( + "invalid response code in: '{}'", + line + )) + })?; + + let separator = line.as_bytes()[3]; + let content = &line[4..]; + + match separator { + b'-' => { + // Mid-reply data line + data_lines.push(content.to_string()); + } + b' ' => { + // Final line + return Ok(ControlResponse { + code, + message: content.to_string(), + data_lines, + }); + } + b'+' => { + // Multi-line data (dot-encoded). Read until lone "." + data_lines.push(content.to_string()); + loop { + line_buf.clear(); + let n = self.reader.read_line(&mut line_buf).await?; + if n == 0 { + return Err(TorControlError::ProtocolError( + "connection closed during multi-line response".into(), + )); + } + let dot_line = + line_buf.trim_end_matches(['\r', '\n']); + if dot_line == "." { + break; + } + // Strip leading dot-escape + let unescaped = dot_line.strip_prefix('.').unwrap_or(dot_line); + data_lines.push(unescaped.to_string()); + } + } + _ => { + return Err(TorControlError::ProtocolError(format!( + "unexpected separator '{}' in: '{}'", + separator as char, line + ))); + } + } + } + } +} + +/// Parsed control port response. +struct ControlResponse { + /// Status code (250 = success, 5xx = error). + code: u16, + /// Message from the final line. + message: String, + /// Data lines from mid-reply (250-) lines. + data_lines: Vec, +} + +// ============================================================================ +// Cookie File +// ============================================================================ + +/// Read a Tor control cookie file (32 bytes of raw binary). +fn read_cookie_file(path: &Path) -> Result, TorControlError> { + let data = std::fs::read(path).map_err(|e| { + TorControlError::AuthFailed(format!("failed to read cookie file '{}': {}", path.display(), e)) + })?; + + if data.len() != 32 { + return Err(TorControlError::AuthFailed(format!( + "cookie file '{}' has {} bytes, expected 32", + path.display(), + data.len() + ))); + } + + Ok(data) +} + +// ============================================================================ +// Unix Socket Detection +// ============================================================================ + +/// Detect whether a control address string is a Unix socket path. +/// +/// Returns true if the string starts with `/` or `./`, indicating a +/// filesystem path rather than a `host:port` TCP address. +fn is_unix_socket_path(addr: &str) -> bool { + addr.starts_with('/') || addr.starts_with("./") +} + +// ============================================================================ +// Hex Encoding (minimal, no dependency) +// ============================================================================ + +mod hex { + const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; + + pub fn encode(data: Vec) -> String { + let mut s = String::with_capacity(data.len() * 2); + for byte in data { + s.push(HEX_CHARS[(byte >> 4) as usize] as char); + s.push(HEX_CHARS[(byte & 0x0f) as usize] as char); + } + s + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::tor::mock_control::{self, MockTorControlServer}; + use tempfile::TempDir; + + // === ControlAuth parsing === + + #[test] + fn test_control_auth_cookie_default() { + let auth = ControlAuth::from_config("cookie", "/var/run/tor/cookie").unwrap(); + match auth { + ControlAuth::Cookie(path) => assert_eq!(path, Path::new("/var/run/tor/cookie")), + _ => panic!("expected Cookie"), + } + } + + #[test] + fn test_control_auth_cookie_custom_path() { + let auth = ControlAuth::from_config("cookie:/tmp/my_cookie", "/default").unwrap(); + match auth { + ControlAuth::Cookie(path) => assert_eq!(path, Path::new("/tmp/my_cookie")), + _ => panic!("expected Cookie"), + } + } + + #[test] + fn test_control_auth_password() { + let auth = ControlAuth::from_config("password:mypass", "/default").unwrap(); + match auth { + ControlAuth::Password(p) => assert_eq!(p, "mypass"), + _ => panic!("expected Password"), + } + } + + #[test] + fn test_control_auth_invalid() { + let result = ControlAuth::from_config("unknown", "/default"); + assert!(result.is_err()); + } + + // === Hex encoding === + + #[test] + fn test_hex_encode() { + assert_eq!(hex::encode(vec![0xde, 0xad, 0xbe, 0xef]), "deadbeef"); + assert_eq!(hex::encode(vec![0x00, 0xff]), "00ff"); + } + + // === Unix socket path detection === + + #[test] + fn test_is_unix_socket_path() { + assert!(is_unix_socket_path("/run/tor/control")); + assert!(is_unix_socket_path("/var/run/tor/control")); + assert!(is_unix_socket_path("./tor-control.sock")); + assert!(!is_unix_socket_path("127.0.0.1:9051")); + assert!(!is_unix_socket_path("tor-daemon:9051")); + assert!(!is_unix_socket_path("localhost:9051")); + } + + #[tokio::test] + async fn test_connect_unix_socket_nonexistent() { + let result = TorControlClient::connect("/tmp/nonexistent-tor-control.sock").await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("control socket")); + } + + #[tokio::test] + async fn test_connect_unix_socket_roundtrip() { + // Create a Unix socket listener, accept a connection, respond to AUTHENTICATE + let dir = TempDir::new().unwrap(); + let sock_path = dir.path().join("control.sock"); + let sock_path_str = sock_path.to_str().unwrap().to_string(); + + let listener = tokio::net::UnixListener::bind(&sock_path).unwrap(); + + // Spawn a minimal control handler + let handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + let mut line = String::new(); + + // Read AUTHENTICATE + reader.read_line(&mut line).await.unwrap(); + assert!(line.starts_with("AUTHENTICATE")); + + use tokio::io::AsyncWriteExt; + writer.write_all(b"250 OK\r\n").await.unwrap(); + writer.flush().await.unwrap(); + }); + + let mut client = TorControlClient::connect(&sock_path_str).await.unwrap(); + let auth = ControlAuth::Password("test".to_string()); + client.authenticate(&auth).await.unwrap(); + + handle.await.unwrap(); + } + + // === Cookie file === + + #[test] + fn test_read_cookie_file_valid() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cookie"); + let cookie_data = vec![0xAA; 32]; + std::fs::write(&path, &cookie_data).unwrap(); + + let loaded = read_cookie_file(&path).unwrap(); + assert_eq!(loaded, cookie_data); + } + + #[test] + fn test_read_cookie_file_wrong_size() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cookie"); + std::fs::write(&path, [0u8; 16]).unwrap(); + + assert!(read_cookie_file(&path).is_err()); + } + + #[test] + fn test_read_cookie_file_nonexistent() { + assert!(read_cookie_file(Path::new("/nonexistent/cookie")).is_err()); + } + + // === Control protocol (requires mock server) === + + #[tokio::test] + async fn test_authenticate_password() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + + let auth = ControlAuth::Password("testpass".to_string()); + client.authenticate(&auth).await.unwrap(); + } + + #[tokio::test] + async fn test_authenticate_cookie() { + let mock = MockTorControlServer::start().await; + + // Create a cookie file + let dir = TempDir::new().unwrap(); + let cookie_path = dir.path().join("cookie"); + std::fs::write(&cookie_path, [0xAA; 32]).unwrap(); + + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + let auth = ControlAuth::Cookie(cookie_path); + client.authenticate(&auth).await.unwrap(); + } + + #[tokio::test] + async fn test_get_bootstrap_phase() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + + let auth = ControlAuth::Password("testpass".to_string()); + client.authenticate(&auth).await.unwrap(); + + let progress = client.get_bootstrap_phase().await.unwrap(); + assert_eq!(progress, 100); + } + + #[tokio::test] + async fn test_auth_failure() { + let mock = MockTorControlServer::start_with_options(mock_control::MockOptions { + reject_auth: true, + }) + .await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + + let auth = ControlAuth::Password("wrongpass".to_string()); + let result = client.authenticate(&auth).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_connect_to_closed_port() { + // Bind and immediately drop to get a port that's closed + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + let result = TorControlClient::connect(&addr.to_string()).await; + assert!(result.is_err()); + } + + // === Monitoring queries === + + #[tokio::test] + async fn test_is_circuit_established() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + assert!(client.is_circuit_established().await.unwrap()); + } + + #[tokio::test] + async fn test_traffic_counters() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + assert_eq!(client.traffic_read().await.unwrap(), 1048576); + assert_eq!(client.traffic_written().await.unwrap(), 524288); + } + + #[tokio::test] + async fn test_network_liveness() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + assert_eq!(client.network_liveness().await.unwrap(), "up"); + } + + #[tokio::test] + async fn test_version() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + assert_eq!(client.version().await.unwrap(), "0.4.8.10"); + } + + #[tokio::test] + async fn test_dormant() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + assert!(!client.is_dormant().await.unwrap()); + } + + #[tokio::test] + async fn test_socks_listeners() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + let listeners = client.socks_listeners().await.unwrap(); + assert_eq!(listeners, vec!["127.0.0.1:9050"]); + } + + #[tokio::test] + async fn test_monitoring_snapshot() { + let mock = MockTorControlServer::start().await; + let mut client = TorControlClient::connect(&mock.addr().to_string()).await.unwrap(); + client.authenticate(&ControlAuth::Password("test".into())).await.unwrap(); + + let info = client.monitoring_snapshot().await.unwrap(); + assert_eq!(info.bootstrap, 100); + assert!(info.circuit_established); + assert_eq!(info.traffic_read, 1048576); + assert_eq!(info.traffic_written, 524288); + assert_eq!(info.network_liveness, "up"); + assert_eq!(info.version, "0.4.8.10"); + assert!(!info.dormant); + } +} diff --git a/src/transport/tor/mock_control.rs b/src/transport/tor/mock_control.rs new file mode 100644 index 0000000..da367e7 --- /dev/null +++ b/src/transport/tor/mock_control.rs @@ -0,0 +1,121 @@ +//! Mock Tor control port server for testing. +//! +//! Implements enough of the Tor control protocol to validate +//! AUTHENTICATE and GETINFO commands. + +use std::net::SocketAddr; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +/// Options for configuring mock behavior. +#[derive(Default)] +pub struct MockOptions { + /// If true, reject all AUTHENTICATE attempts. + pub reject_auth: bool, +} + +/// A mock Tor control port server. +/// +/// Accepts a single client connection and responds to control protocol +/// commands with valid-looking responses for testing. +pub struct MockTorControlServer { + addr: SocketAddr, + _handle: JoinHandle<()>, +} + +impl MockTorControlServer { + /// Start a mock control server with default options. + pub async fn start() -> Self { + Self::start_with_options(MockOptions::default()).await + } + + /// Start a mock control server with custom options. + pub async fn start_with_options(options: MockOptions) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock control"); + let addr = listener.local_addr().expect("local addr"); + + let handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + let mut authenticated = false; + + loop { + line.clear(); + let n = match reader.read_line(&mut line).await { + Ok(n) => n, + Err(_) => break, + }; + if n == 0 { + break; + } + + let cmd = line.trim(); + + if cmd.starts_with("AUTHENTICATE") { + if options.reject_auth { + let _ = writer.write_all(b"515 Authentication failed\r\n").await; + } else { + authenticated = true; + let _ = writer.write_all(b"250 OK\r\n").await; + } + } else if !authenticated { + let _ = writer + .write_all(b"514 Authentication required\r\n") + .await; + } else if cmd.starts_with("GETINFO status/bootstrap-phase") { + let _ = writer.write_all( + b"250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY=\"Done\"\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO status/circuit-established") { + let _ = writer.write_all( + b"250-status/circuit-established=1\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO traffic/read") { + let _ = writer.write_all( + b"250-traffic/read=1048576\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO traffic/written") { + let _ = writer.write_all( + b"250-traffic/written=524288\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO network-liveness") { + let _ = writer.write_all( + b"250-network-liveness=up\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO version") { + let _ = writer.write_all( + b"250-version=0.4.8.10\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO dormant") { + let _ = writer.write_all( + b"250-dormant=0\r\n250 OK\r\n", + ).await; + } else if cmd.starts_with("GETINFO net/listeners/socks") { + let _ = writer.write_all( + b"250-net/listeners/socks=\"127.0.0.1:9050\"\r\n250 OK\r\n", + ).await; + } else { + let _ = writer + .write_all(b"510 Unrecognized command\r\n") + .await; + } + + let _ = writer.flush().await; + } + }); + + Self { + addr, + _handle: handle, + } + } + + /// Get the address the mock server is listening on. + pub fn addr(&self) -> SocketAddr { + self.addr + } +} diff --git a/src/transport/tor/mock_socks5.rs b/src/transport/tor/mock_socks5.rs new file mode 100644 index 0000000..172ac49 --- /dev/null +++ b/src/transport/tor/mock_socks5.rs @@ -0,0 +1,157 @@ +//! Mock SOCKS5 server for testing. +//! +//! Implements just enough of the SOCKS5 protocol (RFC 1928) to support +//! the username/password auth + CONNECT flow used by TorTransport. +//! Proxies bytes bidirectionally between the SOCKS5 client and a real +//! TCP target. + +use std::net::SocketAddr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +/// SOCKS5 protocol constants. +const SOCKS_VERSION: u8 = 0x05; +const AUTH_NONE: u8 = 0x00; +const AUTH_PASSWORD: u8 = 0x02; +const CMD_CONNECT: u8 = 0x01; +const ATYP_IPV4: u8 = 0x01; +const ATYP_DOMAIN: u8 = 0x03; +const REP_SUCCESS: u8 = 0x00; + +/// Username/password auth sub-negotiation version (RFC 1929). +const AUTH_SUBNEG_VERSION: u8 = 0x01; +const AUTH_SUBNEG_SUCCESS: u8 = 0x00; + +/// A minimal mock SOCKS5 proxy server for testing. +/// +/// Accepts a single connection, performs the SOCKS5 handshake (supporting +/// both no-auth and username/password auth), then connects to a fixed +/// target address and proxies bytes bidirectionally. +pub struct MockSocks5Server { + /// Address the mock proxy is listening on. + addr: SocketAddr, + /// The real target address to connect to (ignores SOCKS5 requested target). + target_addr: SocketAddr, + /// Listener handle. + listener: Option, +} + +impl MockSocks5Server { + /// Create a new mock SOCKS5 server that forwards to the given target. + /// + /// Binds to `127.0.0.1:0` (OS-assigned port). + pub async fn new(target_addr: SocketAddr) -> std::io::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + Ok(Self { + addr, + target_addr, + listener: Some(listener), + }) + } + + /// Get the proxy's listen address (for TorConfig.socks5_addr). + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Run the proxy, accepting one connection and proxying it. + /// + /// Returns a JoinHandle that completes when the proxied connection ends. + pub fn spawn(mut self) -> JoinHandle<()> { + let listener = self.listener.take().expect("listener already consumed"); + let target_addr = self.target_addr; + + tokio::spawn(async move { + // Accept one SOCKS5 client + let (mut client, _) = listener.accept().await.expect("accept failed"); + + // === Method negotiation === + // Client sends: [version, nmethods, methods...] + let mut ver_nmethods = [0u8; 2]; + client.read_exact(&mut ver_nmethods).await.expect("read version+nmethods"); + assert_eq!(ver_nmethods[0], SOCKS_VERSION, "expected SOCKS5"); + let nmethods = ver_nmethods[1] as usize; + + let mut methods = vec![0u8; nmethods]; + client.read_exact(&mut methods).await.expect("read methods"); + + // Prefer username/password auth if offered, fall back to no-auth + let selected = if methods.contains(&AUTH_PASSWORD) { + AUTH_PASSWORD + } else if methods.contains(&AUTH_NONE) { + AUTH_NONE + } else { + panic!("no supported auth method offered"); + }; + + // Reply: [version, selected_method] + client.write_all(&[SOCKS_VERSION, selected]).await.expect("write method reply"); + + // === Username/password sub-negotiation (RFC 1929) === + if selected == AUTH_PASSWORD { + // Client sends: [ver(1), ulen(1), uname(ulen), plen(1), passwd(plen)] + let mut subneg_header = [0u8; 2]; + client.read_exact(&mut subneg_header).await.expect("read subneg header"); + assert_eq!(subneg_header[0], AUTH_SUBNEG_VERSION, "expected auth subneg v1"); + + let ulen = subneg_header[1] as usize; + let mut uname = vec![0u8; ulen]; + client.read_exact(&mut uname).await.expect("read username"); + + let mut plen_buf = [0u8; 1]; + client.read_exact(&mut plen_buf).await.expect("read plen"); + let plen = plen_buf[0] as usize; + let mut passwd = vec![0u8; plen]; + client.read_exact(&mut passwd).await.expect("read password"); + + // Always accept (Tor uses these as isolation keys, not real auth) + client.write_all(&[AUTH_SUBNEG_VERSION, AUTH_SUBNEG_SUCCESS]) + .await.expect("write subneg reply"); + } + + // === Connect request === + // Client sends: [version, cmd, rsv, atyp, addr..., port] + let mut header = [0u8; 4]; + client.read_exact(&mut header).await.expect("read connect header"); + assert_eq!(header[0], SOCKS_VERSION); + assert_eq!(header[1], CMD_CONNECT); + + // Read and skip the address (we connect to target_addr regardless) + match header[3] { + ATYP_IPV4 => { + let mut addr_port = [0u8; 6]; // 4 IP + 2 port + client.read_exact(&mut addr_port).await.expect("read IPv4 addr"); + } + ATYP_DOMAIN => { + let mut len_buf = [0u8; 1]; + client.read_exact(&mut len_buf).await.expect("read domain len"); + let domain_len = len_buf[0] as usize; + let mut domain_port = vec![0u8; domain_len + 2]; // domain + 2 port + client.read_exact(&mut domain_port).await.expect("read domain addr"); + } + other => panic!("unsupported ATYP: {}", other), + } + + // Connect to the real target + let mut target = tokio::net::TcpStream::connect(target_addr) + .await + .expect("connect to target"); + + // Reply: success, bind addr = 0.0.0.0:0 + let reply = [ + SOCKS_VERSION, + REP_SUCCESS, + 0x00, // RSV + ATYP_IPV4, + 0, 0, 0, 0, // bind addr + 0, 0, // bind port + ]; + client.write_all(&reply).await.expect("write connect reply"); + + // Proxy bytes bidirectionally + let _ = tokio::io::copy_bidirectional(&mut client, &mut target).await; + }) + } +} diff --git a/src/transport/tor/mod.rs b/src/transport/tor/mod.rs new file mode 100644 index 0000000..85296f1 --- /dev/null +++ b/src/transport/tor/mod.rs @@ -0,0 +1,1869 @@ +//! Tor Transport Implementation +//! +//! Provides Tor-based transport for FIPS peer communication. Supports +//! three modes: +//! +//! - **socks5**: Outbound-only connections through a Tor SOCKS5 proxy +//! to both clearnet peers and .onion hidden services. +//! - **control_port**: Outbound via SOCKS5 plus control port connection +//! for Tor daemon monitoring (bootstrap status, traffic stats, network liveness). +//! - **directory**: Inbound via a Tor-managed `HiddenServiceDir` onion +//! service, outbound via SOCKS5. No control port needed; enables +//! Tor's `Sandbox 1` mode. Reads `.onion` address from hostname file. +//! +//! ## Architecture +//! +//! Like TCP, each peer has its own connection. The transport reuses FMP +//! stream framing from `tcp::stream` and follows the same connection pool +//! pattern as the TCP transport. Inbound connections arrive via a local +//! TCP listener that the Tor daemon forwards onion service traffic to. + +pub mod control; +pub mod stats; + +#[cfg(test)] +mod mock_control; +#[cfg(test)] +mod mock_socks5; + +use super::{ + ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr, + TransportError, TransportId, TransportState, TransportType, +}; +use crate::config::TorConfig; +use crate::transport::tcp::stream::read_fmp_packet; +use control::{ControlAuth, TorControlClient, TorMonitoringInfo}; +use stats::TorStats; + +use futures::FutureExt; +use socket2::TcpKeepalive; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::net::{TcpListener, TcpStream}; +use tokio::net::tcp::OwnedWriteHalf; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tokio_socks::tcp::Socks5Stream; +use tracing::{debug, info, trace, warn}; + +// ============================================================================ +// Tor Address Types +// ============================================================================ + +/// Tor-specific address type for SOCKS5 CONNECT. +#[derive(Clone, Debug)] +pub enum TorAddr { + /// .onion hidden service address (hostname, port). + Onion(String, u16), + /// Clearnet address routed through Tor (IP, port). + Clearnet(SocketAddr), + /// Clearnet hostname routed through Tor (hostname, port). + /// Passed as hostname to SOCKS5 so Tor resolves it — avoids local + /// DNS leaks and is compatible with SafeSocks 1. + ClearnetHostname(String, u16), +} + +/// Parse a TransportAddr string into a TorAddr. +/// +/// If the address contains ".onion:", parse as an onion address. +/// If it parses as a numeric IP:port, use Clearnet. +/// Otherwise, treat as a clearnet hostname:port for Tor-side DNS resolution. +fn parse_tor_addr(addr: &TransportAddr) -> Result { + let s = addr.as_str().ok_or_else(|| { + TransportError::InvalidAddress("Tor address must be a valid UTF-8 string".into()) + })?; + + if s.contains(".onion:") { + // Parse "hostname.onion:port" + let (host, port_str) = s.rsplit_once(':').ok_or_else(|| { + TransportError::InvalidAddress(format!("invalid onion address: {}", s)) + })?; + let port: u16 = port_str.parse().map_err(|_| { + TransportError::InvalidAddress(format!("invalid port in onion address: {}", s)) + })?; + Ok(TorAddr::Onion(host.to_string(), port)) + } else if let Ok(socket_addr) = s.parse::() { + // Numeric IP:port + Ok(TorAddr::Clearnet(socket_addr)) + } else { + // Hostname:port — pass through SOCKS5 for Tor-side DNS resolution + let (host, port_str) = s.rsplit_once(':').ok_or_else(|| { + TransportError::InvalidAddress(format!( + "invalid address (expected host:port): {}", s + )) + })?; + let port: u16 = port_str.parse().map_err(|_| { + TransportError::InvalidAddress(format!("invalid port: {}", s)) + })?; + if !host.contains('.') { + return Err(TransportError::InvalidAddress(format!( + "hostname must be fully qualified (contain a dot): {}", host + ))); + } + Ok(TorAddr::ClearnetHostname(host.to_string(), port)) + } +} + +// ============================================================================ +// Connection Pool +// ============================================================================ + +/// State for a single Tor connection to a peer. +struct TorConnection { + /// Write half of the split stream. + writer: Arc>, + /// Receive task for this connection. + recv_task: JoinHandle<()>, + /// MTU for this connection. + #[allow(dead_code)] + mtu: u16, + /// When the connection was established. + #[allow(dead_code)] + established_at: Instant, +} + +/// Shared connection pool. +type ConnectionPool = Arc>>; + +/// A pending background connection attempt. +/// +/// Holds the JoinHandle for a spawned SOCKS5 connect task. The task +/// produces a configured `TcpStream` and MTU on success. +struct ConnectingEntry { + /// Background task performing SOCKS5 connect + socket configuration. + task: JoinHandle>, +} + +/// Map of addresses with background connection attempts in progress. +type ConnectingPool = Arc>>; + +// ============================================================================ +// Tor Transport +// ============================================================================ + +/// Tor transport for FIPS. +/// +/// Provides connection-oriented, reliable byte stream delivery over Tor. +/// In `socks5` mode, outbound-only through a SOCKS5 proxy. In +/// `control_port` mode, also manages an onion service for inbound +/// connections via the Tor control port. +pub struct TorTransport { + /// Unique transport identifier. + transport_id: TransportId, + /// Optional instance name (for named instances in config). + name: Option, + /// Configuration. + config: TorConfig, + /// Current state. + state: TransportState, + /// Connection pool: addr -> per-connection state. + pool: ConnectionPool, + /// Pending connection attempts: addr -> background connect task. + connecting: ConnectingPool, + /// Channel for delivering received packets to Node. + packet_tx: PacketTx, + /// Transport statistics. + stats: Arc, + /// Accept loop task handle (active when onion service is running). + accept_task: Option>, + /// Onion service hostname (e.g., "abcdef...xyz.onion"). + /// Set in directory mode from the Tor-managed hostname file. + onion_address: Option, + /// Control port client (monitoring queries). + control_client: Option>>, + /// Cached Tor daemon monitoring info, updated by background task. + cached_monitoring: Arc>>, + /// Background monitoring task handle. + monitoring_task: Option>, +} + +impl TorTransport { + /// Create a new Tor transport. + pub fn new( + transport_id: TransportId, + name: Option, + config: TorConfig, + packet_tx: PacketTx, + ) -> Self { + Self { + transport_id, + name, + config, + state: TransportState::Configured, + pool: Arc::new(Mutex::new(HashMap::new())), + connecting: Arc::new(Mutex::new(HashMap::new())), + packet_tx, + stats: Arc::new(TorStats::new()), + accept_task: None, + onion_address: None, + control_client: None, + cached_monitoring: Arc::new(std::sync::RwLock::new(None)), + monitoring_task: None, + } + } + + /// Get the instance name (if configured as a named instance). + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Get the onion service address (if active). + pub fn onion_address(&self) -> Option<&str> { + self.onion_address.as_deref() + } + + /// Get the transport statistics. + pub fn stats(&self) -> &Arc { + &self.stats + } + + /// Get the cached Tor daemon monitoring info (if available). + pub fn cached_monitoring(&self) -> Option { + self.cached_monitoring.read().ok()?.clone() + } + + /// Get the Tor transport mode. + pub fn mode(&self) -> &str { + self.config.mode() + } + + /// Start the transport asynchronously. + /// + /// In `socks5` mode: validates config and transitions to Up. + /// In `control_port` mode: also connects to the Tor control port + /// and authenticates for monitoring. + /// In `directory` mode: reads .onion address from hostname file, + /// binds a listener, and spawns an accept loop for inbound. + pub async fn start_async(&mut self) -> Result<(), TransportError> { + if !self.state.can_start() { + return Err(TransportError::AlreadyStarted); + } + + self.state = TransportState::Starting; + + // Validate SOCKS5 address format (all modes need it for outbound) + let socks5_addr = self.config.socks5_addr().to_string(); + validate_host_port(&socks5_addr, "socks5_addr")?; + + let mode = self.config.mode().to_string(); + match mode.as_str() { + "socks5" => { + // Reject inbound service configs in socks5 mode + if self.config.directory_service.is_some() { + return Err(TransportError::StartFailed( + "directory_service config requires mode 'directory', not 'socks5'".into(), + )); + } + self.state = TransportState::Up; + } + "control_port" => { + self.start_control_port_mode().await?; + } + "directory" => { + self.start_directory_mode().await?; + } + other => { + return Err(TransportError::StartFailed(format!( + "unsupported Tor mode '{}' (expected 'socks5', 'control_port', or 'directory')", + other + ))); + } + } + + if let Some(ref name) = self.name { + info!( + name = %name, + mode = %mode, + socks5_addr = %socks5_addr, + onion_address = ?self.onion_address, + mtu = self.config.mtu(), + "Tor transport started" + ); + } else { + info!( + mode = %mode, + socks5_addr = %socks5_addr, + onion_address = ?self.onion_address, + mtu = self.config.mtu(), + "Tor transport started" + ); + } + + Ok(()) + } + + /// Start control_port mode: connect to control port and authenticate + /// for monitoring queries. + async fn start_control_port_mode(&mut self) -> Result<(), TransportError> { + let control_addr = self.config.control_addr().to_string(); + // Unix socket paths start with / or ./ — skip host:port validation + if !control_addr.starts_with('/') && !control_addr.starts_with("./") { + validate_host_port(&control_addr, "control_addr")?; + } + + // Connect to Tor control port + let mut client = TorControlClient::connect(&control_addr).await.map_err(|e| { + self.stats.record_control_error(); + TransportError::StartFailed(format!("Tor control port: {}", e)) + })?; + + // Authenticate + let auth = ControlAuth::from_config( + self.config.control_auth(), + self.config.cookie_path(), + ) + .map_err(|e| TransportError::StartFailed(format!("Tor auth config: {}", e)))?; + + client.authenticate(&auth).await.map_err(|e| { + self.stats.record_control_error(); + TransportError::StartFailed(format!("Tor authentication: {}", e)) + })?; + + // Store control client (used for monitoring queries) + self.control_client = Some(Arc::new(Mutex::new(client))); + self.state = TransportState::Up; + self.spawn_monitoring_task(); + + Ok(()) + } + + /// Start directory mode: read .onion address from Tor-managed hostname + /// file, bind a local listener, and spawn the accept loop. + /// + /// In directory mode, Tor manages the onion service via `HiddenServiceDir` + /// in torrc. No control port connection is needed. This enables Tor's + /// `Sandbox 1` mode (strongest single hardening option). + async fn start_directory_mode(&mut self) -> Result<(), TransportError> { + let dir_config = self.config.directory_service.clone().unwrap_or_default(); + + // Read .onion address from Tor-managed hostname file + let hostname_file = dir_config.hostname_file(); + let onion_addr = std::fs::read_to_string(hostname_file) + .map_err(|e| { + TransportError::StartFailed(format!( + "failed to read onion hostname from '{}': {} \ + (ensure HiddenServiceDir is configured in torrc and Tor has started)", + hostname_file, e + )) + })? + .trim() + .to_string(); + + if onion_addr.is_empty() || !onion_addr.ends_with(".onion") { + return Err(TransportError::StartFailed(format!( + "invalid onion address in '{}': '{}'", + hostname_file, onion_addr + ))); + } + + self.onion_address = Some(onion_addr.clone()); + + // Bind local listener (must match HiddenServicePort target in torrc) + let bind_addr = dir_config.bind_addr(); + let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + TransportError::StartFailed(format!( + "failed to bind directory-mode listener on {}: {}", + bind_addr, e + )) + })?; + let local_addr = listener.local_addr().map_err(|e| { + TransportError::StartFailed(format!("failed to get local addr: {}", e)) + })?; + + info!( + onion_address = %onion_addr, + local_addr = %local_addr, + hostname_file = %hostname_file, + "Directory-mode onion service active" + ); + + // Spawn accept loop (same as control_port mode) + let transport_id = self.transport_id; + let packet_tx = self.packet_tx.clone(); + let pool = self.pool.clone(); + let mtu = self.config.mtu(); + let max_inbound = self.config.max_inbound_connections(); + let stats = self.stats.clone(); + + let accept_handle = tokio::spawn(async move { + tor_accept_loop( + listener, + transport_id, + packet_tx, + pool, + mtu, + max_inbound, + stats, + ) + .await; + }); + + self.accept_task = Some(accept_handle); + self.state = TransportState::Up; + + // Optionally connect to control port for monitoring (non-fatal) + if self.config.control_addr.is_some() { + self.try_connect_control_port().await; + } + + Ok(()) + } + + /// Attempt to connect to the Tor control port for monitoring. + /// Non-fatal: logs a warning on failure and continues without monitoring. + async fn try_connect_control_port(&mut self) { + let control_addr = self.config.control_addr().to_string(); + if !control_addr.starts_with('/') && !control_addr.starts_with("./") + && let Err(e) = validate_host_port(&control_addr, "control_addr") + { + warn!( + transport_id = %self.transport_id, + error = %e, + "Tor control port address invalid, monitoring disabled" + ); + return; + } + + let client = match TorControlClient::connect(&control_addr).await { + Ok(c) => c, + Err(e) => { + warn!( + transport_id = %self.transport_id, + addr = %control_addr, + error = %e, + "Tor control port connect failed, monitoring disabled" + ); + return; + } + }; + + let auth = match ControlAuth::from_config( + self.config.control_auth(), + self.config.cookie_path(), + ) { + Ok(a) => a, + Err(e) => { + warn!( + transport_id = %self.transport_id, + error = %e, + "Tor control auth config error, monitoring disabled" + ); + return; + } + }; + + let mut client = client; + if let Err(e) = client.authenticate(&auth).await { + warn!( + transport_id = %self.transport_id, + error = %e, + "Tor control port auth failed, monitoring disabled" + ); + return; + } + + info!( + transport_id = %self.transport_id, + addr = %control_addr, + "Tor control port connected (monitoring enabled)" + ); + + self.control_client = Some(Arc::new(Mutex::new(client))); + self.spawn_monitoring_task(); + } + + /// Stop the transport asynchronously. + /// + /// Aborts the accept loop (if running), closes all connections, + /// and transitions to Down. + pub async fn stop_async(&mut self) -> Result<(), TransportError> { + if !self.state.is_operational() { + return Err(TransportError::NotStarted); + } + + // Abort accept loop (if running) + if let Some(task) = self.accept_task.take() { + task.abort(); + let _ = task.await; + debug!( + transport_id = %self.transport_id, + "Onion service accept loop stopped" + ); + } + + // Abort monitoring task (if running) + if let Some(task) = self.monitoring_task.take() { + task.abort(); + let _ = task.await; + } + if let Ok(mut w) = self.cached_monitoring.write() { + *w = None; + } + + self.control_client = None; + self.onion_address = None; + + // Abort pending connection attempts + let mut connecting = self.connecting.lock().await; + for (addr, entry) in connecting.drain() { + entry.task.abort(); + debug!( + transport_id = %self.transport_id, + remote_addr = %addr, + "Tor connect aborted (transport stopping)" + ); + } + drop(connecting); + + // Close all connections + let mut pool = self.pool.lock().await; + for (addr, conn) in pool.drain() { + conn.recv_task.abort(); + let _ = conn.recv_task.await; + debug!( + transport_id = %self.transport_id, + remote_addr = %addr, + "Tor connection closed (transport stopping)" + ); + } + drop(pool); + + self.state = TransportState::Down; + + info!( + transport_id = %self.transport_id, + "Tor transport stopped" + ); + + Ok(()) + } + + /// Spawn a background task that periodically queries the Tor control + /// port for daemon status and caches the result. + fn spawn_monitoring_task(&mut self) { + let Some(client) = self.control_client.clone() else { + return; + }; + let cache = self.cached_monitoring.clone(); + let stats = self.stats.clone(); + let transport_id = self.transport_id; + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + let mut last_bootstrap: u8 = 0; + let mut last_liveness = String::new(); + let mut was_dormant = false; + let mut stall_warned = false; + let started_at = Instant::now(); + + loop { + interval.tick().await; + let mut guard = client.lock().await; + match guard.monitoring_snapshot().await { + Ok(info) => { + // Log bootstrap milestones + for &milestone in &[25u8, 50, 75, 100] { + if info.bootstrap >= milestone + && last_bootstrap < milestone + { + info!( + transport_id = %transport_id, + bootstrap = info.bootstrap, + "Tor bootstrap {}%", + milestone + ); + } + } + + // Bootstrap stall warning + if info.bootstrap < 100 + && started_at.elapsed() > Duration::from_secs(60) + && !stall_warned + { + warn!( + transport_id = %transport_id, + bootstrap = info.bootstrap, + "Tor bootstrap stalled — not at 100% after 60s" + ); + stall_warned = true; + } + if info.bootstrap == 100 { + stall_warned = false; + } + + last_bootstrap = info.bootstrap; + + // Network liveness transitions + if !last_liveness.is_empty() + && info.network_liveness != last_liveness + { + warn!( + transport_id = %transport_id, + from = %last_liveness, + to = %info.network_liveness, + "Tor network liveness changed" + ); + } + last_liveness = info.network_liveness.clone(); + + // Dormant mode entry + if info.dormant && !was_dormant { + warn!( + transport_id = %transport_id, + "Tor daemon entered dormant mode" + ); + } + was_dormant = info.dormant; + + if let Ok(mut w) = cache.write() { + *w = Some(info); + } + } + Err(e) => { + stats.record_control_error(); + warn!( + transport_id = %transport_id, + error = %e, + "Tor monitoring query failed" + ); + } + } + } + }); + + self.monitoring_task = Some(handle); + } + + /// Send a packet asynchronously. + /// + /// If no connection exists to the given address, performs connect-on-send: + /// establishes a new connection through the SOCKS5 proxy, configures + /// socket options, splits the stream, spawns a receive task, and stores + /// the connection in the pool. + pub async fn send_async( + &self, + addr: &TransportAddr, + data: &[u8], + ) -> Result { + if !self.state.is_operational() { + return Err(TransportError::NotStarted); + } + + // Pre-send MTU check + let mtu = self.config.mtu() as usize; + if data.len() > mtu { + self.stats.record_mtu_exceeded(); + return Err(TransportError::MtuExceeded { + packet_size: data.len(), + mtu: self.config.mtu(), + }); + } + + // Get or create connection + let writer = { + let pool = self.pool.lock().await; + pool.get(addr).map(|c| c.writer.clone()) + }; + + let writer = match writer { + Some(w) => w, + None => { + // Connect-on-send + self.connect(addr).await? + } + }; + + // Write packet directly (no framing transformation needed) + let mut w = writer.lock().await; + match w.write_all(data).await { + Ok(()) => { + self.stats.record_send(data.len()); + trace!( + transport_id = %self.transport_id, + remote_addr = %addr, + bytes = data.len(), + "Tor packet sent" + ); + Ok(data.len()) + } + Err(e) => { + self.stats.record_send_error(); + drop(w); + // Remove failed connection from pool + let mut pool = self.pool.lock().await; + if let Some(conn) = pool.remove(addr) { + conn.recv_task.abort(); + } + Err(TransportError::SendFailed(format!("{}", e))) + } + } + } + + /// Establish a new connection through the SOCKS5 proxy. + /// + /// Performs SOCKS5 CONNECT to the target via the proxy, configures + /// socket options, splits the stream, spawns a receive task, and + /// stores in the pool. + async fn connect( + &self, + addr: &TransportAddr, + ) -> Result>, TransportError> { + let tor_addr = parse_tor_addr(addr)?; + let proxy_addr = self.config.socks5_addr(); + let timeout_ms = self.config.connect_timeout_ms(); + + info!( + transport_id = %self.transport_id, + remote_addr = %addr, + proxy = %proxy_addr, + timeout_secs = timeout_ms / 1000, + "Connecting via Tor SOCKS5" + ); + + // SOCKS5 CONNECT through proxy with timeout. + // Uses username/password auth for stream isolation: each destination + // gets its own Tor circuit via IsolateSOCKSAuth. The credentials are + // not verified by Tor — they serve purely as circuit isolation keys. + let isolation_key = addr.to_string(); + let connect_start = Instant::now(); + let socks_result = tokio::time::timeout(Duration::from_millis(timeout_ms), async { + match &tor_addr { + TorAddr::Onion(host, port) + | TorAddr::ClearnetHostname(host, port) => { + Socks5Stream::connect_with_password( + proxy_addr, (host.as_str(), *port), "fips", &isolation_key, + ).await + } + TorAddr::Clearnet(socket_addr) => { + Socks5Stream::connect_with_password( + proxy_addr, *socket_addr, "fips", &isolation_key, + ).await + } + } + }) + .await; + + let stream = match socks_result { + Ok(Ok(socks_stream)) => socks_stream.into_inner(), + Ok(Err(e)) => { + self.stats.record_socks5_error(); + warn!( + transport_id = %self.transport_id, + remote_addr = %addr, + error = %e, + elapsed_secs = connect_start.elapsed().as_secs(), + "Tor SOCKS5 connection failed" + ); + return Err(TransportError::ConnectionRefused); + } + Err(_) => { + self.stats.record_connect_timeout(); + warn!( + transport_id = %self.transport_id, + remote_addr = %addr, + timeout_secs = timeout_ms / 1000, + "Tor SOCKS5 connection timed out" + ); + return Err(TransportError::Timeout); + } + }; + + // Configure socket options via socket2 + let std_stream = stream + .into_std() + .map_err(|e| TransportError::StartFailed(format!("into_std: {}", e)))?; + configure_socket(&std_stream, &self.config)?; + + // Convert back to tokio + let stream = TcpStream::from_std(std_stream) + .map_err(|e| TransportError::StartFailed(format!("from_std: {}", e)))?; + + // Split and spawn receive task + let (read_half, write_half) = stream.into_split(); + let writer = Arc::new(Mutex::new(write_half)); + + let transport_id = self.transport_id; + let packet_tx = self.packet_tx.clone(); + let pool = self.pool.clone(); + let recv_stats = self.stats.clone(); + let remote_addr = addr.clone(); + let mtu = self.config.mtu(); + + let recv_task = tokio::spawn(async move { + tor_receive_loop( + read_half, + transport_id, + remote_addr.clone(), + packet_tx, + pool, + mtu, + recv_stats, + ) + .await; + }); + + let conn = TorConnection { + writer: writer.clone(), + recv_task, + mtu, + established_at: Instant::now(), + }; + + let mut pool = self.pool.lock().await; + pool.insert(addr.clone(), conn); + + self.stats.record_connection_established(); + + info!( + transport_id = %self.transport_id, + remote_addr = %addr, + elapsed_secs = connect_start.elapsed().as_secs(), + "Tor circuit established via SOCKS5" + ); + + Ok(writer) + } + + /// Initiate a non-blocking connection to a remote address. + /// + /// Spawns a background task that performs SOCKS5 connect with timeout, + /// configures socket options, and returns the configured stream. The + /// connection becomes available for `send_async()` once the task + /// completes successfully. + /// + /// Poll `connection_state_sync()` to check progress. + pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> { + if !self.state.is_operational() { + return Err(TransportError::NotStarted); + } + + // Already established? + { + let pool = self.pool.lock().await; + if pool.contains_key(addr) { + return Ok(()); + } + } + + // Already connecting? + { + let connecting = self.connecting.lock().await; + if connecting.contains_key(addr) { + return Ok(()); + } + } + + let tor_addr = parse_tor_addr(addr)?; + let proxy_addr = self.config.socks5_addr().to_string(); + let timeout_ms = self.config.connect_timeout_ms(); + let transport_id = self.transport_id; + let remote_addr = addr.clone(); + let config = self.config.clone(); + + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + timeout_ms, + "Initiating background Tor SOCKS5 connect" + ); + + // Stream isolation key for this destination + let isolation_key = addr.to_string(); + + let task = tokio::spawn(async move { + // SOCKS5 CONNECT through proxy with timeout. + // Uses username/password auth for stream isolation (see connect()). + let socks_result = tokio::time::timeout( + Duration::from_millis(timeout_ms), + async { + match &tor_addr { + TorAddr::Onion(host, port) + | TorAddr::ClearnetHostname(host, port) => { + Socks5Stream::connect_with_password( + proxy_addr.as_str(), (host.as_str(), *port), + "fips", &isolation_key, + ).await + } + TorAddr::Clearnet(socket_addr) => { + Socks5Stream::connect_with_password( + proxy_addr.as_str(), *socket_addr, + "fips", &isolation_key, + ).await + } + } + }, + ) + .await; + + let stream = match socks_result { + Ok(Ok(socks_stream)) => socks_stream.into_inner(), + Ok(Err(e)) => { + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + error = %e, + "Background Tor SOCKS5 connect failed" + ); + return Err(TransportError::ConnectionRefused); + } + Err(_) => { + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + "Background Tor SOCKS5 connect timed out" + ); + return Err(TransportError::Timeout); + } + }; + + // Configure socket options via socket2 + let std_stream = stream + .into_std() + .map_err(|e| TransportError::StartFailed(format!("into_std: {}", e)))?; + configure_socket(&std_stream, &config)?; + + let mtu = config.mtu(); + + // Convert back to tokio + let stream = TcpStream::from_std(std_stream) + .map_err(|e| TransportError::StartFailed(format!("from_std: {}", e)))?; + + Ok((stream, mtu)) + }); + + let mut connecting = self.connecting.lock().await; + connecting.insert(addr.clone(), ConnectingEntry { task }); + + Ok(()) + } + + /// Query the state of a connection to a remote address. + /// + /// Checks both established and connecting pools. If a background + /// connect task has completed, promotes it to the established pool + /// (spawning a receive loop) or reports the failure. + /// + /// This method is synchronous but uses `try_lock` internally. + /// Returns `ConnectionState::Connecting` if locks can't be acquired. + pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState { + // Check established pool first + if let Ok(pool) = self.pool.try_lock() { + if pool.contains_key(addr) { + return ConnectionState::Connected; + } + } else { + return ConnectionState::Connecting; // can't tell, assume still going + } + + // Check connecting pool + let mut connecting = match self.connecting.try_lock() { + Ok(c) => c, + Err(_) => return ConnectionState::Connecting, + }; + + let entry = match connecting.get_mut(addr) { + Some(e) => e, + None => return ConnectionState::None, + }; + + // Check if the background task has completed + if !entry.task.is_finished() { + return ConnectionState::Connecting; + } + + // Task is done — take the result and remove from connecting pool. + let addr_clone = addr.clone(); + let task = connecting.remove(&addr_clone).unwrap().task; + + // Since the task is finished, we can safely poll it with now_or_never. + match task.now_or_never() { + Some(Ok(Ok((stream, mtu)))) => { + // Promote to established pool + self.promote_connection(addr, stream, mtu); + ConnectionState::Connected + } + Some(Ok(Err(e))) => ConnectionState::Failed(format!("{}", e)), + Some(Err(e)) => { + // JoinError (panic or cancel) + ConnectionState::Failed(format!("task failed: {}", e)) + } + None => { + // Shouldn't happen since is_finished() was true + ConnectionState::Connecting + } + } + } + + /// Promote a completed background connection to the established pool. + /// + /// Splits the stream, spawns a receive loop, and inserts into the pool. + /// Called from `connection_state_sync()` when a background task completes. + fn promote_connection(&self, addr: &TransportAddr, stream: TcpStream, mtu: u16) { + let (read_half, write_half) = stream.into_split(); + let writer = Arc::new(Mutex::new(write_half)); + + let transport_id = self.transport_id; + let packet_tx = self.packet_tx.clone(); + let pool = self.pool.clone(); + let recv_stats = self.stats.clone(); + let remote_addr = addr.clone(); + + let recv_task = tokio::spawn(async move { + tor_receive_loop( + read_half, + transport_id, + remote_addr.clone(), + packet_tx, + pool, + mtu, + recv_stats, + ) + .await; + }); + + let conn = TorConnection { + writer, + recv_task, + mtu, + established_at: Instant::now(), + }; + + // Use try_lock since we're in a sync context and the pool + // should be available (connection_state_sync already checked it) + if let Ok(mut pool) = self.pool.try_lock() { + pool.insert(addr.clone(), conn); + self.stats.record_connection_established(); + debug!( + transport_id = %self.transport_id, + remote_addr = %addr, + "Tor connection established (background connect)" + ); + } else { + // Pool locked — abort the recv task, connection will be retried + conn.recv_task.abort(); + warn!( + transport_id = %self.transport_id, + remote_addr = %addr, + "Failed to promote Tor connection (pool locked)" + ); + } + } + + /// Close a specific connection asynchronously. + pub async fn close_connection_async(&self, addr: &TransportAddr) { + let mut pool = self.pool.lock().await; + if let Some(conn) = pool.remove(addr) { + conn.recv_task.abort(); + debug!( + transport_id = %self.transport_id, + remote_addr = %addr, + "Tor connection closed" + ); + } + } +} + +impl Transport for TorTransport { + fn transport_id(&self) -> TransportId { + self.transport_id + } + + fn transport_type(&self) -> &TransportType { + &TransportType::TOR + } + + fn state(&self) -> TransportState { + self.state + } + + fn mtu(&self) -> u16 { + self.config.mtu() + } + + fn link_mtu(&self, _addr: &TransportAddr) -> u16 { + self.config.mtu() + } + + fn start(&mut self) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use start_async() for Tor transport".into(), + )) + } + + fn stop(&mut self) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use stop_async() for Tor transport".into(), + )) + } + + fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> { + Err(TransportError::NotSupported( + "use send_async() for Tor transport".into(), + )) + } + + fn discover(&self) -> Result, TransportError> { + Ok(Vec::new()) + } + + fn accept_connections(&self) -> bool { + self.onion_address.is_some() + } +} + +// ============================================================================ +// Receive Loop (per-connection) +// ============================================================================ + +/// Per-connection Tor receive loop. +/// +/// Reads complete FMP packets using the stream reader, delivers them to +/// the node via the packet channel. On error or EOF, removes the +/// connection from the pool and exits. +async fn tor_receive_loop( + mut reader: tokio::net::tcp::OwnedReadHalf, + transport_id: TransportId, + remote_addr: TransportAddr, + packet_tx: PacketTx, + pool: ConnectionPool, + mtu: u16, + stats: Arc, +) { + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + "Tor receive loop starting" + ); + + loop { + match read_fmp_packet(&mut reader, mtu).await { + Ok(data) => { + stats.record_recv(data.len()); + + trace!( + transport_id = %transport_id, + remote_addr = %remote_addr, + bytes = data.len(), + "Tor packet received" + ); + + let packet = ReceivedPacket::new(transport_id, remote_addr.clone(), data); + + if packet_tx.send(packet).await.is_err() { + info!( + transport_id = %transport_id, + "Packet channel closed, stopping Tor receive loop" + ); + break; + } + } + Err(e) => { + stats.record_recv_error(); + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + error = %e, + "Tor receive error, removing connection" + ); + break; + } + } + } + + // Clean up: remove ourselves from the pool + let mut pool_guard = pool.lock().await; + pool_guard.remove(&remote_addr); + + debug!( + transport_id = %transport_id, + remote_addr = %remote_addr, + "Tor receive loop stopped" + ); +} + +// ============================================================================ +// Socket Configuration +// ============================================================================ + +/// Configure socket options on a SOCKS5-connected stream. +/// +/// Sets TCP_NODELAY and keepalive on the underlying TCP connection. +fn configure_socket( + stream: &std::net::TcpStream, + _config: &TorConfig, +) -> Result<(), TransportError> { + let socket = socket2::SockRef::from(stream); + + // TCP_NODELAY — always enable for FIPS (latency-sensitive protocol messages) + socket + .set_tcp_nodelay(true) + .map_err(|e| TransportError::StartFailed(format!("set nodelay: {}", e)))?; + + // TCP keepalive (30s default, matching TCP transport) + let keepalive_secs = 30u64; + if keepalive_secs > 0 { + let keepalive = TcpKeepalive::new().with_time(Duration::from_secs(keepalive_secs)); + socket + .set_tcp_keepalive(&keepalive) + .map_err(|e| TransportError::StartFailed(format!("set keepalive: {}", e)))?; + } + + Ok(()) +} + +// ============================================================================ +// Accept Loop (onion service inbound) +// ============================================================================ + +/// Accept loop for inbound onion service connections. +/// +/// Mirrors the TCP transport's accept loop. Tor forwards inbound +/// connections to a local TCP listener; we accept them, configure +/// socket options, split the stream, and spawn a per-connection +/// receive task. +async fn tor_accept_loop( + listener: TcpListener, + transport_id: TransportId, + packet_tx: PacketTx, + pool: ConnectionPool, + mtu: u16, + max_inbound: usize, + stats: Arc, +) { + debug!( + transport_id = %transport_id, + "Onion service accept loop starting" + ); + + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(result) => result, + Err(e) => { + warn!( + transport_id = %transport_id, + error = %e, + "Onion service accept error" + ); + continue; + } + }; + + // Check inbound connection limit + let current_count = { + let pool_guard = pool.lock().await; + pool_guard.len() + }; + if current_count >= max_inbound { + stats.record_connection_rejected(); + debug!( + transport_id = %transport_id, + peer_addr = %peer_addr, + max_inbound, + "Rejecting inbound onion connection (limit reached)" + ); + drop(stream); + continue; + } + + // Configure socket options on the accepted stream + let std_stream = match stream.into_std() { + Ok(s) => s, + Err(e) => { + warn!( + transport_id = %transport_id, + error = %e, + "Failed to convert accepted stream to std" + ); + continue; + } + }; + + let socket = socket2::SockRef::from(&std_stream); + let _ = socket.set_tcp_nodelay(true); + let keepalive = TcpKeepalive::new().with_time(Duration::from_secs(30)); + let _ = socket.set_tcp_keepalive(&keepalive); + + let stream = match TcpStream::from_std(std_stream) { + Ok(s) => s, + Err(e) => { + warn!( + transport_id = %transport_id, + error = %e, + "Failed to convert accepted stream back to tokio" + ); + continue; + } + }; + + let remote_addr = TransportAddr::from_string(&peer_addr.to_string()); + + // Split stream and spawn receive task + let (read_half, write_half) = stream.into_split(); + let writer = Arc::new(Mutex::new(write_half)); + + let recv_pool = pool.clone(); + let recv_stats = stats.clone(); + let recv_addr = remote_addr.clone(); + let recv_tx = packet_tx.clone(); + + let recv_task = tokio::spawn(async move { + tor_receive_loop( + read_half, + transport_id, + recv_addr, + recv_tx, + recv_pool, + mtu, + recv_stats, + ) + .await; + }); + + let conn = TorConnection { + writer, + recv_task, + mtu, + established_at: Instant::now(), + }; + + { + let mut pool_guard = pool.lock().await; + pool_guard.insert(remote_addr.clone(), conn); + } + + stats.record_connection_accepted(); + + debug!( + transport_id = %transport_id, + peer_addr = %peer_addr, + "Accepted inbound onion connection" + ); + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Validate that a string is in host:port format. +fn validate_host_port(addr: &str, field_name: &str) -> Result<(), TransportError> { + if addr.parse::().is_ok() { + return Ok(()); + } + // Not a raw IP:port — check it's at least host:port format + let parts: Vec<&str> = addr.rsplitn(2, ':').collect(); + if parts.len() != 2 || parts[0].parse::().is_err() || parts[1].is_empty() { + return Err(TransportError::StartFailed(format!( + "invalid {} '{}': expected host:port or IP:port", + field_name, addr + ))); + } + Ok(()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::packet_channel; + + fn make_config() -> TorConfig { + TorConfig { + socks5_addr: Some("127.0.0.1:19050".to_string()), + ..Default::default() + } + } + + #[test] + fn test_parse_tor_addr_onion() { + let addr = TransportAddr::from_string("abcdef1234567890.onion:2121"); + let tor_addr = parse_tor_addr(&addr).unwrap(); + match tor_addr { + TorAddr::Onion(host, port) => { + assert_eq!(host, "abcdef1234567890.onion"); + assert_eq!(port, 2121); + } + _ => panic!("expected Onion variant"), + } + } + + #[test] + fn test_parse_tor_addr_clearnet() { + let addr = TransportAddr::from_string("192.168.1.1:8080"); + let tor_addr = parse_tor_addr(&addr).unwrap(); + match tor_addr { + TorAddr::Clearnet(socket_addr) => { + assert_eq!(socket_addr, "192.168.1.1:8080".parse::().unwrap()); + } + _ => panic!("expected Clearnet variant"), + } + } + + #[test] + fn test_parse_tor_addr_clearnet_hostname() { + let addr = TransportAddr::from_string("peer1.example.com:2121"); + let tor_addr = parse_tor_addr(&addr).unwrap(); + match tor_addr { + TorAddr::ClearnetHostname(host, port) => { + assert_eq!(host, "peer1.example.com"); + assert_eq!(port, 2121); + } + _ => panic!("expected ClearnetHostname variant"), + } + } + + #[test] + fn test_parse_tor_addr_invalid() { + // Bare name without a dot — not a valid hostname + let addr = TransportAddr::from_string("localhost:2121"); + assert!(parse_tor_addr(&addr).is_err()); + + // No port + let addr = TransportAddr::from_string("not-a-valid-address"); + assert!(parse_tor_addr(&addr).is_err()); + + // Invalid port + let addr = TransportAddr::from_string("example.com:notaport"); + assert!(parse_tor_addr(&addr).is_err()); + } + + #[test] + fn test_config_defaults() { + let config = TorConfig::default(); + assert_eq!(config.mode(), "socks5"); + assert_eq!(config.socks5_addr(), "127.0.0.1:9050"); + assert_eq!(config.connect_timeout_ms(), 120000); + assert_eq!(config.mtu(), 1400); + } + + #[tokio::test] + async fn test_start_stop() { + let (tx, _rx) = packet_channel(32); + let mut transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + transport.start_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Up); + + transport.stop_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Down); + } + + #[tokio::test] + async fn test_double_start_fails() { + let (tx, _rx) = packet_channel(32); + let mut transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + transport.start_async().await.unwrap(); + assert!(transport.start_async().await.is_err()); + } + + #[tokio::test] + async fn test_stop_not_started_fails() { + let (tx, _rx) = packet_channel(32); + let mut transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + assert!(transport.stop_async().await.is_err()); + } + + #[tokio::test] + async fn test_send_not_started() { + let (tx, _rx) = packet_channel(32); + let transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + let addr = TransportAddr::from_string("127.0.0.1:2121"); + let result = transport.send_async(&addr, &[0u8; 10]).await; + assert!(result.is_err()); + } + + #[test] + fn test_transport_type() { + let (tx, _rx) = packet_channel(32); + let transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + let tt = transport.transport_type(); + assert_eq!(tt.name, "tor"); + assert!(tt.connection_oriented); + assert!(tt.reliable); + } + + #[test] + fn test_sync_methods_return_not_supported() { + let (tx, _rx) = packet_channel(32); + let mut transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + assert!(transport.start().is_err()); + assert!(transport.stop().is_err()); + let addr = TransportAddr::from_string("127.0.0.1:2121"); + assert!(transport.send(&addr, &[0u8; 10]).is_err()); + } + + #[test] + fn test_accept_connections_false() { + let (tx, _rx) = packet_channel(32); + let transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + assert!(!transport.accept_connections()); + } + + #[test] + fn test_discover_returns_empty() { + let (tx, _rx) = packet_channel(32); + let transport = TorTransport::new(TransportId::new(1), None, make_config(), tx); + + assert!(transport.discover().unwrap().is_empty()); + } + + #[tokio::test] + async fn test_invalid_socks5_addr_start_fails() { + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + socks5_addr: Some("not-a-socket-addr".to_string()), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + assert!(transport.start_async().await.is_err()); + } + + #[tokio::test] + async fn test_unsupported_mode_start_fails() { + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("embedded".to_string()), + socks5_addr: Some("127.0.0.1:9050".to_string()), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + assert!(transport.start_async().await.is_err()); + } + + // ======================================================================== + // Integration tests using MockSocks5Server + // ======================================================================== + + use mock_socks5::MockSocks5Server; + use crate::transport::tcp::TcpTransport; + use crate::config::TcpConfig; + + /// msg1 wire size: 4 prefix + 4 sender_idx + 106 noise_msg1 = 114 bytes. + const MSG1_WIRE_SIZE: usize = 114; + /// msg1 payload_len: sender_idx(4) + noise_msg1(106) = 110. + const MSG1_PAYLOAD_LEN: u16 = (MSG1_WIRE_SIZE - 4) as u16; + + /// Build a msg1 frame (114 bytes) for testing. + fn build_msg1_frame() -> Vec { + let mut frame = vec![0xAA; MSG1_WIRE_SIZE]; + frame[0] = 0x01; // ver=0, phase=1 + frame[1] = 0x00; // flags + frame[2..4].copy_from_slice(&MSG1_PAYLOAD_LEN.to_le_bytes()); + frame + } + + #[tokio::test] + async fn test_send_recv_via_socks5() { + // Set up a TCP transport as the "destination" with a listener + let (dest_tx, mut dest_rx) = packet_channel(32); + let dest_config = TcpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + ..Default::default() + }; + let mut dest = TcpTransport::new(TransportId::new(100), None, dest_config, dest_tx); + dest.start_async().await.unwrap(); + let dest_addr = dest.local_addr().unwrap(); + + // Set up the mock SOCKS5 proxy pointing at the destination + let mock = MockSocks5Server::new(dest_addr).await.unwrap(); + let proxy_addr = mock.addr(); + let _proxy_handle = mock.spawn(); + + // Set up the Tor transport pointing at the mock proxy + let (tor_tx, _tor_rx) = packet_channel(32); + let tor_config = TorConfig { + socks5_addr: Some(proxy_addr.to_string()), + ..Default::default() + }; + let mut tor = TorTransport::new(TransportId::new(200), None, tor_config, tor_tx); + tor.start_async().await.unwrap(); + + // Send a valid FMP frame (msg1) through the Tor transport + let frame = build_msg1_frame(); + let target = TransportAddr::from_string(&dest_addr.to_string()); + tor.send_async(&target, &frame).await.unwrap(); + + // Receive it on the destination + let received = tokio::time::timeout( + Duration::from_secs(5), + dest_rx.recv(), + ) + .await + .expect("timeout waiting for packet") + .expect("channel closed"); + + assert_eq!(received.data, frame); + + // Clean up + tor.stop_async().await.unwrap(); + dest.stop_async().await.unwrap(); + } + + #[tokio::test] + async fn test_socks5_proxy_down() { + // No SOCKS5 server running on this port + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + socks5_addr: Some("127.0.0.1:19999".to_string()), + connect_timeout_ms: Some(2000), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + transport.start_async().await.unwrap(); + + let addr = TransportAddr::from_string("192.168.1.1:2121"); + let result = transport.send_async(&addr, &build_msg1_frame()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_connect_timeout() { + // Use a non-routable address as the SOCKS5 proxy to trigger timeout + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + // 192.0.2.1 is TEST-NET, should be non-routable and timeout + socks5_addr: Some("192.0.2.1:9050".to_string()), + connect_timeout_ms: Some(500), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + transport.start_async().await.unwrap(); + + let addr = TransportAddr::from_string("10.0.0.1:2121"); + let result = transport.send_async(&addr, &build_msg1_frame()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_close_connection() { + // Set up destination + mock proxy + let (dest_tx, _dest_rx) = packet_channel(32); + let dest_config = TcpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + ..Default::default() + }; + let mut dest = TcpTransport::new(TransportId::new(100), None, dest_config, dest_tx); + dest.start_async().await.unwrap(); + let dest_addr = dest.local_addr().unwrap(); + + let mock = MockSocks5Server::new(dest_addr).await.unwrap(); + let proxy_addr = mock.addr(); + let _proxy_handle = mock.spawn(); + + let (tor_tx, _tor_rx) = packet_channel(32); + let tor_config = TorConfig { + socks5_addr: Some(proxy_addr.to_string()), + ..Default::default() + }; + let mut tor = TorTransport::new(TransportId::new(200), None, tor_config, tor_tx); + tor.start_async().await.unwrap(); + + // Send to establish a connection + let target = TransportAddr::from_string(&dest_addr.to_string()); + tor.send_async(&target, &build_msg1_frame()).await.unwrap(); + + // Verify pool has the connection + { + let pool = tor.pool.lock().await; + assert_eq!(pool.len(), 1); + } + + // Close the connection + tor.close_connection_async(&target).await; + + // Verify pool is empty + { + let pool = tor.pool.lock().await; + assert_eq!(pool.len(), 0); + } + + tor.stop_async().await.unwrap(); + dest.stop_async().await.unwrap(); + } + + // ======================================================================== + // Control port mode tests + // ======================================================================== + + use mock_control::MockTorControlServer; + + #[tokio::test] + async fn test_control_port_start_stop() { + let mock = MockTorControlServer::start().await; + let (tx, _rx) = packet_channel(32); + + let config = TorConfig { + mode: Some("control_port".to_string()), + socks5_addr: Some("127.0.0.1:19050".to_string()), + control_addr: Some(mock.addr().to_string()), + control_auth: Some("password:testpass".to_string()), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + + transport.start_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Up); + assert!(transport.onion_address().is_none()); + assert!(!transport.accept_connections()); + + transport.stop_async().await.unwrap(); + } + + #[tokio::test] + async fn test_config_defaults_phase2() { + let config = TorConfig::default(); + assert_eq!(config.control_addr(), "/run/tor/control"); + assert_eq!(config.control_auth(), "cookie"); + assert_eq!(config.cookie_path(), "/var/run/tor/control.authcookie"); + assert_eq!(config.max_inbound_connections(), 64); + } + + // ======================================================================== + // Directory mode tests + // ======================================================================== + + use crate::config::DirectoryServiceConfig; + use tempfile::TempDir; + + #[test] + fn test_directory_service_config_defaults() { + let config = DirectoryServiceConfig::default(); + assert_eq!(config.hostname_file(), "/var/lib/tor/fips_onion_service/hostname"); + assert_eq!(config.bind_addr(), "127.0.0.1:8443"); + } + + #[tokio::test] + async fn test_directory_mode_start_stop() { + let dir = TempDir::new().unwrap(); + let hostname_path = dir.path().join("hostname"); + std::fs::write( + &hostname_path, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2.onion\n", + ) + .unwrap(); + + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("directory".to_string()), + socks5_addr: Some("127.0.0.1:19050".to_string()), + directory_service: Some(DirectoryServiceConfig { + hostname_file: Some(hostname_path.to_str().unwrap().to_string()), + bind_addr: Some("127.0.0.1:0".to_string()), + }), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + + transport.start_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Up); + assert_eq!( + transport.onion_address(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2.onion"), + ); + assert!(transport.accept_connections()); + + transport.stop_async().await.unwrap(); + assert_eq!(transport.state(), TransportState::Down); + } + + #[tokio::test] + async fn test_directory_mode_missing_hostname_file() { + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("directory".to_string()), + socks5_addr: Some("127.0.0.1:19050".to_string()), + directory_service: Some(DirectoryServiceConfig { + hostname_file: Some("/nonexistent/hostname".to_string()), + bind_addr: Some("127.0.0.1:0".to_string()), + }), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + + let result = transport.start_async().await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("hostname")); + } + + #[tokio::test] + async fn test_directory_mode_invalid_hostname() { + let dir = TempDir::new().unwrap(); + let hostname_path = dir.path().join("hostname"); + std::fs::write(&hostname_path, "not-an-onion-address\n").unwrap(); + + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("directory".to_string()), + socks5_addr: Some("127.0.0.1:19050".to_string()), + directory_service: Some(DirectoryServiceConfig { + hostname_file: Some(hostname_path.to_str().unwrap().to_string()), + bind_addr: Some("127.0.0.1:0".to_string()), + }), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + + let result = transport.start_async().await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("invalid onion address")); + } + + #[tokio::test] + async fn test_directory_mode_accept_inbound() { + let dir = TempDir::new().unwrap(); + let hostname_path = dir.path().join("hostname"); + std::fs::write( + &hostname_path, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2.onion\n", + ) + .unwrap(); + + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("directory".to_string()), + socks5_addr: Some("127.0.0.1:19050".to_string()), + directory_service: Some(DirectoryServiceConfig { + hostname_file: Some(hostname_path.to_str().unwrap().to_string()), + bind_addr: Some("127.0.0.1:0".to_string()), + }), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + transport.start_async().await.unwrap(); + assert!(transport.accept_connections()); + + transport.stop_async().await.unwrap(); + } + + #[tokio::test] + async fn test_socks5_mode_rejects_directory_service_config() { + let (tx, _rx) = packet_channel(32); + let config = TorConfig { + mode: Some("socks5".to_string()), + socks5_addr: Some("127.0.0.1:9050".to_string()), + directory_service: Some(DirectoryServiceConfig::default()), + ..Default::default() + }; + let mut transport = TorTransport::new(TransportId::new(1), None, config, tx); + let result = transport.start_async().await; + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("directory")); + } + +} diff --git a/src/transport/tor/stats.rs b/src/transport/tor/stats.rs new file mode 100644 index 0000000..f37dd77 --- /dev/null +++ b/src/transport/tor/stats.rs @@ -0,0 +1,155 @@ +//! Tor transport statistics. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; + +/// Statistics for a Tor transport instance. +/// +/// Uses atomic counters for lock-free updates from per-connection +/// receive loops and the send path concurrently. +pub struct TorStats { + pub packets_sent: AtomicU64, + pub bytes_sent: AtomicU64, + pub packets_recv: AtomicU64, + pub bytes_recv: AtomicU64, + pub send_errors: AtomicU64, + pub recv_errors: AtomicU64, + pub mtu_exceeded: AtomicU64, + pub connections_established: AtomicU64, + pub connect_timeouts: AtomicU64, + pub connect_refused: AtomicU64, + pub socks5_errors: AtomicU64, + pub connections_accepted: AtomicU64, + pub connections_rejected: AtomicU64, + pub control_errors: AtomicU64, +} + +impl TorStats { + /// Create a new stats instance with all counters at zero. + pub fn new() -> Self { + Self { + packets_sent: AtomicU64::new(0), + bytes_sent: AtomicU64::new(0), + packets_recv: AtomicU64::new(0), + bytes_recv: AtomicU64::new(0), + send_errors: AtomicU64::new(0), + recv_errors: AtomicU64::new(0), + mtu_exceeded: AtomicU64::new(0), + connections_established: AtomicU64::new(0), + connect_timeouts: AtomicU64::new(0), + connect_refused: AtomicU64::new(0), + socks5_errors: AtomicU64::new(0), + connections_accepted: AtomicU64::new(0), + connections_rejected: AtomicU64::new(0), + control_errors: AtomicU64::new(0), + } + } + + /// Record a successful send. + pub fn record_send(&self, bytes: usize) { + self.packets_sent.fetch_add(1, Ordering::Relaxed); + self.bytes_sent.fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Record a successful receive. + pub fn record_recv(&self, bytes: usize) { + self.packets_recv.fetch_add(1, Ordering::Relaxed); + self.bytes_recv.fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Record a send error. + pub fn record_send_error(&self) { + self.send_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Record a receive error. + pub fn record_recv_error(&self) { + self.recv_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Record an MTU exceeded rejection. + pub fn record_mtu_exceeded(&self) { + self.mtu_exceeded.fetch_add(1, Ordering::Relaxed); + } + + /// Record a successful outbound connection. + pub fn record_connection_established(&self) { + self.connections_established.fetch_add(1, Ordering::Relaxed); + } + + /// Record a connect timeout. + pub fn record_connect_timeout(&self) { + self.connect_timeouts.fetch_add(1, Ordering::Relaxed); + } + + /// Record a connection refused. + pub fn record_connect_refused(&self) { + self.connect_refused.fetch_add(1, Ordering::Relaxed); + } + + /// Record a SOCKS5 protocol error. + pub fn record_socks5_error(&self) { + self.socks5_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Record a successful inbound connection via onion service. + pub fn record_connection_accepted(&self) { + self.connections_accepted.fetch_add(1, Ordering::Relaxed); + } + + /// Record a rejected inbound connection (max_inbound limit). + pub fn record_connection_rejected(&self) { + self.connections_rejected.fetch_add(1, Ordering::Relaxed); + } + + /// Record a control port error. + pub fn record_control_error(&self) { + self.control_errors.fetch_add(1, Ordering::Relaxed); + } + + /// Take a snapshot of all counters. + pub fn snapshot(&self) -> TorStatsSnapshot { + TorStatsSnapshot { + packets_sent: self.packets_sent.load(Ordering::Relaxed), + bytes_sent: self.bytes_sent.load(Ordering::Relaxed), + packets_recv: self.packets_recv.load(Ordering::Relaxed), + bytes_recv: self.bytes_recv.load(Ordering::Relaxed), + send_errors: self.send_errors.load(Ordering::Relaxed), + recv_errors: self.recv_errors.load(Ordering::Relaxed), + mtu_exceeded: self.mtu_exceeded.load(Ordering::Relaxed), + connections_established: self.connections_established.load(Ordering::Relaxed), + connect_timeouts: self.connect_timeouts.load(Ordering::Relaxed), + connect_refused: self.connect_refused.load(Ordering::Relaxed), + socks5_errors: self.socks5_errors.load(Ordering::Relaxed), + connections_accepted: self.connections_accepted.load(Ordering::Relaxed), + connections_rejected: self.connections_rejected.load(Ordering::Relaxed), + control_errors: self.control_errors.load(Ordering::Relaxed), + } + } +} + +impl Default for TorStats { + fn default() -> Self { + Self::new() + } +} + +/// Point-in-time snapshot of Tor stats (non-atomic, copyable). +#[derive(Clone, Debug, Default, Serialize)] +pub struct TorStatsSnapshot { + pub packets_sent: u64, + pub bytes_sent: u64, + pub packets_recv: u64, + pub bytes_recv: u64, + pub send_errors: u64, + pub recv_errors: u64, + pub mtu_exceeded: u64, + pub connections_established: u64, + pub connect_timeouts: u64, + pub connect_refused: u64, + pub socks5_errors: u64, + pub connections_accepted: u64, + pub connections_rejected: u64, + pub control_errors: u64, +} diff --git a/testing/README.md b/testing/README.md index dbbaffa..7dcfec3 100644 --- a/testing/README.md +++ b/testing/README.md @@ -20,6 +20,16 @@ configurations. | tcp-chain | 3 | TCP | Linear chain over TCP (port 8443) | | rekey | 5 | UDP | Rekey integration test topology | +### [tor/](tor/) -- Tor Transport Integration + +End-to-end Tor transport testing with Docker containers running real +Tor daemons. Requires internet access for Tor bootstrapping. + +| Scenario | Description | +| -------------- | -------------------------------------------------------- | +| socks5-outbound | Outbound SOCKS5 connections through Tor to clearnet peer | +| directory-mode | Inbound via HiddenServiceDir onion service (co-located) | + ### [chaos/](chaos/) -- Stochastic Simulation Automated network testing with configurable node counts, topology diff --git a/testing/tor/.gitignore b/testing/tor/.gitignore new file mode 100644 index 0000000..52ba4ef --- /dev/null +++ b/testing/tor/.gitignore @@ -0,0 +1,8 @@ +# Release binaries (copied at test time) +**/fips +**/fipsctl +**/fipstop + +# Generated configs (created per-run from templates) +*/configs/node-*.yaml +!*/configs/node-*.yaml.tmpl diff --git a/testing/tor/common/Dockerfile b/testing/tor/common/Dockerfile new file mode 100644 index 0000000..8eec16f --- /dev/null +++ b/testing/tor/common/Dockerfile @@ -0,0 +1,31 @@ +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + iproute2 iputils-ping dnsutils openssh-client openssh-server iperf3 \ + dnsmasq curl python3 rsync && \ + rm -rf /var/lib/apt/lists/* + +# Setup SSH server with no authentication (test only!) +RUN mkdir -p /var/run/sshd && \ + ssh-keygen -A && \ + sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \ + sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \ + passwd -d root + +# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS +RUN printf '%s\n' \ + 'port=53' \ + 'listen-address=127.0.0.1' \ + 'bind-interfaces' \ + 'server=/fips/127.0.0.1#5354' \ + 'server=127.0.0.11' \ + 'no-resolv' \ + >> /etc/dnsmasq.conf + +COPY fips fipsctl /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl + +# Start dnsmasq, SSH server, and run FIPS +ENTRYPOINT ["/bin/bash", "-c", "dnsmasq && /usr/sbin/sshd && exec fips --config /etc/fips/fips.yaml"] diff --git a/testing/tor/common/resolv.conf b/testing/tor/common/resolv.conf new file mode 100644 index 0000000..bbc8559 --- /dev/null +++ b/testing/tor/common/resolv.conf @@ -0,0 +1 @@ +nameserver 127.0.0.1 diff --git a/testing/tor/common/torrc b/testing/tor/common/torrc new file mode 100644 index 0000000..6cbcb95 --- /dev/null +++ b/testing/tor/common/torrc @@ -0,0 +1,21 @@ +# Tor configuration for FIPS socks5-outbound integration testing. +# Provides a SOCKS5 proxy on port 9050 accessible from the Docker network. + +# Bind to all interfaces (Docker bridge network requires non-localhost) +# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password +# for per-destination circuit isolation. Without it, Tor rejects auth method 0x02. +SocksPort 0.0.0.0:9050 IsolateSOCKSAuth + +# Security hardening +ExitRelay 0 +ExitPolicy reject *:* +VanguardsLiteEnabled 1 +ConnectionPadding 1 +SafeLogging 1 + +# Reduce startup time by not waiting for full circuit build +# (we're testing SOCKS5 connectivity, not anonymity properties) +__DisablePredictedCircuits 1 + +# Logging +Log notice stderr diff --git a/testing/tor/directory-mode/Dockerfile.colocated b/testing/tor/directory-mode/Dockerfile.colocated new file mode 100644 index 0000000..983b23d --- /dev/null +++ b/testing/tor/directory-mode/Dockerfile.colocated @@ -0,0 +1,37 @@ +# Dockerfile for directory-mode test: Tor + FIPS co-located in one container. +# +# Tor manages the onion service via HiddenServiceDir. FIPS reads the +# .onion hostname from /var/lib/tor/fips_onion_service/hostname at startup. +# This requires Tor to bootstrap and create the hostname file before FIPS +# starts, so the entrypoint script waits for it. + +FROM debian:bookworm-slim + +ARG TORRC=torrc + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + tor iproute2 iputils-ping dnsutils \ + dnsmasq python3 && \ + rm -rf /var/lib/apt/lists/* + +# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS +RUN printf '%s\n' \ + 'port=53' \ + 'listen-address=127.0.0.1' \ + 'bind-interfaces' \ + 'server=/fips/127.0.0.1#5354' \ + 'server=127.0.0.11' \ + 'no-resolv' \ + >> /etc/dnsmasq.conf + +COPY fips fipsctl /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl + +COPY ${TORRC} /etc/tor/torrc +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +COPY resolv.conf /etc/resolv.conf + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/testing/tor/directory-mode/configs/node-a.yaml.tmpl b/testing/tor/directory-mode/configs/node-a.yaml.tmpl new file mode 100644 index 0000000..bc96d8d --- /dev/null +++ b/testing/tor/directory-mode/configs/node-a.yaml.tmpl @@ -0,0 +1,26 @@ +# FIPS directory-mode test — node A (onion service via HiddenServiceDir) +# +# Runs in directory mode: Tor manages the onion service via HiddenServiceDir +# in torrc. FIPS reads the .onion address from the hostname file. +# No control port needed. Enables Tor Sandbox 1. + +node: + identity: + nsec: "{{NSEC_A}}" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + tor: + mode: "directory" + socks5_addr: "127.0.0.1:9050" + directory_service: + hostname_file: "/var/lib/tor/fips_onion_service/hostname" + bind_addr: "127.0.0.1:8443" diff --git a/testing/tor/directory-mode/configs/node-b.yaml.tmpl b/testing/tor/directory-mode/configs/node-b.yaml.tmpl new file mode 100644 index 0000000..4fb4ea4 --- /dev/null +++ b/testing/tor/directory-mode/configs/node-b.yaml.tmpl @@ -0,0 +1,30 @@ +# FIPS directory-mode test — node B (outbound connector) +# +# Runs in socks5 mode: connects outbound to node A's .onion address +# via the shared Tor daemon's SOCKS5 proxy. The .onion address and +# npub are injected by the test script at runtime. + +node: + identity: + nsec: "{{NSEC_B}}" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + tor: + socks5_addr: "127.0.0.1:9050" + +peers: + - npub: "{{NPUB_A}}" + alias: "dir-a" + addresses: + - transport: tor + addr: "{{ONION_ADDR_A}}" + connect_policy: auto_connect diff --git a/testing/tor/directory-mode/docker-compose.yml b/testing/tor/directory-mode/docker-compose.yml new file mode 100644 index 0000000..a33efa2 --- /dev/null +++ b/testing/tor/directory-mode/docker-compose.yml @@ -0,0 +1,56 @@ +# Tor transport integration test — directory mode +# +# Topology: +# [fips-a] — Tor + FIPS co-located, HiddenServiceDir onion service +# [fips-b] — Tor + FIPS co-located, socks5-only, connects to fips-a's .onion +# +# Both nodes run Tor and FIPS in the same container. Node A's Tor manages +# the onion service via HiddenServiceDir with Sandbox 1. Node B connects +# outbound through its local Tor's SOCKS5 proxy. + +networks: + dir-test: + driver: bridge + +services: + fips-a: + build: + context: . + dockerfile: Dockerfile.colocated + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + container_name: fips-dir-a + hostname: fips-dir-a + volumes: + - ./configs/node-a.yaml:/etc/fips/fips.yaml:ro + environment: + - RUST_LOG=info,fips::transport::tor=debug + networks: + dir-test: + + fips-b: + build: + context: . + dockerfile: Dockerfile.colocated + args: + TORRC: torrc.socks5 + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + container_name: fips-dir-b + hostname: fips-dir-b + volumes: + - ./configs/node-b.yaml:/etc/fips/fips.yaml:ro + environment: + - RUST_LOG=info,fips::transport::tor=debug + networks: + dir-test: diff --git a/testing/tor/directory-mode/entrypoint.sh b/testing/tor/directory-mode/entrypoint.sh new file mode 100755 index 0000000..e30c8f5 --- /dev/null +++ b/testing/tor/directory-mode/entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Entrypoint for directory-mode test container. +# Starts Tor, optionally waits for hostname file, then starts FIPS. + +set -e + +echo "Starting dnsmasq..." +dnsmasq + +# Check if this node uses directory mode (match the YAML value, not comments) +IS_DIRECTORY_MODE=false +if grep -qE '^\s+mode:\s+"directory"' /etc/fips/fips.yaml 2>/dev/null; then + IS_DIRECTORY_MODE=true +fi + +# Pre-create HiddenServiceDir with correct permissions. +# Tor requires 0700 on the directory. +HIDDEN_SERVICE_DIR="/var/lib/tor/fips_onion_service" +if [ "$IS_DIRECTORY_MODE" = true ]; then + mkdir -p "$HIDDEN_SERVICE_DIR" + chmod 700 "$HIDDEN_SERVICE_DIR" +fi + +echo "Starting Tor daemon..." +tor -f /etc/tor/torrc & + +# If this node uses directory mode, wait for Tor to create the hostname file +if [ "$IS_DIRECTORY_MODE" = true ]; then + HOSTNAME_FILE="${HIDDEN_SERVICE_DIR}/hostname" + echo "Waiting for Tor to create ${HOSTNAME_FILE}..." + for i in $(seq 1 120); do + if [ -f "$HOSTNAME_FILE" ]; then + echo "Tor hostname file ready after ${i}s: $(cat "$HOSTNAME_FILE")" + break + fi + sleep 1 + done + + if [ ! -f "$HOSTNAME_FILE" ]; then + echo "FATAL: Tor did not create hostname file within 120s" + exit 1 + fi +fi + +echo "Starting FIPS daemon..." +exec fips --config /etc/fips/fips.yaml diff --git a/testing/tor/directory-mode/resolv.conf b/testing/tor/directory-mode/resolv.conf new file mode 100644 index 0000000..bbc8559 --- /dev/null +++ b/testing/tor/directory-mode/resolv.conf @@ -0,0 +1 @@ +nameserver 127.0.0.1 diff --git a/testing/tor/directory-mode/scripts/directory-test.sh b/testing/tor/directory-mode/scripts/directory-test.sh new file mode 100755 index 0000000..9c9fbf6 --- /dev/null +++ b/testing/tor/directory-mode/scripts/directory-test.sh @@ -0,0 +1,260 @@ +#!/bin/bash +# Tor directory-mode integration test. +# +# Validates end-to-end connectivity through a Tor onion service managed +# by HiddenServiceDir (directory mode) with Sandbox 1: +# fips-a creates onion service via Tor-managed HiddenServiceDir +# fips-b connects outbound to fips-a's .onion address via SOCKS5 +# +# Both containers run Tor + FIPS co-located. This is the recommended +# production deployment mode. +# +# Requires internet — the Tor daemon must bootstrap to the network +# and publish the onion service descriptor for .onion routing. +# +# Usage: ./directory-test.sh + +set -e +trap 'echo ""; echo "Test interrupted — cleaning up..."; docker compose down 2>/dev/null; exit 130' INT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_DIR="$SCRIPT_DIR/.." +DERIVE_KEYS="$SCRIPT_DIR/../../../static/scripts/derive-keys.py" +cd "$TEST_DIR" + +PASSED=0 +FAILED=0 +TIMEOUT_PING=15 +MAX_WAIT_ONION=120 +MAX_WAIT_PEER=180 + +# Count connected peers for a node using fipsctl show peers JSON output +count_connected_peers() { + local container="$1" + docker exec "$container" fipsctl show peers 2>/dev/null \ + | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + print(sum(1 for p in data.get('peers', []) if p.get('connectivity') == 'connected')) +except: + print(0) +" 2>/dev/null || echo 0 +} + +echo "=== FIPS Tor Directory-Mode Integration Test ===" +echo "" + +# ── Phase 0: Setup ─────────────────────────────────────────────── +echo "Phase 0: Setup..." + +# Copy binaries from common (built externally) +if [ ! -f fips ] || [ ! -f fipsctl ]; then + if [ -f ../common/fips ] && [ -f ../common/fipsctl ]; then + cp ../common/fips ../common/fipsctl . + echo " Copied binaries from ../common/" + else + echo " ERROR: fips and fipsctl binaries not found." + echo " Build them first: cargo build --release" + echo " Then copy to testing/tor/common/ or testing/tor/directory-mode/" + exit 1 + fi +fi + +# Generate ephemeral identities +MESH_NAME="dir-test-$(date +%s)-$$" +echo " Mesh name: $MESH_NAME" + +KEYS_A=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "a") +NSEC_A=$(echo "$KEYS_A" | grep "^nsec=" | cut -d= -f2) +NPUB_A=$(echo "$KEYS_A" | grep "^npub=" | cut -d= -f2) + +KEYS_B=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "b") +NSEC_B=$(echo "$KEYS_B" | grep "^nsec=" | cut -d= -f2) +NPUB_B=$(echo "$KEYS_B" | grep "^npub=" | cut -d= -f2) + +echo " Node A: $NPUB_A" +echo " Node B: $NPUB_B" + +# Generate node-a config +sed "s/{{NSEC_A}}/$NSEC_A/" configs/node-a.yaml.tmpl > configs/node-a.yaml +echo " Node A config generated" +echo "" + +# ── Phase 1: Start node A (Tor + FIPS co-located) ──────────────── +echo "Phase 1: Starting node A (Tor+FIPS, directory-mode onion service)..." +docker compose down 2>/dev/null || true +docker compose up -d --build fips-a +echo "" + +# ── Phase 2: Wait for onion service creation ───────────────────── +echo "Phase 2: Waiting for node A's onion service (up to ${MAX_WAIT_ONION}s)..." +echo " (Tor bootstrap + HiddenServiceDir publication)" +ONION_ADDR="" +elapsed=0 +while [ "$elapsed" -lt "$MAX_WAIT_ONION" ]; do + # Extract .onion address from structured log: onion_address=.onion + # Strip ANSI color codes before matching (tracing emits them by default) + ONION_ADDR=$(docker logs fips-dir-a 2>&1 \ + | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -oE 'onion_address=[a-z2-7]{56}\.onion' \ + | head -1 \ + | cut -d= -f2) + + if [ -n "$ONION_ADDR" ]; then + echo " Onion service active after ${elapsed}s" + echo " Address: $ONION_ADDR" + break + fi + sleep 5 + elapsed=$((elapsed + 5)) + echo " ${elapsed}s..." +done + +if [ -z "$ONION_ADDR" ]; then + echo " FAIL: Onion service not created within ${MAX_WAIT_ONION}s" + echo "" + echo "Node A logs (last 30 lines):" + docker logs fips-dir-a 2>&1 | tail -30 + docker compose down + exit 1 +fi +echo "" + +# ── Phase 3: Start node B with .onion address ──────────────────── +echo "Phase 3: Starting node B (Tor+FIPS, socks5-only)..." + +# Generate node-b config with discovered .onion address + virtual port +ONION_CONNECT="${ONION_ADDR}:8443" +sed -e "s/{{NSEC_B}}/$NSEC_B/" \ + -e "s/{{NPUB_A}}/$NPUB_A/" \ + -e "s/{{ONION_ADDR_A}}/$ONION_CONNECT/" \ + configs/node-b.yaml.tmpl > configs/node-b.yaml + +echo " Node B config generated (target: $ONION_CONNECT)" +docker compose up -d fips-b +echo "" + +# ── Phase 4: Wait for peer connection ──────────────────────────── +echo "Phase 4: Waiting for peer connection (up to ${MAX_WAIT_PEER}s)..." +echo " (SOCKS5 circuit setup + .onion routing may take a while)" + +peers_a=0 +peers_b=0 +elapsed=0 +while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do + peers_a=$(count_connected_peers fips-dir-a) + peers_b=$(count_connected_peers fips-dir-b) + + if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then + echo " Both nodes connected after ${elapsed}s (A: ${peers_a}, B: ${peers_b})" + break + fi + sleep 10 + elapsed=$((elapsed + 10)) + echo " ${elapsed}s... (A peers: ${peers_a}, B peers: ${peers_b})" +done + +if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then + echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s" + echo "" + echo "Node A logs (last 30 lines):" + docker logs fips-dir-a 2>&1 | tail -30 + echo "" + echo "Node B logs (last 30 lines):" + docker logs fips-dir-b 2>&1 | tail -30 + docker compose down + exit 1 +fi + +# Extra convergence time for routing +echo " Waiting 10s for routing convergence..." +sleep 10 +echo "" + +# ── Phase 5: Connectivity tests ────────────────────────────────── +echo "Phase 5: Connectivity tests" + +PING_COUNT=11 + +ping_series() { + local from="$1" + local to_npub="$2" + local label="$3" + + echo " $label ($PING_COUNT pings, dropping first):" + local rtts=() + local fails=0 + for i in $(seq 1 "$PING_COUNT"); do + local output + if output=$(docker exec "$from" ping6 -c 1 -W "$TIMEOUT_PING" "${to_npub}.fips" 2>&1); then + local rtt + rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2) + if [ -n "$rtt" ]; then + printf " %2d: %s ms\n" "$i" "$rtt" + rtts+=("$rtt") + else + printf " %2d: OK (no rtt)\n" "$i" + fi + else + printf " %2d: FAIL\n" "$i" + fails=$((fails + 1)) + fi + done + + if [ "$fails" -gt 0 ]; then + FAILED=$((FAILED + fails)) + fi + + # Drop first ping, compute average of remaining + if [ "${#rtts[@]}" -ge 2 ]; then + local avg + local csv + csv=$(IFS=,; echo "${rtts[*]}") + avg=$(python3 -c " +rtts = [$csv] +trimmed = rtts[1:] +print(f'{sum(trimmed)/len(trimmed):.1f}') +") + echo " Avg (excluding first): ${avg} ms" + PASSED=$((PASSED + ${#rtts[@]})) + elif [ "${#rtts[@]}" -eq 1 ]; then + echo " Only 1 successful ping, no average" + PASSED=$((PASSED + 1)) + else + echo " No successful pings" + fi + echo "" +} + +echo "" +echo " Ping via Tor onion service (directory mode, Sandbox 1):" +ping_series fips-dir-a "$NPUB_B" "A → B" +ping_series fips-dir-b "$NPUB_A" "B → A" + +echo "" + +# ── Phase 6: Log analysis ──────────────────────────────────────── +echo "Phase 6: Log analysis" + +for node in fips-dir-a fips-dir-b; do + panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true) + errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true) + onion=$(docker logs "$node" 2>&1 | grep -ci "onion" || true) + directory=$(docker logs "$node" 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -ci "directory.mode" || true) + echo " $node: panics=$panics errors=$errors onion_mentions=$onion directory_mentions=$directory" + if [ "$panics" -gt 0 ]; then + echo " WARNING: panics detected in $node logs" + fi +done + +echo "" + +# ── Cleanup ────────────────────────────────────────────────────── +echo "Cleaning up..." +docker compose down +rm -f configs/node-a.yaml configs/node-b.yaml + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" +[ "$FAILED" -eq 0 ] && exit 0 || exit 1 diff --git a/testing/tor/directory-mode/torrc b/testing/tor/directory-mode/torrc new file mode 100644 index 0000000..9ba70bd --- /dev/null +++ b/testing/tor/directory-mode/torrc @@ -0,0 +1,23 @@ +# Tor configuration for FIPS directory-mode integration test. +# Uses HiddenServiceDir — Tor manages the onion service and key. +# +# NOTE: Sandbox 1 is omitted in Docker tests (requires specific seccomp +# profiles and pre-existing directory ownership). Production deployments +# on bare metal should enable Sandbox 1 per packaging/torrc.fips. + +# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password +# for per-destination circuit isolation. +SocksPort 127.0.0.1:9050 IsolateSOCKSAuth + +# Onion service — Tor manages key and hostname file. +HiddenServiceDir /var/lib/tor/fips_onion_service +HiddenServicePort 8443 127.0.0.1:8443 + +# Security hardening (subset safe for Docker) +ExitRelay 0 +ExitPolicy reject *:* +VanguardsLiteEnabled 1 +SafeLogging 1 + +# Logging +Log notice stderr diff --git a/testing/tor/directory-mode/torrc.socks5 b/testing/tor/directory-mode/torrc.socks5 new file mode 100644 index 0000000..8365b12 --- /dev/null +++ b/testing/tor/directory-mode/torrc.socks5 @@ -0,0 +1,17 @@ +# Tor configuration for directory-mode test node B (socks5-only). +# No onion service — outbound connections only. + +# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password +# for per-destination circuit isolation. +SocksPort 127.0.0.1:9050 IsolateSOCKSAuth + +# Security hardening +ExitRelay 0 +ExitPolicy reject *:* +Sandbox 1 +VanguardsLiteEnabled 1 +ConnectionPadding 1 +SafeLogging 1 + +# Logging +Log notice stderr diff --git a/testing/tor/socks5-outbound/configs/node-a.yaml.tmpl b/testing/tor/socks5-outbound/configs/node-a.yaml.tmpl new file mode 100644 index 0000000..3db40c2 --- /dev/null +++ b/testing/tor/socks5-outbound/configs/node-a.yaml.tmpl @@ -0,0 +1,29 @@ +# FIPS Tor test node A — socks5-outbound +# +# Connects outbound to vps-chi (217.77.8.91:443) via Tor SOCKS5 proxy. +# Identity generated per-run to avoid mesh clashes with parallel tests. + +node: + identity: + nsec: "{{NSEC_A}}" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + tor: + socks5_addr: "tor-daemon:9050" + +peers: + - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98" + alias: "vps-chi" + addresses: + - transport: tor + addr: "217.77.8.91:443" + connect_policy: auto_connect diff --git a/testing/tor/socks5-outbound/configs/node-b.yaml.tmpl b/testing/tor/socks5-outbound/configs/node-b.yaml.tmpl new file mode 100644 index 0000000..6391a60 --- /dev/null +++ b/testing/tor/socks5-outbound/configs/node-b.yaml.tmpl @@ -0,0 +1,29 @@ +# FIPS Tor test node B — socks5-outbound +# +# Connects outbound to vps-chi (217.77.8.91:443) via Tor SOCKS5 proxy. +# Identity generated per-run to avoid mesh clashes with parallel tests. + +node: + identity: + nsec: "{{NSEC_B}}" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + tor: + socks5_addr: "tor-daemon:9050" + +peers: + - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98" + alias: "vps-chi" + addresses: + - transport: tor + addr: "217.77.8.91:443" + connect_policy: auto_connect diff --git a/testing/tor/socks5-outbound/docker-compose.yml b/testing/tor/socks5-outbound/docker-compose.yml new file mode 100644 index 0000000..d827e1d --- /dev/null +++ b/testing/tor/socks5-outbound/docker-compose.yml @@ -0,0 +1,66 @@ +# Tor transport integration test — socks5-outbound +# +# Topology: +# [fips-a] --tor/socks5--> vps-chi (217.77.8.91:443) <--tor/socks5-- [fips-b] +# +# Both FIPS nodes connect outbound through a local Tor daemon's SOCKS5 +# proxy to vps-chi's TCP listener. vps-chi routes between them. +# Ping between fips-a and fips-b validates the full Tor transport path. + +networks: + tor-test: + driver: bridge + +services: + tor-daemon: + image: osminogin/tor-simple:latest + container_name: tor-daemon + restart: "no" + volumes: + - ../common/torrc:/etc/tor/torrc:ro + networks: + tor-test: + + fips-a: + build: + context: ../common + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + container_name: fips-tor-a + hostname: fips-tor-a + depends_on: + - tor-daemon + volumes: + - ../common/resolv.conf:/etc/resolv.conf:ro + - ./configs/node-a.yaml:/etc/fips/fips.yaml:ro + environment: + - RUST_LOG=info,fips::transport::tor=debug + networks: + tor-test: + + fips-b: + build: + context: ../common + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + container_name: fips-tor-b + hostname: fips-tor-b + depends_on: + - tor-daemon + volumes: + - ../common/resolv.conf:/etc/resolv.conf:ro + - ./configs/node-b.yaml:/etc/fips/fips.yaml:ro + environment: + - RUST_LOG=info,fips::transport::tor=debug + networks: + tor-test: diff --git a/testing/tor/socks5-outbound/scripts/tor-test.sh b/testing/tor/socks5-outbound/scripts/tor-test.sh new file mode 100755 index 0000000..6b42852 --- /dev/null +++ b/testing/tor/socks5-outbound/scripts/tor-test.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# Tor transport integration test. +# +# Validates end-to-end connectivity through a real Tor network: +# fips-a --tor/socks5--> vps-chi <--tor/socks5-- fips-b +# +# Both local FIPS nodes connect outbound through a local Tor daemon +# to vps-chi's TCP listener (217.77.8.91:443). Once both are peered +# with vps-chi, traffic between fips-a and fips-b is routed through it. +# +# Each run generates ephemeral identities to avoid mesh clashes when +# multiple instances of this test run concurrently. +# +# Usage: ./tor-test.sh +# +# Timings (approximate): +# Tor bootstrap: 10-30s +# First SOCKS5 attempt: may timeout at 60s (circuits not ready) +# Retry + circuit setup: 10-30s +# FIPS handshake: ~1s per peer +# Routing convergence: ~5s +# Total: ~90-180s +# +# This test requires internet access for the Tor daemon. + +set -e +trap 'echo ""; echo "Test interrupted — cleaning up..."; docker compose down 2>/dev/null; exit 130' INT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOR_DIR="$SCRIPT_DIR/.." +DERIVE_KEYS="$SCRIPT_DIR/../../../static/scripts/derive-keys.py" +cd "$TOR_DIR" + +PASSED=0 +FAILED=0 +TIMEOUT_PING=15 +MAX_WAIT_TOR=90 +MAX_WAIT_PEER=180 + +# Count connected peers for a node using fipsctl show peers JSON output +count_connected_peers() { + local container="$1" + docker exec "$container" fipsctl show peers 2>/dev/null \ + | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + print(sum(1 for p in data.get('peers', []) if p.get('connectivity') == 'connected')) +except: + print(0) +" 2>/dev/null || echo 0 +} + +echo "=== FIPS Tor Transport Integration Test ===" +echo "" + +# ── Phase 0: Generate ephemeral identities ─────────────────────── +echo "Phase 0: Generating ephemeral identities..." + +MESH_NAME="tor-test-$(date +%s)-$$" +echo " Mesh name: $MESH_NAME" + +KEYS_A=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "a") +NSEC_A=$(echo "$KEYS_A" | grep "^nsec=" | cut -d= -f2) +NPUB_A=$(echo "$KEYS_A" | grep "^npub=" | cut -d= -f2) + +KEYS_B=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "b") +NSEC_B=$(echo "$KEYS_B" | grep "^nsec=" | cut -d= -f2) +NPUB_B=$(echo "$KEYS_B" | grep "^npub=" | cut -d= -f2) + +echo " Node A: $NPUB_A" +echo " Node B: $NPUB_B" + +# Generate configs from templates +sed "s/{{NSEC_A}}/$NSEC_A/" configs/node-a.yaml.tmpl > configs/node-a.yaml +sed "s/{{NSEC_B}}/$NSEC_B/" configs/node-b.yaml.tmpl > configs/node-b.yaml +echo " Configs generated" +echo "" + +# ── Phase 1: Build and start ───────────────────────────────────── +echo "Phase 1: Starting Tor daemon and FIPS nodes..." +docker compose down 2>/dev/null || true +docker compose up -d --build +echo "" + +# ── Phase 2: Wait for Tor bootstrap ───────────────────────────── +echo "Phase 2: Waiting for Tor daemon to bootstrap (up to ${MAX_WAIT_TOR}s)..." +elapsed=0 +while [ "$elapsed" -lt "$MAX_WAIT_TOR" ]; do + if docker logs tor-daemon 2>&1 | grep -q "Bootstrapped 100%"; then + echo " Tor bootstrapped after ${elapsed}s" + break + fi + sleep 5 + elapsed=$((elapsed + 5)) + echo " ${elapsed}s..." +done + +if [ "$elapsed" -ge "$MAX_WAIT_TOR" ]; then + echo " FAIL: Tor daemon did not bootstrap within ${MAX_WAIT_TOR}s" + echo "" + echo "Tor daemon logs:" + docker logs tor-daemon 2>&1 | tail -20 + docker compose down + exit 1 +fi +echo "" + +# ── Phase 3: Wait for FIPS peers via Tor ───────────────────────── +echo "Phase 3: Waiting for FIPS nodes to peer with vps-chi via Tor (up to ${MAX_WAIT_PEER}s)..." +echo " (First SOCKS5 attempt may timeout while Tor builds circuits)" + +peers_a=0 +peers_b=0 +elapsed=0 +while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do + peers_a=$(count_connected_peers fips-tor-a) + peers_b=$(count_connected_peers fips-tor-b) + + if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then + echo " Both nodes have connected peers after ${elapsed}s (A: ${peers_a}, B: ${peers_b})" + break + fi + sleep 10 + elapsed=$((elapsed + 10)) + echo " ${elapsed}s... (A peers: ${peers_a}, B peers: ${peers_b})" +done + +if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then + echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s" + echo "" + echo "Node A logs (last 30 lines):" + docker logs fips-tor-a 2>&1 | tail -30 + echo "" + echo "Node B logs (last 30 lines):" + docker logs fips-tor-b 2>&1 | tail -30 + docker compose down + exit 1 +fi + +# Extra convergence time for routing +echo " Waiting 10s for routing convergence..." +sleep 10 +echo "" + +# ── Phase 4: Connectivity tests ────────────────────────────────── +echo "Phase 4: Connectivity tests" + +PING_COUNT=11 + +ping_series() { + local from="$1" + local to_npub="$2" + local label="$3" + + echo " $label ($PING_COUNT pings, dropping first):" + local rtts=() + local fails=0 + for i in $(seq 1 "$PING_COUNT"); do + local output + if output=$(docker exec "$from" ping6 -c 1 -W "$TIMEOUT_PING" "${to_npub}.fips" 2>&1); then + local rtt + rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2) + if [ -n "$rtt" ]; then + printf " %2d: %s ms\n" "$i" "$rtt" + rtts+=("$rtt") + else + printf " %2d: OK (no rtt)\n" "$i" + fi + else + printf " %2d: FAIL\n" "$i" + fails=$((fails + 1)) + fi + done + + if [ "$fails" -gt 0 ]; then + FAILED=$((FAILED + fails)) + fi + + # Drop first ping, compute average of remaining + if [ "${#rtts[@]}" -ge 2 ]; then + local avg + local csv + csv=$(IFS=,; echo "${rtts[*]}") + avg=$(python3 -c " +rtts = [$csv] +trimmed = rtts[1:] +print(f'{sum(trimmed)/len(trimmed):.1f}') +") + echo " Avg (excluding first): ${avg} ms" + PASSED=$((PASSED + ${#rtts[@]})) + elif [ "${#rtts[@]}" -eq 1 ]; then + echo " Only 1 successful ping, no average" + PASSED=$((PASSED + 1)) + else + echo " No successful pings" + fi + echo "" +} + +echo "" +echo " Ping via Tor (routed through vps-chi):" +ping_series fips-tor-a "$NPUB_B" "A → B" +ping_series fips-tor-b "$NPUB_A" "B → A" + +echo "" + +# ── Phase 5: Log analysis ──────────────────────────────────────── +echo "Phase 5: Log analysis" + +for node in fips-tor-a fips-tor-b; do + panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true) + errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true) + socks5=$(docker logs "$node" 2>&1 | grep -ci "socks5\|socks" || true) + echo " $node: panics=$panics errors=$errors socks5_mentions=$socks5" + if [ "$panics" -gt 0 ]; then + echo " WARNING: panics detected in $node logs" + fi +done + +echo "" + +# ── Cleanup ────────────────────────────────────────────────────── +echo "Cleaning up..." +docker compose down +rm -f configs/node-a.yaml configs/node-b.yaml + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" +[ "$FAILED" -eq 0 ] && exit 0 || exit 1