diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 53c89c5..3e6628d 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -373,7 +373,22 @@ PY # 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 + # + # ABER: Phase ist ein Konzept, das nur im Migrationsmodus existiert - control.json + # kann von einem frueheren "arm"/"migrate on" liegen bleiben (RuntimeDirectoryPreserve= + # restart aus Fixrunde 1, C1 laesst es Neustarts jetzt ausdruecklich ueberleben), + # auch nachdem laengst wieder in den Rettungsmodus zurueckgeschaltet wurde - + # "recovery on" raeumt die Datei bewusst nicht auf, weil der Dienst sie im + # Rettungsmodus ohnehin ignoriert (server.py liest sie nur im Migrationsmodus). + # Ohne diese Bedingung wuerde status nach "migrate on --listen; arm X; on" ein + # irrefuehrendes "Phase: armed" im Rettungsmodus zeigen (Fixrunde 2, N2) - genau + # in der sicherheitsrelevanten Dimension "scharf/nicht scharf", die I1 eigentlich + # klarstellen sollte. Deshalb wird control.json nur gelesen, wenn Modus wirklich + # "migration" ist; sonst ist die Phase per Definition "-", unabhaengig davon, was + # noch in control.json steht. + if [ "$_modus" != "migration" ]; then + _phase="-" + elif [ -r "$_control_file" ]; then _phase=$(python3 - "$_control_file" <<'PY' import json import sys diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh index 02be11e..e9903dd 100755 --- a/tools/station/setup_pi.sh +++ b/tools/station/setup_pi.sh @@ -13,6 +13,13 @@ HERE=$(cd "$(dirname "$0")" && pwd) # Stubs statt einen echten Pi laufen kann. SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh} SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp} +# Wo die per scp uebertragenen Dateien auf dem Zielsystem zwischenlanden, bevor +# sie an ihren endgueltigen Platz installiert werden. Ueberschreibbar wie die +# anderen NINLIGHT_*-Pfade: auf dem Pi bleibt es bei /tmp, ein Test kann hier in +# eine Sandbox statt in das echte /tmp des Entwicklungsrechners umleiten +# (Fixrunde 2, N4) - sowohl fuer die lokale scp-Zielangabe unten als auch fuer +# die REMOTE-Bloecke, die dieselbe Variable (mit demselben Default) referenzieren. +STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp} echo "== Voraussetzungen auf $HOST pruefen ==" "$SSH_BIN" "$HOST" bash -s <<'REMOTE' @@ -24,14 +31,14 @@ echo " ok" REMOTE echo "== Konfiguration ==" -"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example" +"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:$STAGE_DIR/ninlight-station.conf.example" # CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es # beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte # Systempfade laufen. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} -TEMPLATE=/tmp/ninlight-station.conf.example +TEMPLATE=${NINLIGHT_STAGE_DIR:-/tmp}/ninlight-station.conf.example if [ -f "$CONF" ]; then # Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die # eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und @@ -43,7 +50,14 @@ if [ -f "$CONF" ]; then ADDED="" for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do - if ! sudo grep -Eq "^${KEY}=" "$CONF"; then + # Nicht nur "^KEY=": eine vorhandene Zuweisung mit fuehrendem Leerraum oder + # einem "export" davor (beides gueltiges ". datei"-Shell) wuerde sonst als + # fehlend gelten, die Vorlagenzeile wird angehaengt, und da das CLI die + # Datei per ". $CONF" sourct, gewinnt die LETZTE Zuweisung - ein eigenes + # Passwort wuerde so still durch den oeffentlichen Vorlagenwert ersetzt + # (Fixrunde 2, N3: genau das "vorhandene Werte nicht ueberschreiben" aus I4, + # nur eine Formatierungsvariante weiter). + if ! sudo grep -Eq "^[[:space:]]*(export[[:space:]]+)?${KEY}=" "$CONF"; then LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true) if [ -n "$LINE" ]; then printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null @@ -64,21 +78,22 @@ rm -f "$TEMPLATE" REMOTE echo "== Server, CLI und Unit installieren ==" -"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" +"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:$STAGE_DIR/" # Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die # Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen # lassen. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e +STAGE_DIR=${NINLIGHT_STAGE_DIR:-/tmp} ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" -sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" -sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" -sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH" -sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" -rm -f /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service +sudo install -m 0755 "$STAGE_DIR/server.py" "$ROOT_DIR/server.py" +sudo install -m 0644 "$STAGE_DIR/products.py" "$ROOT_DIR/products.py" +sudo install -m 0755 "$STAGE_DIR/ninlight-station" "$BIN_PATH" +sudo install -m 0644 "$STAGE_DIR/ninlight-station.service" "$UNIT_PATH" +rm -f "$STAGE_DIR/server.py" "$STAGE_DIR/products.py" "$STAGE_DIR/ninlight-station" "$STAGE_DIR/ninlight-station.service" sudo systemctl daemon-reload echo " installiert" REMOTE diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index dde339e..873f764 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -30,10 +30,37 @@ fail() { echo "FEHLER - $1" >&2; FAILED=1; } file_mode() { # $1 = Pfad. BSD-stat (macOS) und GNU-stat (Linux/Pi) haben unterschiedliche - # Flags - hier reicht "irgendeine funktioniert". - stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" 2>/dev/null + # Flags - hier reicht "irgendeine funktioniert". Reihenfolge ist wichtig + # (Fixrunde 2, N1): "stat -f '%Lp' datei" auf GNU-coreutils deutet "-f" als + # "Dateisystemstatus" und '%Lp' als ZWEITES Dateiargument - der Aufruf schreibt + # mehrere Zeilen Dateisystem-Infos auf stdout und scheitert (kein passendes + # zweites File), aber der "||"-Zweig haengt DANACH trotzdem noch "644" an, und + # der Vergleich "= 644" schlaegt fehl, obwohl die Rechte stimmen - auf dem Pi + # (echtes GNU-coreutils) also immer rot. BSD-"stat -c" dagegen bricht sofort + # mit einer Fehlermeldung auf stderr ab, ganz ohne Ausgabe auf stdout - der + # GNU-Aufruf muss deshalb ZUERST stehen. + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null } +# Erzwingt den GNU-Zweig von file_mode() (der auf dem Pi tatsaechlich laeuft), +# indem "stat" im PATH auf GNU-coreutils' "gstat" umgebogen wird, falls +# vorhanden (macOS mit "brew install coreutils"). Ohne diese Probe wuerde ein +# falsch herum sortiertes file_mode() auf dieser Entwicklungsmaschine (BSD-stat +# zuerst probiert und erfolgreich) unbemerkt bleiben, obwohl es auf dem Pi +# (nur GNU-stat) rot waere - genau der Fehler aus Fixrunde 2, N1. +if command -v gstat >/dev/null 2>&1; then + GSTAT_CHECK_DIR="$TMPROOT/gstat-check" + mkdir -p "$GSTAT_CHECK_DIR/bin" + ln -sf "$(command -v gstat)" "$GSTAT_CHECK_DIR/bin/stat" + GSTAT_CHECK_FILE="$TMPROOT/gstat-check/probe" + : >"$GSTAT_CHECK_FILE" + chmod 0644 "$GSTAT_CHECK_FILE" + GSTAT_MODE=$(PATH="$GSTAT_CHECK_DIR/bin:$PATH" file_mode "$GSTAT_CHECK_FILE") + [ "$GSTAT_MODE" = "644" ] && pass "file_mode() liefert unter erzwungenem GNU-stat korrekt '644'" || fail "file_mode() liefert unter GNU-stat '$GSTAT_MODE' statt '644' - siehe Fixrunde 2, N1" +else + echo "Hinweis: gstat nicht gefunden - GNU-Zweig von file_mode() wird nur auf dem Pi selbst geprueft" >&2 +fi + # --- Stub-Kommandos fuer nmcli/systemctl/sudo im PATH --- # $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis, $4 = RUN_DIR, # $5 = Pfad zur systemd-Unit, deren RuntimeDirectoryPreserve-Zeile ueber das @@ -489,6 +516,39 @@ echo "$ERR" | grep -q "unerwartet" && pass "Fehlermeldung nennt den Mismatch" || ACTIVE_AFTER=$(cat "$SBX/nmstate/active") [ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER" +echo +echo "=== Zusatzfall N2 (Fixrunde 2): 'status' zeigt keine veraltete Migrationsphase mehr, nachdem in den Rettungsmodus zurueckgeschaltet wurde ===" +SBX="$TMPROOT/tN2" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on --listen >/dev/null 2>&1 +run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 # control.json bleibt mit phase=armed liegen +run_cli "$SBX" on >/dev/null 2>&1 # zurueck in den Rettungsmodus - control.json wird bewusst NICHT aufgeraeumt +[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "Vorbedingung: control.json ist tatsaechlich noch mit phase=armed liegen geblieben" || fail "Vorbedingung nicht erfuellt (control.json wurde schon vorher veraendert)" +# status.json so, wie sie der Server nach dem Neustart im Rettungsmodus schreiben +# wuerde (mode=recovery, keine last_seen/phase-Felder - siehe server.py write_state()). +cat >"$SBX/run/status.json" <<'JSON' +{"mode": "recovery"} +JSON +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -qE "Modus[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT" +echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus" +echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" + +echo +echo "=== Zusatzfall N2b (Fixrunde 2): dieselbe veraltete Freigabe, aber ohne aktualisierte status.json (Modus '-') - genau das woertliche Repro aus dem Review ===" +SBX="$TMPROOT/tN2b" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on --listen >/dev/null 2>&1 +run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 +run_cli "$SBX" on >/dev/null 2>&1 +rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -qE "Modus[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT" +echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus" +echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_setup_pi.sh b/tools/station/tests/test_setup_pi.sh index 663c0a0..1c18547 100755 --- a/tools/station/tests/test_setup_pi.sh +++ b/tools/station/tests/test_setup_pi.sh @@ -26,6 +26,18 @@ FAILED=0 pass() { echo "ok - $1"; } fail() { echo "FEHLER - $1" >&2; FAILED=1; } +file_mode() { + # $1 = Pfad. GNU-stat (Linux/Pi) muss zuerst probiert werden, nicht BSD-stat + # (macOS): "stat -f '%Lp' datei" deutet auf GNU-coreutils "-f" als + # "Dateisystemstatus" und '%Lp' als zweites Dateiargument, scheitert dabei + # zwar (rc != 0), schreibt aber vorher schon mehrere Zeilen Dateisystem-Infos + # auf stdout - der "||"-Zweig haengt danach zusaetzlich "600"/"644" an, und + # der Aufrufer sieht Muell statt der reinen Zahl (Fixrunde 2, N1). BSD-"stat + # -c" bricht dagegen sofort mit einem Fehler auf stderr ab, ganz ohne + # Ausgabe auf stdout. + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null +} + setup_stubbin() { local bindir=$1 log=$2 mkdir -p "$bindir" @@ -100,16 +112,23 @@ setup_state() { local state=$1 rm -rf "$state" mkdir -p "$state/etc" "$state/opt" "$state/usr/local/bin" "$state/etc/systemd/system" \ - "$state/etc/NetworkManager/system-connections" + "$state/etc/NetworkManager/system-connections" "$state/tmp" } # Fuehrt setup_pi.sh mit gestubbtem PATH und auf die Sandbox umgebogenen # Systempfaden aus. $1 = state-Verzeichnis, $2 = stub-bin-Verzeichnis. +# +# NINLIGHT_STAGE_DIR biegt auch die per scp "uebertragenen" Zwischendateien auf +# die Sandbox um (Fixrunde 2, N4): ohne das wuerden scp/ssh-Stub sie unter dem +# echten /tmp des Entwicklungsrechners ablegen und am Ende per "rm -f" wieder +# loeschen - mit denselben, leicht kollidierenden Namen wie +# "ninlight-station.conf.example" oder "server.py". run_setup() { local state=$1 stubbin=$2 PATH="$stubbin:$PATH" \ NINLIGHT_SSH_BIN="$stubbin/ssh" \ NINLIGHT_SCP_BIN="$stubbin/scp" \ + NINLIGHT_STAGE_DIR="$state/tmp" \ NINLIGHT_STATION_CONF="$state/etc/ninlight-station.conf" \ NINLIGHT_STATION_ROOT="$state/opt/ninlight-station" \ NINLIGHT_STATION_BIN="$state/usr/local/bin/ninlight-station" \ @@ -172,7 +191,7 @@ for KEY in MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do fail "Schluessel $KEY fehlt weiterhin in der Konfiguration" fi done -MODE=$(stat -f '%Lp' "$CONF_OUT" 2>/dev/null || stat -c '%a' "$CONF_OUT" 2>/dev/null) +MODE=$(file_mode "$CONF_OUT") [ "$MODE" = "600" ] && pass "Konfigurationsdatei bleibt 0600" || fail "Konfigurationsdatei hat falsche Rechte: $MODE" echo @@ -187,6 +206,47 @@ echo "$OUT" | grep -q "alle Schluessel vorhanden" && pass "meldet 'alle Schluess DUP=$(grep -c '^EPD_REVISION=' "$CONF_OUT") [ "$DUP" -eq 1 ] && pass "EPD_REVISION steht nur einmal in der Datei (kein doppeltes Anhaengen)" || fail "EPD_REVISION steht $DUP mal in der Datei" +echo +echo "=== Szenario 4 (Fixrunde 2, N3): ein eingerueckter Bestandswert gilt nicht als fehlend ===" +STATE="$TMPROOT/s4-state"; STUBBIN="$TMPROOT/bin4"; LOG="$TMPROOT/s4.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +# Fuehrendes Leerzeichen vor PSK= - gueltiges ". datei"-Shell, aber eine rein +# zeilenverankerte Erkennung ("^PSK=") wuerde das als "fehlt" werten und die +# Vorlagenzeile anhaengen. Da das CLI die Datei per ". $CONF" sourct, gewinnt +# dann die LETZTE Zuweisung - das eigene Passwort waere durch den oeffentlichen +# Default aus der Vorlage ersetzt, ohne dass die Datei das irgendwo anzeigt. +cat >"$STATE/etc/ninlight-station.conf" <<'CONF' +SSID='eigene-ssid' + PSK='eigenes-eingeruecktes-passwort' +IMAGE_DIR='/opt/ninlight-station/images' +HOME_CONNECTION='preconfigured' +AP_CONNECTION='ninlight-recovery' +CONF +chmod 0600 "$STATE/etc/ninlight-station.conf" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +CONF_OUT4="$STATE/etc/ninlight-station.conf" +PSK_LINES=$(grep -cE '^[[:space:]]*PSK=' "$CONF_OUT4") +[ "$PSK_LINES" -eq 1 ] && pass "PSK steht weiterhin nur einmal in der Datei (nicht dupliziert)" || fail "PSK steht $PSK_LINES mal in der Datei: $(cat "$CONF_OUT4")" +if grep -Fq "eigenes-eingeruecktes-passwort" "$CONF_OUT4"; then + pass "der eingerueckte Bestandswert von PSK blieb erhalten" +else + fail "der eingerueckte Bestandswert von PSK wurde ersetzt: $(cat "$CONF_OUT4")" +fi +if grep -Fqx "PSK='ninlight-recovery'" "$CONF_OUT4"; then + fail "der oeffentliche Vorlagen-Default fuer PSK wurde zusaetzlich angehaengt - eigenes Passwort waere beim Sourcen ueberschrieben worden" +else + pass "der oeffentliche Vorlagen-Default fuer PSK wurde NICHT angehaengt" +fi +for KEY in MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do + grep -Eq "^${KEY}=" "$CONF_OUT4" && pass "fehlender Schluessel $KEY wurde trotzdem ergaenzt" || fail "Schluessel $KEY fehlt weiterhin" +done + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden."