Files
mirror/README.md
T
ducoterra 8fefd83ef8 feat(kiosk): self-recover a hung wall and page when it goes quiet
An unattended wall failed in two ways on 2026-11-02. It hard-hung with the kernel
alive but userspace unable to start a process: ssh completed the handshake and
closed 6-13 ms later with zero bytes and no banner, while 62 GiB RAM and zero OOM
lines rule out memory. Separately, the service knew at 14:03Z that nothing was
watching (healthz viewers:0) and nothing was reading it. It was discovered by
walking over to the panel.

Hang recovery, two independent layers (install step 7/9, asserted by
check-kiosk.sh):

- /etc/sysctl.d/90-mirror-wall-selfheal.conf: soft lockup, hard lockup and any
  oops escalate to a panic, and kernel.panic=30 reboots instead of parking.
- /etc/systemd/system.conf.d/90-mirror-wall-watchdog.conf: PID 1 must keep kicking
  the iTCO hardware watchdog or the chipset resets the box, which is the layer that
  covers a kernel too wedged to notice. Clamped to the driver's own 30s because
  systemd kicks at a third of whatever value it is handed.

Presence alerting: docs/wall-watch/ runs on the server host, probes loopback
/healthz (exempt from the address gate) every minute, debounces three checks, pages
over ntfy and then announces the recovery. WALL_DRILL=1 labels a test page in the
title, body and priority so a drill can never be mistaken for an incident.
Verified against a fake ntfy: caller environment wins over the config file, both
transitions fire, and the debounce holds.

Firewall (install step 8/9): drop FedoraWorkstation's 1025-65535/tcp. The UDP half
is load-bearing for peer-to-peer media (mirror deliberately uses no STUN/TURN) and
stays open; the TCP half was every high port on a box whose only access control is
its address. Measured, not assumed: a live share kept flowing at 459.6 kbit/s
inbound with the TCP range closed, and tcp/8080 stopped answering from a
same-subnet host.

Also fixed: install-kiosk.sh overwrote an existing client's kiosk.conf with its
built-in default URL, so re-running it to repair a wall silently repointed it at
the production default. An existing URL now wins and KIOSK_URL= is an explicit
change.

Documented with the evidence: why gnome-remote-desktop cannot share a gnome-kiosk
session (gnome-kiosk exports no org.gnome.Mutter.* names, so grd has nothing to
capture, and this image has grdctl rather than grdc), and why no userspace RDP
would have survived the hang either.

LLM-Generated: true
2026-11-02 09:53:13 -05:00

