From 882b89d6da86975b9bf4a09124164315cb6d8d2c Mon Sep 17 00:00:00 2001 From: scheianu Date: Mon, 5 Oct 2026 21:34:17 +0300 Subject: [PATCH] Serve a read-only status page on the bridge port GET / now returns a dashboard for the reader: mode, port, uptime, card presence, the current card, event counters and a live event log seeded from /api/taps and updated over the /api/events SSE stream. The page is read-only on purpose - it has no claim button, because POST /api/card/claim spends the tap and merely viewing the status would otherwise consume a real login. Serving the page requires allowing same-origin browser requests: a browser sends Origin even for a request to the same origin, so _cors_ok() used to reject the page's own API calls with 403 unless --allow-origin was passed. Loopback and same-host origins are now accepted, and the Access-Control-Allow-Origin echo is applied consistently in one helper instead of four copies. --no-page reverts POST-free deployments to API-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 25 +++ src/rdm_card_reader/bridge.py | 85 +++++++-- src/rdm_card_reader/cli.py | 5 + src/rdm_card_reader/webui.py | 335 ++++++++++++++++++++++++++++++++++ 4 files changed, 436 insertions(+), 14 deletions(-) create mode 100644 src/rdm_card_reader/webui.py diff --git a/README.md b/README.md index afb7e8e..67bc71d 100644 --- a/README.md +++ b/README.md @@ -309,10 +309,31 @@ cleared, so it cannot be mistaken for a tap still waiting to be used. > per placement may be a long time. Matching them (`--heartbeat 10 --tap-ttl 10`) > avoids the gap for a card held in place. +### Status page + +Serving also puts a **status page** at `GET /` on the same port, so you can see +what the reader is doing from a browser: + +``` +http://127.0.0.1:8765/ +``` + +It shows the reader's mode, port, uptime, whether a card is present and +claimable, the current card, the event counters, and a live event log. The log +is seeded with recent history from `/api/taps` and then updated live over the +`/api/events` SSE stream, so it keeps filling in without a refresh. + +The page is **read-only**: it deliberately has no "claim" button, because +`POST /api/card/claim` spends the tap, and a page that claimed would silently +consume a real login just for being open. Claim from your own app instead. + +Disable it with `--no-page` if you want the port to serve the API only. + ### Endpoints | Method | Path | Purpose | | --- | --- | --- | +| `GET` | `/` | Status page (unless `--no-page`) | | `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` | | `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired | | `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens | @@ -479,6 +500,9 @@ A healthy startup logs one line: ### Checking the reader from the command line ```bash +# Everything at once, in a browser: the status page +xdg-open http://127.0.0.1:8765/ # or open it on another machine + # Is the reader alive? (status, port, uptime) curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool @@ -882,6 +906,7 @@ src/rdm_card_reader/ demo.py simulated reads ports.py stable serial port identification bridge.py the HTTP bridge behind --serve + webui.py the status page served at GET / by the bridge led.py status LED bootstrap.py offline dependency install wheels/ the bundled wheels (offline install source) diff --git a/src/rdm_card_reader/bridge.py b/src/rdm_card_reader/bridge.py index 97735d2..7caa955 100644 --- a/src/rdm_card_reader/bridge.py +++ b/src/rdm_card_reader/bridge.py @@ -55,8 +55,10 @@ import time from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Deque, Dict, List, Optional +from urllib.parse import urlsplit from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent +from .webui import render_page #: Reader modes. ``login`` publishes inserts only (a tap = an authentication #: attempt). ``monitor`` publishes inserts and removals both. @@ -325,14 +327,31 @@ class _Handler(BaseHTTPRequestHandler): # -- helpers ---------------------------------------------------------- + def _origin_is_loopback(self, origin: str) -> bool: + """True when *origin* is this server itself or a loopback address. + + A browser sends ``Origin`` even for same-origin requests, so without + this the status page served from this very port could not call its own + API. A remote site cannot forge ``Origin: http://127.0.0.1``, so + trusting loopback here does not widen what an attacker can reach. + """ + host = self.headers.get("Host") + if host and origin in ("http://" + host, "https://" + host): + return True + try: + hostname = (urlsplit(origin).hostname or "").lower() + except ValueError: + return False + return hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0") + def _cors_ok(self) -> bool: """Refuse cross-origin reads unless the origin was allow-listed.""" origin = self.headers.get("Origin") if not origin: return True # same-origin fetch or a non-browser client + if self._origin_is_loopback(origin): + return True allowed: List[str] = getattr(self.server, "allowed_origins", []) - if not allowed: - return False # browsers only; opt out entirely return origin in allowed def _token_ok(self) -> bool: @@ -351,26 +370,52 @@ class _Handler(BaseHTTPRequestHandler): supplied = pair.split("=", 1)[1] return secrets.compare_digest(supplied, required) + def _allowed_origin(self) -> Optional[str]: + """The Origin to echo in ``Access-Control-Allow-Origin``, or None. + + Same-origin and loopback callers are echoed so the dashboard can read + its own API; any other origin must be on the allow-list. + """ + origin = self.headers.get("Origin") + if not origin: + return None + if self._origin_is_loopback(origin): + return origin + if origin in getattr(self.server, "allowed_origins", []): + return origin + return None + def _send_json(self, payload: Any, status: int = 200) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") - origin = self.headers.get("Origin") - if origin and origin in getattr(self.server, "allowed_origins", []): - self.send_header("Access-Control-Allow-Origin", origin) + allowed = self._allowed_origin() + if allowed: + self.send_header("Access-Control-Allow-Origin", allowed) self.send_header("Vary", "Origin") self.end_headers() self.wfile.write(body) + def _send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _send_page(self) -> None: + self._send_bytes(render_page(), "text/html; charset=utf-8") + def _send_no_content(self) -> None: self.send_response(204) self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", "0") - origin = self.headers.get("Origin") - if origin and origin in getattr(self.server, "allowed_origins", []): - self.send_header("Access-Control-Allow-Origin", origin) + allowed = self._allowed_origin() + if allowed: + self.send_header("Access-Control-Allow-Origin", allowed) self.send_header("Vary", "Origin") self.end_headers() @@ -388,6 +433,16 @@ class _Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API path = self.path.split("?", 1)[0].rstrip("/") or "/" + # The status page is served before the CORS and token checks, on + # purpose: it is static, reveals nothing on its own, and has to be able + # to load so it can prompt for a token when the API demands one. + if path == "/": + if getattr(self.server, "serve_page", True): + self._send_page() + else: + self._send_json({"error": "not found", "path": "/"}, status=404) + return + if path == "/api/health": self._send_json(self._hub.health()) return @@ -476,10 +531,10 @@ class _Handler(BaseHTTPRequestHandler): self._send_json({"mode": mode}) def do_OPTIONS(self) -> None: # noqa: N802 - origin = self.headers.get("Origin") self.send_response(204) - if origin and origin in getattr(self.server, "allowed_origins", []): - self.send_header("Access-Control-Allow-Origin", origin) + allowed = self._allowed_origin() + if allowed: + self.send_header("Access-Control-Allow-Origin", allowed) self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token") self.send_header("Vary", "Origin") @@ -495,9 +550,9 @@ class _Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-store") self.send_header("Connection", "keep-alive") - origin = self.headers.get("Origin") - if origin and origin in getattr(self.server, "allowed_origins", []): - self.send_header("Access-Control-Allow-Origin", origin) + allowed = self._allowed_origin() + if allowed: + self.send_header("Access-Control-Allow-Origin", allowed) self.send_header("Vary", "Origin") self.end_headers() @@ -532,11 +587,13 @@ def create_server( allowed_origins: Optional[List[str]] = None, token: Optional[str] = None, logger: Optional[Callable[[str], None]] = None, + serve_page: bool = True, ) -> ThreadingHTTPServer: """Build (but do not start) the HTTP server around *hub*.""" server = ThreadingHTTPServer((host, port), _Handler) server.daemon_threads = True server.hub = hub # type: ignore[attr-defined] + server.serve_page = serve_page # type: ignore[attr-defined] server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined] server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined] server.logger = logger # type: ignore[attr-defined] diff --git a/src/rdm_card_reader/cli.py b/src/rdm_card_reader/cli.py index fa3796b..9b8231c 100644 --- a/src/rdm_card_reader/cli.py +++ b/src/rdm_card_reader/cli.py @@ -212,6 +212,7 @@ def _run_bridge(args) -> int: allowed_origins=args.allow_origin, token=args.token, logger=print if args.verbose else None, + serve_page=not args.no_page, ) server_thread = threading.Thread(target=server.serve_forever, daemon=True, @@ -220,6 +221,8 @@ def _run_bridge(args) -> int: print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format( device, args.host, args.bridge_port, args.mode)) + if not args.no_page: + print(" GET / <- status page in a browser") print(" GET /api/card <- the card on the reader right now") print(" POST /api/card/claim (single use, for one login)") print(" GET /api/events (SSE stream)") @@ -303,6 +306,8 @@ def build_parser() -> argparse.ArgumentParser: "(or set RDM_BRIDGE_TOKEN)") bridge.add_argument("--allow-remote", action="store_true", help="permit binding a non-loopback address") + bridge.add_argument("--no-page", action="store_true", + help="do not serve the status page at GET /") bridge.add_argument("--verbose", action="store_true", help="also print each event and each HTTP request") diff --git a/src/rdm_card_reader/webui.py b/src/rdm_card_reader/webui.py new file mode 100644 index 0000000..b843293 --- /dev/null +++ b/src/rdm_card_reader/webui.py @@ -0,0 +1,335 @@ +"""The status page served at ``GET /`` by the HTTP bridge. + +Kept as a single string in a Python module rather than a packaged ``.html`` +file on purpose: this way the page is present however the package is +installed - source checkout, editable install, or the venv the installer +builds - with no ``package-data`` entry to forget. + +The page is read-only by design. It deliberately has no "claim" button: +``POST /api/card/claim`` spends the latched tap, so a status page that +claimed would silently consume a real login just for being open. + +Everything lives in one file with no external assets, because the device it +runs on is usually a Raspberry Pi that may have no internet access at all. +""" + +from __future__ import annotations + +#: Poll interval for the status panels, in milliseconds. The event log is +#: pushed over SSE and does not wait for this. +REFRESH_MS = 2000 + +#: How many log rows the page keeps before dropping the oldest. +MAX_LOG_ROWS = 200 + +PAGE_HTML = """ + + + + +RDM card reader + + + +
+

RDM card reader

+ connecting… + +
+ +
+
+

Reader

+
+
mode
—
+
port
—
+
uptime
—
+
card present
—
+
claimable
—
+
+
+ +
+

Current card

+
+
id
—
+
hex
—
+
type
—
+
valid
—
+
age
—
+
+

No card has been read yet.

+
+ +
+

Counters

+
+
inserted
—
+
removed
—
+
invalid
—
+
subscribers
—
+
tap / claimed TTL
—
+
+
+
+ +
+

Event log

+
    +

    Waiting for card events…

    +
    + + + + + + +""" + + +def render_page() -> bytes: + """The dashboard as UTF-8 bytes, ready to write to the socket.""" + page = ( + PAGE_HTML + .replace("__REFRESH_MS__", str(REFRESH_MS)) + .replace("__MAX_ROWS__", str(MAX_LOG_ROWS)) + ) + return page.encode("utf-8")