Add architecture specification for wraith SSH tunnel tool
Docs: - README.md: index with doc table, ADR table, lifecycle definitions - overview.md: purpose, exports, dependencies, constraints - transport.md: Transport trait, TCP/TLS/iroh implementations, stream join - client.md: SOCKS5 server, port forwarding, channel manager, reconnection - server.md: auth, channel handling, stealth mode, outbound proxy - tun-shim.md: separate privileged process, virtual DNS, --unshare mode - napi-and-pubsub.md: NAPI wrapper, pubsub event target adapter ADRs: - 001: Pluggable transport via AsyncRead+AsyncWrite trait - 002: TUN shim as separate process - 003: iroh stream via tokio::io::join - 004: SSH runs over transport, not alongside - 005: SOCKS5 as primary interface, TUN as add-on - 006(007): NAPI exposes single duplex stream Open questions: 11 items covering TLS certs, iroh relay defaults, Windows TUN, auth expansion, NAPI surface, TCP reconstruction
This commit is contained in:
44
docs/architecture/README.md
Normal file
44
docs/architecture/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Wraith Architecture
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Pre-implementation. Feasibility assessment complete (see research/ssh-tunnel-vpn-alternative-feasibility.md). Architecture specification in progress.
|
||||||
|
|
||||||
|
## Architecture Documents
|
||||||
|
|
||||||
|
| Document | Status | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| [overview.md](overview.md) | draft | Package purpose, exports, dependencies |
|
||||||
|
| [transport.md](transport.md) | draft | Transport abstraction: TCP, TLS, iroh |
|
||||||
|
| [client.md](client.md) | draft | Client connection, SOCKS5, port forwarding |
|
||||||
|
| [server.md](server.md) | draft | Server acceptance, channel handling, proxy |
|
||||||
|
| [tun-shim.md](tun-shim.md) | draft | Privileged TUN interface wrapper (separate process) |
|
||||||
|
| [napi-and-pubsub.md](napi-and-pubsub.md) | draft | NAPI wrapper and pubsub event target adapter |
|
||||||
|
|
||||||
|
## ADR Table
|
||||||
|
|
||||||
|
| ADR | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| [001](decisions/001-pluggable-transport.md) | Pluggable transport via `AsyncRead+AsyncWrite` trait | Accepted |
|
||||||
|
| [002](decisions/002-tun-separate-process.md) | TUN shim as separate process | Accepted |
|
||||||
|
| [003](decisions/002-iroh-stream-join.md) | iroh stream via `tokio::io::join` | Accepted |
|
||||||
|
| [004](decisions/004-ssh-over-transport.md) | SSH runs over transport, not alongside | Accepted |
|
||||||
|
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 as primary interface, TUN as add-on | Accepted |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
See [open-questions.md](open-questions.md)
|
||||||
|
|
||||||
|
## Lifecycle Definitions
|
||||||
|
|
||||||
|
| Status | Meaning | Transitions |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `draft` | Under active development. May change significantly. | → `reviewed` when open questions resolved |
|
||||||
|
| `reviewed` | Architecture final. Implementation may begin. Changes require review. | → `stable` when implementation verified |
|
||||||
|
| `stable` | Locked. Changes require review and may warrant an ADR. | → `deprecated` when superseded |
|
||||||
|
| `deprecated` | Superseded. Kept for reference. | Removed when no longer referenced |
|
||||||
143
docs/architecture/client.md
Normal file
143
docs/architecture/client.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Client
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
The wraith client establishes an SSH session to a server (via pluggable transport) and exposes local interfaces for routing traffic through that session: SOCKS5 proxy, port forwarding, and eventually TUN.
|
||||||
|
|
||||||
|
## 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 (`-L` / `-R` style) covers specific service access like Postgres or Redis. TUN covers full-system VPN-like behavior.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Client Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ wraith connect │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ SOCKS5 │ │ Port │ │ Remote │ │ (TUN │ │
|
||||||
|
│ │ Server │ │ Forward │ │ Forward │ │ shim) │ │
|
||||||
|
│ │ :1080 │ │ -L spec │ │ -R spec │ │ separate │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ 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.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
Existing TCP connections through the tunnel are lost on reconnect. This is acceptable — same as any VPN.
|
||||||
|
|
||||||
|
### CLI Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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 iroh (no public IP needed)
|
||||||
|
wraith connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
# 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 server address (required for tcp/tls)
|
||||||
|
--peer <endpoint-id> \ # iroh peer ID (required for iroh)
|
||||||
|
--transport tcp|tls|iroh \ # Transport mode
|
||||||
|
--identity <path> \ # SSH private key path
|
||||||
|
--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/HTTP CONNECT)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- SOCKS5 is always enabled when `wraith connect` runs (it's the primary interface). Port forwards are optional.
|
||||||
|
- The client does not know or log what destinations are accessed. The SOCKS5 server connects and proxies — no logging of SOCKS5 request targets.
|
||||||
|
- Authentication is Ed25519 public key only by default. Password auth supported but not recommended. (OQ-04)
|
||||||
|
- Only one SSH session per `wraith connect` process. Multiple sessions = multiple processes (or a future multiplexer).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-04**: Authentication beyond Ed25519 keys
|
||||||
|
- **OQ-06**: Whether to support SSH config file parsing (`~/.ssh/config`) for default host/key/port settings
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 first | SOCKS5 is the primary interface, TUN forwards to it |
|
||||||
26
docs/architecture/decisions/001-pluggable-transport.md
Normal file
26
docs/architecture/decisions/001-pluggable-transport.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# ADR-001: Pluggable Transport via AsyncRead+AsyncWrite Trait
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Wraith needs to support multiple transport modes (TCP, TLS, iroh) for SSH sessions. Each mode has different connection establishment logic but produces the same result: a bidirectional byte stream. Without an abstraction, each transport would need its own SSH connection code path.
|
||||||
|
|
||||||
|
russh's `client::connect_stream()` and `server::run_stream()` both accept `AsyncRead + AsyncWrite + Unpin + Send`, meaning SSH is already transport-agnostic at the API level. The design question is whether to enshrine this in wraith's own type system or handle each transport case-by-case.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Define a `Transport` trait that produces `AsyncRead + AsyncWrite + Unpin + Send` streams. Each transport (TCP, TLS, iroh) implements this trait. The SSH layer calls `transport.connect()` and passes the result to `russh::client::connect_stream()`.
|
||||||
|
|
||||||
|
On the server side, define a `TransportAcceptor` trait that produces incoming streams. Each acceptor (TCP listener, TLS listener, iroh endpoint) implements this trait. The server calls `acceptor.accept()` and passes the result to `russh::server::run_stream()`.
|
||||||
|
|
||||||
|
This makes adding a new transport (e.g., WebSocket, QUIC directly) a matter of implementing the trait, not modifying SSH code.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Clean separation between transport and protocol. Adding transports is additive. SSH code is transport-agnostic.
|
||||||
|
- **Positive**: Testing is simplified — mock transports can produce in-memory streams.
|
||||||
|
- **Negative**: Slight indirection for the single-transport case (just TCP). The trait boilerplate is minimal though.
|
||||||
|
- **Negative**: The trait must be object-safe if we want dynamic dispatch. Using `impl Trait` in function signatures avoids this but limits runtime transport selection. CLI-selected transport needs dynamic dispatch: `Box<dyn Transport<Stream = Box<dyn AsyncRead+AsyncWrite+Unpin+Send>>>`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [transport.md](../transport.md)
|
||||||
|
- [Feasibility assessment §3](../../../../conversations/research/ssh-tunnel-vpn-alternative-feasibility.md)
|
||||||
30
docs/architecture/decisions/002-tun-separate-process.md
Normal file
30
docs/architecture/decisions/002-tun-separate-process.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR-002: TUN Shim as Separate Process
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
TUN interface creation requires root privileges or `CAP_NET_ADMIN` on Linux, Administrator on Windows, or platform-specific VPN APIs on macOS/iOS/Android. If the core wraith binary required these privileges, the attack surface of root-required code would include the entire SSH implementation, key handling, and transport negotiation.
|
||||||
|
|
||||||
|
The primary use cases (SOCKS5 proxy, port forwarding) need no privileges at all. Only the "route all traffic through TUN" use case needs root.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
The TUN functionality is a separate `wraith-tun` binary that:
|
||||||
|
1. Creates a TUN device (requires root / CAP_NET_ADMIN)
|
||||||
|
2. Reads IP packets from it
|
||||||
|
3. Forwards each connection to the core wraith's SOCKS5 port (127.0.0.1:1080)
|
||||||
|
4. Proxies bytes between TUN packets and SOCKS5 connections
|
||||||
|
|
||||||
|
The core `wraith connect` binary never needs root. The `wraith-tun` binary is ~200-500 lines and does nothing except TUN ↔ SOCKS5 forwarding.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Root-required code surface is tiny and auditable.
|
||||||
|
- **Positive**: Core binary runs unprivileged. SOCKS5 and port forwarding work without any special permissions.
|
||||||
|
- **Positive**: TUN process can crash without affecting the SSH session (it just reconnects to SOCKS5).
|
||||||
|
- **Positive**: Matches the proven tun2proxy architecture.
|
||||||
|
- **Negative**: Two processes to manage instead of one. Requires process supervision (systemd, etc.).
|
||||||
|
- **Negative**: SOCKS5 adds a small latency overhead vs. direct TUN → SSH packet routing. This is acceptable for the security benefit.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [tun-shim.md](../tun-shim.md)
|
||||||
|
- [tun2proxy](https://github.com/tun2proxy/tun2proxy) — proven architecture for TUN → SOCKS5 proxy
|
||||||
31
docs/architecture/decisions/003-iroh-stream-join.md
Normal file
31
docs/architecture/decisions/003-iroh-stream-join.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# ADR-003: iroh Stream via tokio::io::join
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
iroh's QUIC implementation provides separate `RecvStream` (implements `AsyncRead`) and `SendStream` (implements `AsyncWrite`) for each bidirectional channel opened via `open_bi()` / `accept_bi()`. russh's `connect_stream()` and `run_stream()` require a single type implementing both `AsyncRead` and `AsyncWrite`.
|
||||||
|
|
||||||
|
Options considered:
|
||||||
|
1. `tokio::io::join(recv, send)` — Combines the two halves into `Join<RecvStream, SendStream>` which implements both traits.
|
||||||
|
2. Custom `IrohStream` wrapper — A struct with `recv` and `send` fields that delegates `AsyncRead` to `recv` and `AsyncWrite` to `send`.
|
||||||
|
3. Using iroh's `Connection` directly — Opening a new `open_bi()` for each SSH channel instead of running SSH over a single stream.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use `tokio::io::join(recv_stream, send_stream)` (Option 1).
|
||||||
|
|
||||||
|
One line of code, correct trait implementations, no custom types needed. The `Join<A, B>` type implements `AsyncRead` using `A` and `AsyncWrite` using `B`, which maps directly to iroh's split stream model.
|
||||||
|
|
||||||
|
If profiling later shows overhead (unlikely — it's just method dispatch), we can switch to a custom wrapper. But YAGNI until demonstrated.
|
||||||
|
|
||||||
|
Option 3 was rejected because it would require modifying russh to understand iroh connections. The whole point of the transport trait is that SSH doesn't know about iroh.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Minimal code. One line to bridge iroh and russh.
|
||||||
|
- **Positive**: No custom types to maintain.
|
||||||
|
- **Positive**: Correct `AsyncRead` + `AsyncWrite` behavior — `Poll::Pending` on one half doesn't affect the other.
|
||||||
|
- **Negative**: None identified. The `Join` type is a standard tokio combinator with well-tested semantics.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [transport.md](../transport.md)
|
||||||
|
- [Feasibility assessment §11](../../../../conversations/research/ssh-tunnel-vpn-alternative-feasibility.md)
|
||||||
28
docs/architecture/decisions/004-ssh-over-transport.md
Normal file
28
docs/architecture/decisions/004-ssh-over-transport.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# ADR-004: SSH Runs Over Transport, Not Alongside
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
There are two ways to structure the relationship between SSH and the transport layer:
|
||||||
|
|
||||||
|
1. **SSH over transport**: The transport produces one duplex stream. The entire SSH session (handshake, key exchange, channel multiplexing) runs over that single stream via `connect_stream()` / `run_stream()`. SSH has no direct network access.
|
||||||
|
|
||||||
|
2. **Transport alongside SSH**: SSH manages its own TCP connections via `connect()` / `run()`. The transport layer is an additional feature that wraps outgoing connections. SSH knows about the network.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
SSH runs over the transport (Option 1). The SSH layer never opens its own sockets or knows what transport it's on.
|
||||||
|
|
||||||
|
This is directly enabled by russh's `connect_stream()` and `run_stream()` APIs, which accept any `AsyncRead+AsyncWrite+Unpin+Send`. SSH's entire interaction with the network goes through the single stream produced by the transport.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Adding a new transport requires implementing the `Transport` trait, not modifying SSH code.
|
||||||
|
- **Positive**: Testing is straightforward — mock transports produce in-memory streams.
|
||||||
|
- **Positive**: Security audit is clean — the SSH implementation has no network-facing code.
|
||||||
|
- **Positive**: The transport can be layered. Iroh connecting through a SOCKS5 proxy (which itself tunnels through wraith) is just a transport that calls out to a SOCKS5 library before establishing the QUIC connection.
|
||||||
|
- **Negative**: SSH keepalive and reconnection must be handled at the transport level. If the transport stream dies, the SSH session dies. Reconnection means establishing a new transport + new SSH session. There's no "SSH reconnects over the same transport" — you get a new session.
|
||||||
|
- **Negative**: Multiple SSH sessions over the same iroh connection require the iroh `Endpoint` (not stream) to be shared between sessions. The transport trait produces one stream per `connect()` call. The iroh `Endpoint` must be created externally and shared. (The `IrohTransport` struct holds an `Arc<Endpoint>`.)
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [transport.md](../transport.md)
|
||||||
|
- [Feasibility assessment §3.4](../../../../conversations/research/ssh-tunnel-vpn-alternative-feasibility.md)
|
||||||
39
docs/architecture/decisions/005-socks5-before-tun.md
Normal file
39
docs/architecture/decisions/005-socks5-before-tun.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# ADR-005: SOCKS5 as Primary Interface, TUN as Add-on
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
A "VPN-like" tool needs to route traffic. There are three approaches:
|
||||||
|
|
||||||
|
1. **TUN only**: Create a TUN interface, route all OS traffic through it. Full VPN experience but requires root.
|
||||||
|
2. **SOCKS5 only**: Local SOCKS5 proxy. Applications configure proxy settings. No root needed but application support varies.
|
||||||
|
3. **SOCKS5 primary, TUN add-on**: SOCKS5 is the core interface. TUN forwards to SOCKS5.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
SOCKS5 is the primary interface. TUN is a separate process that forwards to SOCKS5 (Option 3).
|
||||||
|
|
||||||
|
SOCKS5 is the core because:
|
||||||
|
- It requires no privileges
|
||||||
|
- `curl --socks5-hostname` works everywhere
|
||||||
|
- Browsers, most CLI tools, and many applications support SOCKS5
|
||||||
|
- SOCKS5h prevents DNS leaks by resolving names server-side
|
||||||
|
- It's the interface that the NAPI wrapper and pubsub adapter build on
|
||||||
|
- TUN is only needed for "route all traffic" use cases, which are a subset of users
|
||||||
|
|
||||||
|
TUN forwards to SOCKS5 rather than directly to SSH because:
|
||||||
|
- The SOCKS5 code already handles TCP connection establishment and bidirectional proxying
|
||||||
|
- TUN's job is just IP packet → SOCKS5 connection, not IP packet → SSH channel
|
||||||
|
- The `wraith-tun` binary stays minimal (~200-500 lines)
|
||||||
|
- No root code in the core binary
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Core binary is root-free. TUN is optional and separate.
|
||||||
|
- **Positive**: SOCKS5 is testable without TUN — just `curl` against it.
|
||||||
|
- **Positive**: TUN implementation is simplified — it's a thin wrapper around tun2proxy's pattern pointed at localhost:1080.
|
||||||
|
- **Negative**: TUN adds one network hop (TUN → localhost SOCKS5 → SSH). The latency impact is negligible (localhost).
|
||||||
|
- **Negative**: SOCKS5 doesn't capture UDP (except DNS via SOCKS5h). TUN mode would handle non-DNS UDP via the SOCKS5 UDP association or drop it.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [client.md](../client.md)
|
||||||
|
- [tun-shim.md](../tun-shim.md)
|
||||||
26
docs/architecture/decisions/007-napi-single-stream.md
Normal file
26
docs/architecture/decisions/007-napi-single-stream.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# ADR-006: NAPI Exposes Single Duplex Stream
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The NAPI wrapper for wraith could expose different granularity levels:
|
||||||
|
|
||||||
|
1. **Full SSH API**: Expose channel multiplexing, `open_direct_tcpip`, `tcpip_forward`, session management. The TypeScript layer would manage channels.
|
||||||
|
2. **Single duplex stream**: The NAPI wrapper establishes one SSH channel and returns it as a Node.js `Duplex` stream. TypeScript multiplexing (if needed) happens at the pubsub layer.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Option 2: NAPI exposes a single duplex stream.
|
||||||
|
|
||||||
|
The NAPI wrapper's job is to get a reliable, authenticated byte stream from A to B. It handles transport (TCP/TLS/iroh), SSH authentication, and channel setup, then hands the caller a single `Duplex` stream that just works.
|
||||||
|
|
||||||
|
If the TypeScript consumer needs multiplexing (e.g., multiple concurrent tool calls over operations), pubsub handles that at the `EventEnvelope` level. Multiple `call.requested` / `call.responded` events flow over the same stream, distinguished by their `id` fields. This is how the existing WebSocket adapter works.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive**: Minimal NAPI surface — one function, one return type. Small binary, small FFI boundary.
|
||||||
|
- **Positive**: The TypeScript side doesn't need to understand SSH at all. It gets a stream and sends/receives `EventEnvelope` JSON.
|
||||||
|
- **Positive**: No need to expose russh types in NAPI. The SSH complexity stays in Rust.
|
||||||
|
- **Negative**: If a consumer wants multiple isolated channels (e.g., one for events, one for file transfer), they'd need multiple `connect()` calls (multiple SSH sessions). This is acceptable for the expected use case (pubsub events over a single stream).
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [napi-and-pubsub.md](../napi-and-pubsub.md)
|
||||||
120
docs/architecture/napi-and-pubsub.md
Normal file
120
docs/architecture/napi-and-pubsub.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# NAPI Wrapper & PubSub Event Target
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
Two integration layers that enable TypeScript/JavaScript consumers to use wraith as a transport:
|
||||||
|
|
||||||
|
1. **NAPI wrapper** (`@alkdev/wraith`) — A minimal Node.js native addon exposing `connect()` and `serve()` that return duplex streams
|
||||||
|
2. **PubSub event target** (`@alkdev/pubsub` adapter) — An implementation of the `TypedEventTarget` interface that routes events over wraith's SSH channel
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The wraith Rust binary serves CLI users. But the broader ecosystem (pubsub, operations, agent spokes) is TypeScript-first. These integration layers let TypeScript code use wraith's transport without reimplementing SSH.
|
||||||
|
|
||||||
|
The NAPI surface is intentionally tiny — it exposes the transport connection, not the full SSH protocol. The pubsub adapter is also minimal — it implements `TypedEventTarget` and serializes `EventEnvelope` JSON over the stream.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### NAPI Wrapper
|
||||||
|
|
||||||
|
The wrapper exposes a single function that establishes a wraith connection and returns a Node.js `Duplex` stream:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// @alkdev/wraith (TypeScript side)
|
||||||
|
|
||||||
|
interface WraithConnectOptions {
|
||||||
|
// TCP/TLS mode
|
||||||
|
server?: string; // e.g., "example.com:443"
|
||||||
|
// iroh mode
|
||||||
|
peer?: string; // iroh EndpointId (hex)
|
||||||
|
// Transport
|
||||||
|
transport: 'tcp' | 'tls' | 'iroh';
|
||||||
|
// Auth
|
||||||
|
identity?: string; // path to SSH key
|
||||||
|
// TLS
|
||||||
|
tlsServerName?: string; // SNI hostname
|
||||||
|
insecure?: boolean; // accept self-signed certs
|
||||||
|
// iroh
|
||||||
|
irohRelay?: string; // relay URL (default: n0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(options: WraithConnectOptions): Duplex;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Duplex` stream carries raw SSH channel data. On the Rust side, the NAPI function:
|
||||||
|
|
||||||
|
1. Creates a transport (TCP/TLS/iroh) based on options
|
||||||
|
2. Establishes an SSH session via `client::connect_stream()`
|
||||||
|
3. Opens a single `direct_tcpip` channel to a well-known destination (or uses a control protocol)
|
||||||
|
4. Returns the channel as a NAPI `Buffer` stream
|
||||||
|
|
||||||
|
**Key design decision**: The NAPI wrapper does NOT expose the full SSH channel multiplexing API. It returns one duplex stream. If the TypeScript consumer needs multiple logical channels, it multiplexes them itself (e.g., via pubsub's event routing).
|
||||||
|
|
||||||
|
### PubSub Event Target Adapter
|
||||||
|
|
||||||
|
This implements `TypedEventTarget` from `@alkdev/pubsub`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// @alkdev/pubsub (new adapter: event-target-wraith.ts)
|
||||||
|
|
||||||
|
export interface WraithEventTargetOptions {
|
||||||
|
stream: Duplex; // from @alkdev/wraith.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WraithEventTarget<TEvent extends TypedEvent>
|
||||||
|
extends TypedEventTarget<TEvent> {
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWraithEventTarget<TEvent extends TypedEvent>(
|
||||||
|
options: WraithEventTargetOptions
|
||||||
|
): WraithEventTarget<TEvent>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire protocol (same as other pubsub adapters):
|
||||||
|
|
||||||
|
- **Framing**: 4-byte big-endian length prefix + JSON payload
|
||||||
|
- **Payload**: `EventEnvelope` JSON (`{ type, id, payload }`)
|
||||||
|
- **Control**: `__subscribe` / `__unsubscribe` messages for topic-based routing
|
||||||
|
- **Direction**: Bidirectional — `dispatchEvent` sends, `addEventListener` subscribes and receives
|
||||||
|
|
||||||
|
### On the Server Side
|
||||||
|
|
||||||
|
The wraith server exposes a "control channel" — a special `direct_tcpip` destination (e.g., `wraith-control:0`) that routes to the pubsub event bus instead of a TCP target. When a client connects to this destination:
|
||||||
|
|
||||||
|
1. Server's `channel_open_direct_tcpip` handler detects the special target
|
||||||
|
2. Instead of opening a TCP connection, it bridges the channel to its local pubsub event bus
|
||||||
|
3. `EventEnvelope` JSON flows bidirectionally over the SSH channel
|
||||||
|
|
||||||
|
Alternatively, the server can listen on a specific port (e.g., `9736`) for the hub's WebSocket server, and wraith simply port-forwards that port.
|
||||||
|
|
||||||
|
### Direction Agnostic
|
||||||
|
|
||||||
|
Because wraith supports both local and remote port forwarding, the event target works in either direction:
|
||||||
|
|
||||||
|
- **Worker connects to hub**: `wraith connect --forward 9736:hub:9736` then create WebSocket event target pointing at `ws://localhost:9736`
|
||||||
|
- **Hub connects to worker**: `wraith connect --remote-forward 9736:worker:9736` — same result, opposite initiator
|
||||||
|
|
||||||
|
The pubsub adapter doesn't care which side initiated the SSH session. It just needs a byte stream.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The NAPI wrapper exposes a single duplex stream, not the full SSH channel API. Multiplexing is done at the pubsub layer.
|
||||||
|
- The pubsub wire protocol is length-prefixed JSON, matching the existing adapter pattern. Binary payloads should be base64-encoded in the `EventEnvelope.payload`.
|
||||||
|
- The NAPI binary size will be ~5-10MB (includes russh + tokio + cryptography). The `iroh` feature adds significant size; it should be an optional feature.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-10**: Whether the NAPI wrapper should expose raw channel access or a higher-level "send JSON, receive JSON" API
|
||||||
|
- **OQ-11**: Whether to use napi-rs or uniffi for the FFI bridge (napi-rs is more established for Node.js, uniffi supports more targets)
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [007](decisions/007-napi-single-stream.md) | NAPI exposes single duplex stream | No SSH multiplexing in JS, pubsub handles it |
|
||||||
93
docs/architecture/open-questions.md
Normal file
93
docs/architecture/open-questions.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Open Questions
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
### OQ-01: TLS certificate management strategy
|
||||||
|
- **Origin**: [server.md](server.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: medium
|
||||||
|
- **Details**: Should the server support ACME/Let's Encrypt auto-provisioning (like https_proxy does), or is manual cert management sufficient? Auto-provisioning is more user-friendly but adds complexity and a dependency on the ACME protocol. Self-signed certs with `--insecure` flag on the client side covers the simple case.
|
||||||
|
- **Cross-references**: Server spec, TlsTransport implementation
|
||||||
|
|
||||||
|
### OQ-02: iroh relay configuration defaults
|
||||||
|
- **Origin**: [transport.md](transport.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: Should the default iroh relay be n0's free servers, or should users be required to specify one? n0's relay is convenient for testing and quick start but creates a dependency. Self-hosted relay is better for production. Consider: default to n0, allow `--iroh-relay` override, and document self-hosting.
|
||||||
|
- **Cross-references**: Transport spec, iroh docs
|
||||||
|
|
||||||
|
### OQ-05: Transport chaining support in CLI
|
||||||
|
- **Origin**: [transport.md](transport.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: Should `--transport iroh --proxy socks5://...` be supported natively, or should chaining be a manual configuration thing? The iroh transport's `connect()` method would need to route its outbound through the proxy. This is possible (iroh's `Endpoint::builder` supports proxy configuration) but adds CLI complexity. Consider: defer to Phase 2.
|
||||||
|
- **Cross-references**: Transport spec
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
### OQ-06: SSH config file parsing
|
||||||
|
- **Origin**: [client.md](client.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: Should the client read `~/.ssh/config` for default host/key/port settings? russh-config crate exists and can parse this. Would reduce CLI verbosity for frequent connections. Consider: `--config` flag to read from a wraith-specific config instead, avoiding OpenSSH config parsing complexity.
|
||||||
|
- **Cross-references**: Client spec
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
### OQ-07: ACME/Let's Encrypt support
|
||||||
|
- **Origin**: [server.md](server.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: medium
|
||||||
|
- **Details**: Auto-provisioning TLS certs from Let's Encrypt would make TLS mode much easier to set up. But it requires port 80 or port 443 + TLS-ALPN-01 challenge support, and a persistent cert store. Consider: defer to Phase 2, document manual cert setup for MVP.
|
||||||
|
- **Cross-references**: Server spec, TlsTransport
|
||||||
|
|
||||||
|
### OQ-08: Connection limits and rate limiting
|
||||||
|
- **Origin**: [server.md](server.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: Should the server support configurable connection limits, rate limiting, and max simultaneous channels? Useful for preventing abuse on public-facing servers. Consider: `--max-connections` and `--max-channels-per-connection` flags.
|
||||||
|
- **Cross-references**: Server spec
|
||||||
|
|
||||||
|
### OQ-04: Authentication beyond Ed25519 keys
|
||||||
|
- **Origin**: [client.md](client.md), [server.md](server.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: Should password authentication be supported? Should SSH certificates (OpenSSH cert-authority) be supported? Password auth is convenient but less secure. Certificates are useful for large-scale deployments. Consider: password auth as optional flag (`--allow-password`), certificates as future feature.
|
||||||
|
- **Cross-references**: Client spec, Server spec
|
||||||
|
|
||||||
|
## TUN
|
||||||
|
|
||||||
|
### OQ-03: Windows TUN support scope
|
||||||
|
- **Origin**: [tun-shim.md](tun-shim.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: tun-rs supports Windows via wintun.dll but distributing a DLL adds complexity. Consider: Linux and macOS only for MVP, Windows as a follow-up.
|
||||||
|
- **Cross-references**: tun-shim.md
|
||||||
|
|
||||||
|
### OQ-09: TCP reconstruction approach for TUN
|
||||||
|
- **Origin**: [tun-shim.md](tun-shim.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: medium
|
||||||
|
- **Details**: Should the TUN shim use a userspace TCP stack (like smoltcp or tun2proxy's ip-stack) for reliable TCP reconstruction, or forward raw IP packets through SOCKS5? Raw packet forwarding requires handling segmentation, retransmission, and reordering. Userspace TCP solves this but is more code. Consider: start with SOCKS5 proxying (each TUN packet becomes a SOCKS5 connection) and add TCP reconstruction if needed.
|
||||||
|
- **Cross-references**: tun-shim.md
|
||||||
|
|
||||||
|
## NAPI / PubSub
|
||||||
|
|
||||||
|
### OQ-10: NAPI wrapper API surface
|
||||||
|
- **Origin**: [napi-and-pubsub.md](napi-and-pubsub.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: medium
|
||||||
|
- **Details**: Should the NAPI wrapper expose just a `connect()` function returning a `Duplex` stream, or also expose `serve()` for server-side use from Node.js? Server-side would enable running a wraith server from a Node.js process. Consider: `connect()` only for MVP, `serve()` as follow-up.
|
||||||
|
- **Cross-references**: napi-and-pubsub.md
|
||||||
|
|
||||||
|
### OQ-11: napi-rs vs uniffi for FFI bridge
|
||||||
|
- **Origin**: [napi-and-pubsub.md](napi-and-pubsub.md)
|
||||||
|
- **Status**: open
|
||||||
|
- **Priority**: low
|
||||||
|
- **Details**: napi-rs is the standard for Node.js native addons and has the best ecosystem. uniffi supports more targets (Python, Swift, Kotlin) but is less mature for Node.js. Since the primary consumer is TypeScript/Node.js (pubsub/operations ecosystem), napi-rs is the logical choice. But if future Python or mobile consumers are anticipated, uniffi could be worth the investment.
|
||||||
|
- **Cross-references**: napi-and-pubsub.md
|
||||||
91
docs/architecture/overview.md
Normal file
91
docs/architecture/overview.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Wraith Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Wraith is a self-hostable SSH-based tunnel tool that provides VPN-like functionality without being a VPN protocol. It enables:
|
||||||
|
|
||||||
|
- **Private tunneling** of services (Postgres, Redis, internal APIs) over SSH
|
||||||
|
- **Censorship circumvention** — SSH over TLS on port 443 looks like HTTPS to DPI
|
||||||
|
- **NAT traversal** — iroh transport allows peer-to-peer connections without public IPs or port forwarding
|
||||||
|
- **Service mesh connectivity** — a lightweight transport layer for the pubsub/operations event system
|
||||||
|
|
||||||
|
The core insight: SSH tunnels work because SSH is fundamental infrastructure. Blocking it breaks the internet. Wraith makes SSH tunneling accessible through a simple CLI with pluggable transports.
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
### Binary: `wraith`
|
||||||
|
|
||||||
|
A single binary with subcommands:
|
||||||
|
|
||||||
|
```
|
||||||
|
wraith serve — Start the server (accepts SSH connections)
|
||||||
|
wraith connect — Start the client (opens SSH session, exposes SOCKS5/port-forwards)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library: `wraith-core`
|
||||||
|
|
||||||
|
The `wraith-core` crate exports the pluggable components for embedding or programmatic use:
|
||||||
|
|
||||||
|
- `Transport` trait — produces a duplex stream for SSH to run over
|
||||||
|
- `TcpTransport` — direct TCP connection
|
||||||
|
- `TlsTransport` — TCP + tokio-rustls TLS
|
||||||
|
- `IrohTransport` — iroh QUIC P2P connection
|
||||||
|
- `Socks5Server` — local SOCKS5 proxy that forwards through SSH channels
|
||||||
|
- `PortForwarder` — manages local/remote port forwards
|
||||||
|
- `ServerHandler` — russh server handler with configurable auth and channel policies
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Dependency | Purpose | Feature-gated |
|
||||||
|
|------------|---------|---------------|
|
||||||
|
| `russh` | SSH client & server | No (core) |
|
||||||
|
| `tokio` | Async runtime | No (core) |
|
||||||
|
| `tokio-rustls` | TLS wrapping | Yes (`tls`) |
|
||||||
|
| `rustls` | TLS implementation | Yes (`tls`) |
|
||||||
|
| `iroh` | P2P QUIC transport | Yes (`iroh`) |
|
||||||
|
| `tun-rs` | TUN interface | Yes (`tun`) |
|
||||||
|
| `clap` | CLI argument parsing | No (core) |
|
||||||
|
| `tracing` | Structured logging | No (core) |
|
||||||
|
| `anyhow` / `thiserror` | Error handling | No (core) |
|
||||||
|
|
||||||
|
## Architecture Constraints
|
||||||
|
|
||||||
|
1. **SSH runs over transport, not alongside** — The transport layer produces a single `AsyncRead+AsyncWrite+Unpin+Send` stream. SSH runs over that stream via `russh::client::connect_stream()` / `russh::server::run_stream()`. The SSH layer never knows what transport it's on. (ADR-001, ADR-004)
|
||||||
|
|
||||||
|
2. **TUN is a separate process** — The core binary never requires root. TUN functionality is a separate `wraith-tun` process that reads from a TUN device and forwards to the core's SOCKS5 port. (ADR-002)
|
||||||
|
|
||||||
|
3. **SOCKS5 is the primary client interface** — Port forwarding and TUN are built on top of SOCKS5, not alongside it. Everything flows through the SSH channel abstraction. (ADR-005)
|
||||||
|
|
||||||
|
4. **No logging of tunnel destinations** — The server logs auth attempts (for fail2ban) but does not log `channel_open_direct_tcpip` destinations, DNS lookups, or bytes transferred. (ADR-006, pending)
|
||||||
|
|
||||||
|
5. **Feature flags control transport inclusion** — `tls`, `iroh`, `tun` are feature-gated so the base install is lean. Users opt in to heavier dependencies.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [001](decisions/001-pluggable-transport.md) | Pluggable transport | Transport trait produces `AsyncRead+AsyncWrite+Unpin+Send`, SSH consumes it |
|
||||||
|
| [002](decisions/002-tun-separate-process.md) | TUN shim separate | Core binary unprivileged, TUN is a thin root wrapper |
|
||||||
|
| [003](decisions/003-iroh-stream-join.md) | iroh stream join | `tokio::io::join(recv, send)` combines QUIC halves |
|
||||||
|
| [004](decisions/004-ssh-over-transport.md) | SSH over transport | SSH never accesses TCP/iroh/TLS directly |
|
||||||
|
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 first | SOCKS5 is the primary interface, TUN forwards to it |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-01**: TLS certificate management strategy (ADR-006, pending)
|
||||||
|
- **OQ-02**: iroh relay configuration defaults (n0 relay vs self-hosted)
|
||||||
|
- **OQ-03**: Windows TUN support scope (wintun.dll dependency)
|
||||||
|
- **OQ-04**: Authentication beyond Ed25519 keys (password auth, certificate auth)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Feasibility Assessment](../../../conversations/research/ssh-tunnel-vpn-alternative-feasibility.md)
|
||||||
|
- [russh API](/workspace/russh) — SSH client/server library
|
||||||
|
- [Dispatch](/workspace/@alkdev/dispatch) — Reference implementation of russh port forwarding
|
||||||
|
- [tun-rs](https://github.com/tun-rs/tun-rs) — Cross-platform TUN/TAP library
|
||||||
|
- [iroh](/workspace/iroh) — P2P QUIC connections
|
||||||
196
docs/architecture/server.md
Normal file
196
docs/architecture/server.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Server
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
The wraith server accepts SSH connections (via pluggable transport) and handles `channel_open_direct_tcpip` requests by connecting to the requested target — either directly or through an outbound proxy.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The server is the tunnel endpoint. It receives SSH channels requesting TCP connections to specific hosts and ports, and makes those connections on behalf of the client. It's the same role as an SSH server with `AllowTcpForwarding yes`, but self-contained and transport-agnostic.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ wraith serve │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ SSH Server (russh) │ │
|
||||||
|
│ │ ServerHandler per connection │ │
|
||||||
|
│ │ - auth_publickey() → Accept/Reject │ │
|
||||||
|
│ │ - channel_open_direct_tcpip() → connect │ │
|
||||||
|
│ │ - channel_open_forwarded_tcpip() → proxy │ │
|
||||||
|
│ └──────────────────┬──────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────▼──────────────────────────┐ │
|
||||||
|
│ │ Transport Acceptor │ │
|
||||||
|
│ │ (TcpListener / TlsListener / IrohEndpoint) │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ Outbound Proxy (optional) │ │
|
||||||
|
│ │ - Direct TCP │ │
|
||||||
|
│ │ - SOCKS5 proxy │ │
|
||||||
|
│ │ - HTTP CONNECT proxy │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The server supports Ed25519 public key authentication by default:
|
||||||
|
|
||||||
|
1. Load authorized keys from `~/.ssh/authorized_keys` or a specified path
|
||||||
|
2. `auth_publickey()` checks the presented key against the authorized set
|
||||||
|
3. Uses constant-time comparison to prevent timing attacks
|
||||||
|
|
||||||
|
Optional password authentication (not recommended, controlled by feature flag or CLI flag).
|
||||||
|
|
||||||
|
### Channel Handling
|
||||||
|
|
||||||
|
When a client opens a `channel_open_direct_tcpip(host, port, originator_addr, originator_port)`:
|
||||||
|
|
||||||
|
1. **ACL check** — verify the client is allowed to connect to `host:port` (if ACLs are configured)
|
||||||
|
2. **Outbound connection** — connect to the target, either directly or via the configured outbound proxy
|
||||||
|
3. **Bidirectional proxy** — `tokio::io::copy_bidirectional` between the SSH channel stream and the outbound TCP stream
|
||||||
|
4. **Cleanup** — close the channel and TCP stream when either side disconnects
|
||||||
|
|
||||||
|
### Outbound Proxy Modes
|
||||||
|
|
||||||
|
| Mode | CLI Flag | Behavior |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **Direct** | (default) | `TcpStream::connect(target)` |
|
||||||
|
| **SOCKS5** | `--proxy socks5://addr:port` | Connect through SOCKS5 proxy |
|
||||||
|
| **HTTP CONNECT** | `--proxy http://addr:port` | Connect through HTTP CONNECT proxy |
|
||||||
|
|
||||||
|
The proxy setting applies globally to all outbound connections from the server.
|
||||||
|
|
||||||
|
### Stealth Mode
|
||||||
|
|
||||||
|
When `--stealth` is enabled on the server alongside TLS transport:
|
||||||
|
|
||||||
|
1. Non-SSH connections (normal web browsers, scanners) receive a fake nginx 404 response
|
||||||
|
2. The server detects whether the connecting client is speaking SSH or HTTP after the TLS handshake
|
||||||
|
3. If SSH: proceed with `server::run_stream()`
|
||||||
|
4. If HTTP: respond with `HTTP/1.1 404 Not Found` + `Server: nginx` headers, then close
|
||||||
|
|
||||||
|
This makes the server appear as an ordinary web server to port scanners and DPI systems.
|
||||||
|
|
||||||
|
### Server Handler (russh)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct WraithServerHandler {
|
||||||
|
authorized_keys: HashSet<PublicKey>,
|
||||||
|
proxy_config: Option<ProxyConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl server::Handler for WraithServerHandler {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Auth {
|
||||||
|
if self.authorized_keys.contains(key) {
|
||||||
|
Auth::Accept
|
||||||
|
} else {
|
||||||
|
Auth::Reject { proceed_with_methods: None, partial_success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_direct_tcpip(
|
||||||
|
&mut self,
|
||||||
|
channel: Channel<server::Msg>,
|
||||||
|
host: &str,
|
||||||
|
port: u32,
|
||||||
|
originator_addr: &str,
|
||||||
|
originator_port: u32,
|
||||||
|
session: &mut server::Session,
|
||||||
|
) -> Result<Channel<server::Msg>, Self::Error> {
|
||||||
|
// ACL check (if configured)
|
||||||
|
// Connect to host:port (directly or via proxy)
|
||||||
|
// Spawn bidirectional proxy task
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- **Log**: Auth attempts (timestamp, source IP, user, key fingerprint, success/failure)
|
||||||
|
- **Do not log**: Channel open targets, DNS resolutions, bytes transferred, connection duration
|
||||||
|
|
||||||
|
This provides enough information for fail2ban integration without creating a privacy-sensitive audit trail.
|
||||||
|
|
||||||
|
### CLI Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic server (SSH on port 22)
|
||||||
|
wraith serve --key ~/.ssh/ssh_host_ed25519_key
|
||||||
|
|
||||||
|
# With TLS on port 443
|
||||||
|
wraith serve --key ~/.ssh/ssh_host_ed25519_key \
|
||||||
|
--transport tls \
|
||||||
|
--tls-cert /etc/ssl/cert.pem \
|
||||||
|
--tls-key /etc/ssl/key.pem
|
||||||
|
|
||||||
|
# With TLS + stealth (fake nginx 404 to scanners)
|
||||||
|
wraith serve --key ~/.ssh/ssh_host_ed25519_key \
|
||||||
|
--transport tls \
|
||||||
|
--tls-cert /etc/ssl/cert.pem \
|
||||||
|
--tls-key /etc/ssl/key.pem \
|
||||||
|
--stealth
|
||||||
|
|
||||||
|
# With iroh transport (no public IP needed)
|
||||||
|
wraith serve --key ~/.ssh/ssh_host_ed25519_key \
|
||||||
|
--transport iroh
|
||||||
|
|
||||||
|
# With outbound proxy
|
||||||
|
wraith serve --key ~/.ssh/ssh_host_ed25519_key \
|
||||||
|
--proxy socks5://127.0.0.1:9050
|
||||||
|
|
||||||
|
# All options
|
||||||
|
wraith serve \
|
||||||
|
--key <path> \ # SSH host key path (required)
|
||||||
|
--authorized-keys <path> \ # Authorized keys file (default: ~/.ssh/authorized_keys)
|
||||||
|
--transport tcp|tls|iroh \ # Transport mode
|
||||||
|
--listen <addr:port> \ # Listen address for TCP/TLS (default: 0.0.0.0:22)
|
||||||
|
--tls-cert <path> \ # TLS certificate (required for tls transport)
|
||||||
|
--tls-key <path> \ # TLS private key (required for tls transport)
|
||||||
|
--stealth \ # Serve fake nginx 404 to non-SSH connections
|
||||||
|
--proxy <url> \ # Outbound proxy URL (socks5:// or http://)
|
||||||
|
--iroh-relay <url> # iroh relay server URL (default: n0 relay)
|
||||||
|
```
|
||||||
|
|
||||||
|
### iroh Server Mode
|
||||||
|
|
||||||
|
When running with `--transport iroh`, the server:
|
||||||
|
|
||||||
|
1. Creates an `iroh::Endpoint` with the SSH ALPN
|
||||||
|
2. Prints its `EndpointId` (Ed25519 public key) — this is what clients use to connect
|
||||||
|
3. Uses `iroh::protocol::Router` to accept incoming connections
|
||||||
|
4. For each connection, accepts a `open_bi()` stream and passes it to `server::run_stream()`
|
||||||
|
|
||||||
|
No listening port is needed. The server connects outbound to the iroh relay and awaits connections from clients who know its `EndpointId`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The server does not log tunnel destinations (ADR-006, pending)
|
||||||
|
- One `ServerHandler` instance per connection. Handler state is not shared between connections (unless explicitly configured via `Arc` shared state for things like connection limits).
|
||||||
|
- The server binds to a single transport at a time. Running multiple transports (e.g., TCP + iroh) simultaneously requires separate processes or a future multiplexing feature.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-07**: Whether to support ACME/Let's Encrypt auto-provisioning for TLS certificates
|
||||||
|
- **OQ-08**: Connection limits and rate limiting configuration
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [001](decisions/001-pluggable-transport.md) | Pluggable transport | Transport trait, SSH consumes stream |
|
||||||
|
| [004](decisions/004-ssh-over-transport.md) | SSH over transport | SSH never touches network directly |
|
||||||
138
docs/architecture/transport.md
Normal file
138
docs/architecture/transport.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Transport Layer
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
The transport layer produces a duplex byte stream (`AsyncRead + AsyncWrite + Unpin + Send`) that the SSH layer consumes via `russh::client::connect_stream()` or `russh::server::run_stream()`. The SSH layer is completely unaware of what transport it runs over.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Pluggable transports are the core architectural insight. They enable:
|
||||||
|
|
||||||
|
- **Simple deployment**: TCP on port 22 for basic use
|
||||||
|
- **Censorship resistance**: TLS on port 443 looks like HTTPS
|
||||||
|
- **NAT traversal**: iroh QUIC allows connections without public IPs
|
||||||
|
- **Composability**: transports can be layered (iroh through SOCKS5 through SSH through TLS)
|
||||||
|
|
||||||
|
Without this abstraction, each transport mode would need its own SSH connection logic. With it, there's one SSH implementation and N transport implementations.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Transport Trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// The core abstraction. Each transport produces ONE duplex stream.
|
||||||
|
// The SSH session runs over this stream for its entire lifetime.
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Transport: Send + Sync + 'static {
|
||||||
|
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
||||||
|
|
||||||
|
/// Connect to the remote endpoint and return a duplex stream.
|
||||||
|
/// For client-side transports.
|
||||||
|
async fn connect(&self) -> Result<Self::Stream>;
|
||||||
|
|
||||||
|
/// Return a human-readable description of this transport for logging.
|
||||||
|
fn describe(&self) -> String;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side Transport Acceptor
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TransportAcceptor: Send + Sync + 'static {
|
||||||
|
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
||||||
|
|
||||||
|
/// Accept an incoming connection and return a duplex stream.
|
||||||
|
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata about the incoming connection.
|
||||||
|
pub struct TransportInfo {
|
||||||
|
pub remote_addr: Option<SocketAddr>,
|
||||||
|
pub transport_kind: TransportKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum TransportKind {
|
||||||
|
Tcp,
|
||||||
|
Tls { server_name: Option<String> },
|
||||||
|
Iroh { peer_id: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport Implementations
|
||||||
|
|
||||||
|
| Transport | Client | Server | Stream Type |
|
||||||
|
|-----------|--------|--------|-------------|
|
||||||
|
| **TcpTransport** | `TcpStream::connect(addr)` | `TcpListener::accept()` | `TcpStream` |
|
||||||
|
| **TlsTransport** | `TlsStream<TcpStream>` (client TLS) | `TlsStream<TcpStream>` (server TLS) | `tokio_rustls::client::TlsStream<TcpStream>` |
|
||||||
|
| **IrohTransport** | `endpoint.connect(peer, alpn)` then `conn.open_bi()` then `join(recv, send)` | `endpoint.accept()` then `conn.accept_bi()` then `join(recv, send)` | `tokio::io::Join<RecvStream, SendStream>` |
|
||||||
|
|
||||||
|
### Iroh Stream Join
|
||||||
|
|
||||||
|
Since QUIC splits streams into separate `RecvStream` and `SendStream`, while russh expects a single duplex stream, we combine them:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One line. This works because RecvStream: AsyncRead and SendStream: AsyncWrite.
|
||||||
|
let stream = tokio::io::join(recv_stream, send_stream);
|
||||||
|
```
|
||||||
|
|
||||||
|
See ADR-003 for the decision to use `tokio::io::join` over a custom wrapper.
|
||||||
|
|
||||||
|
### Connection Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
│ │
|
||||||
|
│ transport.connect() │ transport_acceptor.accept()
|
||||||
|
│ ─────────────────────────────────────────────▶│
|
||||||
|
│ (duplex byte stream established) │
|
||||||
|
│ │
|
||||||
|
│ russh::client::connect_stream(config, │ russh::server::run_stream(config,
|
||||||
|
│ stream, handler) │ stream, handler)
|
||||||
|
│ │
|
||||||
|
│ ═══════ SSH session over stream ═════════════ │
|
||||||
|
│ ═════════════════════════════════════════════ │
|
||||||
|
│ │
|
||||||
|
│ channel_open_direct_tcpip(host, port, ...) │
|
||||||
|
│ ─────────────────────────────────────────────▶│
|
||||||
|
│ │
|
||||||
|
│ ┌─────── TCP proxy ──────────────────┐ │
|
||||||
|
│ │ SSH channel ←→ TcpStream::connect │ │
|
||||||
|
│ └────────────────────────────────────┘ │
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transport Chaining
|
||||||
|
|
||||||
|
Transports can be nested. A common pattern: iroh transport through an upstream SOCKS5 proxy (which itself tunnels through another wraith instance):
|
||||||
|
|
||||||
|
```
|
||||||
|
wraith connect --transport iroh --proxy socks5://127.0.0.1:1080
|
||||||
|
```
|
||||||
|
|
||||||
|
The iroh endpoint's outbound TCP connections go through the SOCKS5 proxy, which itself tunnels through another wraith instance's SSH channel. The SOCKS5 proxy is provided by the core SOCKS5 server — no special chaining code needed.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- SSH sees only the stream. It never opens its own TCP connections. (ADR-004)
|
||||||
|
- Each transport produces exactly one stream per SSH session. Multiple sessions need multiple `connect()` calls.
|
||||||
|
- The iroh transport reuses a single `Endpoint` across multiple sessions (one QUIC connection per peer, multiple `open_bi()` streams). The endpoint is created once and shared.
|
||||||
|
- TLS transport requires certificate configuration on the server side. The client can accept any certificate (self-signed) or verify against a CA.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-02**: iroh relay configuration defaults
|
||||||
|
- **OQ-05**: Whether to support transport chaining in the CLI (`--transport iroh --proxy socks5://...`) or keep it as a separate manual configuration
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [001](decisions/001-pluggable-transport.md) | Pluggable transport | Transport trait produces stream, SSH consumes it |
|
||||||
|
| [003](decisions/003-iroh-stream-join.md) | iroh stream join | `tokio::io::join` combines QUIC halves |
|
||||||
|
| [004](decisions/004-ssh-over-transport.md) | SSH over transport | SSH never touches TCP/iroh/TLS directly |
|
||||||
111
docs/architecture/tun-shim.md
Normal file
111
docs/architecture/tun-shim.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# TUN Shim
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
A separate process (`wraith-tun`) that creates a TUN interface, reads IP packets from it, and forwards them through the core wraith client's SOCKS5 port. Requires root or `CAP_NET_ADMIN`.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The core wraith binary must never require root. TUN interfaces need elevated privileges. By separating TUN into its own minimal process, we:
|
||||||
|
|
||||||
|
- Minimize the root-required code surface (auditable in an afternoon)
|
||||||
|
- Keep the core binary unprivileged for SOCKS5 and port forwarding
|
||||||
|
- Allow the TUN shim to crash without affecting the SSH session
|
||||||
|
- Match the proven tun2proxy architecture
|
||||||
|
|
||||||
|
(ADR-002)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ wraith-tun │ │ wraith connect │
|
||||||
|
│ (root) │ │ (unprivileged) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────────┐ │ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ TUN Device │ │ │ │ SOCKS5 Server │ │
|
||||||
|
│ │ (tun-rs) │◄├─────┤├►│ :1080 │ │
|
||||||
|
│ │ 10.0.0.1/24│ │ │ └──────────────────────────┘ │
|
||||||
|
│ └────────────┘ │ │ │
|
||||||
|
│ │ │ ┌──────────────────────────┐ │
|
||||||
|
│ Route all │ │ │ SSH Client (russh) │ │
|
||||||
|
│ traffic via │ │ │ connect via Transport │ │
|
||||||
|
│ TUN device │ │ └──────────────────────────┘ │
|
||||||
|
└─────────────────┘ └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. OS routing table sends all traffic through TUN device `tun0`
|
||||||
|
2. `wraith-tun` reads IP packets from TUN device
|
||||||
|
3. `wraith-tun` extracts destination IP:port from each packet
|
||||||
|
4. `wraith-tun` connects to `127.0.0.1:1080` (wraith SOCKS5) with `SOCKS5h` (domain resolution by proxy)
|
||||||
|
5. `wraith-tun` proxies the TCP connection through SOCKS5
|
||||||
|
6. wraith's SOCKS5 server opens an SSH direct-tcpip channel to the destination
|
||||||
|
7. Bytes flow: application → TUN → SOCKS5 → SSH channel → server → target
|
||||||
|
|
||||||
|
### Virtual DNS
|
||||||
|
|
||||||
|
The TUN shim implements virtual DNS (same approach as tun2proxy):
|
||||||
|
|
||||||
|
- DNS queries to port 53 arriving at the TUN device are intercepted
|
||||||
|
- Query names are mapped to fake IPs from `198.18.0.0/15`
|
||||||
|
- Connections to fake IPs are resolved to the original domain name via SOCKS5h
|
||||||
|
- This prevents DNS leaks (all DNS resolution happens server-side)
|
||||||
|
|
||||||
|
### CLI Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic TUN mode (uses wraith's SOCKS5 on 127.0.0.1:1080)
|
||||||
|
sudo wraith-tun --socks5 127.0.0.1:1080
|
||||||
|
|
||||||
|
# With custom TUN address
|
||||||
|
sudo wraith-tun --socks5 127.0.0.1:1080 --tun-addr 10.0.0.1/24
|
||||||
|
|
||||||
|
# With DNS configuration
|
||||||
|
sudo wraith-tun --socks5 127.0.0.1:1080 --dns virtual
|
||||||
|
|
||||||
|
# Unprivileged mode (creates network namespace)
|
||||||
|
wraith-tun --socks5 127.0.0.1:1080 --unshare
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unprivileged Mode
|
||||||
|
|
||||||
|
The `--unshare` flag creates a new network namespace, sets up the TUN device inside it, and maintains connectivity to the SOCKS5 proxy via the global namespace. This allows running without root, using only `CAP_NET_ADMIN` capability or namespace creation permissions.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This is Phase 3 of the implementation plan. The core (`wraith serve`, `wraith connect` with SOCKS5 and port forwarding) comes first. TUN is an add-on.
|
||||||
|
|
||||||
|
### What `wraith-tun` Does NOT Do
|
||||||
|
|
||||||
|
- It does not manage SSH sessions
|
||||||
|
- It does not know about transports
|
||||||
|
- It does not handle authentication
|
||||||
|
- It does not read SSH keys
|
||||||
|
|
||||||
|
It only: reads packets from TUN, forwards to SOCKS5. That's it.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Requires root or `CAP_NET_ADMIN` (or `--unshare` namespace isolation)
|
||||||
|
- IPv4 only in initial release, IPv6 follow-up
|
||||||
|
- UDP over TCP (DNS queries are handled via SOCKS5h, other UDP is dropped in initial release)
|
||||||
|
- Approximately 200-500 lines of Rust for the initial implementation
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ-03**: Windows TUN support scope (wintun.dll dependency)
|
||||||
|
- **OQ-09**: Whether to use tun2proxy's `ip-stack` crate for TCP reconstruction or implement a simpler packet-level approach
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| ADR | Decision | Summary |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| [002](decisions/002-tun-separate-process.md) | TUN separate process | Core never needs root, TUN is thin wrapper |
|
||||||
|
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 first | TUN forwards to SOCKS5, not to SSH directly |
|
||||||
Reference in New Issue
Block a user