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>
This commit is contained in:
2026-10-05 21:34:17 +03:00
parent c720f4ca67
commit 882b89d6da
4 changed files with 436 additions and 14 deletions
+25
View File
@@ -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)
+71 -14
View File
@@ -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]
+5
View File
@@ -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")
+335
View File
@@ -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 = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RDM card reader</title>
<style>
:root {
--bg: #14161a; --panel: #1c1f26; --line: #2b3038;
--fg: #e6e9ef; --dim: #8b93a1; --accent: #6ea8fe;
--ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 1rem; background: var(--bg); color: var(--fg);
font: 15px/1.45 var(--mono); -webkit-text-size-adjust: 100%;
}
header {
display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
padding-bottom: .75rem; border-bottom: 1px solid var(--line);
}
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: .02em; }
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--dim);
font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
.pill {
font-size: .75rem; padding: .2rem .55rem; border-radius: 999px;
border: 1px solid var(--line); color: var(--dim); white-space: nowrap;
}
.pill.up { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.pill.down { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
#token {
margin-left: auto; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 6px;
padding: .25rem .5rem; font: inherit; font-size: .78rem; min-width: 14rem;
}
#token:focus { outline: 1px solid var(--accent); }
main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); margin: 1rem 0; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem .9rem; margin: 0; }
dt { color: var(--dim); font-size: .82rem; }
dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; word-break: break-all; }
dd.yes { color: var(--ok); } dd.no { color: var(--dim); }
.empty { color: var(--dim); font-style: italic; font-size: .84rem; }
.logwrap { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
#log { list-style: none; margin: 0; padding: 0; max-height: 24rem; overflow-y: auto; }
#log li {
display: grid; grid-template-columns: 7.5rem 6.5rem 1fr; gap: .6rem;
padding: .28rem 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
font-size: .84rem; align-items: baseline;
}
#log li:last-child { border-bottom: 0; }
.ts { color: var(--dim); }
.ev { font-weight: 600; }
.inserted .ev { color: var(--ok); }
.removed .ev { color: var(--accent); }
.invalid .ev { color: var(--bad); }
.detail { color: var(--dim); word-break: break-all; }
.detail b { color: var(--fg); font-weight: 600; }
footer { margin-top: 1rem; color: var(--dim); font-size: .78rem; }
footer code { color: var(--fg); background: var(--panel); padding: .1rem .3rem; border-radius: 4px; }
</style>
</head>
<body>
<header>
<h1>RDM card reader</h1>
<span id="conn" class="pill">connecting&hellip;</span>
<input id="token" placeholder="token (only if the bridge asks for one)"
autocomplete="off" spellcheck="false">
</header>
<main>
<section class="panel">
<h2>Reader</h2>
<dl>
<dt>mode</dt> <dd id="mode">&mdash;</dd>
<dt>port</dt> <dd id="port">&mdash;</dd>
<dt>uptime</dt> <dd id="uptime">&mdash;</dd>
<dt>card present</dt><dd id="present">&mdash;</dd>
<dt>claimable</dt> <dd id="claimable">&mdash;</dd>
</dl>
</section>
<section class="panel">
<h2>Current card</h2>
<dl>
<dt>id</dt> <dd id="c-id">&mdash;</dd>
<dt>hex</dt> <dd id="c-hex">&mdash;</dd>
<dt>type</dt> <dd id="c-type">&mdash;</dd>
<dt>valid</dt> <dd id="c-valid">&mdash;</dd>
<dt>age</dt> <dd id="c-age">&mdash;</dd>
</dl>
<p id="c-none" class="empty">No card has been read yet.</p>
</section>
<section class="panel">
<h2>Counters</h2>
<dl>
<dt>inserted</dt> <dd id="n-inserted">&mdash;</dd>
<dt>removed</dt> <dd id="n-removed">&mdash;</dd>
<dt>invalid</dt> <dd id="n-invalid">&mdash;</dd>
<dt>subscribers</dt> <dd id="n-subs">&mdash;</dd>
<dt>tap / claimed TTL</dt><dd id="n-ttl">&mdash;</dd>
</dl>
</section>
</main>
<section class="logwrap">
<h2>Event log</h2>
<ol id="log"></ol>
<p id="log-none" class="empty">Waiting for card events&hellip;</p>
</section>
<footer>
Recent history from <code>GET /api/taps</code>, then live via
<code>GET /api/events</code> (SSE); status polled every 2s.
Read-only: this page never claims a tap. The API is documented in the README.
</footer>
<script>
(function () {
"use strict";
var REFRESH_MS = __REFRESH_MS__;
var MAX_ROWS = __MAX_ROWS__;
var el = function (id) { return document.getElementById(id); };
var tokenInput = el("token");
tokenInput.value = localStorage.getItem("rdm-token") || "";
function token() { return tokenInput.value.trim(); }
function authHeaders() {
var t = token();
return t ? { "X-Auth-Token": t } : {};
}
function setConn(up) {
var c = el("conn");
c.className = "pill " + (up ? "up" : "down");
c.textContent = up ? "live" : "disconnected";
}
function fmtDuration(seconds) {
if (seconds === null || seconds === undefined) return "\\u2014";
var s = Math.max(0, Math.round(seconds));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
if (h) return h + "h " + m + "m";
if (m) return m + "m " + r + "s";
return r + "s";
}
// card_type is an integer (0x0F arrives as 15), so it must be re-encoded
// as hex rather than printed as-is.
function hex2(value) {
return "0x" + Number(value).toString(16).toUpperCase().padStart(2, "0");
}
function setYesNo(id, value) {
var node = el(id);
node.textContent = value ? "yes" : "no";
node.className = value ? "yes" : "no";
}
function setText(id, value) {
el(id).textContent = (value === null || value === undefined || value === "")
? "\\u2014" : String(value);
}
function renderStatus(health, card) {
setText("mode", health.mode);
setText("port", health.port || "not open");
setText("uptime", fmtDuration(health.uptime_seconds));
setYesNo("present", !!health.card_present);
setYesNo("claimable", !!(card && card.claimable));
var counts = health.counts || {};
setText("n-inserted", counts.card_inserted || 0);
setText("n-removed", counts.card_removed || 0);
setText("n-invalid", counts.invalid_card || 0);
setText("n-subs", health.subscribers);
setText("n-ttl", (health.tap_ttl_seconds || "\\u221e") + " / " +
(health.claimed_ttl_seconds || "\\u221e"));
var shown = (card && card.card) || health.last_tap;
el("c-none").hidden = !!shown;
if (shown) {
setText("c-id", shown.id);
setText("c-hex", shown.hex);
setText("c-type", hex2(shown.type));
setText("c-valid", shown.is_valid ? "yes" : "no");
setText("c-age", fmtDuration(card ? card.age_seconds : health.last_tap_age_seconds));
}
}
var KIND = { card_inserted: "inserted", card_removed: "removed", invalid_card: "invalid" };
// Guards against showing the same event twice: recent history is seeded from
// /api/taps on load, and the first SSE frames can overlap with it.
var seen = Object.create(null);
function pushLog(event) {
var key = (event.timestamp || "") + "|" + (event.event || "") + "|" + event.id;
if (seen[key]) return;
seen[key] = true;
var list = el("log");
var li = document.createElement("li");
li.className = KIND[event.event] || "";
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = event.timestamp || "";
var name = document.createElement("span");
name.className = "ev";
name.textContent = (event.event || "").replace(/_/g, " ");
var detail = document.createElement("span");
detail.className = "detail";
if (event.event === "card_removed") {
detail.innerHTML = "id " + event.id;
} else {
detail.innerHTML = "id <b>" + event.id + "</b> &middot; " + event.hex +
" &middot; " + hex2(event.type) +
(event.is_valid ? "" : " &middot; checksum mismatch");
}
li.appendChild(ts); li.appendChild(name); li.appendChild(detail);
list.insertBefore(li, list.firstChild);
while (list.childElementCount > MAX_ROWS) list.removeChild(list.lastChild);
el("log-none").hidden = true;
}
function seedLog() {
fetch("/api/taps?limit=" + MAX_ROWS, { headers: authHeaders() })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.taps) return;
// taps() is chronological, oldest first; prepending leaves newest on top.
data.taps.forEach(pushLog);
})
.catch(function () { /* history is a convenience, not required */ });
}
function onError() {
setConn(false);
}
function poll() {
Promise.all([
fetch("/api/health", { headers: authHeaders() }),
fetch("/api/card", { headers: authHeaders() })
]).then(function (responses) {
if (responses[0].status === 401 || responses[1].status === 401) {
// The bridge wants a token for this caller. Say so instead of
// reporting the reader as disconnected.
el("conn").className = "pill down";
el("conn").textContent = "token required";
return null;
}
if (!responses[0].ok) throw new Error("health " + responses[0].status);
return Promise.all([responses[0].json(), responses[1].json()]);
}).then(function (data) {
if (!data) return;
renderStatus(data[0], data[1]);
if (es && es.readyState === 1) setConn(true);
}).catch(onError);
}
var es = null;
function connectEvents() {
if (es) es.close();
var t = token();
// EventSource cannot set headers, so the token goes in the query string -
// the same mechanism the API already accepts for non-local callers.
var url = "/api/events" + (t ? "?token=" + encodeURIComponent(t) : "");
es = new EventSource(url);
es.onopen = function () { setConn(true); };
es.onmessage = function (message) {
try { pushLog(JSON.parse(message.data)); } catch (e) { /* ignore */ }
};
es.onerror = onError; // EventSource reconnects on its own
}
tokenInput.addEventListener("change", function () {
localStorage.setItem("rdm-token", token());
connectEvents();
seedLog();
poll();
});
connectEvents();
seedLog();
poll();
setInterval(poll, REFRESH_MS);
})();
</script>
</body>
</html>
"""
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")