207 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# mirror
**Device-agnostic screen mirroring that lives entirely in the browser.**
`mirror` runs a small HTTP server on your machine. Open it from any device on
your network:
| Page | URL | What it does |
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can
watch. There are **no accounts and no passwords** — this is built for a trusted
local network, and that assumption is deliberate and documented in
[SECURITY.md](SECURITY.md). What *does* decide who gets in is the source address:
by default any address is welcome, and `--allow-cidr` / `MIRROR_ALLOW_CIDRS`
narrows that to the networks you name.
## Quickstart
```bash
uv sync --extra dev
uv run mirror serve # http://0.0.0.0:8055
```
Then, from another machine on the LAN:
```
http://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch
```
Linux is the primary target. On Linux the native picker is X11/Wayland's own
(Chromium uses `xdg-desktop-portal` on Wayland/GNOME/KDE, and its built-in X11
picker on Xorg), so nothing has to be installed on the client side.
The share page works on a phone as well as a desktop: `/mirror` is usable
down to a 320 px-wide viewport, and the preview keeps its shape but never
shrinks below the controls it holds, so the Share button is always on screen.
The target *client* setup for an always-on display is Fedora Silverblue running
GNOME Kiosk — see [`docs/kiosk-silverblue.md`](docs/kiosk-silverblue.md).
## How it works
Media never touches the server. Each mirror is a WebRTC peer connection
established directly between the sharer's browser and each watcher's browser
(a "mesh" fan-out). The server is a **signaling relay and roster only**: it
tracks who is publishing what, forwards SDP/ICE between peers, and pushes
roster deltas to viewers.
```
sharer browser ──ws──► ┌──────────────────────┐ ◄──ws── watcher browser
│ │ FastAPI │ ▲
│ offer/ICE ───►│ /ws (signalling) │◄─── offer/ICE │
│ │ registry (in-mem) │ │
└────── WebRTC (SRTP, LAN, p2p) ─────────┴─────────────────┘
```
## Configuration
Environment variables (all optional; a `.env` file is loaded if present):
| Variable | Default | Meaning |
| ---------------------- | ------- | ---------------------------------------------------- |
| `MIRROR_HOST` | `0.0.0.0` | Bind address for `mirror serve`. |
| `MIRROR_PORT` | `8055` | Bind port. |
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
| `MIRROR_MAX_BITRATE` | `25000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. 25 Mbit/s suits a wired LAN at 1440p; a Wi-Fi sharer with many viewers may need less, not more. |
| `MIRROR_PING_INTERVAL` | `20` | Seconds between socket liveness pings. |
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. A wall needs this range too, and its address is DHCP — give it a reservation. |
| `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. |
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
| `DEBUGPY_PORT` | `5678` | debugpy port. |
| `DEBUGPY_WAIT` | `0` | `1` blocks the server at start until a debugger attaches. |
## Platform notes
**Screen capture needs a secure context.** `http://localhost` is one; a plain
`http://192.168.x.x` opened from another device is not, and the browser hides
`getDisplayMedia()` completely. The `/mirror` page detects this and prints the
three ways forward (share from localhost, serve HTTPS, or mark that one origin as
secure on the sharing device). Watching never needs permission.
This is why the fleet deployment terminates TLS in nginx rather than exposing
`mirror serve` directly — over `https://`, every device on the network can share
and none of this applies. A bare `mirror serve` is for the machine it runs on and
for testing.
**Linux.** On X11/Xorg Chromium uses its native capturer — the picker appears and
that is the end of it. On Wayland (GNOME/KDE) Chromium goes through
`xdg-desktop-portal`, so the compositor shows its **own permission dialog for
each share**, which mirror neither controls nor tries to bypass. Chrome's picker
offers whole screens and individual windows; on Wayland what the portal will
agree to depends on the compositor.
**Audio.** A share carries audio when the browser's picker offers it (Chrome's
"share audio" tickbox, or a tab's own audio); the sharer's panel says
`shared` / `silent` / `not offered`, and the wall shows an audio button only
when one exists. On the wall, only the staged mirror can be audible — **press
`m` on the wall to hear the staged mirror** (unmuting it mutes the others,
leaving the stage re-mutes it). What *system* audio a browser can capture is
a browser/platform fact, not a mirror one: Chrome on Linux gets it where
PipeWire offers it, Firefox's desktop capture does not offer it at all. See
[SECURITY.md](SECURITY.md) §4 "Audio".
## Deployment
The internal deployment is `https://mirror.reeseapps.com`, described and automated
in the deployments repo under `mirror/` (`app/` quadlet, `nginx/` edge, `certbot/`,
`route53/`, and a README with the VM SOP). Three things about it are worth knowing
from this side:
- **TLS ends at nginx on the service VM**, not in this process, so there is no
`--https` flag here. That is what makes `getDisplayMedia()` available on every
device, and it means an `Upgrade` header and a long idle timeout on `/ws` are
load-bearing.
- **The address list is enforced twice** — nginx `allow`/`deny`, and
`MIRROR_ALLOW_CIDRS` in the container for the path that bypasses nginx. Change
both in one commit.
- **The container has no internet egress.** Media goes browser-to-browser and the
server only relays JSON, so it dials nothing; `Internal=true` is what makes
that a fact rather than an intention.
### Kiosk client
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
no monitor. A brand-new box layers the kiosk packages once first
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
after that these two commands are both the setup and the repair path:
```bash
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
'cd /var/tmp/kiosk-silverblue && sudo sh install-kiosk.sh && sudo systemctl restart gdm'
ssh client 'sudo sh /usr/local/share/mirror-kiosk/check-kiosk.sh'
```
Because nobody is standing at the machine, every claim about it is verified from
the outside: `loginctl` for the session, the foreground VT for "is the wall
actually on screen", the server's `/api/streams` for the viewer, the sharer's own
peer state for the media, and a probe page for the rendered resolution. The
runbook records why each file lives where it does on an
otherwise read-only OS.
**When it hangs.** A wall is unattended, so a hang has to end without a person:
the kernel turns lockups and oopses into a reboot, and PID 1 must keep kicking the
hardware watchdog or the chipset resets the box. Being *quiet* is a different
failure than being hung, so the server watches `/healthz` for that and pages over
ntfy ([`docs/wall-watch/`](docs/wall-watch/)). Both are installed by the commands
above and asserted by `check-kiosk.sh`.
**Keys.** The whole wall works from a keyboard — and the box needs no keyboard
attached to be operated *or* debugged; the keys are there when one is plugged
in. On `/watch`:
| Key | Action |
| --- | --- |
| `j` / `↓` · `k` / `↑` | Select the next / previous mirror (wraps). |
| `Enter` / `f` | Stage the selected mirror fullscreen (`f` re-enters fullscreen when already staged). |
| `g` | Back to the wall. |
| `n` / `p` | Stage the next / previous mirror. |
| `1`–`9` | Stage the Nth mirror. |
| `m` | Mute / unmute the staged mirror — on the selected mirror it stages it first. |
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
A mirror's audio is only heard while it is on the stage: every mirror starts
muted, unmuting one (the tile's audio button or `m`) mutes the others, and
leaving the stage re-mutes it. Sound never starts without a click or a
keypress.
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the display-
name field is text, not a command. `?` is the only key that needs Shift, and it
is matched on the character itself — on a layout where `?` is a dead key the
list is not reachable, so keep this table to hand.
## Development
```bash
uv run pytest # unit + API + browser E2E (~50s)
uv run pytest -m "not e2e" # everything except the browser (~0.5s)
uv run ruff check . && uv run ruff format --check . && uv run pyright
```
The browser suite drives **real capture**: Chromium's fake media device yields a
moving monitor stream (1280×720 by default; clamped to the capturing window when
a size is requested, as `/mirror` now asks), so the assertions are on decoded,
changing pixels at the watcher — not on log lines.
There is a second, opt-in rung that exercises Chromium's **real X11 capture
backend** on a private Xvfb display, isolated from your own session so it can
never prompt you or mirror your desktop:
```bash
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay
```
See [AGENTS.md](AGENTS.md) for the operating rules,
[.agents/PLAN.md](.agents/PLAN.md) for the locked architectural decisions, and
[SECURITY.md](SECURITY.md) for why there is no login.