feat: HTTP-Server der Recovery-Station
Liefert ausschliesslich Images aus, deren Name dem Muster ninlight_<target>_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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
76a3253322
commit
37f4dd425e
@@ -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"]
|
||||
Executable
+230
@@ -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_<target>_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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user