feat: decide who may connect at all, by source address

mirror has no accounts, so on a real network the address a connection arrives
from is not a nicety — it is the entire access control. Before this change the
answer was "everyone who can open a TCP connection", which is fine for a laptop
demo and alarming for a box someone leaves running.

`mirror serve --allow-cidr 10.2.0.0/22` (or MIRROR_ALLOW_CIDRS) now restricts
pages, the JSON API and the signaling socket to the networks named, and it is
strict where it needs to be: a malformed CIDR exits 2 with one line instead of
starting a server whose list silently failed to parse, because the two
outcomes of that typo are "nobody can connect" and "everybody can connect".
Empty remains the only permissive state, on purpose.

The rules that matter live in src/mirror/access.py and are the ones that get
either outcome:

  * X-Forwarded-For is believed only from MIRROR_TRUSTED_PROXIES (loopback by
    default), and only its left-most value. An untrusted peer that sends the
    header gets judged on its own address — there is a test for exactly that,
    because it is the shape of a bypass.
  * The socket is closed before it accepts, so a refused address never creates
    a roster entry to clean up behind it.
  * An address that cannot be parsed fails closed, and a v6 address is never
    "inside" a v4 network (comparing the two raises TypeError, which would be
    a 500 on a dual-stack host).
  * /healthz is exempt: the container health probe comes from loopback, which
    a production list otherwise excludes.

The refused client is told why, and which address it was judged as — "403" in
front of a proxy is undiagnosable otherwise. Browsers get a page, fetches get
JSON, and the page is self-contained because a refused address cannot load
/static/app.css any more than it can load a page. net.js keys its own notice on
the /api/config 403 and stops: without that the pill reads "offline,
reconnecting in 32s" forever for a decision that will never change.

