Files
cnats/README.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 78e9c9ff94 Calls: screen share, mic/cam toggles, status badges, pin + fullscreen, call timer
Screen sharing swaps the outgoing video track on every peer's sender with
replaceTrack (same m-line, no renegotiation); the camera returns when the
share stops, including via the browser's own "Stop sharing" bar. Mic and
camera toggles flip track.enabled. A new Status signal kind broadcasts
mic/cam/screen state so tiles can show badges; newcomers get it directed
when their Join arrives.

Tiles carry a name label, click to pin as a stage (a peer's screen share
pins itself), double-click for fullscreen. The call bar has an elapsed
timer and a participant count. Controls are icon-only inline SVGs on
currentColor: plain with a strike when off, a soft glow and ticking LED
when live. Red is reserved for leave.

Also fixed along the way: the peer map was a non-reactive StoredValue, so
the tile list never re-rendered when someone joined; ICE candidates that
arrive before the remote description are now buffered instead of
rejected; a peer whose connection fails or closes is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3sD29ozDvqA7jozeZXZoB
2026-10-02 17:26:00 +02:00

6.7 KiB

cnats — chat over the bus

A realtime web chat built with Leptos (SSR + hydration on Axum) where every room is a NATS subject and sign-in is Kanidm SSO (OIDC authorization-code + PKCE).

browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room>
browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room>
browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm
                               axum ◀─JetStream (CHAT / cnats-postgres)─ NATS
                                 └──INSERT──▶ Postgres (history archive)

The browser never talks to NATS directly: the server publishes on behalf of the signed-in session (sender identity comes from the session, never the client) and fans messages out to browsers over Server-Sent Events. Any other NATS client on the bus can publish/subscribe to chat.room.* and participate.

Install

  • Arch (AUR): yay -S cnats — installs the binary, cnats.service, and config at /etc/cnats/env; then systemctl enable --now cnats.
  • Docker: docker run --env-file .env -p 3000:3000 bendik/cnats (or the compose file below).

Releases are cut by the Gitea workflow in .gitea/workflows/release.yml: a v* tag builds x86_64 + aarch64 tarballs, publishes them as release assets, pushes the image to Docker Hub, and updates the AUR package.

Prerequisites

  • Rust (stable) with the wasm32-unknown-unknown target
  • cargo-leptos: cargo install cargo-leptos --locked
  • A NATS server: docker compose up -d nats (or nats-server locally)
  • A running Kanidm instance you administer

Kanidm setup

Register the app as an OAuth2 client (confidential, with PKCE — Kanidm's default). Replace the URLs with yours:

kanidm login --name idm_admin

# create the client
kanidm system oauth2 create cnats "cnats chat" http://localhost:3000

# register the redirect URL used by this app
kanidm system oauth2 add-redirect-url cnats http://localhost:3000/auth/callback

# map which Kanidm groups may sign in, and grant the scopes the app requests
kanidm group create cnats_users
kanidm group add-members cnats_users your_username
kanidm system oauth2 update-scope-map cnats cnats_users openid profile email

# if you serve the app over plain http in dev, allow insecure redirect urls
kanidm system oauth2 enable-localhost-redirects cnats

# read back the client secret for .env
kanidm system oauth2 show-basic-secret cnats

Kanidm's OIDC discovery endpoint is per-client: https://<kanidm>/oauth2/openid/<client_id>/.well-known/openid-configuration. The app derives this from KANIDM_URL + OAUTH2_CLIENT_ID automatically.

Configuration

cp .env.example .env   # then edit
Variable Meaning
NATS_URL NATS server, default nats://127.0.0.1:4222
DATABASE_URL Postgres for message history, default postgres://cnats:cnats@127.0.0.1:5432/cnats
KANIDM_URL Base URL of Kanidm, e.g. https://idm.example.com
OAUTH2_CLIENT_ID The Kanidm oauth2 client name (cnats above)
OAUTH2_CLIENT_SECRET Output of show-basic-secret
PUBLIC_URL Where browsers reach this app; ${PUBLIC_URL}/auth/callback must be a registered redirect URL
COOKIE_SECURE true behind HTTPS (production)

Run

docker compose up -d nats postgres
cargo leptos watch        # dev, auto-reload on http://127.0.0.1:3000
cargo leptos build --release   # production build (binary + target/site)

Or run the whole stack (app image included) with docker compose up.

Open two browser windows, sign in, and chat — or join from the CLI:

nats sub 'chat.room.*'
nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00","ts":0}'

CLI-published messages are archived too — the JetStream consumer sees everything on chat.room.*, not just what this app publishes.

Layout

src/
  main.rs          axum entrypoint: routes, sessions, NATS + OIDC bootstrap
  lib.rs           hydrate entrypoint (wasm)
  app.rs           Leptos UI (login gate + chat console)
  auth.rs          shared User type + current_user server fn
  chat.rs          shared ChatMessage/rooms + send_message server fn (publishes to NATS)
  call.rs          call signaling types + send_signal server fn (call.room.*, never archived)
  webrtc.rs        browser-only mesh WebRTC: peers, mic/cam toggles, screen share
  server/
    oidc.rs        Kanidm OIDC login/callback/logout handlers
    sse.rs         NATS → browser SSE bridge (one subscription per client)
    store.rs       JetStream → Postgres archive + room history queries
style/main.css     the console theme

Calls

The lobby room has a mesh WebRTC call (every pair of browsers connects directly; fine for a handful of people, not more). Signaling rides NATS on call.room.<room> via /call-sse/<room> and the send_signal server fn, so it is never archived. Only a public STUN server is configured: calls across symmetric NATs will not connect without a TURN relay, which this app does not run.

In a call:

  • MIC / CAM toggle your mic and camera (the track stays attached and sends silence/black, so toggling is instant and needs no renegotiation).
  • SCR ▶ share shares a screen, window, or tab. It swaps the outgoing video track on every peer with replaceTrack; your camera comes back when you stop, or when the browser's own "Stop sharing" bar is used.
  • Tiles show the participant's name and MIC ✕ / CAM ✕ / SCR badges, kept in sync by a Status signal on the same subject.
  • Click a tile to pin it as the stage (a shared screen pins itself); double-click for fullscreen.
  • A peer whose tab closes without leaving is dropped once their connection fails, and ICE candidates that arrive before the offer are buffered rather than lost.

Notes & production hardening

  • Sessions are in-memory (tower-sessions MemoryStore): restart logs everyone out, and it is single-instance. Swap in a Redis/SQL store for multiple replicas.
  • History: a durable JetStream pull consumer (CHAT stream, cnats-postgres consumer) archives chat.room.* into Postgres; joining a room backfills the last 100 messages. Inserts are idempotent on message id (JetStream is at-least-once). The CHAT stream keeps default limits — cap it with nats stream edit CHAT --max-age=… if the bus is chatty, since Postgres already holds the archive.
  • The OIDC client verifies ID-token signature, nonce, and CSRF state, and requires PKCE — but there is no token refresh; the app session lives independently of the Kanidm token lifetime.