Documentation

kino-relay

The public switchboard. Endpoints, deployment behind nginx, TLS, authentication modes, and self-enrollment.

Repository: Samarthegde/kino-relay · Licence: AGPL-3.0 · Language: Rust

The one publicly reachable piece. Agents dial out to it and park; the manager asks it to open a session; it splices the two sockets and forwards bytes. It holds no state on disk (beyond a cached public key), terminates no SSH, and never sees your credentials.

Endpoints#

Route Who connects Purpose
GET /healthz monitors, controllers Liveness. Returns ok.
GET /ws/control?agent_id=<id> agent The parked channel. Kept alive with 30-second pings.
GET /ws/manager/request?agent_id=<id> manager Asks to reach an agent. The relay notifies it and waits up to 10 seconds for the dial-back.
GET /ws/agent/data?session_id=<id> agent The return data socket for one session.

When auth is configured, every /ws/* route requires an Authorization: Bearer header — a header rather than a query parameter, so tokens stay out of proxy access logs.

Quick start#

cargo run --release
# Relay listening on 0.0.0.0:3000 (plaintext - terminate TLS at your proxy)
kino-agent --relay-url ws://localhost:3000 --agent-id test

Configuration#

Env var Default Description
PORT 3000 Port to listen on.
BIND 0.0.0.0:$PORT Full listen address; overrides PORT.
TLS_CERT / TLS_KEY unset PEM chain + key. Set both to serve wss:// directly.
RELAY_TOKEN unset Static bearer token. See Authentication.
RELAY_JWT_PUBLIC_KEY control.pub.pem when enrolling Path to the controller’s Ed25519 public key. Loaded if present, written on enrollment.
KINO_CONTROL_URL unset Controller to enroll with.
KINO_ENROLL_CODE unset One-time enrollment code.
RELAY_PUBLIC_URL unset The address clients dial, e.g. wss://relay.example.com. Required to enroll.
RELAY_NAME unset Display name in the controller’s relay list.
RUST_LOG info Log verbosity.

SIGTERM and SIGINT are handled gracefully, so docker stop and systemd restarts do not cut sessions mid-frame.

Deployment#

The relay speaks plain HTTP by default and expects TLS terminated in front of it so clients can use wss://.

Docker Compose#

cp .env.example .env      # fill in enrollment vars and/or RELAY_TOKEN
docker compose up -d --build

LISTEN_ADDR chooses the interface (127.0.0.1 when nginx is on the same box, 0.0.0.0 or a LAN address when it is not) and RELAY_PORT the published port. The container always listens on 3000 internally. A relay published beyond loopback should be firewalled to the proxy’s IP.

The relay-data volume persists control.pub.pem, so restarts keep enforcing auth without a fresh enrollment code.

nginx#

WebSocket upgrade headers are mandatory — without them every connection fails with 400 Bad Request. A complete server block ships in the repository at deploy/nginx-kino-relay.conf; the parts that matter:

# at http{} level, once:
map $http_upgrade $connection_upgrade { default upgrade; '' close; }

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 1h;        # don't cut idle SSH sessions
    proxy_buffering off;
}

Raise the read timeout

proxy_read_timeout defaults to 60 seconds, which kills an idle terminal after a minute. The relay pings the control channel, but a data socket has no such keepalive. If you are also routing through an nginx stream block, set proxy_timeout 1h; there too — its default is 10 minutes.

Direct TLS, no proxy#

Set both TLS_CERT and TLS_KEY and the relay serves wss:// itself, and refuses plaintext. ALPN is pinned to HTTP/1.1, which is what WebSockets need.

TLS_CERT=/path/fullchain.pem TLS_KEY=/path/privkey.pem PORT=443 kino-relay

Where to host it#

Tiny and stateless, but it holds long-lived WebSocket connections, so it must run somewhere that does not idle-sleep or cap connection duration. An always-on VM is the right shape — Oracle Cloud’s Always Free ARM tier works well, and the aarch64 build covers it. Serverless and scale-to-zero tiers do not work.

Careful with CDN proxies

Putting a relay behind Cloudflare’s orange cloud adds a ~100-second idle WebSocket timeout and puts long-lived non-HTTP tunnelling in the way of the terms of service. Grey-cloud the record, or point clients straight at the origin.

Installing#

Prebuilt Linux binaries (x86_64 and aarch64) hang off every release:

curl -fsSL -o kino-relay \
  https://github.com/Samarthegde/kino-relay/releases/latest/download/kino-relay-x86_64-unknown-linux-gnu
chmod +x kino-relay && sudo mv kino-relay /usr/local/bin/

Building needs stable Rust ≥ 1.85. TLS is rustls with the ring backend — no OpenSSL dependency.

Authentication#

Two mechanisms, independently optional. A caller passes if it satisfies either.

Env var Mechanism
RELAY_TOKEN One static shared secret, compared in constant time. The simple option for a personal relay: the same string goes here, in kino-agent --token, and in the host’s Relay token field in the manager.
RELAY_JWT_PUBLIC_KEY Path to an Ed25519 public key. Verifies JWTs minted by a controller. Tokens carry a role (agent / manager) and an agent_id scope, so a manager token for host A cannot register agents or reach host B.

Verification is asymmetric on purpose: the relay holds no signing material, so an operator can check credentials but never mint them. That is the property that makes a pool of community-run relays possible.

Neither variable set means the relay runs open

It says so loudly at startup. Anyone who can reach it and knows an agent_id can open an SSH transport to that agent’s sshd, whose own authentication becomes the only gate. Fine for a firewalled lab; set a token for anything public.

Enrolling with a controller#

Enrollment puts the relay in the controller’s directory, so agents discover it and park on whichever enrolled relay answers fastest. The relay enrolls itself, two ways.

Interactive — run it in a terminal with no auth configured and it asks:

No auth is configured (RELAY_TOKEN / RELAY_JWT_PUBLIC_KEY unset).
Enroll this relay with a kino-control instance? [y/N]

Env-driven — for docker and systemd, where there is no TTY:

KINO_CONTROL_URL=https://kino.dpdns.org \
KINO_ENROLL_CODE=<one-time code> \
RELAY_PUBLIC_URL=wss://relay.example.com \
RELAY_NAME=eu-1 \
  kino-relay

Get the one-time code from the Relays page.

Either way the relay starts serving first — the controller probes /healthz before accepting, so the public URL must already resolve and answer over TLS — then registers, receives the public key, saves it to RELAY_JWT_PUBLIC_KEY, and starts enforcing auth immediately with no restart. Later starts load the saved key and skip enrollment, so the code is needed exactly once. From then on the controller health-checks the relay every minute.

Behind a TLS-inspecting firewall#

Enrollment is an outbound HTTPS call verified against the host’s certificate store. On a network where a firewall re-signs TLS, it fails with invalid peer certificate: UnknownIssuer — see Troubleshooting.

Operating notes#

  • A relay restart drops every parked agent. They reconnect within ~5 seconds, and in discovery mode may re-park somewhere else entirely.
  • Nothing session-related is persisted. There is no session log to rotate and no database to back up — only control.pub.pem, and that is re-obtainable with a fresh enrollment code.
  • The controller marks a relay unhealthy when /healthz stops answering. Agents then stop picking it, but agents already parked stay put until their connection drops.