Files
ninlight-w600/tools/station/server.py
T
Michael SauerandClaude Sonnet 5 3f27ef4b26 fix: --stall-after Budget der Rettungsstation client-kumulativ zählen
Der Testschalter zählte bisher pro Anfrage, sodass eine Lampe nach ihrem
Empfangs-Timeout einfach per Range neu verband und ein frisches Budget bekam
– am EVK reichten fünf Reconnects, um trotz --stall-after das komplette
Image zu übertragen. Station verfolgt die ausgelieferten Bytes jetzt pro
Client-IP über alle Anfragen hinweg (Station.reserve_stall_bytes, unter dem
bestehenden Lock), sodass kein erneutes Verbinden das Budget auffüllt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:40:55 +02:00

268 lines
12 KiB
Python
Executable File

#!/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 contextlib
import json
import os
import re
import select
import socket
import sys
import tempfile
import threading
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] = []
# Cumulative bytes already delivered to each client while --stall-after is active,
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
# resumes with Range right where it left off, so a per-request counter would hand
# every reconnect a brand-new --stall-after allowance and the stall would never
# actually stick (see reserve_stall_bytes below).
self.stall_sent: dict[str, int] = {}
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
# can be downloading at once. Every mutation of served/recent/stall_sent and the
# snapshot that gets serialized to disk must happen under this same lock, or
# concurrent requests lose increments and stomp on each other's status.json writes.
self.lock = threading.RLock()
self.write_state()
def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img"
def reserve_stall_bytes(self, ip: str, n: int) -> int:
"""How many of the next `n` bytes this client may still receive under --stall-after.
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
that the lamp abandons and resumes via Range does not get a fresh budget just
because it is a new TCP connection. Only called when stall_after is set.
"""
with self.lock:
sent_so_far = self.stall_sent.get(ip, 0)
allowed = max(0, min(n, self.stall_after - sent_so_far))
self.stall_sent[ip] = sent_so_far + allowed
return allowed
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
with self.lock:
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:
with self.lock:
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
try:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
# The service runs as root and mkstemp() creates the file 0600, so
# without this an operator reading status.json as a normal user (see
# tools/station/ninlight-station) gets "permission denied" - and since
# os.replace() preserves the mode it arrives with, that wrong mode
# would otherwise survive onto the live file on every single write,
# not just the first. os.chmod() ignores the process umask (unlike the
# mode argument to mkstemp/open), so a restrictive umask on the Pi
# can't quietly bring the 0600 back. Set it before the rename so
# there's never a window where the path holds a 0600 file.
os.chmod(tmp, 0o644)
os.replace(tmp, self.state_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
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"
remaining = size - start
client_ip = self.client_address[0]
try:
with path.open("rb") as f:
f.seek(start)
while sent < remaining:
read_size = min(CHUNK, remaining - sent)
if self.station.stall_after is not None:
# Reserve against this client's cumulative budget, not just this
# request's byte count - see Station.reserve_stall_bytes.
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
if read_size == 0:
# 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")
chunk = f.read(read_size)
if not chunk:
break
self.wfile.write(chunk)
sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
status = "aborted"
# Record only now that the transfer has actually finished (or failed): "ok" must
# never be written before the last byte is confirmed sent, or status.json can
# claim success for a device that never received the end of the image.
self.log(206 if start else 200, sent, started)
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())