The blit wire protocol (see protocol.md) is defined purely in terms of ordered, reliable byte streams. Any transport providing that can host a blit connection. This document covers all supported transports, how they connect, and their deployment topologies.
crates/cli/src/transport.rs defines a Transport enum:
enum Transport {
Unix(tokio::net::UnixStream),
Tcp(tokio::net::TcpStream),
Duplex(tokio::io::DuplexStream), // SSH channels, WebRTC, etc.
}All variants expose split (Box<dyn AsyncRead>, Box<dyn AsyncWrite>) via .split(). The rest of the CLI is transport-agnostic.
The BlitTransport interface abstracts over WebSocket, WebTransport, and WebRTC:
interface BlitTransport {
connect(): void;
send(data: Uint8Array): void;
close(): void;
readonly status: ConnectionStatus;
addEventListener(
type: "message",
listener: (data: ArrayBuffer) => void,
): void;
addEventListener(
type: "statuschange",
listener: (status: ConnectionStatus) => void,
): void;
removeEventListener(type: string, listener: Function): void;
}Any implementation of this interface can be passed to BlitWorkspace.
The primary transport. blit server binds a UnixListener. All other transports ultimately proxy to this socket.
Socket path resolution (in order):
$BLIT_SOCKenvironment variable- An existing
$TMPDIR/blit-$BLIT_SERVER_NAME.sock - An existing
$XDG_RUNTIME_DIR/blit-$BLIT_SERVER_NAME.sock - An existing
/tmp/blit-$USER-$BLIT_SERVER_NAME.sock - An existing
/run/blit/$USER-$BLIT_SERVER_NAME.sockpackaged system socket - Otherwise the server bind default:
$TMPDIR,$XDG_RUNTIME_DIR, the user-qualified/tmppath, then/tmp/blit-$BLIT_SERVER_NAME.sock
BLIT_SERVER_NAME defaults to default. The CLI, gateway, and SSH transport
consider only name-suffixed sockets; there is no unnamed socket probe.
When LISTEN_FDS=1 is set, the server adopts fd 3 as its listening socket instead of binding. Provided units:
| Unit | Scope | Socket |
|---|---|---|
blit-server.socket / blit-server.service |
user | %t/blit-default.sock (runs blit server) |
blit.socket / blit.service |
user | %t/blit-default.sock (runs blit server) |
blit-server@.socket / blit-server@.service |
system, per-user | /run/blit/%i-default.sock |
blit-share@.service |
system, per-instance | reads /etc/blit/share-%i.env |
An external process can pass pre-connected client file descriptors to the server via SCM_RIGHTS ancillary messages. Configure with --fd-channel FD or BLIT_FD_CHANNEL=<fd>. The server calls recvmsg() and treats each received fd as an already-connected client stream. This is the integration point for embedding blit server inside a custom service manager or sandbox.
blit gateway (and the CLI's embedded gateway) accept WebSocket connections from browsers.
sequenceDiagram
participant B as browser
participant G as blit gateway
B->>G: passphrase (text frame)
alt accepted
G->>B: "ok" (text frame)
note over B,G: binary blit frames follow
else rejected
G->>B: "auth" (text frame)
note over B,G: connection closed, client discards the passphrase
else throttled
G->>B: "busy" (text frame)
note over B,G: connection closed, client keeps the passphrase and retries
end
"busy" means the auth throttle refused the handshake before looking at the
passphrase — a peer lockout or the global concurrent-handshake cap. It is
deliberately distinct from "auth": a client that conflates the two throws
away a working credential and drops the user at the login screen for what is a
transient server condition.
After "ok", all subsequent messages are binary WebSocket frames. Each frame is one blit message with no additional length prefix.
Both the standalone gateway and the CLI's embedded gateway support multiple named upstream destinations. The browser selects a destination via the WebSocket URL path:
/d/{name}— connect to the named destination/(root path) — connect to the first destination (alphabetically), for backward compatibility with single-server deployments
graph LR
B["browser"] -->|"WS /d/rabbit"| G["blit gateway"]
B -->|"WS /d/hound"| G
G -->|Unix| SR["blit server (rabbit)"]
G -->|Unix| SH["blit server (hound)"]
Terminal IDs are namespaced by connection ("rabbit:1", "hound:2") so they never collide across servers.
| Mechanism | Format | Notes |
|---|---|---|
blit.remotes |
name = uri file (~/.config/blit/blit.remotes) |
Live-reloaded, 0600, pushed to browser via WS |
BLIT_REMOTES |
Path to a blit.remotes-format file |
Overrides the default file location |
blit.remotes and the config WebSocket push live destination updates to connected browsers without requiring a page reload. See ARCHITECTURE.md § Config WebSocket protocol.
Enabled with BLIT_QUIC=1. The gateway listens for QUIC connections on the same port and address as WebSocket.
The gateway sends its browser-facing authority as wt-addr=<hostname:port|:port> before the certificate hash. The browser resolves a :port value against its own page hostname, so reverse proxies cannot accidentally replace the public hostname with an upstream loopback address. Set BLIT_QUIC_PUBLIC_ADDR to override the authority; by default the gateway advertises :<listen-port>.
Self-signed certificates are auto-generated at startup and rotated every 13 days (the WebTransport serverCertificateHashes API requires notAfter - notBefore ≤ 14 days). The certificate's SHA-256 hash is:
- Stored in the gateway's in-memory
wt_cert_hashfield. - Served over the config WebSocket as
wt=<hash>alongsidewt-addr=<authority>. - Available at the
/configendpoint for backward compatibility.
The browser constructs new WebTransport(url, { serverCertificateHashes: [{ algorithm: "sha-256", value: hash }] }).
A rotation invalidates the hash a long-lived tab captured when it loaded, so
MuxTransport.updateWtCertHash adopts each republished hash and clears the
QUIC-failed state — a failure against the old certificate says nothing about
the new one. It deliberately does not reconnect: the hash applies to the next
connection attempt, so a healthy session is never interrupted to switch
protocols. Without this a tab open across a rotation fails every subsequent WT
attempt and silently stays on WebSocket until reloaded.
A failed WebTransport attempt is likewise not permanent. The client falls back
to WebSocket and re-probes QUIC after a cooldown (wtReprobeMs, default 5
minutes), so a transient UDP problem costs one cooldown rather than
WebTransport for the lifetime of the page.
A single bidirectional QUIC stream carries the blit protocol, using the standard 4-byte LE length-prefixed framing (same as Unix socket). The auth handshake before the blit protocol:
sequenceDiagram
participant B as browser
participant G as blit gateway
B->>G: [passphrase_len:2 LE][passphrase:N]
alt accepted
G->>B: 0x01
note over B,G: blit protocol begins
else rejected
G->>B: 0x00
note over B,G: stream closed
end
For production deployments with a real CA certificate:
BLIT_QUIC=1 BLIT_TLS_CERT=/path/to/cert.pem BLIT_TLS_KEY=/path/to/key.pem blit gatewayWhen explicit certs are provided, the browser does not need serverCertificateHashes and standard TLS verification applies.
blit share bridges a blit server to browsers over WebRTC using str0m (a sans-I/O WebRTC library). No gateway is involved — the browser connects directly to the forwarder via the signaling hub.
graph LR
S["blit server"] -->|Unix| F["blit share"]
F <-->|WebSocket signaling| H["hub.blit.sh"]
H <-->|WebSocket signaling| B["browser"]
F <-->|WebRTC DataChannel| B
An ordered, reliable DataChannel labeled "blit" carries 4-byte LE length-prefixed frames, identical to the Unix socket protocol. The forwarder connects to blit server via Unix socket when the data channel opens.
graph LR
F["blit share"] -->|"WS /channel/<pubHex>/producer"| H["hub.blit.sh"]
B["browser"] -->|"WS /channel/<pubHex>/consumer"| H
G["blit gateway\n(BLIT_GATEWAY_WEBRTC=1)"] -->|"WS /channel/<pubHex>/consumer"| H
Both producer and consumers connect to the same channel. pubHex is the Ed25519 verifying key derived from the passphrase via PBKDF2-SHA256 (100,000 rounds, salt "https://blit.sh"). The hub assigns each connection a unique sessionId (UUID), so multiple consumers can connect concurrently without colliding.
All SDP offers/answers and ICE candidates transmitted through the hub are signed with the passphrase-derived Ed25519 signing key (whose public key is the channel ID). The hub verifies signatures before relaying. The hub routes by session UUID and never sees the passphrase.
The forwarder gathers three candidate types:
- Host candidates — direct local network addresses.
- Server-reflexive candidates — public IP/port from STUN binding (
stun.blit.sh). - Relay candidates — TURN allocations (UDP first, then TCP/TLS) from
turn.blit.sh.
TURN allocations are refreshed every 4 minutes. TURN permissions are re-established on the same interval.
WebRTC peer connections are decoupled from the signaling WebSocket. An established flag per peer prevents tearing down active data channel sessions on WebSocket reconnect — only peers still in the signaling phase are aborted on reconnect.
blit share # auto-start server, run forwarder, print passphrase
BLIT_PASSPHRASE=mysecret blit share # deterministic passphrase
blit share # subcommand (BLIT_PASSPHRASE, BLIT_HUB)When BLIT_GATEWAY_WEBRTC=1, blit gateway connects to share: entries in blit.remotes as a WebRTC consumer and re-exposes them over its normal WebSocket/WebTransport path:
graph LR
F["blit share"] <-->|WebRTC DataChannel| G["blit gateway\nBLIT_GATEWAY_WEBRTC=1"]
G -->|"WS /d/name"| B["browser"]
BLIT_GATEWAY_WEBRTC=1 BLIT_HUB=hub.blit.sh blit gatewayblit.remotes entries:
hound = share:mysecret
hound = share:mysecret?hub=wss://custom.hub # per-remote hub override
The gateway appends ?proxiable=true to share: URIs in the config WebSocket remotes: message so the browser uses WS /d/<name> instead of attempting a direct WebRTC connection. Without BLIT_GATEWAY_WEBRTC=1, share: entries are ignored by the gateway and the browser connects directly via WebRTC.
blit-cli and blit gateway connect to remote blit server instances over SSH
using an embedded SSH client (russh — pure Rust, no system ssh required).
graph LR
C["blit / blit gateway"] -->|"SSH (russh)\ndirect-streamlocal"| S["blit server\n(remote)"]
The embedded client authenticates via ssh-agent (primary) and key files (fallback),
resolves ~/.ssh/config (Hostname, User, Port, IdentityFile), and opens
direct-streamlocal@openssh.com channels to the remote blit socket. Multiple
channels share a single TCP+SSH connection per host (native SSH multiplexing).
The remote socket path is resolved on the remote host using the standard cascade
(see Unix domain socket). If blit is not installed on the
remote, it is auto-installed to ~/.local/bin. If the server is not running, it
is auto-started. Connection retries with back-off handle the startup window.
Host keys are trust-on-first-use against ~/.ssh/known_hosts, overridable
with BLIT_SSH_KNOWN_HOSTS. A host with no entry is recorded and accepted; a
host that already has one must match it, under any algorithm. Everything else
refuses the connection rather than re-recording: an unreadable or unparseable
known_hosts, no home directory to find one in, or a key that does not match
what is pinned. UserKnownHostsFile, GlobalKnownHostsFile and
HashKnownHosts from ~/.ssh/config are not consulted.
Every browser WebSocket connection to blit gateway requires the gateway to open a fresh connection to the upstream blit server. When the server is remote — reached over TCP or WebSocket — that connection involves a round-trip or more: TCP handshake, gateway auth.
blit proxy-daemon eliminates that latency by maintaining a pool of pre-warmed, already-authenticated connections to each upstream. When a browser tab opens, the gateway gets a live connection from the pool instantly. The pool refills in the background so the next tab is equally fast.
blit proxy-daemon is a persistent daemon: one process per user session, shared across all CLI invocations. It auto-starts transparently on Unix and Windows when the CLI or blit gateway (with BLIT_PROXY=1) needs it. No configuration required — it just works, and restarts automatically if it ever stops.
graph LR
C["gateway / CLI"] -->|Unix| P["blit proxy-daemon"]
P -->|"pool[ssh:rabbit]\nssh bridge"| SR["blit server (rabbit)"]
P -->|"pool[wss://prod/]\nWebSocket"| GW["blit gateway (prod)"]
After connecting to the proxy socket ($XDG_RUNTIME_DIR/blit-proxy.sock on Unix, \\.\pipe\blit-proxy on Windows), the client sends one line before the blit protocol begins:
sequenceDiagram
participant C as client
participant P as blit proxy-daemon
participant U as upstream
C->>P: target <uri>\n
P->>U: connect (pooled or fresh)
alt success
P->>C: ok\n
note over C,U: blit protocol flows transparently
else failure
P->>C: error <msg>\n
note over C,P: connection closed
end
- One
Poolper distinct upstream URI seen. - Each pool pre-connects
BLIT_PROXY_POOL(default: 4) idle connections. - When a client is handed a pooled connection, the refill task immediately opens a replacement.
- If the pool is empty at request time, the proxy connects directly (no queuing).
- Activity tracking per pool:
last_activityisi64::MAXwhile any client is connected; set to the current timestamp when the last client disconnects.
BLIT_PROXY_IDLE=<seconds> causes the proxy to exit when all pools have been idle for that many seconds. A watcher task checks every 5 seconds, ignoring pools with active clients. By default no idle timeout is set and the daemon runs indefinitely.
The CLI auto-starts blit proxy-daemon when needed:
- Check if the proxy socket/pipe exists and accepts connections.
- If not, re-exec the current
blitbinary asblit proxy-daemonin a detached background process:- Unix:
setsid()+ null stdio so the daemon survives terminal close - Windows:
DETACHED_PROCESS | CREATE_NO_WINDOWcreation flags
- Unix:
- Poll until the socket/pipe accepts connections (up to 5 seconds, 50 ms intervals).
The daemon survives the spawning process exiting and is shared across all blit CLI invocations in the same user session.
| Scheme | Example | Auth |
|---|---|---|
socket: |
socket:/run/blit/server.sock |
none (trusted local) |
tcp: |
tcp:host:3264 |
none |
ws:// |
ws://host:3264/?passphrase=secret |
gateway WS auth |
wss:// |
wss://host:3264/?passphrase=secret |
gateway WS auth + TLS |
wt:// |
wt://host:4433/?passphrase=s&certHash=aa… |
gateway WT auth + QUIC |
Passphrase and cert hash are embedded as query parameters so the pool can reconnect without additional state. The proxy performs the auth handshake once per pooled connection and hands the authenticated stream to the client.
When no explicit connection flags are given, the CLI resolves the remote in this order:
--on <uri-or-name>CLI flagBLIT_TARGETenvironment variabletarget = <uri-or-name>key in~/.config/blit/blit.conf- Local blit server (auto-start)
Named targets (bare names with no :) are looked up in ~/.config/blit/blit.remotes. A bare name in blit.remotes is not allowed (no recursive resolution).
local:NAME addresses the correspondingly named local server and auto-starts
it with blit server --name NAME when absent. It is also a valid
blit.remotes value; browser and gateway destination lists resolve it to the
same named socket.
# Save a remote and set it as default
blit remote add prod ssh:prod.example.com
blit remote add work local:work
blit remote set-default prod
# All agent subcommands and blit open now target prod
blit terminal list
blit open
blit --on staging terminal list # one-off override
blit --on local:work terminal list # isolated named local instance| Scenario | Default transport | Override |
|---|---|---|
| Local | Unix socket (auto-start) | --on socket:/path |
| Remote via SSH | blit proxy-daemon → SSH | BLIT_PROXY=0 → direct russh |
| Remote via TCP | blit proxy-daemon → TCP | BLIT_PROXY=0 → direct TCP |
| Gateway → server (local) | blit proxy-daemon → Unix socket | BLIT_PROXY=0 → direct |
| Gateway → server (remote) | Embedded SSH (russh) | Configure in blit.remotes |
| Shareable browser terminal | WebRTC DataChannel | — |
| Browser → gateway | WebSocket | BLIT_QUIC=1 enables WebTransport |