Implement Tor transport with operator visibility

Add TorTransport in src/transport/tor/ supporting three operating modes:

Outbound (socks5 mode):
- Non-blocking SOCKS5 connect via tokio-socks with per-destination
  circuit isolation (IsolateSOCKSAuth)
- TorAddr enum for .onion and clearnet address types
- Connection pool with per-connection receive tasks, reuses TCP
  stream FMP framing
- connect_async()/connection_state_sync()/promote_connection() follow
  the same non-blocking polling pattern as TCP transport

Inbound (directory mode — recommended for production):
- Tor manages the onion service via HiddenServiceDir in torrc
- FIPS reads .onion address from hostname file at startup
- No control port needed — enables Tor Sandbox 1 (seccomp-bpf)
- Accept loop mirrors TCP pattern with DirectoryServiceConfig

Monitoring (control_port mode and optional in directory mode):
- Async control port client supporting TCP and Unix socket connections
  via Box<dyn AsyncRead/Write> trait objects
- AUTHENTICATE with cookie or password auth
- 8 GETINFO queries: bootstrap, circuits, traffic, liveness, version,
  dormant state, SOCKS listeners
- Background monitoring task polls every 10s, caches TorMonitoringInfo
  in Arc<RwLock> for synchronous query access
- Bootstrap milestone logging (25/50/75/100%), stall warning (>60s),
  network liveness transitions, dormant mode entry
- Directory mode optionally connects to control port when control_addr
  is configured (non-fatal on failure)

Operator visibility:
- show_transports query exposes tor_mode, onion_address, tor_monitoring
  (bootstrap, circuit_established, traffic, liveness, version, dormant)
- fipstop transport detail view: Tor mode, onion address, SOCKS5/control
  errors, connection stats, Tor daemon status section
- fipstop table view: tor(mode) label with truncated onion address hint

Security hardening:
- Per-destination circuit isolation via IsolateSOCKSAuth
- Unix socket default for control port (/run/tor/control)
- Reference torrc with HiddenServiceDir, VanguardsLiteEnabled,
  ConnectionPadding, DoS protections (PoW + intro rate limiting)

Config:
- TorConfig with socks5, control_port, and directory modes
- DirectoryServiceConfig: hostname_file, bind_addr
- control_addr, control_auth, cookie_path, connect_timeout,
  max_inbound_connections

Testing:
- 69 unit + integration tests with mock SOCKS5 and control servers
- Docker tests: socks5-outbound (clearnet via Tor) and directory-mode
  (HiddenServiceDir onion service)

Documentation:
- Transport layer design doc: Tor architecture, directory mode
- Configuration doc: Tor config tables and examples
This commit is contained in:
Johnathan Corgan
2026-03-15 15:59:55 +00:00
parent c0f30d8fe8
commit 6c90cf6c02
40 changed files with 4842 additions and 29 deletions

4
.gitignore vendored
View File

@@ -20,4 +20,6 @@ vps.env
reference/
dist/
*.ipk
*.ipk
sim-results/

View File

@@ -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

13
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 <jcorgan@corganlabs.com>"

View File

@@ -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

View File

@@ -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 1060s. |
| `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:<secret>"`. |
| `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:<secret>"
# # 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..."

View File

@@ -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.

View File

@@ -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 1060 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 200ms2s 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 15 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** (0100%) 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:<secret>"
# 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 (51222 bytes) |
| Serial | Future direction | SLIP/COBS framing, point-to-point |

View File

@@ -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

83
packaging/torrc.fips Normal file
View File

@@ -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

View File

@@ -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);

View File

@@ -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";

View File

@@ -293,10 +293,6 @@ pub struct TcpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_buf_size: Option<usize>,
/// SOCKS5 proxy for outbound connections (placeholder; not yet implemented).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_proxy: Option<String>,
/// Maximum simultaneous inbound connections. Defaults to 256.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_inbound_connections: Option<usize>,
@@ -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<String>,
/// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:9050".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_addr: Option<String>,
/// 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<u64>,
/// Default MTU for Tor connections. Defaults to 1400.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// Maximum number of inbound connections via onion service. Default: 64.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_inbound_connections: Option<usize>,
/// 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<DirectoryServiceConfig>,
}
/// 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<String>,
/// 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<String>,
}
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<TcpConfig>,
/// Tor transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub tor: TransportInstances<TorConfig>,
}
/// Helper for skip_serializing_if on TransportInstances.
@@ -370,7 +533,7 @@ fn is_transport_empty<T>(instances: &TransportInstances<T>) -> 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;
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<std::net::SocketAddr> {
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<TorMonitoringInfo> {
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()
}
}
}
}

View File

@@ -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<std::io::Error> 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<Self, TorControlError> {
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<Box<dyn AsyncRead + Unpin + Send>>,
writer: Box<dyn AsyncWrite + Unpin + Send>,
}
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<Self, TorControlError> {
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<Self, TorControlError> {
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<Self, TorControlError> {
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<Self, TorControlError> {
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<String, TorControlError> {
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<u8, TorControlError> {
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::<u8>() {
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<bool, TorControlError> {
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<u64, TorControlError> {
let value = self.getinfo("traffic/read").await?;
value.trim().parse::<u64>().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<u64, TorControlError> {
let value = self.getinfo("traffic/written").await?;
value.trim().parse::<u64>().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<String, TorControlError> {
self.getinfo("network-liveness").await
}
/// Query the Tor daemon version string.
pub async fn version(&mut self) -> Result<String, TorControlError> {
self.getinfo("version").await
}
/// Query whether Tor is in dormant mode (no recent activity).
pub async fn is_dormant(&mut self) -> Result<bool, TorControlError> {
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<Vec<String>, 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<TorMonitoringInfo, TorControlError> {
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<ControlResponse, TorControlError> {
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<String>,
}
// ============================================================================
// Cookie File
// ============================================================================
/// Read a Tor control cookie file (32 bytes of raw binary).
fn read_cookie_file(path: &Path) -> Result<Vec<u8>, 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<u8>) -> 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);
}
}

View File

@@ -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
}
}

View File

@@ -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<TcpListener>,
}
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<Self> {
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;
})
}
}

1869
src/transport/tor/mod.rs Normal file

File diff suppressed because it is too large Load Diff

155
src/transport/tor/stats.rs Normal file
View File

@@ -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,
}

View File

@@ -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

8
testing/tor/.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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"]

View File

@@ -0,0 +1 @@
nameserver 127.0.0.1

21
testing/tor/common/torrc Normal file
View File

@@ -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

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -0,0 +1 @@
nameserver 127.0.0.1

View File

@@ -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=<addr>.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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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