No description
  • Rust 96.3%
  • Nix 3.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium ad5a15f31b
All checks were successful
ci / fmt / clippy / test / deny (push) Successful in 3m20s
chore: stand alone, split out of felis-web-core
The gateway shared a repo with the wasm core and the `<felis-terminal>`
component, and shared nothing else with them: it links felis-client-core
natively, they compile it to wasm, and the only thing crossing between
them is `docs/broker.md` — a wire contract, not code.

The old workspace was already paying for the pretence. Its Cargo.toml
carried a `default-members` override whose comment says why: keeping the
gateway out of the default set "stops `cargo test --target wasm32` from
trying to build the tokio-bound gateway for wasm". Its CI needed
separate, explicitly-named clippy steps for the same reason. That is a
workspace working around holding two unrelated things.

So: `git subtree split -P gateway`, which brings the 15 commits that
touched this code with it. New here is the scaffolding a standalone repo
needs —

- a one-crate `[workspace]`, because `[lints] workspace = true` has to
  resolve somewhere. The lint tables are felis core's policy verbatim: a
  split is not a reason to hold a crate to a different bar.
- a flake with no wasm32 target and no typescript-go, which is the first
  thing the split buys. This is a native tokio binary and never wanted
  either.
- CI with one clippy step and one test step, for the same reason.
- `docs/broker.md`, which comes here rather than staying with the
  browser: the server defines and enforces `felis-broker.v1`, and a
  client can only conform. Copied rather than subtree-split, so its
  history stays in felis-web-core — the one thing this split loses.

Two source changes ride along, both because standing the repo up is what
surfaced them, and neither is cosmetic enough to leave for later:

- `rustls-pemfile` is gone. It is unmaintained (RUSTSEC-2025-0134) and
  its job has moved into `rustls::pki_types`, which rustls already pulls
  in — so `tls_acceptor` loses a direct dependency and its file handling
  at once. The dependency and the advisory both predate this split; what
  changed is that `cargo deny` now sees the crate as first-party, where
  the old layout had it a member's dep and let it through.
- One assertion. The flake here locks a newer nightly whose clippy has
  learned `assert_is_empty`; its suggested rewrite then trips the
  workspace's own `trivial_casts`, so the form satisfying both is
  `Vec::<&str>::new()`.

fmt / clippy -D warnings / 28 tests / deny: all green standalone.

Assisted-by: Claude Code Opus 5
2026-08-09 11:37:19 +09:00
.forgejo/workflows chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
docs chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
nix chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
src chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
.gitignore chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
Cargo.lock chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
Cargo.toml chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +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 chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
LICENSE chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +09:00
README.md chore: stand alone, split out of felis-web-core 2026-08-09 11:37:19 +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.

It negotiates SCROLL_OP | ROW_DELTA_BATCH | SEARCH | CLI_OPS | NOTIFY | GRID_SIZE and reports the intersection with the daemon's own set in its attach header — that intersection is what unlocks search, capture, notifications and dynamic resize on the browser side. PULL_PACING is deliberately withheld: the daemon would then wait for a per-vsync frame request the browser never sends, and the view would freeze.

Run it

nix run . -- --socket "$XDG_RUNTIME_DIR/felis/daemon.sock" --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 --socket "$XDG_RUNTIME_DIR/felis/daemon.sock" \
  --host lab=[email protected] \
  --listen 0.0.0.0:8771 \
  --token-file ~/.config/felis/web-token

--host <name>=<ssh destination> builds a roster beside the implicit local entry, 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.

Serving off-host

felis-web-gateway --socket … --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.

Develop

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

The felis crates arrive as Cargo git dependencies pinned to one rev, so Cargo.toml is the single place a felis version is chosen. That rev 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.