Files
wraith/docs/architecture/client.md
glm-5.1 af8e7e8b44 Review architecture specs, address critical/warning issues, mark reviewed
Address 5 critical and 7 warning issues from architecture review:
- Fix duplicate sentence in napi-and-pubsub.md server side section
- Add wraith- namespace reservation to server.md constraints (ADR-018)
- Document stealth mode TLS-only requirement in server.md
- Create ADR-019 for --proxy dual semantics (client vs server)
- Clarify NAPI connect() vs CLI wraith connect distinction
- Add SOCKS5h default as privacy design decision in client.md
- Expand reconnection section (always-on, re-register port forwards)
- Add graceful shutdown sections to client.md and server.md
- Specify OpenSSH key format for path-or-buffer inputs across all docs
- Resolve pubsub alternative approach ambiguity (ADR-018 is primary)
- Replace server.md handler impl block with behavioral description
- Standardize iroh endpoint ID terminology (base58-encoded)
- Remove iroh API implementation details from transport.md/server.md
- Add error handling pattern as cross-cutting concern in overview.md
- Update all document statuses from draft to reviewed
2026-06-02 07:44:42 +00:00

12 KiB

status, last_updated
status last_updated
reviewed 2026-06-02

Client

What

The wraith client establishes an SSH session to a server (via pluggable transport) and exposes a local SOCKS5 proxy for routing traffic through that session. Port forwarding (-L / -R style) covers specific service access like Postgres or Redis.

Why

Users need a way to route traffic through the SSH tunnel. SOCKS5 is the primary interface — it's standard, well-supported by browsers and CLI tools, and needs no privileges. Port forwarding covers specific service access. For VPN-like "route all traffic" behavior, users run tun2proxy alongside wraith (ADR-014).

Architecture

Client Components

┌────────────────────────────────────────────────────────┐
│                     wraith connect                      │
│                                                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐              │
│  │ SOCKS5   │ │ Port     │ │ Remote   │              │
│  │ Server   │ │ Forward  │ │ Forward  │              │
│  │ :1080    │ │ -L spec  │ │ -R spec  │              │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘              │
│       │             │             │                     │
│       ▼             ▼             ▼                     │
│  ┌─────────────────────────────────┐                   │
│  │        Channel Manager          │                   │
│  │  (opens direct-tcpip,           │                   │
│  │   forwarded-tcpip streams)      │                   │
│  └──────────────┬──────────────────┘                   │
│                  │                                      │
│  ┌──────────────▼──────────────────┐                   │
│  │       SSH Client (russh)        │                   │
│  │   Handle<ClientHandler>         │                   │
│  └──────────────┬──────────────────┘                   │
│                  │                                      │
│  ┌──────────────▼──────────────────┐                   │
│  │         Transport                │                   │
│  │   (Tcp / Tls / Iroh)            │                   │
│  └──────────────────────────────────┘                   │
└────────────────────────────────────────────────────────┘

SOCKS5 Server

The primary client interface. Listens on a local port (default 127.0.0.1:1080), accepts SOCKS5 connections, and for each connection:

  1. Reads the SOCKS5 handshake (auth method negotiation, target address)
  2. Opens a channel_open_direct_tcpip(target_host, target_port, originator_addr, originator_port) on the SSH session
  3. Converts the SSH channel to a stream via channel.into_stream()
  4. Runs tokio::io::copy_bidirectional(&mut local_socket, &mut ssh_stream) to proxy data

Supports SOCKS5h (domain names resolved server-side) by default. This prevents DNS leaks — the client never resolves target hostnames locally, sending them to the server for resolution instead. This is consistent with the project's privacy design (ADR-006).

Port Forwarding

Local port forwards (-L local_addr:local_port:remote_host:remote_port):

  1. Bind TcpListener on local_addr:local_port
  2. For each accepted connection, open channel_open_direct_tcpip(remote_host, remote_port, ...)
  3. Proxy bytes bidirectionally via copy_bidirectional

Remote port forwards (-R remote_addr:remote_port:local_host:local_port):

  1. Send tcpip_forward(remote_addr, remote_port) to request the server listen on a port
  2. When the handler receives server_channel_open_forwarded_tcpip, connect to local_host:local_port
  3. Proxy bytes bidirectionally

Channel Manager

The channel manager owns the Arc<client::Handle<ClientHandler>> and provides methods:

  • open_direct_tcpip(host, port) — open a tunnel channel to a remote host
  • open_streamlocal(socket_path) — open a tunnel to a Unix socket
  • request_tcpip_forward(addr, port) — request remote listening
  • cancel_tcpip_forward(addr, port) — cancel remote listening

It also handles reconnection: if handle.is_closed() returns true, attempt reconnection with exponential backoff.

Reconnection

On transport failure:

  1. Detect via handle.is_closed() or transport read error
  2. Exponential backoff reconnect (1s, 2s, 4s, ... max 30s)
  3. Re-establish transport connection
  4. Re-authenticate SSH session
  5. Notify SOCKS5 server and port forwards (in-flight connections fail, new connections work)

Reconnection is always enabled. The backoff caps at 30 seconds and continues indefinitely until the user terminates the process. Existing TCP connections through the tunnel are lost on reconnect — this is acceptable and consistent with how VPN connections behave.

The channel manager orchestrates reconnection: it creates a new transport stream (by calling transport.connect() again) and establishes a new SSH session over it (ADR-004). This is a full reconnect — there is no "SSH reconnects over the same transport." Port forward listeners (-L, -R) are re-registered with the new session after reconnection.

Programmatic Configuration (ADR-011)

