Files
rdm-card-reader/src/rdm_card_reader/bridge.py
T
ske087 576f22268f RDM card reader: print card events, select the right port, serve taps to a web app
Card reading (rdm6300 + pyserial) printing card inserted/removed with the
full card data, plus a --serve mode that publishes taps over HTTP so a web
page can authenticate a tap.

The bridge reports card ids only - mapping a card to a user stays on the
server. The tap is latched for --tap-ttl seconds, including after the card is
lifted, and is single use, so a page reload cannot replay one tap into two
logins.

Port selection identifies the reader by serial number or by-id path instead of
/dev/ttyUSBn, and refuses to guess when a selector matches several adapters.

deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and
install.sh. Covered by unit tests plus PTY end-to-end tests.
2026-10-01 15:31:04 +03:00

504 lines
19 KiB
Python

"""HTTP bridge: expose card taps to a web page, without ever becoming the
authority on who the card belongs to.
The reader is a *sensor*. This module publishes what the sensor sees and stops
there - resolving a card id to a user is the server's job, so nothing here
writes to a database, mints session tokens or decides whether a login succeeds.
Served by default:
============================ ============================================
``GET /api/health`` reader state, port, uptime, last tap age
``GET /api/card`` the card on the reader right now (latched)
``POST /api/card/claim`` claim that tap, single use, for one login
``GET /api/events`` Server-Sent Events stream of card events
``GET /api/last`` most recent card event (or 204)
``GET /api/taps`` recent tap history (ring buffer)
``POST /api/mode`` switch reader mode: login / monitor
============================ ============================================
The usual integration, for a web page that may load at any moment::
const r = await fetch("http://127.0.0.1:8765/api/card");
const state = await r.json();
if (state.claimable) {
const c = await fetch("http://127.0.0.1:8765/api/card/claim", {method: "POST"});
if (c.ok) {
const { card } = await c.json();
// hand card.id to your server; it decides who that is
}
}
``claimable`` stays true for ``--tap-ttl`` seconds after the card is read -
including after the card is lifted - so a quick tap is not lost.
Security posture (important, and deliberate):
* Binds to ``127.0.0.1`` unless the operator explicitly opts in to more, because
an open card-event endpoint on a LAN lets anyone read who is tapping.
* Cross-origin requests are refused unless the origin is on an allow-list.
A page on another site must not be able to observe your taps.
* Optionally requires a pre-shared token (``--token`` / ``RDM_BRIDGE_TOKEN``)
for non-local callers.
An EM4100 card id is a *username*, not a password: it is broadcast in the clear
and can be cloned. Never let holding a card id alone create a session.
"""
from __future__ import annotations
import json
import os
import secrets
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Deque, Dict, List, Optional
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
#: attempt). ``monitor`` publishes inserts and removals both.
MODE_LOGIN = "login"
MODE_MONITOR = "monitor"
VALID_MODES = (MODE_LOGIN, MODE_MONITOR)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
DEFAULT_HISTORY = 50
#: How long a tap stays claimable. This deliberately outlives the card
#: leaving the reader: a quick tap that is lifted straight away must still be
#: claimable, otherwise a page that polls a moment later sees nothing and the
#: login silently fails. Set to 0 to keep a tap claimable until it is used or
#: replaced.
DEFAULT_TAP_TTL = 10.0
class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers.
This is the only place the raw card id is held. Subscribers are HTTP
connections owned by the same process, never third-party code.
The card currently read is *latched*: it stays readable through
:meth:`current_card` after the card has left the reader, for ``tap_ttl``
seconds from the tap. That is what makes the data "always available" to a
web page that may load or poll at any moment.
Claiming it through :meth:`consume_card` marks it used, so a page reload or
a second tab cannot replay the same physical tap into a second login.
"""
def __init__(
self,
history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL,
):
self._lock = threading.Lock()
self._subscribers: List["deque"] = []
self._history: Deque[CardEvent] = deque(maxlen=history)
self._last: Optional[CardEvent] = None
self._last_at: Optional[float] = None
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
self._started_at = time.time()
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
self._port: Optional[str] = None
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
# The latched tap. It deliberately outlives the card leaving the
# reader, so a page that loads after the tap can still claim it.
self._tap: Optional[CardEvent] = None
self._tap_at: Optional[float] = None
self._on_reader = False
self._consumed_at: Optional[float] = None
# -- state ------------------------------------------------------------
def set_port(self, port: str) -> None:
with self._lock:
self._port = port
@property
def mode(self) -> str:
with self._lock:
return self._mode
def set_mode(self, mode: str) -> bool:
"""Switch mode. Returns False for an unknown mode."""
if mode not in VALID_MODES:
return False
with self._lock:
self._mode = mode
return True
def publish(self, event: CardEvent) -> None:
"""Record an event and wake every subscriber. Safe from any thread."""
with self._lock:
self._counts[event.event] = self._counts.get(event.event, 0) + 1
# Presence tracking always happens, in every mode: the latched tap
# is state, not a published event.
if event.event == EVENT_INSERTED:
self._last = event
self._last_at = time.time()
# A new tap replaces any previous unclaimed one.
self._tap = event
self._tap_at = self._last_at
self._on_reader = True
self._consumed_at = None
elif event.event == EVENT_REMOVED:
# Lifting the card does NOT discard the tap: it must stay
# claimable for tap_ttl, otherwise a quick tap is lost before
# the web page gets a chance to ask for it.
self._on_reader = False
# In login mode a tap is an authentication attempt; card removal is
# not published, so a web app cannot mistake it for a second tap.
if self._mode == MODE_LOGIN and event.event == EVENT_REMOVED:
return
self._history.append(event)
queues = list(self._subscribers)
payload = event.to_dict()
for queue in queues:
try:
queue.append(payload)
except Exception:
pass
# -- latched card -----------------------------------------------------
def current_card(self) -> Dict[str, Any]:
"""The latched tap, so a page can ask at any moment.
``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login.
"""
with self._lock:
if self._tap is None:
return {
"present": False,
"claimable": False,
"consumed": False,
"expired": False,
"age_seconds": None,
"card": None,
}
age = time.time() - (self._tap_at or 0.0)
expired = bool(self._tap_ttl) and age > self._tap_ttl
consumed = self._consumed_at is not None
return {
"present": self._on_reader,
"claimable": (not consumed) and (not expired),
"consumed": consumed,
"expired": expired,
"age_seconds": round(age, 1),
"card": self._tap.to_dict(),
}
def consume_card(self):
"""Atomically claim the latched tap for one authentication.
Returns ``(payload, error, status)``. The tap is marked used only when
this call succeeds, so two concurrent requests cannot both win.
"""
with self._lock:
if self._tap is None:
return None, "no card read yet", 409
age = time.time() - (self._tap_at or 0.0)
if self._tap_ttl and age > self._tap_ttl:
return None, "card tap expired", 410
if self._consumed_at is not None:
return None, "card already used", 409
self._consumed_at = time.time()
return (
{
"card": self._tap.to_dict(),
"age_seconds": round(age, 1),
"consumed": True,
},
None,
200,
)
def subscribe(self) -> "deque":
"""Register a queue that receives every subsequent event."""
queue: Deque[Dict[str, Any]] = deque(maxlen=100)
with self._lock:
self._subscribers.append(queue)
return queue
def unsubscribe(self, queue: "deque") -> None:
with self._lock:
try:
self._subscribers.remove(queue)
except ValueError:
pass
# -- snapshots --------------------------------------------------------
def health(self) -> Dict[str, Any]:
with self._lock:
last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None
)
return {
"status": "ok",
"port": self._port,
"mode": self._mode,
"uptime_seconds": round(time.time() - self._started_at, 1),
"subscribers": len(self._subscribers),
"counts": dict(self._counts),
"last_tap": self._last.to_dict() if self._last else None,
"last_tap_age_seconds": last_age,
"card_present": self._on_reader,
"card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None,
}
def last(self) -> Optional[Dict[str, Any]]:
with self._lock:
return self._last.to_dict() if self._last else None
def taps(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
items = list(self._history)[-max(1, limit):]
return [event.to_dict() for event in items]
class _Handler(BaseHTTPRequestHandler):
"""One HTTP connection. ``server.hub`` holds the shared state."""
server_version = "rdm-card-reader"
protocol_version = "HTTP/1.1"
# -- helpers ----------------------------------------------------------
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
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:
"""Check the pre-shared token for non-loopback callers."""
required: Optional[str] = getattr(self.server, "token", None)
if not required:
return True
client = self.client_address[0]
if client in ("127.0.0.1", "::1", "localhost"):
return True
supplied = self.headers.get("X-Auth-Token") or ""
if not supplied:
query = self.path.split("?", 1)[1] if "?" in self.path else ""
for pair in query.split("&"):
if pair.startswith("token="):
supplied = pair.split("=", 1)[1]
return secrets.compare_digest(supplied, required)
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)
self.send_header("Vary", "Origin")
self.end_headers()
self.wfile.write(body)
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)
self.send_header("Vary", "Origin")
self.end_headers()
def log_message(self, fmt: str, *args: Any) -> None:
logger: Optional[Callable[[str], None]] = getattr(self.server, "logger", None)
if logger:
logger("{} - {}".format(self.client_address[0], fmt % args))
@property
def _hub(self) -> EventHub:
return self.server.hub # type: ignore[attr-defined]
# -- routing ----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/api/health":
self._send_json(self._hub.health())
return
if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403)
return
if not self._token_ok():
self._send_json({"error": "missing or invalid token"}, status=401)
return
if path == "/api/last":
payload = self._hub.last()
if payload is None:
self._send_no_content()
else:
self._send_json(payload)
return
if path == "/api/card":
self._send_json(self._hub.current_card())
return
if path == "/api/taps":
limit = 20
if "?" in self.path:
for pair in self.path.split("?", 1)[1].split("&"):
if pair.startswith("limit="):
try:
limit = int(pair.split("=", 1)[1])
except ValueError:
pass
self._send_json({"taps": self._hub.taps(limit)})
return
if path == "/api/events":
self._stream_events()
return
self._send_json({"error": "not found", "path": path}, status=404)
def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/api/card/claim":
if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403)
return
if not self._token_ok():
self._send_json({"error": "missing or invalid token"}, status=401)
return
payload, error, status = self._hub.consume_card()
if error:
self._send_json(
{"error": error, "state": self._hub.current_card()},
status=status,
)
return
self._send_json(payload, status=status)
return
if path != "/api/mode":
self._send_json({"error": "not found", "path": path}, status=404)
return
if not self._cors_ok():
self._send_json({"error": "origin not allowed"}, status=403)
return
if not self._token_ok():
self._send_json({"error": "missing or invalid token"}, status=401)
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
self._send_json({"error": "invalid JSON body"}, status=400)
return
mode = str(payload.get("mode", ""))
if not self._hub.set_mode(mode):
self._send_json(
{"error": "invalid mode", "valid": list(VALID_MODES)}, status=400
)
return
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)
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")
self.send_header("Content-Length", "0")
self.end_headers()
# -- Server-Sent Events ----------------------------------------------
def _stream_events(self) -> None:
"""Hold the connection open and push each event as it happens."""
queue = self._hub.subscribe()
self.send_response(200)
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)
self.send_header("Vary", "Origin")
self.end_headers()
try:
self.wfile.write(b": connected\n\n")
self.wfile.flush()
while True:
try:
payload = queue.popleft()
except IndexError:
payload = None
if payload is not None:
message = "data: {}\n\n".format(json.dumps(payload))
self.wfile.write(message.encode("utf-8"))
self.wfile.flush()
else:
# Comment frame keeps proxies and the browser from timing out.
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
time.sleep(0.2)
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self._hub.unsubscribe(queue)
def create_server(
hub: EventHub,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
allowed_origins: Optional[List[str]] = None,
token: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None,
) -> 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.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]
return server
def is_loopback(host: str) -> bool:
"""True for hosts that are not reachable off-box."""
return host in ("127.0.0.1", "::1", "localhost")