feat: Aufsetz- und Ausliefer-Skripte der Recovery-Station

setup_pi.sh ist idempotent und laesst eine vorhandene Konfiguration in
Ruhe; deploy.sh prueft jedes Image mit nl_mkimage --verify, bevor es
etwas uebertraegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Sauer
2026-09-12 12:36:03 +02:00
co-authored by Claude Sonnet 5
parent df8e796f1e
commit fda732d6c4
3 changed files with 163 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# ninlight-Recovery-Station
Ein Raspberry Pi spannt den Zugangspunkt `ninlight-recovery` auf und liefert unter
`http://192.168.8.1/ninlight_<target>_ota.img` Firmware-Images aus.
Jede ninlight-Lampe sucht beim Booten bis zu drei Sekunden nach dieser Station. Findet sie
sie, lädt sie das Image für ihren Gerätetyp und flasht es — ohne dass die Lampe im WLAN
erreichbar sein muss. Das ist der Rettungsweg für Lampen, an die man sonst nur noch mit dem
Lötkolben käme.
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
## Bedienung
tools/station/setup_pi.sh <pi-host> einmalig einrichten
tools/station/deploy.sh <pi-host> [target] Images ausliefern
ssh <pi-host> ninlight-station on einschalten
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
ssh <pi-host> ninlight-station off ausschalten
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
## Vertrauensgrenze
Der Trailer eines Images prüft Gerätetyp und SHA256. Er ist **keine** kryptografische
Signatur, und der Download läuft über `http://` ohne TLS. Die Station schützt allein ein
WPA2-Passwort, dessen Default im Repo steht. Wer in Funkreichweite ist und es kennt, kann
jeder Lampe beim nächsten Boot beliebige Firmware unterschieben.
Das ist gewollt: Die Signaturprüfung der alten Firmware auszuhebeln ist der Zweck dieses
Projekts. Daraus folgen zwei Regeln.
**Setz ein eigenes Passwort.** In `config.local.cmake` und in
`/etc/ninlight-station.conf` — an beiden Stellen denselben Wert.
**Lass die Station nur an, solange du sie brauchst.** Nicht nur wegen des Fremdzugriffs:
Die Firmware löscht den Power-Cycle-Zähler, bevor sie einen Recovery-Versuch startet.
Solange eine Station in Reichweite ist, funktioniert der Werksreset per fünf Stromzyklen
bei **keiner** Lampe in Reichweite mehr.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Liefert Firmware-Images und den Stationscode an den Pi aus.
#
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
set -euo pipefail
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
shift || true
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
declare -a FILES=()
if [ $# -gt 0 ]; then
for t in "$@"; do
f="$ROOT/build/$t/ninlight_${t}_ota.img"
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
FILES+=("$f")
done
else
while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
fi
echo "== Images pruefen =="
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
# das faellt sonst erst am Geraet auf.
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
cp "$f" "$STAGE/$base"
done
echo "== uebertragen =="
rsync -a --delete "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
ssh "$HOST" 'set -e
sudo install -d -m 0755 /opt/ninlight-station/images
sudo rsync -a --delete /tmp/ninlight-images/ /opt/ninlight-station/images/
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
sudo systemctl daemon-reload
systemctl is-active --quiet ninlight-station && { sudo systemctl restart ninlight-station; echo " Dienst neu gestartet"; } || echo " Dienst laeuft nicht (Station ist aus)"'
echo "fertig."
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent:
# mehrfaches Ausfuehren aendert nichts und schadet nicht.
#
# 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)
echo "== Voraussetzungen auf $HOST pruefen =="
ssh "$HOST" '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"'
echo "== Konfiguration =="
scp -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
ssh "$HOST" 'set -e
if [ -f /etc/ninlight-station.conf ]; then
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
else
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf
echo " /etc/ninlight-station.conf aus der Vorlage angelegt"
fi
rm -f /tmp/ninlight-station.conf.example'
echo "== Server, CLI und Unit installieren =="
scp -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
ssh "$HOST" 'set -e
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service
rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
sudo systemctl daemon-reload
echo " installiert"'
echo "== NetworkManager-Profil =="
ssh "$HOST" 'set -e
. /etc/ninlight-station.conf
if nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION"; then
sudo nmcli con mod "$AP_CONNECTION" \
802-11-wireless.ssid "$SSID" wifi-sec.psk "$PSK" \
ipv4.addresses 192.168.8.1/24 ipv4.method shared \
connection.autoconnect no
echo " Profil $AP_CONNECTION aktualisiert"
else
sudo nmcli con add type wifi ifname wlan0 con-name "$AP_CONNECTION" ssid "$SSID" \
802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.channel 6 \
wifi-sec.key-mgmt wpa-psk wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp \
wifi-sec.psk "$PSK" \
ipv4.method shared ipv4.addresses 192.168.8.1/24 ipv6.method disabled \
connection.autoconnect no >/dev/null
echo " Profil $AP_CONNECTION angelegt"
fi'
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