Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

PathRole
~/.config/zuko/keyhost identity
~/.config/zuko/current_ticketlive dial ticket, refreshed by host
~/.config/zuko/authorized_clientshost allow-list
~/.config/zuko/hostsclient-side saved hosts
~/.config/zuko/client_keyCLI 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:

FlagDefault
--prefix~/.local
--key~/.config/zuko/key
--shell$SHELL
--no-startdisabled

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, or screen for 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:

FlagDefaultNotes
--ticketcurrent_ticketoverride ticket source
--labelhostnamedefault save name on client
--count1accepted claims before exit
--timeout300seconds; 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

FlagDefaultNotes
--listlist aliases
--dry-runprint launch command/env
--test-patterndraw Kitty test image; no cage
--doctorcheck cage/protocol/geometry
--debug-childlet child stdout/stderr through
--no-sandboxbrowser/container escape hatch
--no-cursorhide crosshair cursor overlay
--fps30max frame rate
--max-mbps80approximate graphics bandwidth cap; 0 disables
--graphics-codecautoauto, png, rgb
--scale1.0render below/above terminal pixel size
--softwareforce 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:

  1. <exe_dir>/cage/
  2. ~/.local/share/zuko/cage
  3. PATH
  4. $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

TypeNameDirectionPayload
0x00DATAbothterminal bytes
0x01RESIZEclient → hostcols:u16 rows:u16 pixel_width:u16 pixel_height:u16
0x04PINGbothnonce:u64
0x05PONGbothnonce:u64
0x06ATTACHclient → hosttoken:16 bytes + resize payload
0x07ATTACHEDhost → clienttoken:16 bytes
0x08AUTHORIZEclient → handoff hosttoken:16 bytes + UTF-8 label

Unknown types are ignored.

Session handshake

  1. Client dials host ticket on zuko/2.
  2. Client opens data bidi stream.
  3. First frame must be ATTACH with a non-zero host-scoped token and current terminal size.
  4. Host checks authorized_clients for the token.
  5. Host creates or reattaches the PTY keyed by that token.
  6. 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: DATA client → host.
  • PTY output: DATA host → client.
  • Size changes: RESIZE on control stream when available, otherwise data stream.
  • PING replies with PONG carrying 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):

  1. Generate memorable code.
  2. Derive throwaway Iroh secret: Argon2id(normalized_code, salt="zuko-share-handoff-v1") -> 32-byte seed.
  3. Bind endpoint on zuko/handoff/1.
  4. Open uni stream and write:
    <label>\n<ticket>
    
  5. Wait briefly for client AUTHORIZE uni stream.
  6. Save token + label to authorized_clients.

Client (zuko claim / zuko <code>):

  1. Derive same throwaway endpoint id from code.
  2. Dial zuko/handoff/1, retrying until timeout.
  3. Read label + ticket.
  4. Parse ticket host id.
  5. Derive stable host-scoped token from local client secret + host id.
  6. Open uni stream, send AUTHORIZE(token, client_label).
  7. 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:

ClientStatusSource
Rust CLIshippedsrc/client.rs
iOS/iPadOSshippedios/Zuko/
Webexperimental/web/ / web/
Androidplanned

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:

  1. Claim ticket via zuko/handoff/1:
    • derive handoff key from code;
    • dial throwaway endpoint;
    • read <label>\n<ticket>;
    • derive host-scoped token;
    • send AUTHORIZE before closing handoff connection.
  2. Persist a client secret. Derive non-zero token from (client secret, host id).
  3. Dial host ticket with ALPN zuko/2.
  4. open_bi; first frame must be ATTACH(token, cols, rows, pixels).
  5. Pump length-prefixed frames:
    • stdin/terminal bytes → DATA;
    • remote DATA → terminal emulator;
    • size changes → RESIZE;
    • optional control stream for RESIZE/PING/PONG.
  6. Store ATTACHED token. 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, or screen; 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: AUTHORIZE during handoff, enforced on ATTACH.

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: wterm with @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 by mise run build-web into target/book/web/.
  • CI publish: .github/workflows/docs.yml builds 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/ghostty renders 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 app Kitty 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:

  • cage
  • libwlroots-0.20.so
  • libliftoff.so.0
  • libseat.so.1
  • libxcb-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/hosts or 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/claim only.
  • zuko host never prints the raw ticket.
  • zuko share rejects stale current_ticket.
  • zuko host admits only tokens in authorized_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 check is green.
  • If iOS Swift/build config changed, run mise run preflight where possible.
  • Keep commits terse and imperative.
  • Update docs/protocol.md for wire changes.
  • Update docs/host.md for CLI/state changes.

Client authors: start with protocol.md, then clients.md.

Security reports: use GitHub Security Advisories.