The static files drop their only inline script and style attribute (the landing
page's counter is /static/home.js, its fine print is .fineprint) so the
deployment can send `script-src 'self'; style-src 'self'` with no
'unsafe-inline'. That policy was verified through a real nginx edge carrying the
exact header set — capture through SRTP to decoded, painted frames at
1280x720, eight distinct, zero securitypolicyviolation events — which also
caught the edge sending `Cache-Control: no-store, no-store` and needed
proxy_hide_header, since nginx appends rather than replaces.

24 tests added; 176 pass, 2 skipped, pyright and ruff clean.

LLM-Generated: true
This commit is contained in:
2026-11-01 17:47:31 -05:00
parent 24cb10ee5c
commit 7988da2b93
16 changed files with 771 additions and 55 deletions
+14
View File
@@ -19,6 +19,20 @@ MIRROR_PING_INTERVAL=20
MIRROR_ICE_SERVERS= MIRROR_ICE_SERVERS=
# MIRROR_ICE_SERVERS=[{"urls":"stun://192.168.1.1:3478"}] # MIRROR_ICE_SERVERS=[{"urls":"stun://192.168.1.1:3478"}]
# Who may connect. Unset (the default) accepts any address, which is right for a
# laptop demo and wrong for anything other people can reach. Once one network is
# listed, everything else is refused — including this machine's own address, so
# when a proxy sits in front, allow the client networks here AND in nginx.
# Loopback is exempt from all of this only for /healthz, which is what the
# container health check probes.
MIRROR_ALLOW_CIDRS=
# MIRROR_ALLOW_CIDRS=10.2.0.0/22,fd00::/64
# Which peers may speak for a client through X-Forwarded-For. Default is loopback,
# correct when nginx runs on the same host and *overwrites* the header
# (proxy_set_header X-Forwarded-For $remote_addr). Only add a proxy you control.
MIRROR_TRUSTED_PROXIES=
# Developer only: attach a debugpy listener before serving. # Developer only: attach a debugpy listener before serving.
DEBUGPY=0 DEBUGPY=0
DEBUGPY_PORT=5678 DEBUGPY_PORT=5678
+30 -2
View File
@@ -11,6 +11,7 @@ from dataclasses import replace
from typing import Any, get_args from typing import Any, get_args
from . import __version__, protocol from . import __version__, protocol
from .access import ConfigError, format_cidrs, parse_cidrs
def _positive_int(raw: str) -> int: def _positive_int(raw: str) -> int:
@@ -28,7 +29,8 @@ def build_parser() -> argparse.ArgumentParser:
prog="mirror", prog="mirror",
description="Device-agnostic screen mirroring, served entirely from the browser.", description="Device-agnostic screen mirroring, served entirely from the browser.",
epilog="Environment: MIRROR_HOST, MIRROR_PORT, MIRROR_ICE_SERVERS, " epilog="Environment: MIRROR_HOST, MIRROR_PORT, MIRROR_ICE_SERVERS, "
"MIRROR_MAX_BITRATE, MIRROR_MAX_MIRRORS, DEBUGPY.", "MIRROR_MAX_BITRATE, MIRROR_MAX_MIRRORS, MIRROR_ALLOW_CIDRS, "
"MIRROR_TRUSTED_PROXIES, DEBUGPY.",
) )
parser.add_argument("--version", action="version", version=f"mirror {__version__}") parser.add_argument("--version", action="version", version=f"mirror {__version__}")
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
@@ -41,6 +43,15 @@ def build_parser() -> argparse.ArgumentParser:
help="Bind address. Defaults to $MIRROR_HOST, or 0.0.0.0, which reaches other devices.", help="Bind address. Defaults to $MIRROR_HOST, or 0.0.0.0, which reaches other devices.",
) )
serve.add_argument("--port", type=_positive_int, default=None, help="Bind port.") serve.add_argument("--port", type=_positive_int, default=None, help="Bind port.")
serve.add_argument(
"--allow-cidr",
action="append",
default=None,
metavar="NET",
help="Only serve connections from this address or CIDR network, e.g. "
"10.2.0.0/22. Repeatable, and it replaces $MIRROR_ALLOW_CIDRS. "
"Unset means unrestricted.",
)
serve.add_argument( serve.add_argument(
"--role-hint", "--role-hint",
choices=list(get_args(protocol.Role)), choices=list(get_args(protocol.Role)),
@@ -63,12 +74,22 @@ def cmd_serve(args: argparse.Namespace) -> int:
from .config import Settings, lan_addresses from .config import Settings, lan_addresses
from .debug import maybe_enable_debugpy from .debug import maybe_enable_debugpy
settings = Settings.from_env()
overrides: dict[str, Any] = {} overrides: dict[str, Any] = {}
try:
settings = Settings.from_env()
if args.host is not None: if args.host is not None:
overrides["host"] = args.host overrides["host"] = args.host
if args.port is not None: if args.port is not None:
overrides["port"] = args.port overrides["port"] = args.port
if args.allow_cidr is not None:
# Replacing rather than appending: two sources of truth about who may
# connect is how a server ends up open by accident.
overrides["allow_cidrs"] = parse_cidrs(",".join(args.allow_cidr), source="--allow-cidr")
except ConfigError as exc:
# One line, no traceback: this is a typo in a deployment file, not a bug.
print(f"mirror: {exc}", file=sys.stderr)
return 2
if overrides: if overrides:
settings = replace(settings, **overrides) settings = replace(settings, **overrides)
@@ -84,6 +105,13 @@ def cmd_serve(args: argparse.Namespace) -> int:
print(f" from another device: http://{addr}:{settings.port}", flush=True) print(f" from another device: http://{addr}:{settings.port}", flush=True)
print(" share a screen -> /mirror", flush=True) print(" share a screen -> /mirror", flush=True)
print(" watch everything -> /watch", flush=True) print(" watch everything -> /watch", flush=True)
if settings.allow_cidrs:
print(f" open to: {format_cidrs(settings.allow_cidrs)}", flush=True)
else:
print(
" open to: any address (restrict with --allow-cidr / MIRROR_ALLOW_CIDRS)",
flush=True,
)
uvicorn.run( uvicorn.run(
app, app,
+146
View File
@@ -0,0 +1,146 @@
"""Source-address admission control.
mirror has no accounts — the point of the tool is that anyone in the room can
share and watch, and an account system would defeat that. What makes that safe
is *where* it runs: the address a connection arrives from is the access control,
and this module is the one place that decides it, for pages, the JSON API and
the signaling socket alike.
Two rules are worth stating because they are the ones that get someone locked
out or let something in:
* **An empty allow list means no restriction.** That is the development default,
and it is deliberately the *only* permissive state. Once a single network is
listed everything else is refused, including this machine's own address.
* **A proxy is only believed when it is on the trusted list**, and then only the
left-most ``X-Forwarded-For`` value is used. In the fleet deployment nginx
runs on the same host as this container and sets the header to
``$remote_addr`` (overwriting, never appending), so loopback is the only
thing that can honestly claim to have seen the real client and a browser
cannot invent its own history.
Reach the media is not: WebRTC runs browser-to-browser and never touches this
process, so admitting a peer to the roster lets it *arrange* a stream, which is
the same thing the roster is for.
"""
from __future__ import annotations
from dataclasses import dataclass
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
Address = IPv4Address | IPv6Address
Network = IPv4Network | IPv6Network
#: Proxies we believe when they speak for a client. See the module docstring.
DEFAULT_TRUSTED_PROXIES = "127.0.0.0/8, ::1/128"
#: Close code for "you may not use this service" (RFC 6455 policy violation).
POLICY_VIOLATION = 1008
class ConfigError(ValueError):
"""A CIDR list was unusable.
Raised while parsing configuration, never while serving a request: a typo
here means either "nobody can connect" or "everybody can connect", and
neither is something to discover quietly at runtime.
"""
def _address(value: str | None) -> Address | None:
"""Parse an address, or None when it is not one."""
if not value:
return None
try:
return ip_address(value.strip())
except ValueError:
return None
def parse_cidrs(raw: str | None, *, source: str = "MIRROR_ALLOW_CIDRS") -> tuple[Network, ...]:
"""Parse a comma or whitespace separated list of networks.
Accepts bare addresses (``10.2.1.5`` becomes ``10.2.1.5/32``) and
normalises a host part that does not fit its mask (``10.2.1.5/22`` becomes
the ``10.2.0.0/22`` it was meant to be) because that is what someone
pasting their laptop's address and netmask means. Anything else raises.
"""
out: list[Network] = []
for chunk in (raw or "").replace(";", ",").replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
network = ip_network(chunk, strict=False)
except ValueError as exc:
raise ConfigError(
f"{source}: {chunk!r} is not an IP address or CIDR network "
"(e.g. 10.2.0.0/22, fd00::/64, 10.2.1.5)"
) from exc
if network not in out:
out.append(network)
return tuple(out)
def format_cidrs(networks: tuple[Network, ...]) -> str:
"""A short, readable rendering for status pages and error messages."""
return ", ".join(str(network) for network in networks)
@dataclass(frozen=True, slots=True)
class Gate:
"""Decides whether a peer address may use this server."""
allow: tuple[Network, ...] = ()
trusted_proxies: tuple[Network, ...] = ()
@property
def enabled(self) -> bool:
"""False when any address is welcome (nothing was listed)."""
return bool(self.allow)
@staticmethod
def _inside(ip: Address | None, networks: tuple[Network, ...]) -> bool:
if ip is None:
return False
# Version matters: an IPv6 address is never "inside" an IPv4 network, and
# comparing the two is a TypeError rather than a False.
return any(ip.version == network.version and ip in network for network in networks)
def client_address(self, peer: str | None, forwarded: str | None = None) -> str | None:
"""The address to judge, unwrapping one trusted proxy hop.
The socket peer wins unless it is a trusted proxy *and* a usable address
arrived in ``X-Forwarded-For``, in which case the left-most value is the
client. An unparseable header is ignored rather than fatal: a broken
proxy should not lock an otherwise valid client out of its own tool.
"""
peer_address = _address(peer)
if not self._inside(peer_address, self.trusted_proxies) or not forwarded:
return peer
for part in forwarded.split(","):
if _address(part) is not None:
return part.strip()
return peer
def permits(self, address: str | None) -> bool:
"""Whether ``address`` may connect. Fails closed when unknown."""
if not self.allow:
return True
ip = _address(address)
if ip is None:
return False
return self._inside(ip, self.allow)
def check(self, peer: str | None, forwarded: str | None = None) -> tuple[bool, str | None]:
"""``(allowed, judged_address)`` — the shape the HTTP and WS paths want."""
address = self.client_address(peer, forwarded)
return self.permits(address), address
+100 -2
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
from contextlib import suppress from contextlib import suppress
from html import escape
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -18,6 +19,7 @@ from pydantic import ValidationError
from starlette.websockets import WebSocketDisconnected from starlette.websockets import WebSocketDisconnected
from . import __version__, protocol from . import __version__, protocol
from .access import POLICY_VIOLATION, Gate, format_cidrs
from .config import Settings from .config import Settings
from .registry import Envelope, Registry, RegistryError from .registry import Envelope, Registry, RegistryError
@@ -27,11 +29,73 @@ STATIC_DIR = Path(__file__).parent / "static"
#: anything bigger is noise or an attack, and this server has no authentication. #: anything bigger is noise or an attack, and this server has no authentication.
MAX_MESSAGE_BYTES = 64 * 1024 MAX_MESSAGE_BYTES = 64 * 1024
#: A roster changes second to second, and a refusal must never be cached by an
#: intermediary on behalf of an address that is allowed.
_NO_STORE = {"Cache-Control": "no-store"}
def _page(name: str) -> FileResponse: def _page(name: str) -> FileResponse:
return FileResponse(STATIC_DIR / name, media_type="text/html") return FileResponse(STATIC_DIR / name, media_type="text/html")
def _refusal_html(address: str | None, gate: Gate) -> str:
"""A self-contained refusal page.
It cannot link to /static/app.css — a refused address cannot fetch it any
more than it can fetch a page — so the styling is inline. It says which
address was judged, because "you are not allowed" without that is undiagnosable
when a proxy is in front of you and has rewritten it.
"""
allowed = format_cidrs(gate.allow)
you = escape(address or "unknown")
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mirror — not open to this address</title>
<style>
:root {{ color-scheme: dark; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0c0f13;
color: #e6edf3;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}}
main {{ max-width: 40rem; padding: 3rem 1.5rem; }}
h1 {{ font-size: 1.5rem; margin: 0 0 .75rem; }}
p {{ margin: 0 0 .9rem; color: #9fb0c0; }}
code {{
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: #161b22;
border: 1px solid #232a33;
border-radius: 5px;
padding: 1px 6px;
color: #7ee7dc;
}}
</style>
</head>
<body>
<main>
<h1>This mirror is not open to your address</h1>
<p>
It accepts connections from <code>{escape(allowed)}</code>. Your request was
judged as <code>{you}</code>.
</p>
<p>
If you reached it through a proxy, the address shown is what the proxy reported
— check its <code>allow</code>/<code>deny</code> list and the
<code>X-Forwarded-For</code> it sends.
</p>
</main>
</body>
</html>
"""
class Hub: class Hub:
"""Owns the sockets and delivers the roster's outbound envelopes.""" """Owns the sockets and delivers the roster's outbound envelopes."""
@@ -201,6 +265,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings.from_env() settings = settings or Settings.from_env()
registry = Registry(max_streams=settings.max_streams) registry = Registry(max_streams=settings.max_streams)
hub = Hub(registry) hub = Hub(registry)
gate = settings.gate()
app = FastAPI( app = FastAPI(
title="mirror", title="mirror",
@@ -253,13 +318,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.websocket("/ws") @app.websocket("/ws")
async def signaling(websocket: WebSocket) -> None: async def signaling(websocket: WebSocket) -> None:
if not gate.permits(
gate.client_address(
websocket.client.host if websocket.client else None,
websocket.headers.get("x-forwarded-for"),
)
):
# Close before accepting, so there is no session to tear down and no
# roster entry to clean behind it. The page explains itself: the same
# address already got a 403 from /api/config.
await websocket.close(code=POLICY_VIOLATION, reason="address not allowed")
return
await hub.serve(websocket) await hub.serve(websocket)
PAGES = ("/", "/mirror", "/share", "/watch") PAGES = ("/", "/mirror", "/share", "/watch")
@app.middleware("http") @app.middleware("http")
async def no_store_for_pages(request: Request, call_next): # type: ignore[no-untyped-def] async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
"""A mirror roster changes second to second; never let a browser cache it.""" """Refuse addresses outside the allowed space; never let a roster be cached.
`/healthz` is exempt: the container health check comes from loopback and a
probe that reports counts is not information an address filter is for.
"""
if gate.enabled and request.url.path != "/healthz":
allowed, address = gate.check(
request.client.host if request.client else None,
request.headers.get("x-forwarded-for"),
)
if not allowed:
payload = {
"error": "forbidden",
"message": "This mirror only accepts connections from "
f"{format_cidrs(gate.allow)}.",
"you": address,
}
if "text/html" in request.headers.get("accept", ""):
return HTMLResponse(
_refusal_html(address, gate), status_code=403, headers=_NO_STORE
)
return JSONResponse(payload, status_code=403, headers=_NO_STORE)
response = await call_next(request) response = await call_next(request)
if request.url.path in PAGES: if request.url.path in PAGES:
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
+16
View File
@@ -8,6 +8,8 @@ import socket
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from .access import DEFAULT_TRUSTED_PROXIES, Gate, Network, parse_cidrs
DEFAULT_HOST = "0.0.0.0" DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8055 DEFAULT_PORT = 8055
DEFAULT_MAX_BITRATE_BPS = 12_000_000 DEFAULT_MAX_BITRATE_BPS = 12_000_000
@@ -56,6 +58,15 @@ class Settings:
max_bitrate_bps: int = DEFAULT_MAX_BITRATE_BPS max_bitrate_bps: int = DEFAULT_MAX_BITRATE_BPS
max_streams: int = 0 max_streams: int = 0
ping_interval: float = 20.0 ping_interval: float = 20.0
#: Source networks that may use this server. Empty means unrestricted.
allow_cidrs: tuple[Network, ...] = ()
trusted_proxies: tuple[Network, ...] = parse_cidrs(
DEFAULT_TRUSTED_PROXIES, source="MIRROR_TRUSTED_PROXIES"
)
def gate(self) -> Gate:
"""The admission rule for this server, derived from the settings."""
return Gate(allow=self.allow_cidrs, trusted_proxies=self.trusted_proxies)
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:
@@ -72,6 +83,11 @@ class Settings:
max_bitrate_bps=_env_int("MIRROR_MAX_BITRATE", DEFAULT_MAX_BITRATE_BPS), max_bitrate_bps=_env_int("MIRROR_MAX_BITRATE", DEFAULT_MAX_BITRATE_BPS),
max_streams=_env_int("MIRROR_MAX_MIRRORS", 0), max_streams=_env_int("MIRROR_MAX_MIRRORS", 0),
ping_interval=float(_env_int("MIRROR_PING_INTERVAL", 20)), ping_interval=float(_env_int("MIRROR_PING_INTERVAL", 20)),
allow_cidrs=parse_cidrs(os.environ.get("MIRROR_ALLOW_CIDRS")),
trusted_proxies=parse_cidrs(
os.environ.get("MIRROR_TRUSTED_PROXIES") or DEFAULT_TRUSTED_PROXIES,
source="MIRROR_TRUSTED_PROXIES",
),
) )
def client_config(self) -> dict[str, Any]: def client_config(self) -> dict[str, Any]:
+11
View File
@@ -88,6 +88,9 @@ a { color: var(--accent); }
.net[data-state="online"] i { background: #37d67a; box-shadow: 0 0 8px rgba(55, 214, 122, 0.7); } .net[data-state="online"] i { background: #37d67a; box-shadow: 0 0 8px rgba(55, 214, 122, 0.7); }
.net[data-state="connecting"] i { animation: pulse 1.1s ease-in-out infinite; } .net[data-state="connecting"] i { animation: pulse 1.1s ease-in-out infinite; }
.net[data-state="offline"] i { background: var(--live); } .net[data-state="offline"] i { background: var(--live); }
/* Refused by the server's address list. Deliberately not pulsing: pulsing means
"we are trying", and this is a decision, not an outage. */
.net[data-state="blocked"] i { background: var(--warn); box-shadow: 0 0 8px rgba(245, 165, 36, 0.55); }
@keyframes pulse { 50% { opacity: 0.25; } } @keyframes pulse { 50% { opacity: 0.25; } }
@@ -204,6 +207,14 @@ a { color: var(--accent); }
font-size: 14px; font-size: 14px;
} }
/* A class rather than a style attribute, so `style-src 'self'` needs no
'unsafe-inline'. */
.fineprint {
margin-top: 26px;
color: var(--fg-dim);
font-size: 13.5px;
}
/* ------------------------------------------------------------------ share */ /* ------------------------------------------------------------------ share */
.share { .share {
+33
View File
@@ -0,0 +1,33 @@
/* The landing page's live counter.
*
* This is a separate file rather than an inline <script> so the edge can send
* `script-src 'self'` without 'unsafe-inline'. The page is the one URL on this
* server that a curious visitor reads before deciding to trust it, and an
* inline script would force the whole origin's policy to be looser than it
* needs to be. Nothing here imports from net.js: the landing page has no
* reason to pull in the signaling client. */
const bar = document.getElementById("livebar");
const net = document.getElementById("net");
async function tick() {
try {
const res = await fetch("/api/streams", { cache: "no-store" });
const data = await res.json();
const s = data.stats || {};
net.dataset.state = "online";
net.querySelector("span").textContent = "server up";
bar.textContent = data.mirrors.length
? `${data.mirrors.length} mirror${data.mirrors.length === 1 ? "" : "s"} live: ` +
data.mirrors.map((m) => m.label || m.name).join(", ") +
` · ${s.viewers || 0} watching`
: "No mirrors live right now. Open “Share my screen” to start one.";
} catch {
net.dataset.state = "offline";
net.querySelector("span").textContent = "server unreachable";
bar.textContent = "Lost the server.";
}
}
tick();
setInterval(tick, 2000);
+6 -28
View File
@@ -47,37 +47,15 @@
<div class="livebar" id="livebar">Checking for mirrors…</div> <div class="livebar" id="livebar">Checking for mirrors…</div>
<p style="color: var(--fg-dim); font-size: 13.5px; margin-top: 26px"> <p class="fineprint">
There is no authentication: anyone who can reach this address can mirror and watch. Keep it There is no authentication: anyone this server accepts a connection from can mirror and
on a network you trust. Screen capture also requires a secure context — watch, so keep it on a network you trust and restrict which addresses it answers. Screen
<code>http://localhost</code> qualifies, a plain <code>http://&lt;lan-ip&gt;</code> does not capture also needs a secure context — <code>http://localhost</code> and any
<code>https://</code> origin qualify, a plain <code>http://&lt;lan-ip&gt;</code> does not
(the Share page explains the workaround). (the Share page explains the workaround).
</p> </p>
</main> </main>
<script type="module"> <script type="module" src="/static/home.js"></script>
const bar = document.getElementById("livebar");
const net = document.getElementById("net");
async function tick() {
try {
const res = await fetch("/api/streams", { cache: "no-store" });
const data = await res.json();
const s = data.stats || {};
net.dataset.state = "online";
net.querySelector("span").textContent = "server up";
bar.textContent = data.mirrors.length
? `${data.mirrors.length} mirror${data.mirrors.length === 1 ? "" : "s"} live: ` +
data.mirrors.map((m) => m.label || m.name).join(", ") +
` · ${s.viewers || 0} watching`
: "No mirrors live right now. Open “Share my screen” to start one.";
} catch {
net.dataset.state = "offline";
net.querySelector("span").textContent = "server unreachable";
bar.textContent = "Lost the server.";
}
}
tick();
setInterval(tick, 2000);
</script>
</body> </body>
</html> </html>
+54 -2
View File
@@ -20,6 +20,20 @@ export async function loadConfig() {
const fallback = { iceServers: [], maxBitrateBps: 12000000, pingIntervalMs: 20000, version: "?" }; const fallback = { iceServers: [], maxBitrateBps: 12000000, pingIntervalMs: 20000, version: "?" };
try { try {
const res = await fetch("/api/config", { cache: "no-store" }); const res = await fetch("/api/config", { cache: "no-store" });
if (res.status === 403) {
// The server refused this address. Nothing else on the page can work — the
// signaling socket is closed before it accepts — so carry the reason
// instead of letting the page look healthy and retry forever.
const detail = await res.json().catch(() => ({}));
cachedConfig = {
...fallback,
refused: true,
refusedMessage:
detail.message || "This mirror only accepts connections from addresses it trusts.",
refusedYou: detail.you || null,
};
return cachedConfig;
}
cachedConfig = res.ok ? { ...fallback, ...(await res.json()) } : fallback; cachedConfig = res.ok ? { ...fallback, ...(await res.json()) } : fallback;
} catch { } catch {
cachedConfig = fallback; cachedConfig = fallback;
@@ -27,6 +41,28 @@ export async function loadConfig() {
return cachedConfig; return cachedConfig;
} }
/**
* A `showNotice`-shaped explanation of a refusal, or null when we were accepted.
*
* @param {Object} config the value loadConfig() resolved with
*/
export function refusalNotice(config) {
if (!config || !config.refused) return null;
return {
ok: false,
code: "address_not_allowed",
title: "This mirror is not open to your address",
body:
escapeHtml(config.refusedMessage || "") +
(config.refusedYou ? ` You appear to be <code>${escapeHtml(config.refusedYou)}</code>.` : ""),
bullets: [
"Ask whoever runs this mirror to add your address or network to its allow list.",
"Behind a proxy, the address shown is the one the proxy reported — check its " +
"<code>allow</code>/<code>deny</code> list.",
],
};
}
/** PeerConnection configuration. On a LAN we deliberately ship no STUN/TURN: /** PeerConnection configuration. On a LAN we deliberately ship no STUN/TURN:
* host candidates reach each other directly, and we do not want to announce * host candidates reach each other directly, and we do not want to announce
* internal addresses to an external STUN server. */ * internal addresses to an external STUN server. */
@@ -107,10 +143,11 @@ const BACKOFF_MAX_MS = 8000;
/** /**
* Open the signaling socket and keep it open. * Open the signaling socket and keep it open.
* @param {{onMessage: Function, onOpen?: Function, onClose?: Function, onStatus?: Function}} handlers * @param {{onMessage: Function, onOpen?: Function, onClose?: Function, onStatus?: Function,
* refused?: boolean, refusedReason?: string}} handlers
*/ */
export function connectSignaling(handlers) { export function connectSignaling(handlers) {
const { onMessage, onOpen, onClose, onStatus } = handlers; const { onMessage, onOpen, onClose, onStatus, refused, refusedReason } = handlers;
let ws = null; let ws = null;
let attempts = 0; let attempts = 0;
let closedByUs = false; let closedByUs = false;
@@ -152,6 +189,21 @@ export function connectSignaling(handlers) {
}; };
} }
// A refused address is not a transient outage: opening the socket would only
// produce a close we would retry through, burying the reason in backoff noise.
if (refused) {
status("blocked", refusedReason || "this address is not allowed");
return {
send() {
return false;
},
get open() {
return false;
},
close() {},
};
}
open(); open();
return { return {
+10 -1
View File
@@ -11,6 +11,7 @@ import {
deviceLabel, deviceLabel,
escapeHtml, escapeHtml,
loadConfig, loadConfig,
refusalNotice,
rtcConfig, rtcConfig,
showNotice, showNotice,
toast, toast,
@@ -365,6 +366,8 @@ function hello() {
} }
state.ws = connectSignaling({ state.ws = connectSignaling({
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() { onOpen() {
hello(); hello();
// A reconnect means the server forgot us; republish whatever is live. // A reconnect means the server forgot us; republish whatever is live.
@@ -420,7 +423,13 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.stream) stop(); if (event.key === "Escape" && state.stream) stop();
}); });
if (!captureSupport().ok) { // A refused address outranks every other diagnosis: capture would work and the
// viewers would never arrive, so say the true thing first.
const refusal = refusalNotice(config);
if (refusal) {
showNotice(ui.notice, refusal);
ui.start.disabled = true;
} else if (!captureSupport().ok) {
showNotice(ui.notice, captureSupport()); showNotice(ui.notice, captureSupport());
ui.start.disabled = true; ui.start.disabled = true;
} }
+8 -8
View File
@@ -2,14 +2,7 @@
* stage — the tile element itself moves into the stage container, so the * stage — the tile element itself moves into the stage container, so the
* RTCPeerConnection and the <video> keep running through the transition. */ * RTCPeerConnection and the <video> keep running through the transition. */
import { import { $, bitrateFormat, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
$,
bitrateFormat,
connectSignaling,
loadConfig,
rtcConfig,
toast,
} from "./net.js";
const ui = { const ui = {
grid: $("#grid"), grid: $("#grid"),
@@ -302,6 +295,8 @@ function absorb(stream) {
} }
state.ws = connectSignaling({ state.ws = connectSignaling({
refused: !!config.refused,
refusedReason: config.refusedMessage,
onOpen() { onOpen() {
state.ws.send({ type: "hello", role: "viewer", name: "", device: navigator.platform || "" }); state.ws.send({ type: "hello", role: "viewer", name: "", device: navigator.platform || "" });
}, },
@@ -381,5 +376,10 @@ state.ticker = setInterval(async () => {
} }
}, 1500); }, 1500);
// Same rule as the share page: if the server refused this address, say so
// rather than showing an empty wall that will never fill.
const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
if (ui.share) ui.share.href = `${location.origin}/mirror`; if (ui.share) ui.share.href = `${location.origin}/mirror`;
renderCounts(); renderCounts();
+28 -6
View File
@@ -13,6 +13,7 @@ import socket
import subprocess import subprocess
import sys import sys
import time import time
from collections.abc import Iterator, Sequence
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -40,9 +41,8 @@ def _free_port() -> int:
return probe.getsockname()[1] return probe.getsockname()[1]
@pytest.fixture(scope="session") def _start(extra_args: Sequence[str] = ()) -> tuple[str, subprocess.Popen[str]]:
def base_url() -> str: """Spawn a real `mirror serve` on a free port and wait for it to answer."""
"""A real `mirror serve` process on a real port — not a test double."""
import httpx import httpx
port = _free_port() port = _free_port()
@@ -58,6 +58,7 @@ def base_url() -> str:
str(port), str(port),
"--log-level", "--log-level",
"warning", "warning",
*extra_args,
], ],
cwd=REPO_ROOT, cwd=REPO_ROOT,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -79,17 +80,18 @@ def base_url() -> str:
output = process.stdout.read() if process.stdout else "" output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}") raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}")
try: try:
# /healthz is the readiness probe *and* the one route an address gate
# never refuses, so it works for a restricted server too.
if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200: if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200:
break return url, process
except Exception as exc: # still booting except Exception as exc: # still booting
last_error = exc last_error = exc
time.sleep(0.15) time.sleep(0.15)
else:
process.terminate() process.terminate()
raise RuntimeError(f"mirror serve never came up: {last_error}") raise RuntimeError(f"mirror serve never came up: {last_error}")
yield url
def _stop(process: subprocess.Popen[str]) -> None:
process.terminate() process.terminate()
try: try:
process.wait(timeout=5) process.wait(timeout=5)
@@ -97,6 +99,26 @@ def base_url() -> str:
process.kill() process.kill()
@pytest.fixture(scope="session")
def base_url() -> Iterator[str]:
"""A real `mirror serve` process on a real port — not a test double."""
url, process = _start()
yield url
_stop(process)
@pytest.fixture(scope="session")
def restricted_url() -> Iterator[str]:
"""A server that admits only 203.0.113.0/24 — so it refuses this machine.
That range is reserved for documentation by RFC 5737, which is why it is safe
to use as "everybody else": nothing real is ever locked out by it.
"""
url, process = _start(["--allow-cidr", "203.0.113.0/24"])
yield url
_stop(process)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def browser(): def browser():
with sync_playwright() as playwright: with sync_playwright() as playwright:
+33
View File
@@ -267,3 +267,36 @@ class TestRosterTruthfulness:
mirror = payload["mirrors"][0] mirror = payload["mirrors"][0]
assert mirror["width"] and mirror["height"] assert mirror["width"] and mirror["height"]
assert mirror["displaySurface"] in ("monitor", "window", "browser") assert mirror["displaySurface"] in ("monitor", "window", "browser")
class TestAddressGate:
"""`--allow-cidr` in a real process, refused by a real browser.
``tests/test_app.py::TestAddressGate`` covers the JSON refusal the pages key
their own notice on, the proxy unwrapping, and the socket closing before it
accepts. This covers what a person actually gets when a server refuses them:
an explanation naming the address it judged, not an empty wall that looks
like a bug in the tool.
"""
@pytest.mark.parametrize("path", ["/mirror", "/watch"])
def test_a_refused_address_sees_why_and_none_of_the_app(self, browser, restricted_url, path):
context = browser.new_context(viewport={"width": 1100, "height": 720})
page = context.new_page()
try:
response = page.goto(f"{restricted_url}{path}")
assert response.status == 403
expect(page.locator("body")).to_contain_text("not open to your address")
expect(page.locator("body")).to_contain_text("203.0.113.0/24")
# Which address it judged — without this, "you are not allowed" in
# front of a proxy is undiagnosable.
expect(page.locator("body")).to_contain_text("127.0.0.1")
# None of the app shell leaked past the gate.
expect(page.locator("#start, #grid, #notice")).to_have_count(0)
finally:
context.close()
def test_the_health_probe_is_not_gated(self, restricted_url):
import httpx
assert httpx.get(f"{restricted_url}/healthz", timeout=5).status_code == 200
+120
View File
@@ -0,0 +1,120 @@
"""The source-address gate: who may use this server at all."""
from __future__ import annotations
import pytest
from mirror.access import (
DEFAULT_TRUSTED_PROXIES,
ConfigError,
Gate,
format_cidrs,
parse_cidrs,
)
class TestParseCidrs:
def test_empty_means_no_networks(self):
assert parse_cidrs("") == ()
assert parse_cidrs(None) == ()
assert parse_cidrs(" , ") == ()
def test_networks_addresses_and_both_families(self):
networks = parse_cidrs("10.2.0.0/22, 192.168.4.7, fd00::/64")
assert [str(network) for network in networks] == [
"10.2.0.0/22",
"192.168.4.7/32",
"fd00::/64",
]
def test_separators_people_actually_type(self):
assert len(parse_cidrs("10.2.0.0/22; 10.3.0.0/22\n10.4.0.0/22")) == 3
def test_a_host_part_that_does_not_fit_its_mask_becomes_the_network_meant(self):
# Someone pasting "10.2.1.5/22" means the /22 they live in, not nothing.
assert str(parse_cidrs("10.2.1.5/22")[0]) == "10.2.0.0/22"
def test_duplicates_collapse(self):
assert parse_cidrs("10.2.0.0/22,10.2.0.0/22") == parse_cidrs("10.2.0.0/22")
@pytest.mark.parametrize("raw", ["laptop", "10.2.0.0/33", "10.2.0.0/22/x", "999.1.1.1"])
def test_nonsense_is_refused_at_startup_not_at_request_time(self, raw):
with pytest.raises(ConfigError, match="MIRROR_ALLOW_CIDRS"):
parse_cidrs(raw)
def test_the_error_names_the_source_so_two_settings_are_distinguishable(self):
with pytest.raises(ConfigError, match="MIRROR_TRUSTED_PROXIES"):
parse_cidrs("nope", source="MIRROR_TRUSTED_PROXIES")
with pytest.raises(ConfigError, match="--allow-cidr"):
parse_cidrs("nope", source="--allow-cidr")
def test_formatting_round_trips_for_a_status_line(self):
assert format_cidrs(parse_cidrs("10.2.0.0/22,fd00::/64")) == "10.2.0.0/22, fd00::/64"
class TestGatePermits:
def test_no_networks_listed_means_the_gate_is_open(self):
gate = Gate()
assert gate.enabled is False
assert gate.permits("8.8.8.8")
assert gate.permits("10.2.1.5")
def test_the_listed_network_is_in_and_everything_else_is_out(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"))
assert gate.enabled is True
assert gate.permits("10.2.3.255") # last address of the /22
assert not gate.permits("10.2.4.0") # one past it
assert not gate.permits("10.1.99.99")
def test_an_address_we_cannot_parse_fails_closed(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"))
assert not gate.permits(None)
assert not gate.permits("testclient")
assert not gate.permits("")
def test_a_v6_address_is_never_inside_a_v4_network(self):
# ipaddress raises TypeError on a cross-family `in`, which must not
# become a 500 on a dual-stack host.
gate = Gate(allow=parse_cidrs("10.2.0.0/22"))
assert not gate.permits("fd00::5")
def test_each_family_is_judged_on_its_own_list(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22,fd00::/64"))
assert gate.permits("10.2.1.10")
assert gate.permits("fd00::1")
assert not gate.permits("2001:db8::1")
class TestGateClientAddress:
def test_the_socket_peer_is_the_answer_without_a_proxy(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"))
assert gate.client_address("10.2.1.10") == "10.2.1.10"
def test_an_untrusted_peer_cannot_claim_to_be_someone_else(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"), trusted_proxies=parse_cidrs("127.0.0.0/8"))
assert gate.client_address("203.0.113.9", "10.2.1.5") == "203.0.113.9"
def test_a_trusted_proxy_speaks_for_the_client_and_leftmost_wins(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"), trusted_proxies=parse_cidrs("127.0.0.0/8"))
assert gate.client_address("127.0.0.1", "10.2.1.5") == "10.2.1.5"
assert gate.client_address("127.0.0.1", "10.2.1.5, 10.9.9.9") == "10.2.1.5"
def test_a_broken_header_falls_back_to_the_peer_instead_of_500ing(self):
gate = Gate(allow=parse_cidrs("127.0.0.0/8"), trusted_proxies=parse_cidrs("127.0.0.0/8"))
assert gate.client_address("127.0.0.1", "unknown") == "127.0.0.1"
assert gate.client_address("127.0.0.1", "") == "127.0.0.1"
def test_check_returns_the_verdict_and_the_address_it_judged(self):
gate = Gate(allow=parse_cidrs("10.2.0.0/22"), trusted_proxies=parse_cidrs("127.0.0.0/8"))
assert gate.check("127.0.0.1", "10.2.1.5") == (True, "10.2.1.5")
assert gate.check("127.0.0.1", "8.8.8.8") == (False, "8.8.8.8")
class TestDefaults:
def test_loopback_is_the_default_trusted_proxy(self):
gate = Gate(
allow=parse_cidrs("10.2.0.0/22"),
trusted_proxies=parse_cidrs(DEFAULT_TRUSTED_PROXIES),
)
assert gate.client_address("127.0.0.1", "10.2.1.5") == "10.2.1.5"
assert gate.client_address("10.1.1.27", "10.2.1.5") == "10.1.1.27"
+107
View File
@@ -9,8 +9,22 @@ import subprocess
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from mirror.access import parse_cidrs
from mirror.app import create_app
from mirror.config import Settings
STATIC = Path(__file__).resolve().parent.parent / "src/mirror/static" STATIC = Path(__file__).resolve().parent.parent / "src/mirror/static"
def gated(*allow, client_address="203.0.113.9", **kwargs):
"""A client speaking as ``client_address`` to a server restricted to ``allow``."""
settings = Settings(allow_cidrs=parse_cidrs(",".join(allow)), **kwargs)
return TestClient(create_app(settings), client=(client_address, 44444))
EXTERNAL_REF = re.compile(r"""(?:src|href)\s*=\s*["']https?://""", re.IGNORECASE) EXTERNAL_REF = re.compile(r"""(?:src|href)\s*=\s*["']https?://""", re.IGNORECASE)
@@ -166,6 +180,99 @@ class TestStylesheet:
assert offenders == [] assert offenders == []
class TestAddressGate:
"""mirror has no accounts; the address is the access control."""
ALLOW = "10.2.0.0/22"
def test_unset_allows_everyone_which_is_the_development_default(self):
with gated(client_address="203.0.113.9") as client:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize("path", ["/", "/mirror", "/watch", "/api/streams", "/static/app.css"])
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client:
assert client.get(path).status_code == 403
def test_the_probe_stays_answerable_from_anywhere(self):
"""The container health check comes from loopback, which the list excludes."""
with gated(self.ALLOW) as client:
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_a_browser_gets_an_explanation_rather_than_json(self):
with gated(self.ALLOW) as client:
response = client.get("/mirror", headers={"accept": "text/html"})
assert response.status_code == 403
assert "text/html" in response.headers["content-type"]
body = response.text
assert self.ALLOW in body
assert "203.0.113.9" in body # which address was judged, or this is undiagnosable
def test_the_refusal_page_cannot_depend_on_assets_the_client_cannot_fetch(self):
with gated(self.ALLOW) as client:
body = client.get("/", headers={"accept": "text/html"}).text
assert not EXTERNAL_REF.search(body)
assert "/static/" not in body
def test_a_fetch_gets_the_machine_readable_refusal(self):
with gated(self.ALLOW) as client:
response = client.get("/api/config")
assert response.status_code == 403
assert response.json() == {
"error": "forbidden",
"message": "This mirror only accepts connections from 10.2.0.0/22.",
"you": "203.0.113.9",
}
def test_a_refusal_is_never_cacheable(self):
with gated(self.ALLOW) as client:
for headers in ({"accept": "text/html"}, {}):
response = client.get("/api/config", headers=headers)
assert response.headers["cache-control"] == "no-store"
def test_the_socket_closes_before_it_accepts(self):
with (
gated(self.ALLOW) as client,
pytest.raises(WebSocketDisconnect),
client.websocket_connect("/ws") as socket,
):
socket.send_json({"type": "hello", "role": "viewer"})
def test_the_real_address_is_in_and_connects(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
assert client.get("/watch").status_code == 200
with client.websocket_connect("/ws") as socket:
assert handshake(socket, "viewer")
def test_a_trusted_local_proxy_carries_the_client_through(self):
"""The fleet shape: nginx on the same host, X-Forwarded-For set by nginx."""
with gated(self.ALLOW, client_address="127.0.0.1") as client:
assert client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"}).status_code == 200
with client.websocket_connect("/ws", headers={"x-forwarded-for": "10.2.1.5"}) as socket:
assert handshake(socket, "viewer")
def test_a_trusted_proxy_carrying_a_foreign_client_is_still_refused(self):
with gated(self.ALLOW, client_address="127.0.0.1") as client:
assert client.get("/watch", headers={"x-forwarded-for": "8.8.8.8"}).status_code == 403
with (
pytest.raises(WebSocketDisconnect),
client.websocket_connect("/ws", headers={"x-forwarded-for": "8.8.8.8"}),
):
pass
def test_an_untrusted_peer_cannot_write_its_own_pass(self):
with gated(self.ALLOW, client_address="203.0.113.9") as client:
response = client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"})
assert response.status_code == 403
assert response.json()["you"] == "203.0.113.9"
def test_both_address_families_can_be_admitted_side_by_side(self):
with gated(f"{self.ALLOW}, fd00::/64", client_address="fd00::1234") as client:
assert client.get("/watch").status_code == 200
class TestJsonApi: class TestJsonApi:
def test_config_advertises_the_media_defaults(self, client): def test_config_advertises_the_media_defaults(self, client):
body = client.get("/api/config").json() body = client.get("/api/config").json()
+49
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import pytest import pytest
from mirror import __main__ as entry from mirror import __main__ as entry
from mirror.access import ConfigError, parse_cidrs
from mirror.config import DEFAULT_PORT, Settings, lan_addresses, parse_ice_servers from mirror.config import DEFAULT_PORT, Settings, lan_addresses, parse_ice_servers
from mirror.debug import maybe_enable_debugpy from mirror.debug import maybe_enable_debugpy
@@ -30,6 +31,8 @@ class TestSettings:
"MIRROR_ICE_SERVERS", "MIRROR_ICE_SERVERS",
"MIRROR_MAX_BITRATE", "MIRROR_MAX_BITRATE",
"MIRROR_MAX_MIRRORS", "MIRROR_MAX_MIRRORS",
"MIRROR_ALLOW_CIDRS",
"MIRROR_TRUSTED_PROXIES",
): ):
monkeypatch.delenv(name, raising=False) monkeypatch.delenv(name, raising=False)
settings = Settings.from_env() settings = Settings.from_env()
@@ -37,6 +40,13 @@ class TestSettings:
assert settings.host == "0.0.0.0" assert settings.host == "0.0.0.0"
assert settings.ice_servers == [] assert settings.ice_servers == []
assert settings.max_streams == 0 assert settings.max_streams == 0
# Unrestricted until told otherwise, and only loopback may speak for a client.
assert settings.allow_cidrs == ()
assert [str(network) for network in settings.trusted_proxies] == [
"127.0.0.0/8",
"::1/128",
]
assert settings.gate().enabled is False
def test_environment_wins(self, monkeypatch): def test_environment_wins(self, monkeypatch):
monkeypatch.setenv("MIRROR_HOST", "127.0.0.1") monkeypatch.setenv("MIRROR_HOST", "127.0.0.1")
@@ -44,8 +54,12 @@ class TestSettings:
monkeypatch.setenv("MIRROR_MAX_BITRATE", "250000") monkeypatch.setenv("MIRROR_MAX_BITRATE", "250000")
monkeypatch.setenv("MIRROR_MAX_MIRRORS", "3") monkeypatch.setenv("MIRROR_MAX_MIRRORS", "3")
monkeypatch.setenv("MIRROR_ICE_SERVERS", '[{"urls": "stun:box:3478"}]') monkeypatch.setenv("MIRROR_ICE_SERVERS", '[{"urls": "stun:box:3478"}]')
monkeypatch.setenv("MIRROR_ALLOW_CIDRS", "10.2.0.0/22, fd00::/64")
monkeypatch.setenv("MIRROR_TRUSTED_PROXIES", "10.1.1.27")
settings = Settings.from_env() settings = Settings.from_env()
assert (settings.host, settings.port) == ("127.0.0.1", 9123) assert (settings.host, settings.port) == ("127.0.0.1", 9123)
assert [str(network) for network in settings.allow_cidrs] == ["10.2.0.0/22", "fd00::/64"]
assert [str(network) for network in settings.trusted_proxies] == ["10.1.1.27/32"]
assert settings.max_bitrate_bps == 250_000 assert settings.max_bitrate_bps == 250_000
assert settings.max_streams == 3 assert settings.max_streams == 3
assert settings.ice_servers == [{"urls": "stun:box:3478"}] assert settings.ice_servers == [{"urls": "stun:box:3478"}]
@@ -60,6 +74,19 @@ class TestSettings:
config = Settings.from_env().client_config() config = Settings.from_env().client_config()
assert set(config) == {"iceServers", "maxBitrateBps", "pingIntervalMs"} assert set(config) == {"iceServers", "maxBitrateBps", "pingIntervalMs"}
def test_a_bad_cidr_refuses_to_start_rather_than_failing_open(self, monkeypatch):
monkeypatch.setenv("MIRROR_ALLOW_CIDRS", "office")
with pytest.raises(ConfigError, match="MIRROR_ALLOW_CIDRS"):
Settings.from_env()
def test_the_gate_carries_both_halves_of_the_setting(self):
settings = Settings(
allow_cidrs=parse_cidrs("10.2.0.0/22"), trusted_proxies=parse_cidrs("127.0.0.1")
)
gate = settings.gate()
assert gate.enabled is True
assert gate.check("127.0.0.1", "10.2.1.5") == (True, "10.2.1.5")
def test_lan_addresses_are_real_addresses(self): def test_lan_addresses_are_real_addresses(self):
for address in lan_addresses(): for address in lan_addresses():
assert not address.startswith("127.") assert not address.startswith("127.")
@@ -74,6 +101,28 @@ class TestCli:
assert args.command == "serve" assert args.command == "serve"
assert args.port == 8123 assert args.port == 8123
def test_allow_cidr_is_repeatable_and_replaces_the_environment(self, monkeypatch):
monkeypatch.setenv("MIRROR_ALLOW_CIDRS", "192.168.0.0/16")
args = entry.build_parser().parse_args(
["serve", "--allow-cidr", "10.2.0.0/22", "--allow-cidr", "fd00::/64"]
)
assert args.allow_cidr == ["10.2.0.0/22", "fd00::/64"]
# cmd_serve replaces rather than merges, so the environment cannot leave a
# second, wider network open by accident.
assert [str(network) for network in parse_cidrs(",".join(args.allow_cidr))] == [
"10.2.0.0/22",
"fd00::/64",
]
def test_a_bad_allow_cidr_exits_with_a_message_not_a_traceback(self, capsys):
# Config is parsed before anything binds or imports, so this cannot stall
# on a stray DEBUGPY in the operator's shell.
code = entry.cmd_serve(
entry.build_parser().parse_args(["serve", "--allow-cidr", "nonsense"])
)
assert code == 2
assert "--allow-cidr" in capsys.readouterr().err
def test_port_validation_rejects_nonsense(self): def test_port_validation_rejects_nonsense(self):
parser = entry.build_parser() parser = entry.build_parser()
with pytest.raises(SystemExit): with pytest.raises(SystemExit):