diff --git a/tools/station/README.md b/tools/station/README.md new file mode 100644 index 0000000..133cea1 --- /dev/null +++ b/tools/station/README.md @@ -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__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 einmalig einrichten + tools/station/deploy.sh [target] Images ausliefern + ssh ninlight-station on einschalten + ssh ninlight-station status Zustand und letzte Auslieferungen + ssh 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. diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh new file mode 100755 index 0000000..fdbc7cd --- /dev/null +++ b/tools/station/deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Liefert Firmware-Images und den Stationscode an den Pi aus. +# +# Aufruf: tools/station/deploy.sh [target ...] +# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen. +set -euo pipefail +HOST=${1:?Aufruf: tools/station/deploy.sh [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." diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh new file mode 100755 index 0000000..c659b2f --- /dev/null +++ b/tools/station/setup_pi.sh @@ -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 +set -euo pipefail +HOST=${1:?Aufruf: tools/station/setup_pi.sh } +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 Images ausliefern + ssh ninlight-station on Station einschalten + ssh ninlight-station off wieder ausschalten (nicht vergessen) +EOF