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:
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 |
|
||||
Reference in New Issue
Block a user