zuko docs
Remote PTYs over Iroh. Operator/developer reference.
zuko is for hosts you own and prefer to keep off public network surfaces and VPN/bastion plumbing. Iroh handles dial-by-key reachability, relay fallback, NAT traversal, and transport encryption. zuko stays narrow: a host PTY, explicit pairing, an authorised-client list, and a small framed protocol.
Experimental browser client: open zuko web. It is a static Pages app using Iroh WASM and a Ghostty-derived terminal core; claimed host tickets are stored locally in browser IndexedDB.
Fast path
curl https://mise.run | sh
mise use --global github:adonm/zuko
zuko install
zuko share # on host
zuko <code> # on client
Useful commands:
zuko ls
zuko rm <name>
zuko reset
zuko upgrade --check
zuko app --doctor
Read next
Source: github.com/adonm/zuko.
Host & CLI
zuko is one binary: host daemon, CLI client, pairing helper, service installer,
upgrader, and Linux zuko app launcher.
Commands
zuko host # foreground host
zuko install # install/start user service
zuko uninstall # remove service; keep user state
zuko upgrade # mise-managed binary upgrade
zuko share # authorise a client with a one-time code
zuko <code> # claim, save, connect
zuko claim <code> # flags: --as, --no-connect, --timeout
zuko <name> # connect to saved host
zuko connect <name>
zuko # TTY picker / non-TTY list
zuko ls # saved hosts + authorised clients
zuko rm <name> # remove saved host and/or authorised client
zuko reset # rotate host key; clear authorised clients
zuko app <command> # Linux GUI app over Kitty graphics
State
| Path | Role |
|---|---|
~/.config/zuko/key | host identity |
~/.config/zuko/current_ticket | live dial ticket, refreshed by host |
~/.config/zuko/authorized_clients | host allow-list |
~/.config/zuko/hosts | client-side saved hosts |
~/.config/zuko/client_key | CLI client token seed |
All secret state is user-local and written 0600 where Unix permissions apply.
Install service
curl https://mise.run | sh
mise use --global github:adonm/zuko
zuko install
Linux:
journalctl --user -u zuko-host -f
sudo loginctl enable-linger "$USER" # servers that must run without login
macOS:
tail -f ~/.config/zuko/zuko-host.out.log
Install flags:
| Flag | Default |
|---|---|
--prefix | ~/.local |
--key | ~/.config/zuko/key |
--shell | $SHELL |
--no-start | disabled |
Foreground host:
zuko host --shell /bin/bash --cwd "$HOME"
Pair and connect
# host
zuko share
# client
zuko <code>
claim saves the ticket under the host label unless --as <name> is set.
After pairing:
zuko ls
zuko <name>
The host admits only tokens in authorized_clients. Pairing writes that list.
Trust management
zuko ls
zuko rm ipad
zuko reset
zuko reset --yes
reset removes key, removes current_ticket, and writes an empty
authorized_clients. Restart the host, then re-pair each client.
Session behavior
- Host runs a real PTY with
TERM=xterm-256color. - Shell exit ends the session and kills the PTY.
- Network/client drop detaches the PTY for 5 minutes.
- Detached output is discarded.
- CLI reconnects while the process is alive; iOS redials while screen is active.
- Use
tmux,zellij, orscreenfor durable work.
Force-exit a stuck CLI: Ctrl-C three times within ~1s with no remote output.
Pairing internals
share derives a throwaway Iroh key from the code, serves
<label>\n<ticket> over ALPN zuko/handoff/1, then receives the client’s
AUTHORIZE frame. claim retries the handoff dial for --timeout seconds
(default 60). share reads current_ticket; stale/missing files fail.
zuko share flags:
| Flag | Default | Notes |
|---|---|---|
--ticket | current_ticket | override ticket source |
--label | hostname | default save name on client |
--count | 1 | accepted claims before exit |
--timeout | 300 | seconds; 0 waits forever |
Upgrade
zuko upgrade --check
zuko upgrade
zuko upgrade --version 0.8.0
zuko upgrade --no-restart
Only works for mise-managed installs. Restarting the service kills in-memory PTYs.
Debug
RUST_LOG=iroh=info zuko home
RUST_LOG=iroh=debug zuko home
Host logs are stderr in foreground, systemd journal on Linux, and
~/.config/zuko/zuko-host.out.log on macOS.
Build/test
cargo build --release
cargo test
cargo test --release --test e2e -- --ignored --nocapture
The ignored e2e test uses a real PTY and the live Iroh network.
zuko app
Linux-only GUI app streaming. Run inside an existing zuko <host> shell.
Implementation: host spawns headless cage/wlroots, captures frames, writes Kitty graphics to stdout, and injects keyboard/mouse input back into cage.
Quick use
zuko app --list
zuko app text-editor
zuko app firefox
zuko app --fps 5 -- firefox --new-window
Flags go before the child command. Use -- before child flags.
Diagnostics
Run in this order:
zuko app --test-pattern
zuko app --doctor
zuko app --dry-run firefox
zuko app --debug-child firefox
If --test-pattern fails, fix terminal Kitty graphics or the zuko PTY path
before debugging cage/app launch.
Flags
| Flag | Default | Notes |
|---|---|---|
--list | — | list aliases |
--dry-run | — | print launch command/env |
--test-pattern | — | draw Kitty test image; no cage |
--doctor | — | check cage/protocol/geometry |
--debug-child | — | let child stdout/stderr through |
--no-sandbox | — | browser/container escape hatch |
--no-cursor | — | hide crosshair cursor overlay |
--fps | 30 | max frame rate |
--max-mbps | 80 | approximate graphics bandwidth cap; 0 disables |
--graphics-codec | auto | auto, png, rgb |
--scale | 1.0 | render below/above terminal pixel size |
--software | — | force software GL/WebRender in child |
Flatpak
Flatpak launches are Wayland-only cage children with --die-with-parent. zuko
detects exported Flatpaks and simple Exec=flatpak run <app-id> ... desktop
files.
Portal-heavy/full-desktop flows: run an RDP client inside zuko app and connect
to GNOME/KDE RDP on the host.
Runtime deps
x86_64 Linux release tarballs bundle cage plus uncommon wlroots libs next to the
zuko binary. Lookup order:
<exe_dir>/cage/~/.local/share/zuko/cagePATH$ZUKO_CAGE
Not bundled: libwayland, libxkbcommon, libdrm, libxcb, libinput,
libudev, mesa libEGL/libGLESv2.
aarch64 Linux currently needs cage on PATH.
Wire protocol
Transport
- Iroh QUIC endpoint ticket (
endpointa…). - Session ALPN:
zuko/2. - Handoff ALPN:
zuko/handoff/1. - First bidi stream is data. Optional second bidi stream is control.
Frame format
[type: u8][len: u16 BE][payload: len bytes]
len excludes the 3-byte header. Max payload: 65535 bytes. Receivers must
accumulate and parse greedily; frames can split/coalesce across QUIC reads.
Frame types
| Type | Name | Direction | Payload |
|---|---|---|---|
0x00 | DATA | both | terminal bytes |
0x01 | RESIZE | client → host | cols:u16 rows:u16 pixel_width:u16 pixel_height:u16 |
0x04 | PING | both | nonce:u64 |
0x05 | PONG | both | nonce:u64 |
0x06 | ATTACH | client → host | token:16 bytes + resize payload |
0x07 | ATTACHED | host → client | token:16 bytes |
0x08 | AUTHORIZE | client → handoff host | token:16 bytes + UTF-8 label |
Unknown types are ignored.
Session handshake
- Client dials host ticket on
zuko/2. - Client opens data bidi stream.
- First frame must be
ATTACHwith a non-zero host-scoped token and current terminal size. - Host checks
authorized_clientsfor the token. - Host creates or reattaches the PTY keyed by that token.
- Host sends
ATTACHED(token).
RESIZE is valid only after ATTACH. Cell dimensions are clamped to at least
1x1. Pixel dimensions may be zero.
Pump
- Keystrokes/stdin:
DATAclient → host. - PTY output:
DATAhost → client. - Size changes:
RESIZEon control stream when available, otherwise data stream. PINGreplies withPONGcarrying the same nonce.
Shell EOF closes the stream and kills the PTY. Network/client drop detaches the PTY for 5 minutes; output while detached is discarded.
Ticket handoff
Purpose: let a client learn the host ticket and register its future ATTACH
token without putting the ticket on argv/stdin/stdout.
Host (zuko share):
- Generate memorable code.
- Derive throwaway Iroh secret:
Argon2id(normalized_code, salt="zuko-share-handoff-v1") -> 32-byte seed. - Bind endpoint on
zuko/handoff/1. - Open uni stream and write:
<label>\n<ticket> - Wait briefly for client
AUTHORIZEuni stream. - Save token + label to
authorized_clients.
Client (zuko claim / zuko <code>):
- Derive same throwaway endpoint id from code.
- Dial
zuko/handoff/1, retrying until timeout. - Read label + ticket.
- Parse ticket host id.
- Derive stable host-scoped token from local client secret + host id.
- Open uni stream, send
AUTHORIZE(token, client_label). - Save ticket locally and optionally connect.
Token derivation
Rust CLI:
SHA256("zuko-session-token-v1" || client_key_bytes || host_id_bytes)[0..16]
iOS:
SHA256("zuko-ios-session-token-v1" || keychain_seed || host_id_string)[0..16]
Tokens must be non-zero.
Security notes
- Host ticket is a bearer secret.
- Host key stays on host at
~/.config/zuko/key. - Host admits only authorised client tokens.
- Iroh provides transport encryption; relays see encrypted traffic.
- Rotate host trust with
zuko reset, restart, then re-pair clients.
Reference implementations: src/wire.rs, src/client.rs, src/host.rs,
src/handoff.rs, ios/ZukoWire/.
Clients
Reference clients:
| Client | Status | Source |
|---|---|---|
| Rust CLI | shipped | src/client.rs |
| iOS/iPadOS | shipped | ios/Zuko/ |
| Web | experimental | /web/ / web/ |
| Android | planned | — |
The web client is published with the docs. It uses browser Iroh over relays and stores claimed host tickets in IndexedDB. See Targets for its security boundary and known gaps.
Implementing a client
Read protocol.md. Checklist:
- Claim ticket via
zuko/handoff/1:- derive handoff key from code;
- dial throwaway endpoint;
- read
<label>\n<ticket>; - derive host-scoped token;
- send
AUTHORIZEbefore closing handoff connection.
- Persist a client secret. Derive non-zero token from
(client secret, host id). - Dial host ticket with ALPN
zuko/2. open_bi; first frame must beATTACH(token, cols, rows, pixels).- Pump length-prefixed frames:
- stdin/terminal bytes →
DATA; - remote
DATA→ terminal emulator; - size changes →
RESIZE; - optional control stream for
RESIZE/PING/PONG.
- stdin/terminal bytes →
- Store
ATTACHEDtoken. Reuse it for short reconnects.
Operational details:
- First frame is mandatory; Iroh exposes streams to the peer after initiator data.
- Clamp terminal cells to at least
1x1. - Serialise writes; frame interleaving corrupts the stream.
- Bound outbound queues. Dropping impatient input is better than unbounded memory.
- Forward terminal bytes verbatim. Local Ctrl-C handling should be an explicit escape hatch only.
- Clean EOF means shell exit. Redial transient link errors.
Mobile clients should call the Rust FFI derive_handoff_key(code) instead of
reimplementing Argon2id. See src/ffi.rs and ios/Zuko/.
Reference code:
- Rust framing/session:
src/wire.rs,src/client.rs,src/handoff.rs - Swift framing/session:
ios/ZukoWire/,ios/Zuko/Zuko/Net/
Design notes
This is a terminal-first project.
Constraints
- Base experience is a PTY over Iroh.
- Stock terminals must remain useful clients.
- Host state should be explicit files under
~/.config/zuko. - Failure modes should be bounded: handshakes, queues, detached leases.
- Long-running user work belongs in
tmux,zellij, orscreen; zuko’s in-memory lease handles short disconnects.
zuko app
Current GUI path: cage/wlroots on host, Kitty graphics over the existing zuko PTY, terminal input back into cage.
Keep it as the baseline because it needs no second listener, no separate client, and no additional pairing surface. It works anywhere the terminal can render Kitty graphics.
A native GUI/video stream can be added later as an opt-in fast path alongside the PTY/Kitty path.
Protocol shape
Session ALPN: zuko/2.
- data stream:
ATTACH,DATA,ATTACHED; - optional control stream:
RESIZE,PING,PONG; - handoff ALPN:
zuko/handoff/1; - host authorisation:
AUTHORIZEduring handoff, enforced onATTACH.
Prefer additive frame types over negotiation layers until there is a concrete client that needs more.
Targets
Browser client
Goal: a static web client published with the docs at /web/ that can claim and
connect to zuko hosts from the browser.
Open it from the published docs: zuko web.
Chosen stack:
- Transport: Iroh’s browser/WASM build. Browser peers are relay-only because the browser sandbox cannot do UDP hole punching, but Iroh still keeps payloads end-to-end encrypted.
- Terminal:
wtermwith@wterm/ghostty, which uses a libghostty VT core compiled to WASM. This is the best Ghostty-derived browser terminal available today; it is not the full native Ghostty app. - UI: semantic HTML plus bundled Bootstrap 5 CSS. No CDN scripts/styles are
loaded at runtime; Vite emits static assets under
/web/. - Build: Deno runs TypeScript/Vite tasks and consumes npm packages through
package.json+deno.lock. - Storage: IndexedDB under the Pages origin. It stores the browser client key and claimed host tickets locally.
Security boundaries:
- Host tickets are bearer secrets. The browser client never sends them to a zuko server other than the claimed host, but any script that runs in the same origin can read the IndexedDB state.
- Keep the web app dependency-free at runtime except bundled assets, use a strict
CSP, and avoid third-party scripts on the
/web/page. - Browser Iroh traffic is relay-only. Relays can see metadata and volume, not decrypted zuko frames.
Implementation status:
- Static app:
web/, built bymise run build-webintotarget/book/web/. - CI publish:
.github/workflows/docs.ymlbuilds mdBook first, then the web app, and uploads one Pages artifact. - Iroh bridge:
web/wasm/exposes claim/connect/session streaming to TypeScript. - Terminal UI:
@wterm/ghosttyrenders the remote PTY stream in the browser. - Rendering hygiene: remote output is batched per animation frame, resize frames are debounced, and the browser sends measured grid pixels instead of the outer element size.
Known gaps:
- No reconnect/backoff loop for an already-attached terminal session yet.
zuko appKitty graphics should be treated as experimental until wterm’s renderer is verified against zuko’s graphics stream.- Pages security headers are limited compared with a dedicated app origin; move to a hardened subdomain if this becomes a daily-use client.
Releasing
Tags v* trigger binary releases for:
- Linux x86_64/aarch64
- macOS x86_64/aarch64
The release tarballs are consumed by:
mise use --global github:adonm/zuko
Cut a release
mise run test
mise run lint # if Swift changed
mise run build-ios # if iOS changed
mise run release v0.8.0
scripts/release.sh checks Cargo.toml version, commits pending work, pushes
branch, creates an annotated tag, and pushes the tag.
Manual equivalent:
git commit -m "..."
git tag -a v0.8.0 -m "zuko v0.8.0"
git push origin main v0.8.0
Linux zuko app bundle
x86_64 Linux release includes cage/ next to the binary:
cagelibwlroots-0.20.solibliftoff.so.0libseat.so.1libxcb-errors.so.0
Built in release.yml from fedora:latest packages. aarch64 Linux does not
bundle cage yet; users need cage on PATH.
iOS
Signed/TestFlight builds: ios/DISTRIBUTION.md.
Security
Model
- Host identity:
~/.config/zuko/key. - Host ticket:
endpointa…, a bearer secret containing host public key + current addresses. - Client allow-list:
~/.config/zuko/authorized_clients. - Saved client tickets:
~/.config/zuko/hostsor iOS Keychain.
Connections are Iroh QUIC and end-to-end encrypted. Public relays see encrypted traffic.
Rules
- Treat host tickets like SSH private keys.
- Tickets are handed out through
zuko share/claimonly. zuko hostnever prints the raw ticket.zuko sharerejects stalecurrent_ticket.zuko hostadmits only tokens inauthorized_clients.
Manage trust:
zuko ls
zuko rm <name>
zuko reset # remove key/current_ticket, clear authorised clients
zuko reset --yes
After reset, restart host and re-pair clients.
Report vulnerabilities
Use GitHub Security Advisories: adonm/zuko/security/advisories/new.
Use private advisories for vulnerabilities.
Scope: src/, wire protocol, handoff, service installer, iOS app.
Contributing
Use mise; CI uses the same tasks.
mise install
mise run check # fmt --check + clippy + tests + swiftlint
mise run test # Rust clippy + unit tests
mise run test-e2e # live Iroh network + PTY
mise run preflight # CI-ish, includes iOS build
mise run build
iOS:
mise run setup-ios
mise run build-ios
swift test --package-path ios/ZukoWire
Before PR:
mise run checkis green.- If iOS Swift/build config changed, run
mise run preflightwhere possible. - Keep commits terse and imperative.
- Update
docs/protocol.mdfor wire changes. - Update
docs/host.mdfor CLI/state changes.
Client authors: start with protocol.md, then
clients.md.
Security reports: use GitHub Security Advisories.