mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,4 +20,6 @@ vps.env
|
||||
reference/
|
||||
|
||||
dist/
|
||||
*.ipk
|
||||
*.ipk
|
||||
|
||||
sim-results/
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
can now use `host:port` with either IP addresses or DNS hostnames (e.g.,
|
||||
`"peer1.example.com:2121"`). DNS resolution with 60-second cache for UDP,
|
||||
one-shot resolution at connect time for TCP.
|
||||
- Tor transport with three operating modes:
|
||||
- `socks5` mode: outbound connections to .onion, clearnet IP, and
|
||||
clearnet hostname addresses via SOCKS5 proxy with per-destination
|
||||
circuit isolation (IsolateSOCKSAuth)
|
||||
- `directory` mode: inbound via Tor-managed `HiddenServiceDir` onion
|
||||
service, enables Tor Sandbox 1 (seccomp-bpf)
|
||||
- `control_port` mode: Tor daemon monitoring via async control port client
|
||||
with cookie and password authentication
|
||||
- Optional control port monitoring in directory mode when `control_addr`
|
||||
is configured
|
||||
- Docker integration tests for SOCKS5 outbound and directory-mode inbound
|
||||
- Tor operator visibility:
|
||||
- Background monitoring task polling Tor daemon status every 10s
|
||||
- `show_transports` query exposes `tor_mode`, `onion_address`, and
|
||||
`tor_monitoring` (bootstrap, circuits, liveness, traffic, version)
|
||||
- fipstop Tor transport detail view with daemon status, connection stats,
|
||||
and truncated onion address in table view
|
||||
- Bootstrap milestone logging (25/50/75/100%), stall warning, network
|
||||
liveness transitions, dormant mode alerts
|
||||
|
||||
## [0.1.0] - 2026-03-12
|
||||
|
||||
|
||||
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# FIPS: Free Internetworking Peering System
|
||||
|
||||

