#!/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" # /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im # Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt # einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten. SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh} # Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh, # dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst # beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die # RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt # _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten. NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} declare -a FILES=() declare -a RESCUE_NOCREDS_FILES=() if [ $# -gt 0 ]; then # Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle # anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC # unten). FULL_SYNC=0 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") # build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf # nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht # gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU # dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete, # wenn diese Liste nicht jedes Ziel aus FILES abdeckt). nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img" if [ -f "$nf" ]; then RESCUE_NOCREDS_FILES+=("$nf") else echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2 fi done else FULL_SYNC=1 # Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B. # build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN- # Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base, # entscheidet allein die Sortierreihenfolge, welches gewinnt - und die # Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht. # Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch, # kein Zufall. declare -A SEEN_AT=() while IFS= read -r f; do base=$(basename "$f") if [ -n "${SEEN_AT[$base]:-}" ]; then echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2 exit 1 fi SEEN_AT[$base]=$f 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; } # Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy # oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt # RESCUE_NOCREDS_FILES einfach leer, kein Abbruch. if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then declare -A SEEN_AT_NOCREDS=() while IFS= read -r f; do base=$(basename "$f") if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2 exit 1 fi SEEN_AT_NOCREDS[$base]=$f RESCUE_NOCREDS_FILES+=("$f") done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort) fi fi echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) ==" STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT printf '%-26s %-10s %s\n' GERAET VERSION BYTES declare -A NINL_VERSION=() 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" NINL_VERSION[$target]="$2" # Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE - # server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter # wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort). mkdir -p "$STAGE/rescue-wlan" cp "$f" "$STAGE/rescue-wlan/$base" done # Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten # Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den # gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine # Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie, # ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer # jedes Ziel gelaufen" (siehe oben). if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) ==" for f in "${RESCUE_NOCREDS_FILES[@]}"; do base=$(basename "$f") target=${base#ninlight_}; target=${target%_ota.img} out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || { echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; } # shellcheck disable=SC2086 set -- $out printf '%-26s %-10s %s\n' "$1" "$2" "$3" mkdir -p "$STAGE/rescue-nocreds" cp "$f" "$STAGE/rescue-nocreds/$base" done else echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2 fi # "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz # 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf # dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die # Migrationsimages weiter unten, nur ohne Signierschritt dazwischen). RESCUE_NOCREDS_COMPLETE=1 if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then RESCUE_NOCREDS_COMPLETE=0 else declare -A RESCUE_NOCREDS_TARGETS=() for f in "${RESCUE_NOCREDS_FILES[@]}"; do base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img} RESCUE_NOCREDS_TARGETS[$t]=1 done for f in "${FILES[@]}"; do base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img} [ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0 done fi echo "== Migrationsimages signieren ==" # Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur # gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner). # Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige # Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem # ausgeliefert (Exit-Code bleibt 0). # ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein # unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages # sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1). SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign} KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem" KEY_DEV="$SIGN_DIR/fw_sign.pem" NL_SIGN="$ROOT/tools/mkimage/nl_sign.py" # MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das # lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten # fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das # Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden, # die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1). MIGRATE_COMPLETE=0 # Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier # zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur # einer, darf --delete auf dem Pi nicht laufen. NOCREDS_COMPLETE=0 # NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht). # Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json. # $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut # $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds) # $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen. # Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen), # sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres # Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen. sign_set() { local src_root=$1 stage_sub=$2 strictness=$3 local complete=1 produced=0 local f base target gz for f in "${FILES[@]}"; do base=$(basename "$f") target=${base#ninlight_}; target=${target%_ota.img} # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide # belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein # signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb # annimmt. gz="$src_root/$target/ninlight_${target}_gz.img" if [ ! -f "$gz" ]; then if [ "$strictness" = "strict" ]; then echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1 fi echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2 complete=0 continue fi mkdir -p "$STAGE/$stage_sub" if [ "$HAVE_PROD" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \ || { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; } produced=1 fi if [ "$HAVE_DEV" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \ || { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; } produced=1 fi done [ "$produced" -eq 1 ] || return 1 local VERSION_ARGS=() local t for t in "${!NINL_VERSION[@]}"; do VERSION_ARGS+=("$t=${NINL_VERSION[$t]}") done # manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu # berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit # ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec # (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich # liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur) # manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald # der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue # Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das # manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur # gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen. python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY' import hashlib import json import os import sys from datetime import datetime, timezone migrate_dir = sys.argv[1] version_map = dict(a.split("=", 1) for a in sys.argv[2:]) entries = [] for name in sorted(os.listdir(migrate_dir)): if name.endswith("_prod.img"): target, variant = name[: -len("_prod.img")], "prod" elif name.endswith("_dev.img"): target, variant = name[: -len("_dev.img")], "dev" else: continue with open(os.path.join(migrate_dir, name), "rb") as fh: data = fh.read() entries.append({ "target": target, "variant": variant, "file": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), "ninlight_version": version_map.get(target, ""), }) manifest = { "built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "images": entries, } versions = {e["ninlight_version"] for e in entries} if len(versions) == 1: (version,) = versions if version: manifest["ninlight_version"] = version with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh: json.dump(manifest, fh, indent=2, sort_keys=True) fh.write("\n") PY [ "$complete" -eq 1 ] } HAVE_PROD=0 HAVE_DEV=0 if [ -d "$SIGN_DIR" ]; then if [ -f "$KEY_PROD" ]; then HAVE_PROD=1 else echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2 fi if [ -f "$KEY_DEV" ]; then HAVE_DEV=1 else echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 fi if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then BOTH_KEYS=0 [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1 # ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken # und die Station beide vorhalten soll (der Bediener schaltet per # "ninlight-station bildsatz" bzw. am Taster um): # # migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN # migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP # # Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft # wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus # build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau # (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch # nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser # Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen. echo " Bildsatz migrate-wlan (aus build/)" if sign_set "$ROOT/build" migrate-wlan strict; then [ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1 fi echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)" if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then [ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1 else if [ ! -d "$STAGE/migrate-nocreds" ]; then echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2 fi fi else echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2 fi else echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2 fi # Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede # alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht # umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte # aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte # dagegen ungeprueft, was in build-nocreds/ lag. # # Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes # build-nocreds/, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das # Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des # Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare # Fehler. Lieber ein abgebrochener Deploy. # # Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. # Geprueft werden BEIDE Saetze "ohne-wlan" - der Migrationssatz migrate-nocreds/ UND der # Rettungssatz rescue-nocreds/. Der Rettungssatz ist dabei der gefaehrlichere von beiden: # er geht ohne Signatur an die Lampe. Frueher hing dieser ganze Block allein an # migrate-nocreds/, der Rettungssatz lief ungeprueft durch. # migrate-wlan/ und rescue-wlan/ bleiben ungeprueft: die SOLLEN die Zugangsdaten # enthalten, das ist ihr Zweck. # # Gesucht wird im ENTPACKTEN Rumpf, nicht in der Datei, wie sie auf der Platte liegt: # jedes Abbild (_ota.img wie die signierten _prod.img/_dev.img) ist 56 Byte Kopf, dann # ein gzip-Strom, dann 512 Byte Trailer bzw. Signatur. Im komprimierten Strom steht der # Klartext des Passworts nicht - eine Suche im Rohbyteinhalt meldete deshalb IMMER # "sauber", auch fuer den Satz aus build/, der die Zugangsdaten nachweislich traegt. Die # Pruefung hat bis hierhin also nie irgendetwas geprueft. # # Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht # "dann eben ungeprueft durchlassen". Genau diese Nachsicht waere der Weg, auf dem das # Passwort am Ende doch hinausgeht. # # Der Pruefkoerper in tools/release.sh ist verwandt, aber nicht mehr identisch: dort # wird ein fertiges Archiv aus lauter verschiedenen Dateiarten geprueft, hier ein # Bildsatz aus Firmware-Abbildern. # Als if-Zweige und nicht als "[ -d … ] && CHECK_SETS+=(…)": unter "set -e" waere eine # solche Zeile, wenn das Verzeichnis fehlt, ein fehlgeschlagenes letztes Kommando und # risse den Deploy ab. declare -a CHECK_SETS=() if [ -d "$STAGE/migrate-nocreds" ]; then CHECK_SETS+=(migrate-nocreds); fi if [ -d "$STAGE/rescue-nocreds" ]; then CHECK_SETS+=(rescue-nocreds); fi if [ ${#CHECK_SETS[@]} -gt 0 ]; then echo "== Bildsaetze 'ohne-wlan' auf Zugangsdaten pruefen ==" LOCAL="$ROOT/config.local.cmake" if [ -f "$LOCAL" ]; then python3 - "$LOCAL" "$STAGE" "${CHECK_SETS[@]}" <<'CHECKPY' || exit 1 import os import re import sys import zlib local, stage, sets = sys.argv[1], sys.argv[2], sys.argv[3:] text = open(local, encoding="utf-8", errors="replace").read() # Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der # Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen # haette nur falsche Alarme erzeugt. SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN") PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"} # Aufbau eines Abbilds: 56 Byte Kopf (vom wm_tool erzeugt), dann der gzip-Strom mit der # eigentlichen Firmware, dann 512 Byte ninlight-Trailer bzw. RSA-Signatur. HEADER_LEN = 56 TRAILER_LEN = 512 needles = [] for name in SECRETS: m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M) if m and m.group(1) not in PUBLIC: needles.append((name, m.group(1).encode())) def unpack(raw): """Entpackt den gzip-Rumpf eines Abbilds. Wirft bei allem, was nicht passt.""" if len(raw) <= HEADER_LEN + TRAILER_LEN: raise ValueError("Datei ist kuerzer als Kopf + Trailer") dec = zlib.decompressobj(zlib.MAX_WBITS | 16) body = dec.decompress(raw[HEADER_LEN:-TRAILER_LEN]) # Ohne diese Abfrage waere ein ABGESCHNITTENER gzip-Strom kein Fehler: decompressobj # liefert stillschweigend so viel, wie es lesen konnte. Der ungelesene Rest bliebe # ungeprueft - und genau dort koennte das Passwort stehen. if not dec.eof: raise ValueError("gzip-Strom ist unvollstaendig") return body hits = [] broken = [] images = 0 for image_set in sets: root_dir = os.path.join(stage, image_set) for root, _dirs, files in os.walk(root_dir): for fn in sorted(files): path = os.path.join(root, fn) rel = os.path.relpath(path, stage) with open(path, "rb") as fh: raw = fh.read() # Durchsucht werden beide Sichten: der entpackte Rumpf (dort steht die # Firmware) UND die Rohbytes (Kopf und Trailer werden nicht komprimiert, # und Beidateien wie manifest.json gibt es nur roh). views = [raw] if fn.endswith(".img"): images += 1 try: views.append(unpack(raw)) except Exception as exc: broken.append("%s (%s)" % (rel, exc)) continue for name, needle in needles: if any(needle in view for view in views): # NUR der Name des Wertes und die Datei - niemals der Wert selbst. hits.append("%s in %s" % (name, rel)) if broken: sys.stderr.write( "Fehler: Abbild im Bildsatz 'ohne-wlan' laesst sich nicht entpacken, also auch " "nicht auf Zugangsdaten pruefen - nichts uebertragen:\n " + "\n ".join(broken) + "\n") sys.exit(1) if hits: sys.stderr.write( "Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, " "nichts uebertragen:\n " + "\n ".join(hits) + "\n") sys.exit(1) print(" sauber (%d Abbilder in %d Saetzen geprueft: %s)" % (images, len(sets), ", ".join(sets))) CHECKPY else echo " uebersprungen - $LOCAL nicht vorhanden" >&2 fi fi echo "== uebertragen ==" # -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues # Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei # schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer # den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde # rsync ein solches Update dann stillschweigend ueberspringen. # # /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen # Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live # ausgelieferte Verzeichnis. # -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen. rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/" rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \ "$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \ "$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \ "$HOST:/tmp/" # Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden # uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf # den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter # /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere # Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen. rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/" # Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne # __pycache__ dieses Rechners. rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/" # Pfade als Variablen mit Default: so kann ein Test die Installation gegen # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. "$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE' set -e FULL_SYNC=$1 MIGRATE_COMPLETE=$2 NOCREDS_COMPLETE=$3 RESCUE_NOCREDS_COMPLETE=$4 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} DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service} BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service} POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service} REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service} sudo install -d -m 0755 "$ROOT_DIR/images" if [ "$FULL_SYNC" -eq 1 ]; then # Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen. # Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst # auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt # per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs # "deleting"-Ausgabe, die ohne -v gar nicht erscheint). OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort) NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort) REMOVED="" if [ -n "$OLD_LIST" ]; then REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST")) fi if [ -n "$REMOVED" ]; then echo " voller Abgleich - folgende Images werden entfernt:" echo "$REMOVED" | sed 's/^/ /' else echo " voller Abgleich - keine Images werden entfernt" fi # migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) - # hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne # Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage # unter /tmp/ninlight-images deshalb gar nicht existiert. # # Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die # Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die # Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem # Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle # unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt # noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt. sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \ --exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/" else # Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle # anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete). echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten" sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \ --exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/" fi # $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG # beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den # Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist. # # Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND # seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name): # fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein # tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT # geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung # frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur # diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten # betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die # Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren # greift dort also einfach nicht. sync_image_set() { set=$1 complete=$2 if [ -d "/tmp/ninlight-images/$set" ]; then sudo install -d -m 0755 "$ROOT_DIR/images/$set" if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then # Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt # STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete # ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden. sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/" else # Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel: # in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen # soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde # 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche # _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben, # sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst # verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu # gebaut hat, obwohl ihre Images unveraendert liegen bleiben). sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/" if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE' import json import sys manifest_path, new_manifest_path = sys.argv[1], sys.argv[2] with open(new_manifest_path) as fh: new_manifest = json.load(fh) if not isinstance(new_manifest, dict): new_manifest = {} try: with open(manifest_path) as fh: old_manifest = json.load(fh) except (OSError, ValueError): old_manifest = {} # Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem # die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst # kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen, # denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und # vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem # Stationscode zurueck. if not isinstance(old_manifest, dict): old_manifest = {} old_images = old_manifest.get("images") if not isinstance(old_images, list): old_images = [] by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)} new_images = new_manifest.get("images") if not isinstance(new_images, list): new_images = [] for e in new_images: if isinstance(e, dict): by_key[(e.get("target"), e.get("variant"))] = e merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or "")) merged = { "built": new_manifest.get("built"), "images": merged_images, } # Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr # einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege # koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein # Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein # unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt # seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den # Fall, dass wirklich alle Eintraege uebereinstimmen. versions = {e.get("ninlight_version") for e in merged_images} if len(versions) == 1: (version,) = versions if isinstance(version, str) and version: merged["ninlight_version"] = version with open(manifest_path, "w") as fh: json.dump(merged, fh, indent=2, sort_keys=True) fh.write("\n") MERGE fi fi else # Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis # vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern # ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") # ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund # (Fixrunde 2, Kleinigkeit). echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet" fi } # Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem # Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der # andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der # fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus. sync_image_set migrate-wlan "$MIGRATE_COMPLETE" sync_image_set migrate-nocreds "$NOCREDS_COMPLETE" # rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht # 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie # das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein # Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen # Schluesseln. sync_image_set rescue-wlan 1 sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE" # Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die # Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf # es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz. if [ -d "$ROOT_DIR/images/migrate" ]; then sudo rm -rf "$ROOT_DIR/images/migrate" echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)" fi # Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege # FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest # dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung, # warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die # normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja # nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht # uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser # Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv # (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen # gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten. OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null) if [ -n "$OLD_FLAT_RESCUE" ]; then echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)" fi sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH" sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" # Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images): # display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy # ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden. sudo install -d -m 0755 "$ROOT_DIR/display" sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/" sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH" # Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem # Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste. sudo install -d -m 0755 "$ROOT_DIR/button" sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/" sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH" # Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in # setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren). sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH" sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH" rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \ /tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \ /tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service sudo systemctl daemon-reload if systemctl is-active --quiet ninlight-station; then if sudo systemctl restart ninlight-station; then echo " Dienst neu gestartet" else echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2 exit 1 fi else echo " Dienst laeuft nicht (Station ist aus)" fi # Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird. # Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim # Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein # Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu # uebersehen. if systemctl is-active --quiet ninlight-display; then if sudo systemctl restart ninlight-display; then echo " Anzeigedienst neu gestartet" else echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2 fi else echo " Anzeigedienst laeuft nicht" fi # Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er # ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern. if systemctl is-active --quiet ninlight-button; then if sudo systemctl restart ninlight-button; then echo " Tasterdienst neu gestartet" else echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2 fi else echo " Tasterdienst laeuft nicht" fi REMOTE echo "fertig."