diff --git a/tools/station/server.py b/tools/station/server.py index 2ec9fd1..acb67aa 100755 --- a/tools/station/server.py +++ b/tools/station/server.py @@ -86,6 +86,21 @@ def trailer_version(path: Path) -> str: IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"} DEFAULT_IMAGE_SET = "ohne-wlan" +# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (nicht nur die Migration): der +# Eigentuemer hat das ausdruecklich verlangt, denn bisher bekam eine Rettung IMMER ein +# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ stammten aus build/). Die +# Rettungsabzuege OHNE Zugangsdaten kommen aus tools/build_nocreds.sh, genau wie ihr +# Migrations-Gegenstueck - deshalb dasselbe Namensschema, nur mit "rescue-" statt +# "migrate-" (deploy.sh legt sie entsprechend ab, siehe dort). +# +# Zwei getrennte Verzeichnispaare (migrate-*/rescue-*) statt einem gemeinsamen: die +# Migrationsabzuege sind signiert und tragen ein eigenes manifest.json, die +# Rettungsabzuege sind die gewoehnlichen _ota.img mit ninlight-Trailer. Sie +# unterscheiden sich in Form und Herstellung; ein gemeinsames Verzeichnis wuerde beide +# Herstellungswege ununterscheidbar ineinander mischen (siehe auch: "migrate-wlan/ und +# migrate-nocreds/ werden bei der Rettung nie angefasst" im Auftrag). +RESCUE_SET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"} + def read_image_set(path: Path | None) -> str: """Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar @@ -117,6 +132,13 @@ class Station: self.since = now_iso() self.served = 0 self.recent: list[dict] = [] + # Zeitpunkt der letzten Anfrage JEDER Art (erfolgreich oder nicht) - das + # Lebenszeichen fuer die Anzeige (display.py:_recent_request()). Anders als + # "served" zaehlt hier auch eine erfolglose Anfrage: der Eigentuemer wollte + # gerade nicht erst am Servierzaehler raten muessen, ob ueberhaupt etwas + # anklopft (Bedienerwunsch 2026-09-23). None, solange seit dem Start der + # Station noch keine einzige Anfrage einging. + self.last_activity: str | None = None # Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert # wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2). self.last_seen: dict | None = None @@ -148,8 +170,36 @@ class Station: self.lock = threading.RLock() self.write_state() + def rescue_dir(self) -> Path: + """Das Verzeichnis des EINGESTELLTEN Bildsatzes fuer die RETTUNG. Dieselbe Regel + wie migrate_dir() (dort steht die ausfuehrliche Begruendung): der Umschalter + entscheidet, nicht eine Abfrage je Rettung, und es wird nie stillschweigend auf + den jeweils anderen Satz ausgewichen. + + Bestandskompatibilitaet bewusst NICHT gegeben: vor diesem Umschalter lagen + Rettungsabzuege FLACH in image_dir (immer MIT Zugangsdaten, aus build/). Ein + Pi mit diesem alten Bestand wuerde, faende image_path() dort noch etwas, bei der + Vorgabe "ohne-wlan" weiterhin klaglos ein Abbild MIT Zugangsdaten ausliefern - + also genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend + fortsetzen, die dieser Umschalter gerade abstellen soll. Ein Pi mit altem + Bestand muss deshalb einmal per deploy.sh neu bestueckt werden (das raeumt den + flachen Altbestand auch auf); bis dahin liefert die Rettung in der Vorgabe + "ohne-wlan" sichtbar nichts aus (Status "notfound"), statt einen Fehlgriff zu + verstecken.""" + return self.image_dir / RESCUE_SET_DIRS[self.image_set()] + + def rescue_set_present(self) -> bool: + """Liegt der eingestellte Rettungs-Bildsatz ueberhaupt auf dem Pi? Dieselbe Regel + wie migrate_set_present() (Verzeichnis vorhanden UND mindestens ein Image + darin) - Panel und CLI sollen fuer die Rettung nach demselben Massstab + urteilen wie fuer die Migration.""" + try: + return any(p.name.endswith(".img") for p in self.rescue_dir().iterdir()) + except OSError: + return False + def image_path(self, target: str) -> Path: - return self.image_dir / f"ninlight_{target}_ota.img" + return self.rescue_dir() / f"ninlight_{target}_ota.img" def image_set(self) -> str: """Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan").""" @@ -326,7 +376,11 @@ class Station: with self.lock: if status == "ok": self.served += 1 - self.recent.append({"ts": now_iso(), "ip": ip, "target": target, + ts = now_iso() + # Jede Anfrage zaehlt als Lebenszeichen, nicht nur eine erfolgreiche - siehe + # self.last_activity oben. + self.last_activity = ts + self.recent.append({"ts": ts, "ip": ip, "target": target, "version": version, "bytes": nbytes, "status": status}) del self.recent[:-RECENT_MAX] self.write_state() @@ -347,6 +401,9 @@ class Station: "variant_source": variant_source, "product_id": product_id, "lamp_version": lamp_version, "version": version, "bytes": nbytes, "status": status} + # Jede Anfrage zaehlt als Lebenszeichen, auch "listen"/"unknown"/"notfound"/ + # "noimageset" - siehe self.last_activity oben und record()'s Gegenstueck. + self.last_activity = entry["ts"] self.recent.append(entry) del self.recent[:-RECENT_MAX] if target is not None: @@ -359,7 +416,11 @@ class Station: def write_state(self) -> None: with self.lock: payload = {"since": self.since, "mode": self.mode, "served": self.served, - "recent": list(self.recent)} + "recent": list(self.recent), + # In BEIDEN Modi (nicht nur migration, siehe unten) - das + # Lebenszeichen auf dem Panel gilt fuer die Rettung genauso wie + # fuer die Migration (display.py:_recent_request()). + "last_request_ts": self.last_activity} if self.mode == "migration": phase, armed_target = self.read_control() payload["phase"] = phase diff --git a/tools/station/tests/test_server.py b/tools/station/tests/test_server.py index 73cb481..f59f287 100644 --- a/tools/station/tests/test_server.py +++ b/tools/station/tests/test_server.py @@ -43,6 +43,14 @@ class Station: def __init__(self, tmp_path, *extra): self.image_dir = tmp_path / "images" self.image_dir.mkdir() + # image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es + # tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py, + # DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds. + # Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den + # Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen + # ihre Abbilder gezielt in andere Verzeichnisse. + self.rescue_dir = self.image_dir / "rescue-nocreds" + self.rescue_dir.mkdir() self.state = tmp_path / "status.json" self.port = free_port() self.proc = subprocess.Popen( @@ -140,7 +148,7 @@ def station(tmp_path): def test_serves_a_known_image(station): - data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500) + data = make_image(station.rescue_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() @@ -157,7 +165,7 @@ def test_missing_image_is_404(station): def test_unknown_target_is_404(station): - make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100) + make_image(station.rescue_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) @@ -216,7 +224,7 @@ def test_log_error_is_not_silenced_by_log_message(capsys): 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) + data = make_image(station.rescue_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() @@ -227,7 +235,7 @@ def test_range_request_returns_206_and_the_right_slice(station): def test_state_file_records_a_delivery(station): - make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000) + make_image(station.rescue_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() @@ -241,6 +249,21 @@ def test_state_file_records_a_delivery(station): assert entry["bytes"] == 1000 + TRAILER_SIZE +def test_state_file_carries_a_last_request_timestamp_for_the_display(station): + """display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art + hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur + den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None.""" + initial = station.read_state() + assert "last_request_ts" in initial + assert initial["last_request_ts"] is None + + c = station.conn() + c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem + c.getresponse().read() + state = station.read_state(lambda s: s["last_request_ts"] is not None) + assert isinstance(state["last_request_ts"], str) and state["last_request_ts"] + + def test_state_file_records_a_missing_image(station): c = station.conn() c.request("GET", "/ninlight_g95_cw_ota.img") @@ -266,7 +289,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path): finally: os.umask(old_umask) try: - make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100) + make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100) def world_readable() -> bool: mode = stat.S_IMODE(s.state.stat().st_mode) @@ -289,7 +312,7 @@ def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path): def test_recent_is_capped_at_eight(station): - make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10) + make_image(station.rescue_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") @@ -302,7 +325,7 @@ def test_recent_is_capped_at_eight(station): 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) + make_image(s.rescue_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) @@ -326,7 +349,7 @@ def test_aborted_transfer_is_recorded(tmp_path): 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) + make_image(s.rescue_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"" @@ -369,7 +392,7 @@ def test_stall_after_budget_survives_a_resumed_request(tmp_path): """ s = Station(tmp_path, "--stall-after", "1000") try: - make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000) + make_image(s.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000) # First request: burns through the whole --stall-after budget, then the server # holds the connection open and sends nothing further. @@ -419,7 +442,7 @@ def test_concurrent_downloads_are_all_recorded(station): concurrent requests can lose increments (two threads read the same old value) or have one thread's status.json write clobber another's. """ - make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000) + make_image(station.rescue_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000) expected_len = 5000 + TRAILER_SIZE n_workers = 20 errors = [] @@ -1092,6 +1115,75 @@ def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_serve )["recent"][-1]["version"] == "0.4.0-wlan" +# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG --------------------------- +# +# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein +# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path() +# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die +# Migration, nur gegen den Rettungspfad "/ninlight__ota.img". + +def _both_rescue_sets(imgdir): + (imgdir / "rescue-nocreds").mkdir(exist_ok=True) + make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", + b"OHNE" * 100) + (imgdir / "rescue-wlan").mkdir(exist_ok=True) + make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", + b"MITW" * 100) + + +def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server): + """Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein + Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben. + Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war + genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte.""" + srv, imgdir = server + _both_rescue_sets(imgdir) + r = srv.get("/ninlight_g95_cw_ota.img") + assert r.status == 200 + assert r.body.startswith(b"OHNE") + + +def test_rescue_switch_decides_which_file_is_actually_delivered(server): + srv, imgdir = server + _both_rescue_sets(imgdir) + srv.set_image_set("mit-wlan") + assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW") + # ... und wieder zurueck, ohne Dienstneustart. + srv.set_image_set("ohne-wlan") + assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE") + + +def test_rescue_missing_selected_set_delivers_nothing(server): + """Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus - + insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes. + Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der + Umschalter da ist (siehe rescue_dir() in server.py).""" + srv, imgdir = server + (imgdir / "rescue-nocreds").mkdir(exist_ok=True) + make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", + b"OHNE" * 100) + srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht + + r = srv.get("/ninlight_g95_cw_ota.img") + assert r.status == 404 + assert b"OHNE" not in r.body + st = srv.status(lambda s: len(s["recent"]) >= 1) + assert st["served"] == 0 + assert st["recent"][-1]["status"] == "notfound" + + +def test_rescue_old_flat_layout_is_no_longer_served(server): + """Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi + mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus + build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den + flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern - + genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt.""" + srv, imgdir = server + make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50) + r = srv.get("/ninlight_g95_cw_ota.img") + assert r.status == 404 + + def test_read_image_set_defaults_without_a_file(tmp_path): module = _load_server_module() assert module.read_image_set(None) == "ohne-wlan"