|
||||
[](LICENSE)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -373,6 +373,111 @@ transports:
|
||||
max_inbound_connections: 64
|
||||
```
|
||||
|
||||
### Tor (`transports.tor.*`)
|
||||
|
||||
Tor transport routes FIPS traffic through the Tor network for anonymity.
|
||||
Requires an external Tor daemon providing a SOCKS5 proxy. Three modes:
|
||||
`socks5` for outbound-only, `control_port` for outbound + monitoring,
|
||||
`directory` for outbound + inbound via Tor-managed onion service.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `transports.tor.mode` | string | `"socks5"` | Tor access mode: `socks5` (outbound only), `control_port` (outbound + monitoring), or `directory` (outbound + inbound onion service) |
|
||||
| `transports.tor.socks5_addr` | string | `"127.0.0.1:9050"` | SOCKS5 proxy address (host:port) |
|
||||
| `transports.tor.connect_timeout_ms` | u64 | `120000` | Connect timeout in milliseconds. Tor circuits take 10–60s. |
|
||||
| `transports.tor.mtu` | u16 | `1400` | Default MTU |
|
||||
| `transports.tor.control_addr` | string | `"/run/tor/control"` | Tor control port address: Unix socket path or host:port. Used in `control_port` mode; optional in `directory` mode for monitoring. |
|
||||
| `transports.tor.control_auth` | string | `"cookie"` | Control port authentication: `"cookie"`, `"cookie:/path/to/cookie"`, or `"password:<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..."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -442,6 +442,225 @@ If `bind_addr` is configured, the transport accepts inbound connections.
|
||||
Without it, the transport operates in outbound-only mode (no listener
|
||||
socket is created).
|
||||
|
||||
## Tor: The Anonymity Transport
|
||||
|
||||
The Tor transport routes FIPS traffic through the Tor network, hiding
|
||||
a node's IP address from its peers. A node behind Tor connects outbound
|
||||
through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit
|
||||
node's IP, not the initiator's. After the Noise IK handshake, the remote
|
||||
peer knows the initiator's FIPS identity (npub) but not its network
|
||||
location.
|
||||
|
||||
Like TCP, Tor is connection-oriented and reliable. The same TCP-over-TCP
|
||||
considerations apply — MMP correctly measures the elevated latency and
|
||||
cost-based parent selection naturally deprioritizes Tor links.
|
||||
|
||||
### Architecture
|
||||
|
||||
The Tor transport is a separate `TorTransport` implementation, not a TCP
|
||||
variant, because it manages SOCKS5 proxy negotiation, has different
|
||||
address semantics (.onion vs IP:port), and has significantly different
|
||||
latency characteristics. It reuses the FMP header-based stream reader
|
||||
(`tcp/stream.rs`) for packet framing on the underlying TCP connection.
|
||||
|
||||
The transport maintains two pools (same pattern as TCP): a
|
||||
`ConnectingPool` for background SOCKS5 connection attempts, and an
|
||||
established pool of `TorConnection` entries. Each `TorConnection` holds
|
||||
a write half, a per-connection receive task, the negotiated MTU, and
|
||||
a connection timestamp.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ----- |
|
||||
| Addressing | .onion:port or IP:port |
|
||||
| Default MTU | 1400 bytes |
|
||||
| Framing | FMP header-based (shared with TCP) |
|
||||
| Connection model | Non-blocking connect, outbound SOCKS5 + inbound via onion service |
|
||||
| Platform | Cross-platform (requires external Tor daemon) |
|
||||
|
||||
### Address Types
|
||||
|
||||
The Tor transport accepts three address formats, parsed into a `TorAddr`
|
||||
enum:
|
||||
|
||||
- **Onion**: `.onion:port` — connects to a Tor hidden service. Both
|
||||
sides anonymous. (e.g., `abcdef...xyz.onion:8443`)
|
||||
- **Clearnet IP**: `IP:port` — connects through a Tor exit node to a
|
||||
remote TCP listener. Hides the initiator's IP; the remote peer sees
|
||||
the exit node's IP.
|
||||
- **Clearnet Hostname**: `hostname:port` — hostname is passed through
|
||||
SOCKS5 for Tor-side DNS resolution, avoiding local DNS leaks. Compatible
|
||||
with SafeSocks 1. (e.g., `fips.example.com:8443`)
|
||||
|
||||
All address types are routed through the same SOCKS5 proxy.
|
||||
|
||||
### Connection Establishment
|
||||
|
||||
Connection setup follows the same non-blocking pattern as TCP. When FMP
|
||||
needs to reach a peer, the node calls `connect(addr)` on the transport.
|
||||
The transport spawns a background tokio task that:
|
||||
|
||||
1. Opens a SOCKS5 connection through the local Tor proxy
|
||||
2. Configures the socket: `TCP_NODELAY`, keepalive (30s)
|
||||
3. Returns the connected stream
|
||||
|
||||
The call returns immediately. `connection_state(addr)` reports progress.
|
||||
Tor circuit establishment typically takes 10–60 seconds (vs milliseconds
|
||||
for TCP), making non-blocking connect essential — a blocking connect
|
||||
would stall the entire FMP event loop.
|
||||
|
||||
The connect timeout defaults to 120 seconds (vs 5 seconds for TCP),
|
||||
accounting for Tor circuit setup time. As a fallback, `send(addr, data)`
|
||||
performs synchronous connect-on-send if no connection exists.
|
||||
|
||||
### Inbound via Onion Service (Directory Mode)
|
||||
|
||||
In `directory` mode (recommended for production), Tor manages the onion
|
||||
service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion` address
|
||||
from the hostname file at startup and binds a local TCP listener that the
|
||||
Tor daemon forwards inbound connections to.
|
||||
|
||||
This mode enables Tor's `Sandbox 1` (seccomp-bpf) — the strongest single
|
||||
hardening option — because no control port interaction is required for
|
||||
onion service management. Tor handles key generation and persistence
|
||||
directly through the `HiddenServiceDir`.
|
||||
|
||||
The inbound accept loop mirrors the TCP transport's pattern: accept
|
||||
connection, configure socket (TCP_NODELAY, keepalive), spawn a
|
||||
per-connection receive loop using the shared FMP stream reader. Inbound
|
||||
connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer
|
||||
identity is resolved during the Noise IK handshake, not from the transport
|
||||
address.
|
||||
|
||||
Configuration requires coordinating `torrc` and `fips.yaml`:
|
||||
|
||||
```text
|
||||
# torrc
|
||||
HiddenServiceDir /var/lib/tor/fips
|
||||
HiddenServicePort 8443 127.0.0.1:8444
|
||||
|
||||
# fips.yaml tor section
|
||||
mode: "directory"
|
||||
directory_service:
|
||||
hostname_file: "/var/lib/tor/fips/hostname"
|
||||
bind_addr: "127.0.0.1:8444"
|
||||
```
|
||||
|
||||
The `HiddenServicePort` external port (8443) is what peers connect to.
|
||||
The bind_addr must match the `HiddenServicePort` target address.
|
||||
|
||||
### Session Independence
|
||||
|
||||
Same as TCP: Tor connection loss does **not** tear down the FIPS peer.
|
||||
Noise keys, MMP state, and FSP sessions survive reconnection.
|
||||
|
||||
### Bridge Node Pattern
|
||||
|
||||
A node running both Tor and UDP transports acts as a bridge between
|
||||
anonymous and clearnet portions of the mesh:
|
||||
|
||||
```text
|
||||
[Anonymous node] --tor--> [Bridge node] --udp--> [Clearnet node]
|
||||
```
|
||||
|
||||
No special code is needed — FIPS multi-transport routing handles it.
|
||||
Anonymous nodes connect to the bridge via Tor; the bridge forwards
|
||||
traffic to clearnet peers over UDP. Clearnet peers never see the
|
||||
anonymous node's IP.
|
||||
|
||||
### Latency Characteristics
|
||||
|
||||
Tor adds 200ms–2s RTT per circuit. First-packet latency after connection
|
||||
is higher (~2.8s) due to circuit warm-up. MMP measures this elevated
|
||||
latency, and cost-based parent selection penalizes Tor links (high SRTT
|
||||
→ high link cost). ETX is 1.0 since TCP handles retransmission.
|
||||
|
||||
Tor throughput is typically 1–5 Mbps — adequate for control plane and
|
||||
moderate data transfer, not for bulk transfer.
|
||||
|
||||
### Monitoring
|
||||
|
||||
In `control_port` mode and optionally in `directory` mode (when
|
||||
`control_addr` is configured), the transport spawns a background
|
||||
monitoring task that polls the Tor daemon every 10 seconds via the
|
||||
control port. The cached monitoring data is exposed through the
|
||||
`show_transports` control socket query and displayed in fipstop.
|
||||
|
||||
Monitoring data includes:
|
||||
|
||||
- **Bootstrap progress** (0–100%) with INFO logging at milestones
|
||||
(25/50/75/100%) and WARN if stalled >60s
|
||||
- **Circuit status** (whether Tor has a working circuit)
|
||||
- **Network liveness** (up/down) with WARN on transitions
|
||||
- **Dormant mode** detection with WARN on entry
|
||||
- **Tor daemon version** and **traffic counters** (bytes read/written)
|
||||
|
||||
The control port connection uses cookie authentication by default
|
||||
(reading from `/var/run/tor/control.authcookie`). Unix socket
|
||||
connections (`/run/tor/control`) are preferred over TCP for security.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
tor:
|
||||
mode: "socks5" # "socks5", "control_port", or "directory"
|
||||
socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address
|
||||
connect_timeout_ms: 120000 # Connect timeout (120s for Tor circuits)
|
||||
mtu: 1400 # Default MTU
|
||||
# control_port mode: monitoring via Tor control port (no inbound)
|
||||
# control_addr: "/run/tor/control" # Unix socket (preferred) or host:port
|
||||
# control_auth: "cookie" # "cookie" or "password:<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 (51–222 bytes) |
|
||||
| Serial | Future direction | SLIP/COBS framing, point-to-point |
|
||||
|
||||
@@ -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
83
packaging/torrc.fips
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
776
src/transport/tor/control.rs
Normal file
776
src/transport/tor/control.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
121
src/transport/tor/mock_control.rs
Normal file
121
src/transport/tor/mock_control.rs
Normal 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
|
||||
}
|
||||
}
|
||||
157
src/transport/tor/mock_socks5.rs
Normal file
157
src/transport/tor/mock_socks5.rs
Normal 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
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
155
src/transport/tor/stats.rs
Normal 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,
|
||||
}
|
||||
@@ -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
8
testing/tor/.gitignore
vendored
Normal 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
|
||||
31
testing/tor/common/Dockerfile
Normal file
31
testing/tor/common/Dockerfile
Normal 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"]
|
||||
1
testing/tor/common/resolv.conf
Normal file
1
testing/tor/common/resolv.conf
Normal file
@@ -0,0 +1 @@
|
||||
nameserver 127.0.0.1
|
||||
21
testing/tor/common/torrc
Normal file
21
testing/tor/common/torrc
Normal 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
|
||||
37
testing/tor/directory-mode/Dockerfile.colocated
Normal file
37
testing/tor/directory-mode/Dockerfile.colocated
Normal 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"]
|
||||
26
testing/tor/directory-mode/configs/node-a.yaml.tmpl
Normal file
26
testing/tor/directory-mode/configs/node-a.yaml.tmpl
Normal 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"
|
||||
30
testing/tor/directory-mode/configs/node-b.yaml.tmpl
Normal file
30
testing/tor/directory-mode/configs/node-b.yaml.tmpl
Normal 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
|
||||
56
testing/tor/directory-mode/docker-compose.yml
Normal file
56
testing/tor/directory-mode/docker-compose.yml
Normal 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:
|
||||
46
testing/tor/directory-mode/entrypoint.sh
Executable file
46
testing/tor/directory-mode/entrypoint.sh
Executable 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
|
||||
1
testing/tor/directory-mode/resolv.conf
Normal file
1
testing/tor/directory-mode/resolv.conf
Normal file
@@ -0,0 +1 @@
|
||||
nameserver 127.0.0.1
|
||||
260
testing/tor/directory-mode/scripts/directory-test.sh
Executable file
260
testing/tor/directory-mode/scripts/directory-test.sh
Executable 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
|
||||
23
testing/tor/directory-mode/torrc
Normal file
23
testing/tor/directory-mode/torrc
Normal 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
|
||||
17
testing/tor/directory-mode/torrc.socks5
Normal file
17
testing/tor/directory-mode/torrc.socks5
Normal 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
|
||||
29
testing/tor/socks5-outbound/configs/node-a.yaml.tmpl
Normal file
29
testing/tor/socks5-outbound/configs/node-a.yaml.tmpl
Normal 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
|
||||
29
testing/tor/socks5-outbound/configs/node-b.yaml.tmpl
Normal file
29
testing/tor/socks5-outbound/configs/node-b.yaml.tmpl
Normal 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
|
||||
66
testing/tor/socks5-outbound/docker-compose.yml
Normal file
66
testing/tor/socks5-outbound/docker-compose.yml
Normal 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:
|
||||
230
testing/tor/socks5-outbound/scripts/tor-test.sh
Executable file
230
testing/tor/socks5-outbound/scripts/tor-test.sh
Executable 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
|
||||
Reference in New Issue
Block a user