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:
2026-06-01 15:01:45 +00:00
parent c1275e2dfd
commit dad8224686
14 changed files with 1116 additions and 0 deletions

View 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 |