#!/bin/sh # Schaltet die ninlight-Station (Rettungs- oder Migrationsmodus) an und aus. # # Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder # in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige # Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert # bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor # jedem Recovery-Versuch). Der Migrationsmodus (Zugangspunkt "qconnex-firmware", # oeffentlich bekanntes Passwort aus der alten Klyqa-Firmware) verschiebt diese # Vertrauensgrenze noch weiter nach aussen - siehe README.md. # # Die beiden Zugangspunkte schliessen sich gegenseitig aus: ap_switch() faehrt # immer erst jedes andere Stationsprofil herunter, bevor es das gewuenschte # hochfaehrt, und prueft danach per nmcli nach, dass wirklich nur eines aktiv # ist. set -eu # Ueberschreibbar fuer Tests (dasselbe Prinzip wie NINLIGHT_STATION_ROOT/_BIN/_UNIT # in deploy.sh): so kann tests/test_cli.sh gegen ein Sandbox-Verzeichnis statt # gegen echte Systempfade laufen. CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station} STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/ninlight-station} if [ -r "$CONF" ]; then # shellcheck disable=SC1090 . "$CONF" elif [ -e "$CONF" ]; then # $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar # ist. Der dokumentierte Aufruf "ssh ninlight-station on" laeuft # aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre # Rechte aufzuweichen. eval "$(sudo cat "$CONF")" else echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2 exit 1 fi for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do eval "test -n \"\${$_key-}\"" || { echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2 exit 1 } done unset _key need_profile() { # $1 = NetworkManager-Profilname, der existieren muss. # -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 } } images() { find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort } migrate_images() { find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort } # Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist) # das gewuenschte hoch, und prueft danach per nmcli nach, dass wirklich genau # dieses Profil auf wlan0 aktiv ist. Weicht es ab, geht alles wieder herunter und # die Funktion bricht mit Fehler ab - das ist die Absicherung fuer Fall 15 der # Testliste: zwei gleichzeitig aktive Stationsprofile auf demselben Interface # duerfen nie vorkommen. # # $1 leer bedeutet "nur alles herunterfahren" (Grundlage fuer "off"). ap_switch() { _target=${1:-} for _p in "$AP_CONNECTION" "$MIGRATE_AP_CONNECTION"; do if [ -n "$_target" ] && [ "$_p" = "$_target" ]; then continue fi sudo nmcli con down "$_p" >/dev/null 2>&1 || true done 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 sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2 return 1 fi } # Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann # umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file. write_mode() { sudo mkdir -p "$RUN_DIR" _tmp="$RUN_DIR/.mode-$$" 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 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() { sudo mkdir -p "$RUN_DIR" _tmp="$RUN_DIR/.control-$$.json" if [ -n "${2:-}" ]; then _json=$(printf '{"phase": "%s", "armed_target": "%s"}' "$1" "$2") else _json=$(printf '{"phase": "%s", "armed_target": null}' "$1") fi 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, 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"). sudo systemctl reset-failed ninlight-station 2>/dev/null || true if ! sudo systemctl restart ninlight-station; then # Der Neustart selbst ist gescheitert (z.B. start-limit-hit) - unter # 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 "$_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 # "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess # gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich # nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen. sleep 1 if ! systemctl is-active --quiet ninlight-station; then # Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund # 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 "$_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() { need_profile "$AP_CONNECTION" n=$(images | wc -l | tr -d ' ') [ "$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" "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 # die eine Haelfte der Betriebsregeln aus README.md/ยง9, die andere (Station wieder # ausschalten) steht unten. if [ "$PSK" = "ninlight-recovery" ]; then echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2 fi echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." } migrate_on() { # $1 = "--listen" oder leer. _phase=auto if [ "${1:-}" = "--listen" ]; then _phase=listen elif [ -n "${1:-}" ]; then usage exit 2 fi need_profile "$MIGRATE_AP_CONNECTION" n=$(migrate_images | wc -l | tr -d ' ') [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR/migrate enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2 # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist. ap_switch "$MIGRATE_AP_CONNECTION" write_mode "migration" write_control "$_phase" "" || { echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 exit 1 } 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." } full_off() { sudo systemctl stop ninlight-station 2>/dev/null || true ap_switch "" 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 echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2 fi else echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2 fi } listen_cmd() { write_control "listen" "" || { echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 exit 1 } echo "Phase: listen" } arm_cmd() { _target=${1:-} if [ -z "$_target" ]; then _status_file="$RUN_DIR/status.json" if ! _target=$(python3 - "$_status_file" <<'PY' import json import sys try: with open(sys.argv[1]) as f: d = json.load(f) except (OSError, ValueError): sys.exit(1) t = (d.get("last_seen") or {}).get("target") if not t: sys.exit(1) print(t) PY ); then echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm " >&2 exit 1 fi fi if ! python3 - "$STATION_LIB" "$_target" <<'PY' import sys sys.path.insert(0, sys.argv[1]) import products target = sys.argv[2] sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1) PY then echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2 exit 1 fi write_control "armed" "$_target" || { echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 exit 1 } echo "Freigegeben: $_target" } status_cmd() { # "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und # liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist - # ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide # 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/Erkannt unten und fuer # Phase/control.json weiter unten. if [ -r "$_status_file" ]; then _info=$(python3 - "$_status_file" <<'PY' import json import sys try: with open(sys.argv[1]) as f: d = json.load(f) except (OSError, ValueError): d = {} last_seen = d.get("last_seen") if last_seen: seen = "{} ({})".format( last_seen.get("product_name") or last_seen.get("target", "?"), last_seen.get("lamp_version", "?"), ) else: seen = "-" print(d.get("mode", "-")) print(seen) PY ) _modus=$(printf '%s\n' "$_info" | sed -n '1p') _erkannt=$(printf '%s\n' "$_info" | sed -n '2p') elif [ -e "$_status_file" ]; then _modus="unbekannt (Datei nicht lesbar)" _erkannt="unbekannt (Datei nicht lesbar)" else _modus="-" _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" printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)" printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)" printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')" echo "Images :" images | while read -r f; do printf ' %-34s %s\n' "$(basename "$f")" \ "$(python3 - "$f" <<'PY' import pathlib, sys t = pathlib.Path(sys.argv[1]).read_bytes()[-512:] print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" else "kein Trailer") PY )" done if [ -r "$_status_file" ]; then echo "Zuletzt ausgeliefert:" sed 's/^/ /' "$_status_file" elif [ -e "$_status_file" ]; then echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2 fi # "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie # ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier # der des fehlgeschlagenen "-r"-Tests, weil das die letzte Anweisung im Zweig ist. exit 0 } usage() { echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm []|listen|off|status" >&2 } case "${1:-}" in on) recovery_on ;; recovery) case "${2:-}" in on) recovery_on ;; off) full_off ;; *) usage; exit 2 ;; esac ;; migrate) case "${2:-}" in on) migrate_on "${3:-}" ;; off) full_off ;; *) usage; exit 2 ;; esac ;; arm) arm_cmd "${2:-}" ;; listen) listen_cmd ;; off) full_off ;; status) status_cmd ;; *) usage exit 2 ;; esac