"""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 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. 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 #: How long a *claimed* tap stays readable afterwards. The page that claimed it #: needs a moment to confirm which card logged in, but an id that stays #: readable forever is indistinguishable from a tap still waiting to be spent - #: so it is cleared once this window passes. Set to 0 to keep it indefinitely. DEFAULT_CLAIMED_TTL = 30.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, claimed_ttl: float = DEFAULT_CLAIMED_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, EVENT_INVALID: 0} self._port: Optional[str] = None self._tap_ttl = float(tap_ttl if tap_ttl else 0.0) self._claimed_ttl = float(claimed_ttl if claimed_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. A claimed tap is reported for ``claimed_ttl`` seconds after it was spent, so the page that claimed it can confirm which card logged in. An expired unclaimed tap is cleared too: keeping it would leave an id that can never be claimed again, which is indistinguishable from a tap that is still waiting to be used. """ with self._lock: self._drop_dead_tap() 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 _drop_dead_tap(self) -> None: """Clear the latched tap once it can no longer be acted on. Called with the lock held. Two independent deadlines: an unclaimed tap is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of zero TTL means "keep until replaced", which the claimed case uses to keep the id around indefinitely. Crucially this drops only the *tap*, never the presence flag: whether a card is on the reader is a physical fact learnt from insert/remove events, and a tap ageing out says nothing about it. Clearing presence here reported "no card" while a card was sitting on the antenna. """ if self._tap is None: return now = time.time() if self._consumed_at is not None: if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl: self._tap = None self._tap_at = None self._consumed_at = None return if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl: self._tap = None self._tap_at = None 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: self._drop_dead_tap() 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: self._drop_dead_tap() 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, "claimed_ttl_seconds": self._claimed_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 _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", []) 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 _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") 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") allowed = self._allowed_origin() if allowed: self.send_header("Access-Control-Allow-Origin", allowed) 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 "/" # 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 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 self.send_response(204) 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") 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") allowed = self._allowed_origin() if allowed: self.send_header("Access-Control-Allow-Origin", allowed) 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, 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] return server