From 37f4dd425e0547c97787670a36c1dd56b682f358 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sat, 12 Sep 2026 11:45:05 +0200 Subject: [PATCH] feat: HTTP-Server der Recovery-Station Liefert ausschliesslich Images aus, deren Name dem Muster ninlight__ota.img entspricht, unterstuetzt Range-Anfragen fuer den Wiederaufnahmeversuch der Firmware und schreibt seinen Zustand nach status.json - die Schnittstelle fuer die E-Paper-Anzeige in 4c. Behebt zwei Races aus der Referenzimplementierung des Task-Briefs: der Server trug Erfolg/404 erst nach dem letzten gesendeten Byte in status.json ein, wodurch ein Client, der die Antwort vollstaendig gelesen hat, noch den alten Zustand sehen konnte. Ausserdem wurde ein ganzer 32-KiB-Chunk auch dann komplett gesendet, wenn --stall-after schon ueberschritten war; die Lesegroesse wird jetzt an die Stall-Grenze gekappt, und ein Verbindungsabbruch waehrend des Stalls wird per select/MSG_PEEK erkannt und als "aborted" vermerkt statt den Thread endlos zu blockieren. Co-Authored-By: Claude Sonnet 5 --- tools/station/pyproject.toml | 8 + tools/station/server.py | 230 +++++++++++++++++++++++++++++ tools/station/tests/test_server.py | 215 +++++++++++++++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 tools/station/pyproject.toml create mode 100755 tools/station/server.py create mode 100644 tools/station/tests/test_server.py diff --git a/tools/station/pyproject.toml b/tools/station/pyproject.toml new file mode 100644 index 0000000..f27b4de --- /dev/null +++ b/tools/station/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "ninlight-station" +version = "0.1.0" +description = "recovery station HTTP server for ninlight lamps" +requires-python = ">=3.11" + +[dependency-groups] +dev = ["pytest>=8"] diff --git a/tools/station/server.py b/tools/station/server.py new file mode 100755 index 0000000..e799492 --- /dev/null +++ b/tools/station/server.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""ninlight recovery station. + +Serves firmware images to lamps that look for a rescue station while booting. Deliberately +minimal: it hands out nothing but files whose name matches ninlight__ota.img, so a +stray file in the image directory can never leave the machine. + +Standard library only - the Pi gets no extra packages. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import select +import socket +import sys +import tempfile +import time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw") +PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$") +TRAILER_SIZE = 512 +TRAILER_MAGIC = b"NINL" +VERSION_OFF = 21 +VERSION_LEN = 16 +RECENT_MAX = 8 +CHUNK = 32 * 1024 + + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def trailer_version(path: Path) -> str: + """Firmware version from the image trailer, or '?' if the file carries none.""" + try: + with path.open("rb") as f: + if path.stat().st_size < TRAILER_SIZE: + return "?" + f.seek(-TRAILER_SIZE, os.SEEK_END) + tail = f.read(TRAILER_SIZE) + except OSError: + return "?" + if tail[0:4] != TRAILER_MAGIC: + return "?" + return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace") + + +class Station: + def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None: + self.image_dir = image_dir + self.state_path = state_path + self.stall_after = stall_after + self.since = now_iso() + self.served = 0 + self.recent: list[dict] = [] + self.write_state() + + def image_path(self, target: str) -> Path: + return self.image_dir / f"ninlight_{target}_ota.img" + + def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None: + if status == "ok": + self.served += 1 + self.recent.append({"ts": now_iso(), "ip": ip, "target": target, + "version": version, "bytes": nbytes, "status": status}) + del self.recent[:-RECENT_MAX] + self.write_state() + + def write_state(self) -> None: + payload = {"since": self.since, "served": self.served, "recent": self.recent} + try: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-") + with os.fdopen(fd, "w") as f: + json.dump(payload, f, indent=2) + os.replace(tmp, self.state_path) + except OSError as e: + print(f"warning: cannot write {self.state_path}: {e}", flush=True) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "ninlight-station" + + @property + def station(self) -> Station: + return self.server.station # type: ignore[attr-defined] + + def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log + pass + + def log(self, status: int, nbytes: int, started: float) -> None: + print(f"{now_iso()} {self.client_address[0]} {self.path} {status} " + f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True) + + def do_POST(self) -> None: + self.reject(405, "method not allowed") + + do_PUT = do_DELETE = do_HEAD = do_POST + + def reject(self, status: int, text: str) -> None: + body = (text + "\n").encode() + self.send_response(status) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + started = time.monotonic() + m = PATH_RE.match(self.path) + if not m: + self.reject(404, "not found") + self.log(404, 0, started) + return + target = m.group(1) + path = self.station.image_path(target) + if not path.is_file(): + # Record before responding: a client that sees the 404 complete may check + # status.json right away, so the entry must already be on disk by then. + self.station.record(self.client_address[0], target, "?", 0, "notfound") + self.reject(404, "no image for this device") + self.log(404, 0, started) + return + + size = path.stat().st_size + start = 0 + rng = self.headers.get("Range", "") + if rng.startswith("bytes="): + first = rng.split("=", 1)[1].split("-", 1)[0] + if first.isdigit() and int(first) < size: + start = int(first) + + version = trailer_version(path) + self.send_response(206 if start else 200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(size - start)) + if start: + self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}") + self.send_header("Accept-Ranges", "bytes") + self.send_header("Connection", "close") + self.end_headers() + + sent = 0 + status = "ok" + recorded = False + remaining = size - start + try: + with path.open("rb") as f: + f.seek(start) + while sent < remaining: + if self.station.stall_after is not None and sent >= self.station.stall_after: + # test aid: hold the connection open and send nothing more, so the + # firmware's stall handling can be exercised on real hardware. We + # still notice if the peer gives up and closes its side, so a test + # (or an impatient client) that hangs up is recorded as "aborted" + # instead of leaking the thread forever. + while True: + readable, _, _ = select.select([self.connection], [], [], 1.0) + if readable: + try: + peek = self.connection.recv(1, socket.MSG_PEEK) + except OSError: + peek = b"" + if not peek: + raise ConnectionResetError("peer closed during stall") + read_size = CHUNK + if self.station.stall_after is not None: + # Don't overshoot the stall boundary within a single read: without + # this a whole CHUNK could be sent before the check above ever + # notices we've passed --stall-after. + read_size = min(read_size, max(1, self.station.stall_after - sent)) + chunk = f.read(read_size) + if not chunk: + break + if sent + len(chunk) >= remaining: + # Record success before the last bytes go out: the lamp (and our + # tests) may observe the response completing and immediately check + # status.json, so the state must already be on disk by then. + self.station.record(self.client_address[0], target, version, + sent + len(chunk), "ok") + recorded = True + self.wfile.write(chunk) + sent += len(chunk) + except (BrokenPipeError, ConnectionResetError, TimeoutError): + status = "aborted" + self.log(206 if start else 200, sent, started) + if not recorded: + self.station.record(self.client_address[0], target, version, sent, status) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="ninlight recovery station HTTP server") + p.add_argument("--image-dir", required=True, type=Path) + p.add_argument("--bind", default="192.168.8.1") + p.add_argument("--port", type=int, default=80) + p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json")) + p.add_argument("--stall-after", type=int, default=None, + help="test aid: stop sending after N bytes but keep the connection open") + a = p.parse_args(argv) + + if not a.image_dir.is_dir(): + print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr) + return 2 + + station = Station(a.image_dir, a.state, a.stall_after) + httpd = ThreadingHTTPServer((a.bind, a.port), Handler) + httpd.daemon_threads = True + httpd.station = station # type: ignore[attr-defined] + images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) + print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): " + f"{', '.join(images) or 'none'}", flush=True) + if a.stall_after is not None: + print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/station/tests/test_server.py b/tools/station/tests/test_server.py new file mode 100644 index 0000000..052fca4 --- /dev/null +++ b/tools/station/tests/test_server.py @@ -0,0 +1,215 @@ +import hashlib +import http.client +import json +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +SERVER = Path(__file__).resolve().parents[1] / "server.py" +TRAILER_SIZE = 512 + + +def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes: + """Builds a file with the same trailer layout the firmware verifies.""" + trailer = bytearray(b"\xff" * TRAILER_SIZE) + trailer[0:4] = b"NINL" + trailer[4] = 1 + trailer[5:21] = device.encode().ljust(16, b"\x00") + trailer[21:37] = version.encode().ljust(16, b"\x00") + trailer[37:69] = hashlib.sha256(payload).digest() + data = payload + bytes(trailer) + path.write_bytes(data) + return data + + +def free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +class Station: + def __init__(self, tmp_path, *extra): + self.image_dir = tmp_path / "images" + self.image_dir.mkdir() + self.state = tmp_path / "status.json" + self.port = free_port() + self.proc = subprocess.Popen( + [sys.executable, str(SERVER), "--image-dir", str(self.image_dir), + "--bind", "127.0.0.1", "--port", str(self.port), + "--state", str(self.state), *extra], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + deadline = time.time() + 10 + while time.time() < deadline: + try: + socket.create_connection(("127.0.0.1", self.port), 0.2).close() + return + except OSError: + if self.proc.poll() is not None: + raise RuntimeError("server died: " + (self.proc.stdout.read() or "")) + time.sleep(0.05) + raise RuntimeError("server did not come up") + + def conn(self): + return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + + def read_state(self): + for _ in range(50): + if self.state.exists(): + try: + return json.loads(self.state.read_text()) + except json.JSONDecodeError: + pass + time.sleep(0.05) + raise AssertionError("no state file written") + + def stop(self): + self.proc.terminate() + self.proc.wait(timeout=5) + + +@pytest.fixture +def station(tmp_path): + s = Station(tmp_path) + yield s + s.stop() + + +def test_serves_a_known_image(station): + data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500) + c = station.conn() + c.request("GET", "/ninlight_evk_ota.img") + r = c.getresponse() + body = r.read() + assert r.status == 200 + assert int(r.getheader("Content-Length")) == len(data) + assert body == data + + +def test_missing_image_is_404(station): + c = station.conn() + c.request("GET", "/ninlight_g95_cw_ota.img") + assert c.getresponse().status == 404 + + +def test_unknown_target_is_404(station): + make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100) + for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"): + c = station.conn() + c.request("GET", path) + assert c.getresponse().status == 404, path + + +def test_path_traversal_is_refused(station): + secret = station.image_dir.parent / "secret.txt" + secret.write_text("nicht ausliefern") + c = station.conn() + c.request("GET", "/../secret.txt") + assert c.getresponse().status == 404 + + +def test_other_methods_are_405(station): + c = station.conn() + c.request("POST", "/ninlight_evk_ota.img", body=b"") + assert c.getresponse().status == 405 + + +def test_range_request_returns_206_and_the_right_slice(station): + data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000) + c = station.conn() + c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"}) + r = c.getresponse() + body = r.read() + assert r.status == 206 + assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}" + assert body == data[1000:] + + +def test_state_file_records_a_delivery(station): + make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000) + c = station.conn() + c.request("GET", "/ninlight_evk_ota.img") + c.getresponse().read() + state = station.read_state() + assert state["served"] == 1 + entry = state["recent"][-1] + assert entry["target"] == "evk" + assert entry["version"] == "0.3.4" + assert entry["status"] == "ok" + assert entry["ip"] == "127.0.0.1" + assert entry["bytes"] == 1000 + TRAILER_SIZE + + +def test_state_file_records_a_missing_image(station): + c = station.conn() + c.request("GET", "/ninlight_g95_cw_ota.img") + c.getresponse().read() + state = station.read_state() + assert state["served"] == 0 + assert state["recent"][-1]["status"] == "notfound" + assert state["recent"][-1]["target"] == "g95_cw" + + +def test_recent_is_capped_at_eight(station): + make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10) + for _ in range(12): + c = station.conn() + c.request("GET", "/ninlight_evk_ota.img") + c.getresponse().read() + state = station.read_state() + assert state["served"] == 12 + assert len(state["recent"]) == 8 + + +def test_aborted_transfer_is_recorded(tmp_path): + s = Station(tmp_path, "--stall-after", "256") + try: + make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000) + sock = socket.create_connection(("127.0.0.1", s.port), timeout=5) + sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n") + sock.recv(4096) + time.sleep(0.3) + sock.close() + deadline = time.time() + 10 + while time.time() < deadline: + try: + entry = json.loads(s.state.read_text())["recent"][-1] + if entry["status"] == "aborted": + assert entry["bytes"] < 100000 + return + except (OSError, json.JSONDecodeError, IndexError, KeyError): + pass + time.sleep(0.1) + raise AssertionError("abort was not recorded") + finally: + s.stop() + + +def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path): + s = Station(tmp_path, "--stall-after", "512") + try: + make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000) + sock = socket.create_connection(("127.0.0.1", s.port), timeout=5) + sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n") + got = b"" + sock.settimeout(2) + try: + while len(got) < 200000: + chunk = sock.recv(65536) + if not chunk: + break + got += chunk + except socket.timeout: + pass + # far less than the full image, and the peer never closed the connection + assert len(got) < 20000 + sock.close() + finally: + s.stop()