#!/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. nmcli -t -f NAME con show | grep -qx "$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 [ -n "$_target" ] || return 0 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 install -m 0644 /dev/stdin "$_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. # # $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 install -m 0644 /dev/stdin "$_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. start_and_verify() { # 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 "$1" >/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 "$1" >/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 } 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" 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" 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 -qx "$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" # "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. 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(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') 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 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