docs: what actually gates access, now that there is a deployment
Build and Push Containers / test (push) Failing after 8s
Build and Push Containers / build-and-push-app (push) Skipped

The internal deployment exists (deployments repo, `mirror/`: quadlet, nginx
edge, certbot, route53, VM SOP), so the docs that said "the real fix is HTTPS"
were describing a future that has arrived from a different direction — TLS
terminates in nginx on the service VM, not in this process, and there is no
`--https` flag here.

SECURITY.md's trust model said "trusted: anyone who can open a TCP connection".
With an allow list in production that sentence was simply no longer true, so the
table now names the address space, and a new section says what the list is and
what it is not: one wall (network/nginx) and one belt (the app, for the path
that bypasses nginx at 127.0.0.1:8080), plus the thing neither can do — once two
browsers are in the room their media never asks the server again, and it can
never tell you *who*, only *where from*. Saying "defence in depth" about that
would have been the dishonest version.

PLAN.md gains A19 (TLS + admission in production, LOCKED) and A18 becomes
deprioritised rather than deleted — standalone `mirror serve --https` is still
worth having for offline/ad-hoc use, it just stopped being the thing blocking
cross-device sharing, and candidate phase 1 is now share-page resilience.
AGENTS.md gains the two rules most likely to be broken by a well-meaning edit:
trust XFF only from a trusted proxy, and change the nginx and app lists in the
same commit.

