Resolved all 11 open questions based on project guidance: Transport: - OQ-01/OQ-07: ACME/Let's Encrypt with domain + IP paths (ADR-008) - OQ-02: Default to n0 relay, --iroh-relay override (ADR-009) - OQ-05: Transport chaining supported natively (ADR-010) Client: - OQ-06: Programmatic-first API, no ~/.ssh/config (ADR-011) Server: - OQ-04: Ed25519 + OpenSSH cert-authority, no password auth (ADR-012) - OQ-08: fail2ban-friendly logging + built-in rate limiting (ADR-013) TUN: - OQ-03/OQ-09: Deferred entirely, recommend tun2proxy (ADR-014) - tun-shim.md marked deprecated NAPI: - OQ-10: Expose both connect() and serve() (ADR-016) - OQ-11: Use napi-rs for FFI bridge (ADR-015) Additional ADRs created during review: - ADR-006: No logging of tunnel destinations (was phantom reference) - ADR-017: Stealth mode protocol multiplexing - ADR-018: Control channel for pubsub over SSH Fixed: ADR-002 status → Superseded, ADR-007 title typo, WRAUTH_SERVER typo, ADR-005 stale wraith-tun refs, undefined ACL feature removed from server.md, --proxy semantic difference documented.
6.5 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-01 |
NAPI Wrapper & PubSub Event Target
What
Two integration layers that enable TypeScript/JavaScript consumers to use wraith as a transport:
- NAPI wrapper (
@alkdev/wraith) — A Node.js native addon (via napi-rs) exposingconnect()andserve()that return duplex streams - PubSub event target (
@alkdev/pubsubadapter) — An implementation of theTypedEventTargetinterface 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 minimal — it exposes transport connections as duplex streams, not the full SSH protocol. The pubsub adapter wraps those streams with EventEnvelope serialization.
Architecture
NAPI Wrapper (napi-rs)
The wrapper uses napi-rs (ADR-015) and exposes two functions (ADR-016):
// @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, or Buffer with key data
// TLS
tlsServerName?: string; // SNI hostname
insecure?: boolean; // accept self-signed certs
// iroh
irohRelay?: string; // relay URL (default: n0)
// Proxy
proxy?: string; // upstream SOCKS5/HTTP proxy URL
}
interface WraithServeOptions {
// Transport
transport: 'tcp' | 'tls' | 'iroh';
// Auth
hostKey?: string; // path to SSH host key, or Buffer with key data
authorizedKeys?: string; // path to authorized_keys, or Buffer with key data
certAuthority?: string; // path to CA public key for cert-authority auth
// TLS
tlsCert?: string; // path to TLS cert
tlsKey?: string; // path to TLS key
acmeDomain?: string; // ACME domain for auto-cert (ADR-008)
// Listen
listen?: string; // listen address (default: 0.0.0.0:22)
// iroh
irohRelay?: string; // relay URL (default: n0)
}
// Returns a Duplex stream for the SSH channel
function connect(options: WraithConnectOptions): Promise<Duplex>;
// Returns a server object with close() and connection events
function serve(options: WraithServeOptions): Promise<WraithServer>;
interface WraithServer {
close(): Promise<void>;
onConnection(callback: (stream: Duplex, info: ConnectionInfo) => void): void;
}
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.
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.
PubSub Event Target Adapter
This implements TypedEventTarget from @alkdev/pubsub:
// @alkdev/pubsub (new adapter: event-target-wraith.ts)
export interface WraithEventTargetOptions {
stream: Duplex; // from @alkdev/wraith.connect() or serve()
}
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:
EventEnvelopeJSON ({ type, id, payload }) - Control:
__subscribe/__unsubscribemessages for topic-based routing - Direction: Bidirectional —
dispatchEventsends,addEventListenersubscribes and receives
On the Server Side
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:
- The server's
channel_open_direct_tcpiphandler detects the reservedwraith-controltarget
When a client connects to this destination:
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:9736then create WebSocket event target pointing atws://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 duplex streams, 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
irohfeature adds significant size; it should be an optional feature. - Keys can be provided as file paths or
Bufferdata, supporting both CLI and programmatic usage patterns (ADR-011).
Open Questions
None — all resolved.
Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| 007 | NAPI exposes single duplex stream | No SSH multiplexing in JS, pubsub handles it |
| 011 | Programmatic-first API | No file-based config; options are structs or env vars |
| 015 | napi-rs for FFI | Standard Node.js native addon tooling |
| 016 | Both connect() and serve() | NAPI exposes client and server sides from the start |
| 018 | Control channel for pubsub | Reserved wraith-control destination for event bus |