Files
ninlight-w600/tools/station/setup_pi.sh
T
Michael Sauer 74198f6d31 fix(station): stat-Reihenfolge, Phase nur im Migrationsmodus, eingerueckte Konfig-Werte, kein /tmp-Zugriff im Test
Fixrunde 2 zu Task 4:

- N1 (wichtig): file_mode() in test_cli.sh/test_setup_pi.sh probierte
  "stat -f '%Lp'" vor "stat -c '%a'" - auf GNU-coreutils (dem Pi) deutet
  "-f" dort "Dateisystemstatus" und liefert Muell statt der Rechte-Zahl,
  faellt aber trotzdem in den "||"-Zweig durch, der zusaetzlich "644"
  anhaengt. Reihenfolge umgedreht und mit einem per gstat erzwungenen
  GNU-Testlauf zusaetzlich abgesichert.
- N2 (wichtig): status zeigte nach einem Wechsel zurueck in den
  Rettungsmodus weiterhin die letzte Migrationsphase aus einer liegen
  gebliebenen control.json (moeglich seit RuntimeDirectoryPreserve=
  restart aus Fixrunde 1). Phase wird jetzt nur noch angezeigt, wenn
  Modus tatsaechlich "migration" ist.
- N3 (klein): die Schluesselerkennung in setup_pi.sh war rein
  zeilenverankert ("^KEY=") und haette einen eingerueckten Bestandswert
  als fehlend gewertet - beim naechsten Sourcen der Konfiguration haette
  die zusaetzlich angehaengte Vorlagenzeile das eigene Passwort
  ueberschrieben. Muster auf fuehrenden Leerraum/export gehaertet.
- N4 (klein): test_setup_pi.sh liess setup_pi.sh ueber den scp-Stub echte
  Dateien im /tmp des Entwicklungsrechners anlegen und wieder loeschen.
  Neue Variable NINLIGHT_STAGE_DIR biegt das auf eine Sandbox um.

Alle vier Punkte mit Regressionstests, N2 und N3 zusaetzlich per
Mutationsprobe (Fix zurueckgenommen, Test wird rot) verifiziert.
2026-09-13 19:28:42 +02:00

171 lines
7.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches
# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem
# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration"
# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet).
#
# Aufruf: tools/station/setup_pi.sh <pi-host>
set -euo pipefail
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
HERE=$(cd "$(dirname "$0")" && pwd)
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen
# 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'
set -e
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
echo " ok"
REMOTE
echo "== Konfiguration =="
"$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=${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
# Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das
# wuerde das Ausrollen eines neuen ninlight-station auf einen bereits
# eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel
# ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis
# jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4).
ADDED=""
for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
# 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
ADDED="$ADDED $KEY"
fi
fi
done
if [ -n "$ADDED" ]; then
echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)"
else
echo " $CONF existiert bereits, alle Schluessel vorhanden"
fi
else
sudo install -m 0600 "$TEMPLATE" "$CONF"
echo " $CONF aus der Vorlage angelegt"
fi
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:$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 "$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
echo "== NetworkManager-Profil =="
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied"
# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der
# Datei aufzuweichen.
# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen-
# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per
# "ps"/"/proc/<pid>/cmdline" sichtbar. Deshalb wird das Verbindungsprofil
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
# Prozessliste.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections}
eval "$(sudo cat "$CONF")"
CONN_FILE="$NM_DIR/${AP_CONNECTION}.nmconnection"
if sudo test -f "$CONN_FILE"; then
UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1)
ACTION="aktualisiert"
else
# /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback
# auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt
# ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst.
UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]')
ACTION="angelegt"
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
{
printf '%s\n' '[connection]'
printf 'id=%s\n' "$AP_CONNECTION"
printf 'uuid=%s\n' "$UUID"
printf '%s\n' 'type=wifi'
printf '%s\n' 'interface-name=wlan0'
printf '%s\n' 'autoconnect=false'
printf '\n%s\n' '[wifi]'
printf '%s\n' 'mode=ap'
printf 'ssid=%s\n' "$SSID"
printf '%s\n' 'band=bg'
printf '%s\n' 'channel=6'
printf '\n%s\n' '[wifi-security]'
printf '%s\n' 'key-mgmt=wpa-psk'
printf '%s\n' 'proto=rsn'
printf '%s\n' 'pairwise=ccmp;'
printf '%s\n' 'group=ccmp;'
printf 'psk=%s\n' "$PSK"
printf '\n%s\n' '[ipv4]'
printf '%s\n' 'method=shared'
printf '%s\n' 'address1=192.168.8.1/24'
printf '\n%s\n' '[ipv6]'
printf '%s\n' 'method=disabled'
} >"$TMP"
sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE"
rm -f "$TMP"
sudo nmcli connection load "$CONN_FILE" >/dev/null
echo " Profil $AP_CONNECTION $ACTION"
REMOTE
cat <<'EOF'
Fertig. Die Station ist absichtlich noch AUS.
Naechste Schritte:
tools/station/deploy.sh <pi-host> Images ausliefern
ssh <pi-host> ninlight-station on Station einschalten
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
EOF