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 = """
+
+