- Rust 90.3%
- Nix 9.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci / fmt / clippy / test / deny (push) Successful in 30s
Reviewed-on: #6 |
||
| .forgejo/workflows | ||
| docs | ||
| nix | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| clippy.toml | ||
| deny.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| rustfmt.toml | ||
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, isdocs/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.