diff --git a/.env.example b/.env.example
index d61e5d8..81ca9de 100644
--- a/.env.example
+++ b/.env.example
@@ -19,6 +19,20 @@ MIRROR_PING_INTERVAL=20
MIRROR_ICE_SERVERS=
# 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.
DEBUGPY=0
DEBUGPY_PORT=5678
diff --git a/src/mirror/__main__.py b/src/mirror/__main__.py
index d69dd39..529ad2a 100644
--- a/src/mirror/__main__.py
+++ b/src/mirror/__main__.py
@@ -11,6 +11,7 @@ from dataclasses import replace
from typing import Any, get_args
from . import __version__, protocol
+from .access import ConfigError, format_cidrs, parse_cidrs
def _positive_int(raw: str) -> int:
@@ -28,7 +29,8 @@ def build_parser() -> argparse.ArgumentParser:
prog="mirror",
description="Device-agnostic screen mirroring, served entirely from the browser.",
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__}")
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.",
)
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(
"--role-hint",
choices=list(get_args(protocol.Role)),
@@ -63,12 +74,22 @@ def cmd_serve(args: argparse.Namespace) -> int:
from .config import Settings, lan_addresses
from .debug import maybe_enable_debugpy
- settings = Settings.from_env()
overrides: dict[str, Any] = {}
- if args.host is not None:
- overrides["host"] = args.host
- if args.port is not None:
- overrides["port"] = args.port
+ try:
+ settings = Settings.from_env()
+ if args.host is not None:
+ overrides["host"] = args.host
+ if args.port is not None:
+ 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:
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(" share a screen -> /mirror", 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(
app,
diff --git a/src/mirror/access.py b/src/mirror/access.py
new file mode 100644
index 0000000..dfcb276
--- /dev/null
+++ b/src/mirror/access.py
@@ -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
diff --git a/src/mirror/app.py b/src/mirror/app.py
index 43c78c8..969eda4 100644
--- a/src/mirror/app.py
+++ b/src/mirror/app.py
@@ -8,6 +8,7 @@ from __future__ import annotations
import json
from contextlib import suppress
+from html import escape
from pathlib import Path
from typing import Any
@@ -18,6 +19,7 @@ from pydantic import ValidationError
from starlette.websockets import WebSocketDisconnected
from . import __version__, protocol
+from .access import POLICY_VIOLATION, Gate, format_cidrs
from .config import Settings
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.
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:
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"""
+
+
+
+
+ mirror — not open to this address
+
+
+
+
+ This mirror is not open to your address
+
+ It accepts connections from {escape(allowed)}. Your request was
+ judged as {you}.
+
+
+ If you reached it through a proxy, the address shown is what the proxy reported
+ — check its allow/deny list and the
+ X-Forwarded-For it sends.
+
+
+
+
+"""
+
+
class Hub:
"""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()
registry = Registry(max_streams=settings.max_streams)
hub = Hub(registry)
+ gate = settings.gate()
app = FastAPI(
title="mirror",
@@ -253,13 +318,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.websocket("/ws")
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)
PAGES = ("/", "/mirror", "/share", "/watch")
@app.middleware("http")
- async def no_store_for_pages(request: Request, call_next): # type: ignore[no-untyped-def]
- """A mirror roster changes second to second; never let a browser cache it."""
+ async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
+ """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)
if request.url.path in PAGES:
response.headers["Cache-Control"] = "no-store"
diff --git a/src/mirror/config.py b/src/mirror/config.py
index aae5268..b71370d 100644
--- a/src/mirror/config.py
+++ b/src/mirror/config.py
@@ -8,6 +8,8 @@ import socket
from dataclasses import dataclass, field
from typing import Any
+from .access import DEFAULT_TRUSTED_PROXIES, Gate, Network, parse_cidrs
+
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8055
DEFAULT_MAX_BITRATE_BPS = 12_000_000
@@ -56,6 +58,15 @@ class Settings:
max_bitrate_bps: int = DEFAULT_MAX_BITRATE_BPS
max_streams: int = 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
def from_env(cls) -> Settings:
@@ -72,6 +83,11 @@ class Settings:
max_bitrate_bps=_env_int("MIRROR_MAX_BITRATE", DEFAULT_MAX_BITRATE_BPS),
max_streams=_env_int("MIRROR_MAX_MIRRORS", 0),
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]:
diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css
index 3f3b0d7..c7a101c 100644
--- a/src/mirror/static/app.css
+++ b/src/mirror/static/app.css
@@ -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="connecting"] i { animation: pulse 1.1s ease-in-out infinite; }
.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; } }
@@ -204,6 +207,14 @@ a { color: var(--accent); }
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 {
diff --git a/src/mirror/static/home.js b/src/mirror/static/home.js
new file mode 100644
index 0000000..f7bf8dd
--- /dev/null
+++ b/src/mirror/static/home.js
@@ -0,0 +1,33 @@
+/* The landing page's live counter.
+ *
+ * This is a separate file rather than an inline
+