docs: what actually gates access, now that there is a deployment
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:
+13
-6
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user