LLM-Generated: true
This commit is contained in:
2026-11-01 17:47:31 -05:00
parent 7988da2b93
commit 2e79430ac7
4 changed files with 91 additions and 15 deletions
+13 -6
View File
@@ -62,7 +62,8 @@ on a phone, and a person on a work machine can all join by opening a URL.**
| A15 | CLI | `argparse`, single `serve` subcommand, flags also accepted bare | One command; no dependency for a two-option CLI | **LOCKED** | | A15 | CLI | `argparse`, single `serve` subcommand, flags also accepted bare | One command; no dependency for a two-option CLI | **LOCKED** |
| A16 | Quality gates | `ruff check` + `ruff format --check` + `pyright` (0 errors) + `pytest` green, always | Non-negotiable before a phase is called complete | **LOCKED** | | A16 | Quality gates | `ruff check` + `ruff format --check` + `pyright` (0 errors) + `pytest` green, always | Non-negotiable before a phase is called complete | **LOCKED** |
| A17 | Scale-out beyond a mesh | *Proposed*: WebSocket/MSE relay or WebCodecs SFU when a sharer's uplink, not the CPU, is the limit | Revisit only with a measured problem. Status: **PROPOSED** | Proposed | | A17 | Scale-out beyond a mesh | *Proposed*: WebSocket/MSE relay or WebCodecs SFU when a sharer's uplink, not the CPU, is the limit | Revisit only with a measured problem. Status: **PROPOSED** | Proposed |
| A18 | TLS | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Solves the secure-context problem (§6) for LAN IPs without a CA. Status: **PROPOSED** | Proposed | | A18 | TLS (standalone) | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Still open for a bare `mirror serve`. The problem it existed to solve is solved in production by A19's deployment, which terminates TLS in nginx. Status: **PROPOSED** (deprioritised) |
| A19 | TLS + admission in production | TLS terminates in **nginx on the service VM** (no gateway, no `proxy_protocol`); access is decided by **source address** at both edges — nginx `allow`/`deny` and `MIRROR_ALLOW_CIDRS`, with `X-Forwarded-For` believed only from `MIRROR_TRUSTED_PROXIES` (loopback) | There are no accounts, so the address *is* the access control; the app repeats the decision because a container port can be reached without the proxy. Over HTTPS every device gets `getDisplayMedia()`, which is what the fleet needs. Deployment lives in `deployments/mirror/` | **LOCKED** |
## 3. Architecture ## 3. Architecture
@@ -198,7 +199,8 @@ MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native capture
| Constraint | Why | Handling | | Constraint | Why | Handling |
|---|---|---| |---|---|---|
| `getDisplayMedia` needs a secure context | Browser rule. `http://<lan-ip>` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Real fix is A18 | | `getDisplayMedia` needs a secure context | Browser rule. `http://<lan-ip>` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Production is HTTPS at the edge (A19), so this only bites a bare `mirror serve` on a LAN IP |
| No accounts means the address list is the whole gate | Product promise (§1) | Enforced twice (nginx + app, A19). `/healthz` is the one exempt route, because the container health probe comes from loopback. See `SECURITY.md` §4 |
| Mesh upload = N × bitrate per sharer | WebRTC mesh | Per-leg `maxBitrate` cap (default 12 Mbit/s). A17 when it bites | | Mesh upload = N × bitrate per sharer | WebRTC mesh | Per-leg `maxBitrate` cap (default 12 Mbit/s). A17 when it bites |
| Wayland capture depends on the portal | Compositor policy | Each share is confirmed by the user; X11 needs no such prompt. Documented in `SECURITY.md` | | Wayland capture depends on the portal | Compositor policy | Each share is confirmed by the user; X11 needs no such prompt. Documented in `SECURITY.md` |
| No reconnection of a *sharer* identity | Peer ids are per-socket | Viewer clears the tile and the sharer re-publishes on reconnect; the wall recovers by itself | | No reconnection of a *sharer* identity | Peer ids are per-socket | Viewer clears the tile and the sharer re-publishes on reconnect; the wall recovers by itself |
@@ -210,12 +212,17 @@ Ordered by what a user would notice first. Each would become
`.agents/phases/todo/NN_name/{00_phase.md,NN_task.md}` via the `phase-authoring` `.agents/phases/todo/NN_name/{00_phase.md,NN_task.md}` via the `phase-authoring`
skill, with its own independently-runnable test suite. skill, with its own independently-runnable test suite.
1. **HTTPS mode** — self-signed cert + a "how to trust it" screen (A18). Unblocks > HTTPS is no longer first, because the deployment solved it: TLS terminates in
capture from `http://<lan-ip>` on other devices, the single biggest papercut. > nginx (A19) and every device on the LAN can share. `mirror serve --https` (A18)
2. **Share-page resilience** — reconnect with roster resync, "source lost" > stays on the list for the standalone case, ranked where its real cost lands.
1. **Share-page resilience** — reconnect with roster resync, "source lost"
states, `surfaceSwitching` UX, per-leg health on the sharer panel. states, `surfaceSwitching` UX, per-leg health on the sharer panel.
3. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout 2. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout
memory, "auto-stage newest mirror", PiP. memory, "auto-stage newest mirror", PiP.
3. **HTTPS for `mirror serve`** — A18: self-signed cert, the "why is this
untrusted" screen, and a hint that a real deployment should terminate TLS in
nginx instead. For ad-hoc and offline use only now.
4. **Capacity & fairness** — adaptive bitrate per leg by watcher count, 4. **Capacity & fairness** — adaptive bitrate per leg by watcher count,
`MIRROR_MAX_MIRRORS` surfaced in the UI, graceful degradation for a weak `MIRROR_MAX_MIRRORS` surfaced in the UI, graceful degradation for a weak
sharer uplink. sharer uplink.
+3 -1
View File
@@ -48,7 +48,9 @@ came first, and the candidate phase list is §7 of `PLAN.md`.)*
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 | | **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage | | **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test | | **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
| **No authentication** (A11) | Product brief. If you think it needs auth, that is a `PLAN.md` discussion first — and see `SECURITY.md` | | **No authentication** (A11) — but the **address is the access control** (A19) | Product brief: no accounts. What decides who gets in is `MIRROR_ALLOW_CIDRS` (`src/mirror/access.py`), enforced in the HTTP middleware *and* before the socket accepts. If you think it needs auth, that is a `PLAN.md` discussion first — and see `SECURITY.md` |
| **Trust `X-Forwarded-For` only from `MIRROR_TRUSTED_PROXIES`, left-most value only** | Otherwise a browser writes its own pass. The deployment's nginx must *overwrite* the header (`$remote_addr`, never `$proxy_add_x_forwarded_for`) — and the nginx `allow`/`deny` list and `MIRROR_ALLOW_CIDRS` change in the same commit |
| **`/healthz` stays exempt from the address gate** | The podman `HealthCmd` probes it from loopback, which the production list otherwise excludes — an exempt probe is why the container is not permanently unhealthy |
| **No STUN/TURN by default** | A LAN tool must not announce internal addresses to a third party | | **No STUN/TURN by default** | A LAN tool must not announce internal addresses to a third party |
| **Teardown is broadcast to every viewer**, not just subscribers | Tiles are drawn on `stream_added`; a non-subscribed viewer still has a stale tile to clear | | **Teardown is broadcast to every viewer**, not just subscribers | Tiles are drawn on `stream_added`; a non-subscribed viewer still has a stale tile to clear |
| **Keep both `<video>` elements `muted playsinline`** | Autoplay policy on iOS/Safari otherwise blocks the wall entirely | | **Keep both `<video>` elements `muted playsinline`** | Autoplay policy on iOS/Safari otherwise blocks the wall entirely |
+30 -3
View File
@@ -12,9 +12,11 @@ your network:
| **Index** | `/` | Signpost + live count of mirrors. | | **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can Any number of people can mirror at the same time and any number of people can
watch. There is **no authentication** — this is built for a trusted local watch. There are **no accounts and no passwords** — this is built for a trusted
network, and that assumption is deliberate and documented in local network, and that assumption is deliberate and documented in
[SECURITY.md](SECURITY.md). [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 ## Quickstart
@@ -60,6 +62,8 @@ Environment variables (all optional; a `.env` file is loaded if present):
| `MIRROR_PORT` | `8055` | Bind port. | | `MIRROR_PORT` | `8055` | Bind port. |
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. | | `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_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. |
| `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` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
| `DEBUGPY_PORT` | `5678` | debugpy port. | | `DEBUGPY_PORT` | `5678` | debugpy port. |
@@ -71,6 +75,11 @@ Environment variables (all optional; a `.env` file is loaded if present):
three ways forward (share from localhost, serve HTTPS, or mark that one origin as three ways forward (share from localhost, serve HTTPS, or mark that one origin as
secure on the sharing device). Watching never needs permission. 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 **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 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 `xdg-desktop-portal`, so the compositor shows its **own permission dialog for
@@ -78,6 +87,24 @@ 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 offers whole screens and individual windows; on Wayland what the portal will
agree to depends on the compositor. agree to depends on the compositor.
## 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.
## Development ## Development
```bash ```bash
+45 -5
View File
@@ -8,8 +8,8 @@ run?". This file answers the second question honestly.
| | | | | |
|---|---| |---|---|
| **Trusted** | Anyone who can open a TCP connection to the server's port, and every host on the same L2/L3 network as them | | **Trusted** | Anyone inside the address space the server answers — with no allow list configured that is anyone who can open a TCP connection, and in the fleet deployment it is the client LAN (`10.2.0.0/22`) and nothing else |
| **Untrusted** | Anyone who can reach that port and is not in the above set — including anything on the LAN that should not be (guest Wi-Fi, IoT VLAN, a cafe) | | **Untrusted** | Anyone outside that space — including anything on the LAN that was not named in it (guest Wi-Fi, IoT VLAN, a cafe), and everything on the internet |
| **Not a threat model** | Malicious JavaScript served from this origin (there is only one origin and you run the server), supply-chain of a machine you already control | | **Not a threat model** | Malicious JavaScript served from this origin (there is only one origin and you run the server), supply-chain of a machine you already control |
The product promise is *"open a URL, share your screen"*. Every gate we add The product promise is *"open a URL, share your screen"*. Every gate we add
@@ -52,7 +52,10 @@ limiting, no accounts, and no audit trail.
`/mirror` page detects this and prints the three ways forward: `/mirror` page detects this and prints the three ways forward:
1. Share from the machine running the server, via `http://localhost:8055/mirror`. 1. Share from the machine running the server, via `http://localhost:8055/mirror`.
2. Serve over HTTPS (planned: `mirror serve --https` with a self-signed cert). 2. Put TLS in front of it. This is no longer a plan: the fleet deployment
terminates HTTPS in nginx on the service VM (`https://mirror.reeseapps.com`),
so every device there can share. `mirror serve --https` for standalone use is
still open (A18).
3. Per-device and explicit: `chrome://flags/#unsafely-treat-insecure-origin-as-secure` 3. Per-device and explicit: `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
with the exact origin, then reload. with the exact origin, then reload.
@@ -60,6 +63,36 @@ Option 3 is a *deliberate, per-device, user-granted* exception. Do not automate
on someone else's machine without telling them what it does: it marks that origin on someone else's machine without telling them what it does: it marks that origin
as trusted for powerful APIs, permanently, on that profile. as trusted for powerful APIs, permanently, on that profile.
### Access control is an address list, not an account
Because there is nothing to log in with, the source address decides everything.
That makes the list strong and also makes it the only thing standing between a
stranger and every screen in the building, so treat it as the security boundary
it is:
| Layer | Mechanism | Bypass it and you have |
|---|---|---|
| Network | routing / firewall, `allow`/`deny` in nginx | nothing — this is the wall |
| App | `MIRROR_ALLOW_CIDRS` in the container | a process already running on the server host |
The app layer exists because a container port can be reached without going
through the proxy (`127.0.0.1:8080` on the service VM). It judges the
`X-Forwarded-For` value **only** from an address in `MIRROR_TRUSTED_PROXIES`, and
the proxy must *overwrite* that header (`proxy_set_header X-Forwarded-For
$remote_addr`) rather than append, so a browser cannot write its own pass. What
the app layer cannot do is resist someone who already has code on the server: at
that height they can forge the header, so it is a second lock on the outside door
and not a host firewall. Say that plainly rather than implying defence in depth
where there is one wall and one belt.
Two things the address list does **not** do, both inherent to WebRTC:
* Once two browsers are in the room, their media flows directly between them and
never consults the server again. Admission to the roster is admission to the
stream.
* It cannot tell you *who* is watching — only *where from*. Device labels are
self-reported and cosmetic; never treat one as an identity.
### Wayland asks every time; X11 does not ### Wayland asks every time; X11 does not
On **X11/Xorg**, Chromium uses its native capturer: the picker appears and that is On **X11/Xorg**, Chromium uses its native capturer: the picker appears and that is
@@ -81,8 +114,15 @@ user-initiated action.
## 5. Operational guidance ## 5. Operational guidance
* **Bind narrowly.** `MIRROR_HOST=0.0.0.0` is the default because the point is * **Name the networks, do not just bind.** `--allow-cidr 10.2.0.0/22` (or
other devices. If only this machine should reach it, `MIRROR_HOST=127.0.0.1`. `MIRROR_ALLOW_CIDRS`) is the setting that actually restricts *who*, whereas
`MIRROR_HOST` only restricts *which interface*. `MIRROR_HOST=0.0.0.0` with an
allow list is the deployment shape; `MIRROR_HOST=127.0.0.1` is how you keep a
dev instance to yourself.
* **A refusal should explain itself.** A refused client gets a 403 naming the
networks that are allowed and the address that was judged — because "not
allowed" in front of a proxy is undiagnosable otherwise. Keep that behaviour if
you touch the gate.
* **Firewall the port.** Restrict 8055 to the LAN range you actually want: * **Firewall the port.** Restrict 8055 to the LAN range you actually want:
`firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.168.1.0/24 port port=8055 protocol=tcp accept'` `firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.168.1.0/24 port port=8055 protocol=tcp accept'`
* **Cap the blast radius.** `MIRROR_MAX_MIRRORS` limits concurrent mirrors; * **Cap the blast radius.** `MIRROR_MAX_MIRRORS` limits concurrent mirrors;