No description
  • Rust 90.3%
  • Nix 9.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium d65ad00306
All checks were successful
ci / fmt / clippy / test / deny (push) Successful in 30s
Answer each broker request without waiting on the others (#6)
Reviewed-on: ⁨#6⁩
2026-10-14 14:01:44 +09:00
.forgejo/workflows Follow felis to v0.1.0 (#4) 2026-09-26 20:53:18 +09:00
docs Answer each broker request without waiting on the others (#6) 2026-10-14 14:01:44 +09:00
nix Keep browser tabs across daemon upgrades, and run as a user service (#5) 2026-10-14 13:23:43 +09:00
src Answer each broker request without waiting on the others (#6) 2026-10-14 14:01:44 +09:00
.gitignore chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
Cargo.lock Follow felis to v0.1.0 (#4) 2026-09-26 20:53:18 +09:00
Cargo.toml Follow felis to v0.1.0 (#4) 2026-09-26 20:53:18 +09:00
clippy.toml chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
deny.toml chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
flake.lock chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
flake.nix Keep browser tabs across daemon upgrades, and run as a user service (#5) 2026-10-14 13:23:43 +09:00
LICENSE chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
README.md Keep browser tabs across daemon upgrades, and run as a user service (#5) 2026-10-14 13:23:43 +09:00
rustfmt.toml chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00

felis-web-gateway

A WebSocket ↔ felis relay. It attaches to a felis-daemon as a native client and re-exposes the session's felis-protocol frames as JSON, so a browser can run a live, bidirectional terminal without speaking felis's wire format.

The browser halves live elsewhere: felis-web-component (the <felis-terminal> element and its wasm grid core) and felis-web (the app). This repo and those share no code — only docs/broker.md, the wire contract.

Two contracts on one listener

  • felis.v1 — one WebSocket, one session. What an embedded <felis-terminal src="wss://…"> uses.
  • felis-broker.v1 — one WebSocket, many sessions, plus a roster and a notification stream. What an app with tabs uses. The full contract, including why it is multiplexed rather than a socket per tab, is docs/broker.md.

A client picks by offering the subprotocol; no offer means felis.v1.

Both carry the same frames: felis's own felis-json v1, envelope included — {"felis_json":1,"kind":"grid"|"image"|"conn"|"session", "msg":…}, with the broker adding a sid. The format is felis_grid::json_v1, whose schema felis publishes (crates/felis-grid/schemas/felis-json-v1.schema.json), and a .fcast recording carries the identical frames — so the gateway names no shape of its own and a browser reads live and recorded sessions with one decoder. Browser→daemon input is the same format's Input payload ({"type":"key_bytes","bytes":[…]}), the envelope's msg alone.

Input the gateway cannot read is dropped, never answered: a message that is not JSON, one whose payload is not a valid Input, and — on the broker — one whose sid names no live stream or whose req carries no readable op. The client hears nothing back and the socket stays open, with the reason on the gateway's stderr — bar the stale sid, dropped without a word, since a frame racing its own stream's teardown is ordinary. A malformed keystroke must not cost a session, and on the broker one tab's bad frame must not cost the others theirs. The cost is that a malformed request never gets its re; anything the gateway can parse is answered, errors included.

The reverse direction is not so forgiving. A daemon frame of a kind the browser consumes whose body will not encode as v1 ends that relay — the one-session socket closes, a broker stream sends {"ev":"closed"} — so the app can re-attach. Skipping it would leave a screen missing state with nothing to notice it by.

felis has no capability bitmap. A connection states one mode and one pacing flag, and every wire addition rides the protocol minor the two peers settle on — the first public contract being 1.0, so today's minor is 0 and the first addition after it is the first real transition. The gateway attaches as a window and reports that connection's effective minor — min(gateway, daemon) — in its attach header, so a browser feature needing an addition gates on what the daemon behind this socket can actually send. Pull pacing stays off: with it the daemon withholds pushes until a per-vsync frame request the browser never sends, and the view would freeze.

Search and scrollback export do not ride this relay. They are correlated frame families of their own, and their ids come from the connection driver this gateway drops when it splits a stream into a reader task and a writer task, so neither is routed in either direction.

Run it

nix run . -- --listen 127.0.0.1:8771

Multiple daemons — the topology that makes a phone useful — are named rather than dialled by the client:

felis-web-gateway \
  --host lab=[email protected] \
  --listen 0.0.0.0:8771 \
  --token-file ~/.config/felis/web-token

The local entry is the daemon a felis window on this machine would use (felis's default socket, or FELIS_SOCKET); --socket names another. --host <name>=<ssh destination> builds a roster beside it, reached over ssh <dest> felis-daemon relay. The browser picks a roster key and never sends a destination, so this is not an open ssh proxy and the keys stay on the gateway host.

No daemon has to be running first, on either carrier. A cold local socket starts felis-daemon (beside this binary, else on PATH) the way a felis window does — under a systemd user manager as a transient unit of its own, otherwise as a detached child — and an ssh host's relay starts its own. Either way the daemon outlives the gateway, so restarting the gateway costs the browser a reconnect, not its sessions. Run the gateway as a user service for that to hold: from a system unit the daemon would be forked into the gateway's cgroup and stopped with it.

Serving off-host

felis-web-gateway --listen 0.0.0.0:8771 \
  --token-file ~/.config/felis/web-token \
  --origin https://felis.example \
  --tls-cert /path/fullchain.pem --tls-key /path/privkey.pem

This refuses to start if --listen is non-loopback and no token is set; --insecure-no-auth is the opt-out for when something in front already authenticates. The reason it refuses rather than warns: an open gateway hands out shell sessions on every configured host, and its ssh keys are what reach them. A VPN is not an auth boundary — phones get lost, and other devices are on the VPN too.

--token-file rather than --token, because a command line is world-readable in /proc. The token rides the Sec-WebSocket-Protocol offer as felis-token.<value>: a WebSocket constructor cannot set request headers, and the only alternative — a query parameter — puts the credential in access logs, history and Referer.

--tls-cert/--tls-key serve wss:// in-process, so "phone on a VPN, daemon at home" is one process rather than a reverse proxy in front of one.

--ping-interval (25 s, 0 disables) keeps both relays alive through idle-timeout proxies.

As a service

The flake exports a home-manager module (a systemd user unit on Linux, a launchd agent on macOS) and a NixOS module for a host without home-manager. Both run the gateway as the user it serves, for the reason under "Run it":

# home-manager
imports = [ felis-web-gateway.homeManagerModules.default ];
services.felis-web-gateway = {
  enable = true;
  felisPackage = felis.packages.${system}.default;
  listen = "0.0.0.0:8771";
  tokenFile = "/run/secrets/felis-web-token";
  origins = [ "https://felis.example" ];
  hosts.lab = "[email protected]";
};

The NixOS module takes the same options plus user, and turns on lingering for that user: without it the user manager, and the gateway with it, runs only while somebody is logged in. A home-manager setup on a host nobody logs in to needs loginctl enable-linger for the same reason.

Develop

nix develop
cargo clippy --all-targets -- -D warnings
cargo test

The felis crates arrive as Cargo git dependencies pinned to one tag, so Cargo.toml is the single place a felis version is chosen. That tag has to stay in step with the one felis-web-component pins: the two halves decode the same frames, and split repos cannot enforce that with a lockfile.