fix(station): RuntimeDirectory-Race, Statusphase, off/ap_switch-Nachpruefung, Pi-Bestandskonfiguration
Fixrunde 1 zu Task 4: - C1 (kritisch): die Unit leerte /run/ninlight-station bei jedem systemctl restart, weil RuntimeDirectoryPreserve fehlte - genau der Neustart, mit dem migrate/recovery on den Dienst hochfahren, wischte die eben geschriebene Modus-/Steuerdatei wieder weg, und der Server fiel still auf den Recovery-Default zurueck. Unit bekommt RuntimeDirectoryPreserve=restart, und start_and_verify() prueft nach dem Neustart zusaetzlich ausdruecklich nach, dass Modus und Phase noch stimmen. - I1: status las die Phase aus status.json, die arm/listen (die den Dienst bewusst nicht neu starten) nie aktualisieren - jetzt kommt sie aus control.json. - I2: off pruefte nach dem Herunterfahren nichts nach und konnte "Station aus" melden, waehrend ein Profil wegen haengendem NetworkManager weiterhin aktiv war - ap_switch() verifiziert das jetzt auch im Aus-Fall. - I3: der Fehlerzweig von ap_switch (Mismatch nach dem Umschalten) war im bisherigen Test nie erreichbar; der nmcli-Stub kann das jetzt erzwingen. - I4: neue Pflichtschluessel blockierten jeden Unterbefehl auf einem bereits eingerichteten Pi. setup_pi.sh ergaenzt fehlende Schluessel in einer bestehenden Konfiguration jetzt einzeln, statt sie unangetastet zu lassen; dafuer musste das Skript per bash-s-Heredocs und ueberschreibbaren Pfadvariablen erst testbar gemacht werden (neuer test_setup_pi.sh). Dazu vier kleinere Punkte aus derselben Review: grep -Fqx statt grep -qx fuer Profilnamen, install-Stub durch tee+chmod ersetzt (POSIX, kein Sonderfall mehr fuer macOS), Rechtepruefung auf control.json in den arm/listen-Testfaellen, stderr sauber von stdout getrennt in den Warnungs-/Fehlerfaellen.
This commit is contained in:
+115
-21
@@ -47,7 +47,9 @@ unset _key
|
||||
|
||||
need_profile() {
|
||||
# $1 = NetworkManager-Profilname, der existieren muss.
|
||||
nmcli -t -f NAME con show | grep -qx "$1" || {
|
||||
# -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
|
||||
# "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
|
||||
nmcli -t -f NAME con show | grep -Fqx "$1" || {
|
||||
echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -77,7 +79,22 @@ ap_switch() {
|
||||
fi
|
||||
sudo nmcli con down "$_p" >/dev/null 2>&1 || true
|
||||
done
|
||||
[ -n "$_target" ] || return 0
|
||||
if [ -z "$_target" ]; then
|
||||
# "off": jedes "con down" oben ist mit "|| true" abgesichert, weil ein
|
||||
# haengender NetworkManager den Aufruf sonst zum Abbruch braechte, bevor
|
||||
# ueberhaupt versucht wurde, beide Profile herunterzufahren. Das darf aber
|
||||
# nicht heissen, dass ein haengengebliebenes Profil stillschweigend als
|
||||
# "aus" durchgeht: nachsehen, ob wlan0 wirklich kein Stationsprofil mehr
|
||||
# zeigt, und erst dann Erfolg melden (Fixrunde 1, I2) - sonst kann z.B. der
|
||||
# Migrations-AP mit oeffentlich bekanntem Passwort weitersenden, waehrend
|
||||
# das CLI "Station aus" behauptet.
|
||||
_active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
|
||||
if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
|
||||
echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
sudo nmcli con up "$_target" >/dev/null
|
||||
_active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
|
||||
if [ "$_active" != "$_target" ]; then
|
||||
@@ -93,14 +110,14 @@ ap_switch() {
|
||||
write_mode() {
|
||||
sudo mkdir -p "$RUN_DIR"
|
||||
_tmp="$RUN_DIR/.mode-$$"
|
||||
printf '%s' "$1" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1
|
||||
printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
|
||||
sudo mv -f "$_tmp" "$RUN_DIR/mode"
|
||||
}
|
||||
|
||||
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei per
|
||||
# "install" anlegen, dann umbenennen) und dieselben Rechte wie server.py's eigenes
|
||||
# write_control(), sonst kann der Dienst die Datei anschliessend nicht mehr
|
||||
# ueberschreiben oder das CLI sie nicht mehr lesen.
|
||||
# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
|
||||
# selben Verzeichnis anlegen, Rechte setzen, dann umbenennen) und dieselben Rechte
|
||||
# wie server.py's eigenes write_control(), sonst kann der Dienst die Datei
|
||||
# anschliessend nicht mehr ueberschreiben oder das CLI sie nicht mehr lesen.
|
||||
#
|
||||
# $1 = phase (auto|listen|armed), $2 = armed_target oder leer (-> null im JSON).
|
||||
write_control() {
|
||||
@@ -111,13 +128,36 @@ write_control() {
|
||||
else
|
||||
_json=$(printf '{"phase": "%s", "armed_target": null}' "$1")
|
||||
fi
|
||||
printf '%s' "$_json" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1
|
||||
printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
|
||||
sudo mv -f "$_tmp" "$RUN_DIR/control.json"
|
||||
}
|
||||
|
||||
# Startet (neu) und wartet kurz auf Lebenszeichen. $1 = das gerade hochgefahrene
|
||||
# AP-Profil, das bei einem Fehlschlag wieder heruntergefahren wird.
|
||||
# Startet (neu) und wartet kurz auf Lebenszeichen, dann prueft es nach, dass der
|
||||
# eben gestartete Prozess wirklich den beabsichtigten Modus/die beabsichtigte
|
||||
# Phase sieht.
|
||||
#
|
||||
# $1 = das gerade hochgefahrene AP-Profil (fuer Rollback bei Fehlschlag)
|
||||
# $2 = erwarteter Inhalt von $RUN_DIR/mode nach dem Neustart
|
||||
# $3 = erwartete Phase in control.json (nur im Migrationsmodus angeben, sonst leer)
|
||||
#
|
||||
# Der Grund fuer $2/$3: die systemd-Unit traegt RuntimeDirectory=ninlight-station,
|
||||
# und systemd leert dieses Verzeichnis bei jedem Stop/Restart, sofern die Unit
|
||||
# nicht RuntimeDirectoryPreserve=restart setzt. write_mode()/write_control() oben
|
||||
# laufen VOR diesem "systemctl restart" - ein Neustart, der das Verzeichnis doch
|
||||
# leert (weil die Unit die Zeile verliert, oder auf einem Pi mit alter Unit-Datei
|
||||
# installiert wird), wischt also genau die Dateien wieder weg, die gerade erst
|
||||
# geschrieben wurden. Der frisch gestartete Serverprozess faende dann keine
|
||||
# Modusdatei und faellt laut server.py:resolve_mode() still auf "recovery"
|
||||
# zurueck (bzw. read_control() auf "auto" bei fehlender Steuerdatei) - ein
|
||||
# ausdrueckliches "migrate on --listen" wuerde so unbemerkt zu "Rettungsmodus,
|
||||
# antwortet 404" bzw. zu "automatische Auslieferung" (Fixrunde 1, C1). Die Unit
|
||||
# traegt deshalb RuntimeDirectoryPreserve=restart; diese Nachpruefung faengt es
|
||||
# zusaetzlich ab, falls das je auseinanderlaeuft, statt sich allein auf die
|
||||
# Unit-Datei zu verlassen.
|
||||
start_and_verify() {
|
||||
_ap=$1
|
||||
_expect_mode=$2
|
||||
_expect_phase=${3:-}
|
||||
# Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht
|
||||
# blockieren (systemd verweigert "start"/"restart" sonst sofort mit
|
||||
# "start-limit-hit").
|
||||
@@ -127,7 +167,7 @@ start_and_verify() {
|
||||
# set -eu wuerde das Skript hier sonst abbrechen, bevor die
|
||||
# Lebendigkeitspruefung unten je erreicht wird, und der eben
|
||||
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
|
||||
sudo nmcli con down "$1" >/dev/null 2>&1 || true
|
||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||
echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -140,10 +180,37 @@ start_and_verify() {
|
||||
# weiter in seiner Neustartschleife - erst stoppen, dann den AP runter,
|
||||
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
sudo nmcli con down "$1" >/dev/null 2>&1 || true
|
||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
|
||||
exit 1
|
||||
fi
|
||||
_actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
|
||||
if [ "$_actual_mode" != "$_expect_mode" ]; then
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||
echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$_expect_phase" ]; then
|
||||
_actual_phase=$(python3 - "$RUN_DIR/control.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
d = {}
|
||||
print(d.get("phase", ""))
|
||||
PY
|
||||
)
|
||||
if [ "$_actual_phase" != "$_expect_phase" ]; then
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
|
||||
echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
recovery_on() {
|
||||
@@ -152,7 +219,7 @@ recovery_on() {
|
||||
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
|
||||
ap_switch "$AP_CONNECTION"
|
||||
write_mode "recovery"
|
||||
start_and_verify "$AP_CONNECTION"
|
||||
start_and_verify "$AP_CONNECTION" "recovery"
|
||||
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
|
||||
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
|
||||
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
|
||||
@@ -184,7 +251,7 @@ migrate_on() {
|
||||
echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
|
||||
exit 1
|
||||
}
|
||||
start_and_verify "$MIGRATE_AP_CONNECTION"
|
||||
start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
|
||||
echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)"
|
||||
echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2
|
||||
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
|
||||
@@ -193,7 +260,7 @@ migrate_on() {
|
||||
full_off() {
|
||||
sudo systemctl stop ninlight-station 2>/dev/null || true
|
||||
ap_switch ""
|
||||
if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then
|
||||
if nmcli -t -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
|
||||
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
|
||||
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
|
||||
else
|
||||
@@ -262,11 +329,13 @@ status_cmd() {
|
||||
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
|
||||
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand.
|
||||
_status_file="$RUN_DIR/status.json"
|
||||
_control_file="$RUN_DIR/control.json"
|
||||
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
|
||||
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
|
||||
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
|
||||
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
|
||||
# "noch nie ausgeliefert". Das gilt auch fuer Modus/Phase/Erkannt unten.
|
||||
# "noch nie ausgeliefert". Das gilt auch fuer Modus/Erkannt unten und fuer
|
||||
# Phase/control.json weiter unten.
|
||||
if [ -r "$_status_file" ]; then
|
||||
_info=$(python3 - "$_status_file" <<'PY'
|
||||
import json
|
||||
@@ -286,22 +355,47 @@ if last_seen:
|
||||
else:
|
||||
seen = "-"
|
||||
print(d.get("mode", "-"))
|
||||
print(d.get("phase", "-"))
|
||||
print(seen)
|
||||
PY
|
||||
)
|
||||
_modus=$(printf '%s\n' "$_info" | sed -n '1p')
|
||||
_phase=$(printf '%s\n' "$_info" | sed -n '2p')
|
||||
_erkannt=$(printf '%s\n' "$_info" | sed -n '3p')
|
||||
_erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
|
||||
elif [ -e "$_status_file" ]; then
|
||||
_modus="unbekannt (Datei nicht lesbar)"
|
||||
_phase="unbekannt (Datei nicht lesbar)"
|
||||
_erkannt="unbekannt (Datei nicht lesbar)"
|
||||
else
|
||||
_modus="-"
|
||||
_phase="-"
|
||||
_erkannt="-"
|
||||
fi
|
||||
# Die Phase kommt bewusst aus control.json, nicht aus status.json: "arm" und
|
||||
# "listen" starten den Dienst absichtlich nicht neu, also schreibt server.py's
|
||||
# write_state() status.json in der Zwischenzeit nicht neu (Fixrunde 1, I1) -
|
||||
# wer die Phase dort ablesen wuerde, saehe nach einer erfolgreichen Freigabe
|
||||
# weiterhin die Phase von vor dem letzten Dienststart und koennte "arm" fuer
|
||||
# gescheitert halten oder faelschlich glauben, nichts sei mehr scharf.
|
||||
if [ -r "$_control_file" ]; then
|
||||
_phase=$(python3 - "$_control_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
d = {}
|
||||
phase = d.get("phase", "-")
|
||||
target = d.get("armed_target")
|
||||
if phase == "armed" and target:
|
||||
print("{} (Ziel: {})".format(phase, target))
|
||||
else:
|
||||
print(phase)
|
||||
PY
|
||||
)
|
||||
elif [ -e "$_control_file" ]; then
|
||||
_phase="unbekannt (Datei nicht lesbar)"
|
||||
else
|
||||
_phase="-"
|
||||
fi
|
||||
printf 'Modus : %s\n' "$_modus"
|
||||
printf 'Phase : %s\n' "$_phase"
|
||||
printf 'Erkannt: %s\n' "$_erkannt"
|
||||
|
||||
Reference in New Issue
Block a user