The client uses programmatic configuration — no ~/.ssh/config parsing, no custom config files. Configuration comes from:

  1. CLI flags: --server, --identity, --transport, etc.
  2. Library API: ConnectOptions and ServeOptions structs in wraith-core, constructable programmatically
  3. Environment variables: WRAITH_SERVER, WRAITH_IDENTITY as convenience defaults

This approach avoids cross-platform path issues (~ expansion, Windows USERPROFILE) and makes the library API clean for programmatic consumers like the NAPI wrapper. Keys can be provided as file paths or in-memory data.

Key Material Format

Key inputs (--identity, --authorized-keys, --cert-authority, --key) accept either:

  • File path: A filesystem path to a key file (e.g., ~/.ssh/id_ed25519, /etc/wraith/ca.pub)
  • In-memory data: Raw key bytes provided programmatically via the library API or NAPI wrapper (as Vec<u8> in Rust, Buffer in Node.js)

The accepted format is OpenSSH key format (the format used by ssh-keygen and OpenSSH's ~/.ssh/ files). This includes:

  • Private keys: OpenSSH format (begins with -----BEGIN OPENSSH PRIVATE KEY-----)
  • Public keys: OpenSSH format (e.g., ssh-ed25519 AAAA... user@host)
  • Certificate authority keys: OpenSSH public key format
  • Authorized keys files: Standard OpenSSH authorized_keys format

PEM-encoded keys (PKCS#1, PKCS#8) are not supported. Use OpenSSH format keys throughout.

CLI Interface

# Basic connection (TCP, default port 22)
wraith connect --server example.com --identity ~/.ssh/id_ed25519

# With TLS
wraith connect --server example.com:443 --transport tls --identity ~/.ssh/id_ed25519

# With TLS + insecure (self-signed certs)
wraith connect --server example.com:443 --transport tls --identity ~/.ssh/id_ed25519 --insecure

# With iroh (no public IP needed)
wraith connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519

# With iroh + custom relay
wraith connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519 --iroh-relay https://relay.example.com

# With iroh + proxy (transport chaining)
wraith connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519 --proxy socks5://127.0.0.1:1080

# SOCKS5 on custom port
wraith connect --server example.com --socks5 127.0.0.1:1080 --identity ~/.ssh/id_ed25519

# With port forwards
wraith connect --server example.com --forward 5432:db.internal:5432 --forward 6379:redis.internal:6379

# All options
wraith connect \
  --server <addr> \          # TCP/TLS server address (required for tcp/tls)
  --peer <endpoint-id> \    # iroh endpoint ID, base58-encoded (required for iroh)
  --transport tcp|tls|iroh \ # Transport mode
  --identity <path-or-buffer> \ # SSH private key (path or in-memory)
  --socks5 <addr:port> \    # SOCKS5 listen address (default: 127.0.0.1:1080)
  --forward <spec> \        # Port forward spec (repeatable)
  --remote-forward <spec> \ # Remote port forward spec (repeatable)
  --proxy <url> \            # Upstream proxy (socks5:// or http://)
  --iroh-relay <url> \      # iroh relay URL (default: n0 relay)
  --tls-server-name <host> \ # SNI hostname for TLS
  --insecure                 # Accept self-signed TLS certs

Constraints

  • SOCKS5 is always enabled when wraith connect runs (it's the primary interface). Port forwards are optional.
  • The client does not log tunnel destinations. The SOCKS5 server connects and proxies — no logging of SOCKS5 request targets.
  • Authentication is Ed25519 public key or OpenSSH certificate (ADR-012). No password authentication over SSH.
  • Only one SSH session per wraith connect process. Multiple sessions = multiple processes (or a future multiplexer).
  • No ~/.ssh/config parsing. Configuration is programmatic via CLI flags, env vars, or library API structs (ADR-011).
  • VPN-like "route all traffic" behavior is provided by running tun2proxy --proxy socks5://127.0.0.1:1080 alongside the client, not by a built-in TUN interface (ADR-014).
  • The CLI wraith connect command manages a full SSH session with SOCKS5 and port forwarding. The NAPI connect() function is a different operation — it opens a single SSH channel as a Duplex stream for programmatic use, with no SOCKS5 server or port forwarding. See napi-and-pubsub.md for details.

Graceful Shutdown

On SIGTERM or SIGINT:

  1. Stop accepting new SOCKS5 connections and port forward connections
  2. Send an SSH disconnect message to the server
  3. Wait for in-flight channel data to drain (brief timeout, ~2 seconds)
  4. Close the transport stream
  5. Exit

In-flight connections are not preserved across shutdown — they receive a connection reset. This matches the behavior of standard SSH tunnel tools.

Error Handling

Error handling follows the project's layered pattern (see overview.md):

  • Transport errors: Trigger reconnection with exponential backoff (see Reconnection section above). If reconnection fails indefinitely, the process continues retrying until the user terminates it.
  • Auth errors: Cause reconnection retry. After repeated auth failures, the SOCKS5 server and port-forward listeners remain active but new channel opens fail until reconnection succeeds.
  • Channel-level errors: Individual channel failures (target unreachable, proxy failure) close that channel without affecting the SSH session or other channels.
  • CLI errors: Reported to stderr with a non-zero exit code. Fatal errors (invalid flags, key file not found) exit immediately.

Open Questions

None — all resolved.

Design Decisions

ADR Decision Summary
005 SOCKS5 first SOCKS5 is the primary interface; TUN is external (tun2proxy)
006 No logging of destinations Client does not log SOCKS5 request targets (consistent with ADR-006)
011 Programmatic-first API No file-based config; options are structs, env vars, or CLI flags
012 Key + cert-authority No password auth; OpenSSH cert-authority for multi-user
019 Proxy dual semantics --proxy routes transport on client, data on server