fix(station): Panel ermittelt die Adresse per Netlink und nennt das Geraet
Am echten Pi stand im Aus-Zustand "LAN: not connected", obwohl er unter 192.168.2.96 erreichbar war - derselbe build_state()-Aufruf von Hand lieferte die Adresse dagegen richtig. Ursache ist die Absicherung des Anzeigedienstes: ninlight-display.service setzt RestrictAddressFamilies=AF_UNIX AF_NETLINK, der UDP-Kniff in local_ip() legt aber ein AF_INET-Socket an. Das scheitert dort mit EAFNOSUPPORT, und die Zeile fiel auf "not connected" zurueck. Kein Test hat das gefunden, weil alle ohne diese Beschraenkung liefen. Statt die Absicherung zu lockern ermittelt local_address() die Adresse jetzt mit "ip -4 route get" - das spricht Netlink und bleibt damit innerhalb der Erlaubnis. Der Unterprozess faellt nur im Aus-Zustand an, dem einzigen Statusbild mit dieser Zeile. Dazu die Beschriftung: "LAN" war eine Falschauskunft, dieser Pi haengt per WLAN im Netz und hat gar kein Kabel. Der Kernel nennt in derselben Auskunft beides, also steht dort jetzt "wlan0: 192.168.2.96" (bzw. "eth0: ..." am Kabel) und ohne Netz "no home network" statt eines nackten "no network", das schon einmal auf den Rettungs-Zugangspunkt bezogen wurde. Der neue Test sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem - genau die Luecke, die den Befund ueberhaupt durchgelassen hat.
This commit is contained in:
@@ -319,33 +319,54 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl
|
||||
return name, False
|
||||
|
||||
|
||||
def local_ip() -> str:
|
||||
"""Die Adresse, unter der der Pi im Heimnetz erreichbar ist, oder "" wenn keine da ist.
|
||||
def _route_lookup() -> str:
|
||||
"""Die Rohauskunft von "ip -4 route get" zur Standardroute, oder "" wenn es keine gibt.
|
||||
|
||||
Ueber einen UDP-Socket, der nur verbunden und nie benutzt wird: das erzeugt keinen
|
||||
Datenverkehr und fragt kein DNS, liefert aber genau die Adresse, die der Kernel fuer
|
||||
die Standardroute waehlen wuerde - also die, ueber die der Bediener tatsaechlich per
|
||||
SSH hereinkommt. Ein Aufruf von "hostname -I" waere die naheliegende Alternative,
|
||||
listet aber ALLE Adressen (inklusive der des Zugangspunkts und IPv6) und braucht einen
|
||||
Unterprozess in einer Schleife, die alle 5 s laeuft.
|
||||
Eigene Funktion, damit die Tests sie ersetzen koennen, ohne einen Unterprozess zu
|
||||
starten - und damit local_address() nur noch das Auswerten macht."""
|
||||
import subprocess
|
||||
|
||||
Wirft nie: ohne Netz gibt es keine Route, dann liefert der Aufruf "" und die Anzeige
|
||||
sagt das - eine Ausnahme aus der Adressermittlung darf das Panel nicht aufhalten."""
|
||||
import socket
|
||||
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# Die Adresse wird nie angesprochen; sie waehlt nur die Route aus. Bewusst eine
|
||||
# dokumentierte Testadresse (RFC 5737) statt eines echten Ziels wie 8.8.8.8 -
|
||||
# es soll auch dann nichts Fremdes anklingen, wenn jemand mitliest.
|
||||
sock.connect(("192.0.2.1", 9))
|
||||
return str(sock.getsockname()[0])
|
||||
except OSError:
|
||||
fertig = subprocess.run(
|
||||
# Bewusst eine dokumentierte Testadresse (RFC 5737) statt eines echten Ziels
|
||||
# wie 8.8.8.8: "route get" fragt nur den Kernel, es geht kein Paket hinaus,
|
||||
# aber es soll auch beim Mitlesen nichts Fremdes anklingen.
|
||||
["ip", "-4", "route", "get", "192.0.2.1"],
|
||||
capture_output=True, text=True, timeout=2, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
finally:
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
return fertig.stdout if fertig.returncode == 0 else ""
|
||||
|
||||
|
||||
def local_address() -> tuple[str, str]:
|
||||
"""(Geraet, Adresse), unter der der Pi im Heimnetz erreichbar ist - ("", "") ohne Netz.
|
||||
|
||||
Ueber "ip route get", NICHT ueber einen verbundenen UDP-Socket: die Unit erlaubt dem
|
||||
Anzeigedienst nur AF_UNIX und AF_NETLINK (ninlight-display.service:
|
||||
RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT, und
|
||||
genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe
|
||||
Aufruf von Hand - ohne Sandbox - die Adresse lieferte (Rueckmeldung 2026-09-23).
|
||||
"ip" spricht Netlink und laeuft damit innerhalb der Erlaubnis. Der Unterprozess
|
||||
kostet einen fork alle POLL_INTERVAL_S Sekunden und faellt nur im Aus-Zustand an,
|
||||
dem einzigen Statusbild mit dieser Zeile.
|
||||
|
||||
Das Geraet kommt mit, weil "LAN" am Panel eine Falschauskunft war: dieser Pi haengt
|
||||
per WLAN im Netz, nicht am Kabel. Der Kernel nennt in derselben Auskunft beides -
|
||||
"192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000".
|
||||
|
||||
Wirft nie: ohne Route gibt es nichts auszuwerten, dann liefert der Aufruf ("", "")
|
||||
und die Anzeige sagt das - eine Ausnahme aus der Adressermittlung darf das Panel
|
||||
nicht aufhalten."""
|
||||
worte = _route_lookup().split()
|
||||
geraet = adresse = ""
|
||||
for i, w in enumerate(worte):
|
||||
if w == "dev" and i + 1 < len(worte) and not geraet:
|
||||
geraet = worte[i + 1]
|
||||
elif w == "src" and i + 1 < len(worte) and not adresse:
|
||||
adresse = worte[i + 1]
|
||||
# Ohne Adresse ist auch der Geraetename wertlos - er wuerde ein Netz vortaeuschen,
|
||||
# das nicht steht.
|
||||
return (geraet, adresse) if adresse else ("", "")
|
||||
|
||||
|
||||
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
@@ -394,8 +415,9 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
||||
# ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos
|
||||
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
|
||||
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
|
||||
geraet, adresse = local_address()
|
||||
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
|
||||
"ip": local_ip()}
|
||||
"ip": adresse, "iface": geraet}
|
||||
|
||||
status = _read_json(run_dir / "status.json")
|
||||
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
||||
@@ -769,7 +791,7 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
||||
now = clock.monotonic()
|
||||
if now - last_state_poll >= POLL_INTERVAL_S:
|
||||
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
|
||||
# schon ab (siehe _read_json/_read_conf/_service_active/local_ip oben) - aber
|
||||
# schon ab (siehe _read_json/_read_conf/_service_active/local_address oben) - aber
|
||||
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
|
||||
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
|
||||
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
|
||||
|
||||
@@ -317,16 +317,23 @@ def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
# die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz"
|
||||
# sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert.
|
||||
#
|
||||
# Beschriftung "LAN" statt eines nackten "IP"/"no network": Rueckmeldung eines
|
||||
# Bedieners (2026-09-23) las "no network" so, als sei damit der Migrations-/
|
||||
# Rettungs-Zugangspunkt gemeint (der ja tatsaechlich aus ist, die Station ist
|
||||
# ausgeschaltet). Gemeint ist hier aber das GEGENTEIL - das gewoehnliche Heimnetz,
|
||||
# in dem der Pi als normaler Rechner haengt, sobald die Station aus ist (siehe
|
||||
# Kommentar oben). "LAN" grenzt das eindeutig vom Zugangspunkt-Netz ab, das auf
|
||||
# jedem ANDEREN Statusbild als SSID steht.
|
||||
# Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe
|
||||
# Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt
|
||||
# und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt
|
||||
# ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN
|
||||
# Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt.
|
||||
# Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde
|
||||
# auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja
|
||||
# tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz.
|
||||
ip = state.get("ip")
|
||||
if isinstance(ip, str):
|
||||
out.append((f"LAN: {ip}" if ip else "LAN: not connected", ROLE_DETAIL))
|
||||
geraet = state.get("iface")
|
||||
if not ip:
|
||||
out.append(("no home network", ROLE_DETAIL))
|
||||
elif isinstance(geraet, str) and geraet:
|
||||
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
|
||||
else:
|
||||
out.append((f"IP: {ip}", ROLE_DETAIL))
|
||||
return out + footer
|
||||
|
||||
if mode not in ("recovery", "migration"):
|
||||
|
||||
Reference in New Issue
Block a user