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
This commit is contained in:
@@ -1,24 +1,24 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Wraith Architecture
|
||||
|
||||
## Current State
|
||||
|
||||
Pre-implementation. Feasibility assessment complete. Architecture specification drafted — all open questions resolved, pending review.
|
||||
Architecture specification reviewed and ready for implementation. All open questions resolved. 19 ADRs accepted.
|
||||
|
||||
## 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 |
|
||||
| [overview.md](overview.md) | reviewed | Package purpose, exports, dependencies |
|
||||
| [transport.md](transport.md) | reviewed | Transport abstraction: TCP, TLS, iroh |
|
||||
| [client.md](client.md) | reviewed | Client connection, SOCKS5, port forwarding |
|
||||
| [server.md](server.md) | reviewed | Server acceptance, channel handling, proxy |
|
||||
| [tun-shim.md](tun-shim.md) | deprecated | TUN interface wrapper — **deferred**, use tun2proxy |
|
||||
| [napi-and-pubsub.md](napi-and-pubsub.md) | draft | NAPI wrapper and pubsub event target adapter |
|
||||
| [napi-and-pubsub.md](napi-and-pubsub.md) | reviewed | NAPI wrapper and pubsub event target adapter |
|
||||
|
||||
## ADR Table
|
||||
|
||||
@@ -42,6 +42,7 @@ Pre-implementation. Feasibility assessment complete. Architecture specification
|
||||
| [016](decisions/016-napi-expose-connect-and-serve.md) | NAPI exposes both connect() and serve() | Accepted |
|
||||
| [017](decisions/017-stealth-mode-protocol-multiplexing.md) | Stealth mode — protocol multiplexing on port 443 | Accepted |
|
||||
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel for pubsub over SSH | Accepted |
|
||||
| [019](decisions/019-proxy-dual-semantics.md) | `--proxy` dual semantics (client vs server) | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Client
|
||||
@@ -55,7 +55,7 @@ The primary client interface. Listens on a local port (default `127.0.0.1:1080`)
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -92,7 +92,9 @@ On transport failure:
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -104,6 +106,21 @@ The client uses programmatic configuration — no `~/.ssh/config` parsing, no cu
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -134,7 +151,7 @@ wraith connect --server example.com --forward 5432:db.internal:5432 --forward 63
|
||||
# All options
|
||||
wraith connect \
|
||||
--server <addr> \ # TCP/TLS server address (required for tcp/tls)
|
||||
--peer <endpoint-id> \ # iroh peer ID (required for iroh)
|
||||
--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)
|
||||
@@ -154,6 +171,28 @@ wraith connect \
|
||||
- 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
|
||||
|
||||
@@ -164,5 +203,7 @@ None — all resolved.
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 first | SOCKS5 is the primary interface; TUN is external (tun2proxy) |
|
||||
| [006](decisions/006-no-logging-of-tunnel-destinations.md) | No logging of destinations | Client does not log SOCKS5 request targets (consistent with ADR-006) |
|
||||
| [011](decisions/011-no-ssh-config-programmatic-api.md) | Programmatic-first API | No file-based config; options are structs, env vars, or CLI flags |
|
||||
| [012](decisions/012-auth-ed25519-and-cert-authority.md) | Key + cert-authority | No password auth; OpenSSH cert-authority for multi-user |
|
||||
| [019](decisions/019-proxy-dual-semantics.md) | Proxy dual semantics | `--proxy` routes transport on client, data on server |
|
||||
42
docs/architecture/decisions/019-proxy-dual-semantics.md
Normal file
42
docs/architecture/decisions/019-proxy-dual-semantics.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ADR-019: `--proxy` Has Different Semantics on Client vs Server
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The `--proxy` CLI flag appears on both `wraith connect` (client) and `wraith serve` (server), but the two sides proxy fundamentally different things:
|
||||
|
||||
- **Client**: `--proxy` routes the *transport connection* through the proxy. For example, `wraith connect --transport iroh --proxy socks5://127.0.0.1:1080` means the iroh endpoint's outbound TCP connections go through the specified SOCKS5 proxy before reaching the iroh relay. The proxy wraps the transport layer.
|
||||
|
||||
- **Server**: `--proxy` routes *outbound target connections* through the proxy. For example, `wraith serve --proxy socks5://127.0.0.1:9050` means when an SSH client opens a `direct_tcpip` channel to `db.internal:5432`, the server connects to that target through the specified proxy. The proxy wraps the data-plane connections.
|
||||
|
||||
Using the same flag name for both is intentional — from the user's perspective, both mean "route traffic through a proxy." But the layer at which the proxy operates differs, and this needs to be explicit so implementers don't confuse the two.
|
||||
|
||||
ADR-010 addressed transport chaining for the client side only. The server-side outbound proxy behavior has no ADR. This ADR documents both semantics and the rationale for sharing the flag name.
|
||||
|
||||
## Decision
|
||||
The `--proxy` flag uses the same name on client and server, with documented different semantics:
|
||||
|
||||
| Side | Flag | What gets proxied | Example |
|
||||
|------|------|-------------------|---------|
|
||||
| Client | `--proxy` | Transport connection (outbound to server/relay) | `--transport iroh --proxy socks5://...` → iroh endpoint connects through proxy |
|
||||
| Server | `--proxy` | Outbound target connections (data plane) | `--proxy socks5://...` → direct_tcpip targets reached through proxy |
|
||||
|
||||
On the **client**, `--proxy` affects the transport layer. It only applies to transports that make outbound TCP connections (iroh through a proxy, TLS through a proxy). For plain TCP transport, `--proxy` has no meaningful effect since the transport is already a direct TCP connection — use the SOCKS5 server instead.
|
||||
|
||||
On the **server**, `--proxy` affects the data plane. All `channel_open_direct_tcpip` outbound connections are routed through the proxy, regardless of transport mode.
|
||||
|
||||
This is not a naming collision — it's the same conceptual operation ("route through a proxy") at different layers. The shared name avoids forcing users to learn two proxy flags.
|
||||
|
||||
## Consequences
|
||||
- **Positive**: One flag name (`--proxy`) instead of two. Users already understand "proxy" as "route through this."
|
||||
- **Positive**: Client-side proxy is minimal implementation — iroh's endpoint builder accepts proxy config natively.
|
||||
- **Positive**: Server-side proxy is straightforward — all outbound TCP from channel handlers goes through the proxy.
|
||||
- **Negative**: Implementers must read the correct spec (client vs server) to understand what `--proxy` does for their side. This is mitigated by CLI help text that clearly describes the behavior per side.
|
||||
- **Negative**: On the client, `--proxy` with `--transport tcp` is effectively a no-op (the transport is already a direct TCP connection to the server). The CLI should handle this case gracefully.
|
||||
|
||||
## References
|
||||
- [ADR-010](010-transport-chaining-cli.md) — client-side transport chaining
|
||||
- [transport.md](../transport.md) — transport layer spec
|
||||
- [client.md](../client.md) — client CLI
|
||||
- [server.md](../server.md) — server outbound proxy
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# NAPI Wrapper & PubSub Event Target
|
||||
@@ -31,7 +31,7 @@ interface WraithConnectOptions {
|
||||
// TCP/TLS mode
|
||||
server?: string; // e.g., "example.com:443"
|
||||
// iroh mode
|
||||
peer?: string; // iroh EndpointId (hex)
|
||||
peer?: string; // iroh endpoint ID (base58-encoded)
|
||||
// Transport
|
||||
transport: 'tcp' | 'tls' | 'iroh';
|
||||
// Auth
|
||||
@@ -76,10 +76,21 @@ interface WraithServer {
|
||||
|
||||
The NAPI layer is **transport-agnostic** — it doesn't know about pubsub's `EventEnvelope`. The pubsub adapter wraps the `Duplex` stream to implement `TypedEventTarget`. This separation ensures the NAPI wrapper is reusable for any stream-based protocol, not tied specifically to pubsub.
|
||||
|
||||
### NAPI `connect()` vs CLI `wraith connect`
|
||||
|
||||
The NAPI `connect()` function and the CLI `wraith connect` command are fundamentally different operations despite sharing the same name:
|
||||
|
||||
- **CLI `wraith connect`**: Starts a full SSH client session with a local SOCKS5 server and optional port forwards. It manages multiple SSH channels over a single session — the user routes traffic through it via SOCKS5 or forwarded ports.
|
||||
- **NAPI `connect()`**: Opens a single SSH channel and returns it as a `Duplex` stream. No SOCKS5 server, no port forwarding. The caller reads and writes bytes directly. This is designed for the pubsub/programmatic use case where a single bidirectional byte stream is needed.
|
||||
|
||||
For SOCKS5 proxy functionality, use the CLI binary (`wraith connect`). The NAPI wrapper is for programmatic consumers that need a raw stream.
|
||||
|
||||
### Programmatic Configuration (ADR-011)
|
||||
|
||||
Both `connect()` and `serve()` accept options as plain objects. No file paths are mandatory — keys can be provided as `Buffer` data directly, making programmatic usage straightforward. Environment variables (`WRAITH_SERVER`, `WRAITH_IDENTITY`) provide convenience defaults.
|
||||
|
||||
Key material provided as `Buffer` must be in **OpenSSH key format** (the format used by `ssh-keygen`). Private keys: OpenSSH format (`-----BEGIN OPENSSH PRIVATE KEY-----`). Public keys: OpenSSH format (`ssh-ed25519 AAAA...`). PEM-encoded keys (PKCS#1, PKCS#8) are not supported.
|
||||
|
||||
### PubSub Event Target Adapter
|
||||
|
||||
This implements `TypedEventTarget` from `@alkdev/pubsub`:
|
||||
@@ -112,13 +123,11 @@ Wire protocol (same as other pubsub adapters):
|
||||
|
||||
The wraith server uses a reserved `direct_tcpip` destination (`wraith-control:0`) for the pubsub control channel (ADR-018). When a client connects to this destination:
|
||||
|
||||
1. The server's `channel_open_direct_tcpip` handler detects the reserved `wraith-control` target
|
||||
|
||||
When a client connects to this destination:
|
||||
1. The server's `channel_open_direct_ip` handler detects the reserved `wraith-control` 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.
|
||||
Users who prefer not to use the control channel can alternatively run a pubsub hub on a specific port and use standard port forwarding: `wraith connect --forward 9736:hub:9736`. This is a deployment choice, not a separate implementation — wraith's port forwarding works normally for any TCP service.
|
||||
|
||||
### Direction Agnostic
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Wraith Overview
|
||||
@@ -72,6 +72,8 @@ The `wraith-core` crate exports the pluggable components for embedding or progra
|
||||
|
||||
7. **NAPI exposes both connect() and serve()** — The napi-rs wrapper provides client and server functionality, using napi-rs as the FFI bridge. The NAPI layer is transport-agnostic and not tied to pubsub. (ADR-015, ADR-016)
|
||||
|
||||
8. **Error handling follows a consistent layered pattern** — Transport and auth errors cause reconnection (client, with exponential backoff) or connection rejection (server). Channel-level errors (target unreachable, proxy failure) close the individual channel without killing the session. Library API errors propagate via `anyhow::Result` / `thiserror` types. CLI reports errors to stderr with appropriate exit codes. NAPI errors are marshalled as JavaScript exceptions.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
@@ -94,6 +96,7 @@ The `wraith-core` crate exports the pluggable components for embedding or progra
|
||||
| [016](decisions/016-napi-expose-connect-and-serve.md) | connect + serve | NAPI exposes both client and server from the start |
|
||||
| [017](decisions/017-stealth-mode-protocol-multiplexing.md) | Stealth mode | Protocol multiplexing on port 443 |
|
||||
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel | Reserved `wraith-control` destination for pubsub |
|
||||
| [019](decisions/019-proxy-dual-semantics.md) | Proxy dual semantics | `--proxy` routes transport on client, data on server |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Server
|
||||
@@ -67,6 +67,10 @@ This enables multi-user deployments where adding one CA line to `authorized_keys
|
||||
|
||||
**No password authentication over SSH.** Keys and certificates are sufficient and more secure. If a local SOCKS5 proxy needs its own auth layer, that's a separate concern.
|
||||
|
||||
### Key Material Format
|
||||
|
||||
Key inputs (`--key`, `--authorized-keys`, `--cert-authority`) accept either file paths or in-memory data (via library API or NAPI wrapper). The accepted format is **OpenSSH key format** throughout — private keys in OpenSSH format (`-----BEGIN OPENSSH PRIVATE KEY-----`), public keys in OpenSSH format (`ssh-ed25519 AAAA... user@host`), and authorized keys files in standard OpenSSH `authorized_keys` format. PEM-encoded keys (PKCS#1, PKCS#8) are not supported.
|
||||
|
||||
### TLS Certificate Provisioning
|
||||
|
||||
The server supports three TLS certificate modes (ADR-008):
|
||||
@@ -81,6 +85,10 @@ ACME support is feature-gated behind the `acme` feature flag to keep the base bi
|
||||
|
||||
When a client opens a `channel_open_direct_tcpip(host, port, originator_addr, originator_port)`:
|
||||
|
||||
**Reserved destination** — If `host` starts with `wraith-` (e.g., `wraith-control`), the server routes the channel internally instead of connecting to a TCP target. The primary reserved destination is `wraith-control:0`, which bridges the channel to the local pubsub event bus (ADR-018).
|
||||
|
||||
**Regular destination** — For all other targets:
|
||||
|
||||
1. **Connection** — connect to `host:port`, either directly or via the configured outbound proxy
|
||||
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
|
||||
@@ -107,49 +115,23 @@ When `--stealth` is enabled on the server alongside TLS transport:
|
||||
|
||||
This makes the server appear as an ordinary web server to port scanners and DPI systems.
|
||||
|
||||
### Server Handler (russh)
|
||||
**Stealth mode requires TLS transport (`--transport tls`).** It has no effect with TCP or iroh transports — in those cases, there is no TLS handshake to peek behind, and protocol multiplexing is impossible. The CLI should reject or warn if `--stealth` is used without `--transport tls`.
|
||||
|
||||
```rust
|
||||
struct WraithServerHandler {
|
||||
authorized_keys: HashSet<PublicKey>,
|
||||
cert_authorities: Vec<PublicKey>,
|
||||
proxy_config: Option<ProxyConfig>,
|
||||
}
|
||||
### Server Handler Behavior
|
||||
|
||||
impl server::Handler for WraithServerHandler {
|
||||
type Error = anyhow::Error;
|
||||
The server handler implements `russh::server::Handler` with two primary responsibilities:
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Auth {
|
||||
// Check direct key match
|
||||
if self.authorized_keys.contains(key) {
|
||||
return Auth::Accept;
|
||||
}
|
||||
// Check certificate authority validation
|
||||
if let Some(cert) = key.as_certificate() {
|
||||
for ca in &self.cert_authorities {
|
||||
if cert.verify(ca) && cert.is_valid() {
|
||||
return Auth::Accept;
|
||||
}
|
||||
}
|
||||
}
|
||||
Auth::Reject { proceed_with_methods: None, partial_success: false }
|
||||
}
|
||||
**Authentication (`auth_publickey`)**:
|
||||
- Check the presented key against the configured `authorized_keys` set (constant-time comparison)
|
||||
- If no direct match, check whether the key is a certificate signed by a trusted cert-authority
|
||||
- Validate certificate signature, expiry, and principal restrictions (e.g., `permit-port-forwarding`, `no-pty`, `source-address`)
|
||||
- Return `Accept` or `Reject`
|
||||
|
||||
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> {
|
||||
// Connect to host:port (directly or via proxy)
|
||||
// Spawn bidirectional proxy task
|
||||
Ok(channel)
|
||||
}
|
||||
}
|
||||
```
|
||||
**Channel handling (`channel_open_direct_tcpip`)**:
|
||||
- If the destination host starts with `wraith-`, route internally (control channel, ADR-018)
|
||||
- Otherwise, connect to `host:port` (directly or via the configured outbound proxy)
|
||||
- Spawn a bidirectional proxy task between the SSH channel and the outbound TCP stream
|
||||
- Return the channel for data flow
|
||||
|
||||
### Logging and Rate Limiting
|
||||
|
||||
@@ -236,20 +218,43 @@ wraith serve \
|
||||
|
||||
When running with `--transport iroh`, the server:
|
||||
|
||||
1. Creates an `iroh::Endpoint` with ALPN value `b"wraith-ssh"`
|
||||
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()`
|
||||
1. Creates an iroh endpoint with ALPN value `b"wraith-ssh"`
|
||||
2. Prints its endpoint ID (base58-encoded Ed25519 public key) — this is what clients use as the `--peer` value
|
||||
3. Accepts incoming connections on the endpoint
|
||||
4. For each connection, accepts a bidirectional stream and passes it to `server::run_stream()`
|
||||
|
||||
No listening port is needed. The server connects outbound to the iroh relay (default: n0, override with `--iroh-relay`) and awaits connections from clients who know its `EndpointId`.
|
||||
No listening port is needed. The server connects outbound to the iroh relay (default: n0, override with `--iroh-relay`) and awaits connections from clients who know its endpoint ID (base58-encoded, printed on startup).
|
||||
|
||||
## Constraints
|
||||
|
||||
- The server does not log tunnel destinations (ADR-006). Auth events and connection events are logged for fail2ban integration (ADR-013).
|
||||
- Destination strings beginning with `wraith-` are reserved for internal use (ADR-018). The server must not attempt TCP connections to `wraith-*` destinations — these are intercepted for control channel routing.
|
||||
- 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.
|
||||
- ACME support requires the `acme` feature flag. Without it, only manual TLS certs are supported.
|
||||
- No password authentication over SSH channels. Key-based and cert-authority only (ADR-012).
|
||||
- Stealth mode (`--stealth`) requires TLS transport. It has no effect on TCP or iroh transports (ADR-017).
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
On SIGTERM or SIGINT:
|
||||
|
||||
1. Stop accepting new connections on the transport listener
|
||||
2. Send SSH disconnect messages to all active sessions
|
||||
3. Wait for in-flight channel data to drain (brief timeout, ~2 seconds per session)
|
||||
4. Close all transport listeners
|
||||
5. Exit
|
||||
|
||||
The server does not wait indefinitely for idle connections to close. After the drain timeout, remaining connections are forcibly terminated. This prevents a slow or stuck client from blocking shutdown indefinitely.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Error handling follows the project's layered pattern (see overview.md):
|
||||
|
||||
- **Transport errors**: Cause connection rejection. The listener remains active — a failed TLS handshake or iroh connection attempt does not affect other incoming connections.
|
||||
- **Auth errors**: Result in connection rejection with a logged auth failure event (for fail2ban, ADR-013). Repeated failures from one connection trigger disconnect after `--max-auth-attempts`.
|
||||
- **Channel-level errors**: Individual channel failures (target unreachable, proxy failure) close that channel without affecting the SSH session or other channels. The client receives a channel open failure message.
|
||||
- **CLI errors**: Reported to stderr with a non-zero exit code. Fatal errors (invalid flags, key file not found, bind failure) exit immediately.
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -267,3 +272,4 @@ None — all resolved.
|
||||
| [013](decisions/013-fail2ban-friendly-logging.md) | Fail2ban-friendly logging | Structured auth logs + built-in rate limiting |
|
||||
| [017](decisions/017-stealth-mode-protocol-multiplexing.md) | Stealth mode | Protocol multiplexing on port 443 |
|
||||
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel | Reserved `wraith-control` destination for pubsub |
|
||||
| [019](decisions/019-proxy-dual-semantics.md) | Proxy dual semantics | `--proxy` routes transport on client, data on server |
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-01
|
||||
status: reviewed
|
||||
last_updated: 2026-06-02
|
||||
---
|
||||
|
||||
# Transport Layer
|
||||
@@ -61,7 +61,7 @@ pub struct TransportInfo {
|
||||
pub enum TransportKind {
|
||||
Tcp,
|
||||
Tls { server_name: Option<String> },
|
||||
Iroh { peer_id: String },
|
||||
Iroh { endpoint_id: String },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -75,12 +75,7 @@ pub enum TransportKind {
|
||||
|
||||
### 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);
|
||||
```
|
||||
Since QUIC splits streams into separate `RecvStream` (implements `AsyncRead`) and `SendStream` (implements `AsyncWrite`), while russh expects a single duplex stream, they are combined using `tokio::io::join(recv_stream, send_stream)` which produces a `Join<RecvStream, SendStream>` implementing both traits.
|
||||
|
||||
See ADR-003 for the decision to use `tokio::io::join` over a custom wrapper.
|
||||
|
||||
@@ -100,13 +95,13 @@ Transports can be nested. The CLI supports `--transport iroh --proxy socks5://..
|
||||
wraith connect --transport iroh --proxy socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
This routes iroh's outbound TCP connections through the specified SOCKS5 proxy. iroh's `Endpoint::builder` accepts proxy configuration directly, so the implementation is minimal — parse the proxy URL and pass it to the endpoint builder.
|
||||
This routes iroh's outbound TCP connections through the specified SOCKS5 proxy. The iroh transport supports SOCKS5 and HTTP proxy configuration for its outbound connections — the proxy URL is applied during transport initialization.
|
||||
|
||||
For other combinations:
|
||||
- TCP + TLS is already implicit (TLS wraps TCP in `TlsTransport`)
|
||||
- TLS + SOCKS5 proxy is also supported via `--proxy` with `--transport tls`
|
||||
|
||||
**Note**: `--proxy` has different semantics on the client vs the server:
|
||||
**Note**: `--proxy` has different semantics on the client vs the server (ADR-019):
|
||||
- **Client**: `--proxy` routes the *transport connection* through the proxy (e.g., iroh endpoint → SOCKS5 → iroh relay)
|
||||
- **Server**: `--proxy` routes *outbound target connections* through the proxy (e.g., SSH channel request → SOCKS5 → target host)
|
||||
|
||||
@@ -154,3 +149,4 @@ None — all resolved.
|
||||
| [008](decisions/008-acme-lets-encrypt.md) | ACME/Let's Encrypt | Auto-provision TLS certs, domain and IP paths |
|
||||
| [009](decisions/009-default-iroh-relay.md) | Default iroh relay | n0 relay by default, `--iroh-relay` override |
|
||||
| [010](decisions/010-transport-chaining-cli.md) | Transport chaining | `--proxy` works with all transports natively |
|
||||
| [019](decisions/019-proxy-dual-semantics.md) | Proxy dual semantics | `--proxy` routes transport on client, data on server |
|
||||
Reference in New Issue
Block a user