#!/usr/bin/env bash # Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten # (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration # (dev und prod signiert) und serielle Erstbespielung. # # Aufruf: tools/release.sh # Ergebnis: dist/ninlight-.tar.gz # # Umgebungsvariablen: # NINLIGHT_SIGN_DIR Schluesselverzeichnis (Vorgabe ~/workspace/fw-sign) # NINLIGHT_NOCREDS_BUILD_REL Build-Verzeichnis relativ zur Repo-Wurzel (s. build_nocreds.sh) # NINLIGHT_DOCKER Build-Vorspann (s. build_nocreds.sh); Tests stubben ihn set -euo pipefail VERSION=${1:?Aufruf: tools/release.sh } ROOT=$(cd "$(dirname "$0")/.." && pwd) TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} BUILD_DIR="$ROOT/$BUILD_REL" NAME="ninlight-$VERSION" TGZ="$ROOT/dist/$NAME.tar.gz" # Ein vorhandenes Archiv wird NICHT ueberschrieben: eine schon weitergegebene Version # still durch einen anderen Inhalt zu ersetzen, ist der Fehler, den man am spaetesten # bemerkt. [ -e "$TGZ" ] && { echo "Fehler: $TGZ existiert bereits - Version erhoehen oder Datei entfernen" >&2; exit 1; } # sha256: GNU zuerst (Debian/Pi), BSD/macOS als Rueckfall. if command -v sha256sum >/dev/null 2>&1; then SHA_CMD="sha256sum" elif command -v shasum >/dev/null 2>&1; then SHA_CMD="shasum -a 256" else echo "Fehler: weder sha256sum noch shasum gefunden" >&2; exit 1 fi echo "== Firmware ohne Zugangsdaten bauen ==" # Der Bauweg liegt bewusst in einem eigenen, wiederverwendbaren Skript (die # Migrationsstation braucht dieselben Abzuege) - inklusive des Beiseitelegens von # config.local.cmake, das jedes -D schlaegt. "$ROOT/tools/build_nocreds.sh" "${TARGETS[@]}" STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT OUT="$STAGE/$NAME" mkdir -p "$OUT/ota" "$OUT/flash" echo "== Images einsammeln ==" for t in "${TARGETS[@]}"; do ota="$BUILD_DIR/$t/ninlight_${t}_ota.img" gz="$BUILD_DIR/$t/ninlight_${t}_gz.img" [ -f "$ota" ] || { echo "Fehler: $ota fehlt" >&2; exit 1; } [ -f "$gz" ] || { echo "Fehler: $gz fehlt" >&2; exit 1; } cp "$ota" "$OUT/ota/" cp "$gz" "$OUT/flash/" done echo "== Migrationsimages signieren ==" # Wie tools/station/deploy.sh: die Schluessel werden nur gelesen, sie gehoeren nie ins # Repo, nie auf den Pi und schon gar nicht ins Archiv. Fehlt das Verzeichnis oder ein # einzelner Schluessel, entfaellt die jeweilige Variante mit einer Meldung - das Archiv # entsteht trotzdem, und README.md sagt dann, warum migrate/ fehlt. # ${HOME:-} statt $HOME, damit `set -u` unter env -i/cron nicht unnoetig abbricht. 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_NOTE="" 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" >&2 fi if [ -f "$KEY_DEV" ]; then HAVE_DEV=1; else echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img" >&2 fi else echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2 fi if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then mkdir -p "$OUT/migrate" for t in "${TARGETS[@]}"; do # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen # denselben 512-Byte-Block am Dateiende (Signatur bzw. Trailer). gz="$BUILD_DIR/$t/ninlight_${t}_gz.img" if [ "$HAVE_PROD" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$OUT/migrate/${t}_prod.img" \ || { echo "Fehler: Signieren von $t (prod) fehlgeschlagen" >&2; exit 1; } fi if [ "$HAVE_DEV" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$OUT/migrate/${t}_dev.img" \ || { echo "Fehler: Signieren von $t (dev) fehlgeschlagen" >&2; exit 1; } fi done fi if [ "$HAVE_PROD" -eq 0 ] && [ "$HAVE_DEV" -eq 0 ]; then MIGRATE_NOTE="**\`migrate/\` fehlt in diesem Archiv.** Beim Bauen war kein Signierschluessel erreichbar (\`$SIGN_DIR\`). Ohne Signatur nimmt die alte Klyqa-Firmware kein Image an; alte Lampen lassen sich mit diesem Archiv also nicht migrieren. Die Schluessel gehoeren nie in ein Archiv - wer sie hat, baut sich das Archiv mit \`tools/release.sh\` neu." elif [ "$HAVE_PROD" -eq 0 ]; then MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_prod.img\`.** Beim Bauen war nur der Entwicklungsschluessel erreichbar. Lampen mit Produktionssignatur lassen sich damit nicht migrieren." elif [ "$HAVE_DEV" -eq 0 ]; then MIGRATE_NOTE="**In \`migrate/\` fehlen die \`_dev.img\`.** Beim Bauen war nur der Produktionsschluessel erreichbar. Lampen mit Entwicklungssignatur lassen sich damit nicht migrieren." fi echo "== Bruno-Collection, Onboarding-Skript und README ==" mkdir -p "$OUT/bruno" cp "$ROOT/bruno/ninlight.yml" "$OUT/bruno/" cat >"$OUT/onboard.sh" <<'ONBOARD' #!/usr/bin/env bash # Bringt eine frisch geflashte ninlight-Lampe ins WLAN. # # Aufruf: ./onboard.sh [wlan-passwort] # # VORHER VON HAND (das kann dir kein Skript portabel abnehmen - sich per Skript mit # einem WLAN zu verbinden ist auf jedem Betriebssystem anders): # 1. Lampe einschalten. Ohne gespeicherte Zugangsdaten pulsiert sie warmweiss und # spannt den Zugangspunkt "ninlight-" auf (Passwort: ninlight). # 2. Diesen Rechner mit genau diesem WLAN verbinden. # 3. Dieses Skript aufrufen. # 4. Danach wieder ins normale WLAN wechseln; die Lampe ist per mDNS # (ninlight-.local, Port 3333) erreichbar. # # Optionen: # --host Adresse der Lampe im AP (Vorgabe 192.168.4.1) # --port REST-Port (Vorgabe 3333) # --token API-Token. Im unkonfigurierten Onboarding-AP NICHT noetig (der # Zugang ist dort offen). Noetig im Fallback-AP, den eine Lampe # aufspannt, die bereits Zugangsdaten hat (Router > 5 min weg) - # dort ist system/settings wie ueberall tokengeschuetzt. # Vorgabewert der Firmware: aabbccddeeff0011223344 set -euo pipefail command -v curl >/dev/null 2>&1 || { echo "Fehler: curl wird gebraucht" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "Fehler: python3 wird gebraucht (JSON-Body)" >&2; exit 1; } HOST=192.168.4.1 PORT=3333 TOKEN="" SSID="" PASSWORD="" # Hilfetext ist der Kommentarblock oben - keine feste Zeilennummer, die beim naechsten # Satz im Kopf stillschweigend falsch wird. usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; } while [ $# -gt 0 ]; do case "$1" in --host) HOST=${2:?--host braucht einen Wert}; shift 2 ;; --port) PORT=${2:?--port braucht einen Wert}; shift 2 ;; --token) TOKEN=${2:?--token braucht einen Wert}; shift 2 ;; -h|--help) usage; exit 0 ;; -*) echo "Unbekannte Option: $1" >&2; usage >&2; exit 2 ;; *) if [ -z "$SSID" ]; then SSID=$1; else PASSWORD=$1; fi; shift ;; esac done [ -n "$SSID" ] || { echo "Fehler: SSID fehlt" >&2; usage >&2; exit 2; } URL="http://$HOST:$PORT/api/v1/system/settings" BODY=$(SSID="$SSID" PASSWORD="$PASSWORD" python3 -c ' import json, os print(json.dumps({"type": "wifi", "ssid": os.environ["SSID"], "password": os.environ["PASSWORD"]}))') set -- -X PUT "$URL" -H "Content-Type: application/json" -d "$BODY" --max-time 15 [ -n "$TOKEN" ] && set -- "$@" -H "Authorization: $TOKEN" echo "== WLAN setzen ($URL) ==" curl -fsS "$@" || { echo "Fehler: die Lampe hat die Zugangsdaten nicht angenommen." >&2 echo " 401 -> Fallback-AP: --token mitgeben." >&2 echo " keine Verbindung -> bist du wirklich im WLAN ninlight-?" >&2 exit 1 } echo echo "== Verbindung pruefen (system/info) ==" # Die Lampe braucht ein paar Sekunden, bis sie im neuen WLAN haengt; danach schliesst # der AP nach 60 s. wifi.sta_ip zeigt die neue Adresse, wifi.error den Grund, falls es # nicht geklappt hat (z.B. falsches Passwort). Wartezeit fuer Tests ueberschreibbar. sleep "${NINLIGHT_ONBOARD_WAIT:-8}" curl -fsS "http://$HOST:$PORT/api/v1/system/info" --max-time 15 || { echo "Hinweis: system/info nicht mehr erreichbar - meist ein gutes Zeichen (der AP ist" >&2 echo " zu, die Lampe haengt im neuen WLAN). Im normalen WLAN nachsehen." >&2 } echo ONBOARD chmod +x "$OUT/onboard.sh" cat >"$OUT/README.md" <\` (Passwort \`ninlight\`) und wird mit \`onboard.sh\` ins WLAN gebracht. Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb. ## Was liegt wo | Verzeichnis | Inhalt | wofuer | |---|---|---| | \`ota/\` | \`ninlight__ota.img\` | Update einer Lampe, auf der ninlight schon laeuft (REST \`fw_update\`), und die Auslieferung an die Rettungsstation | | \`migrate/\` | \`_prod.img\`, \`_dev.img\` | Erstmigration einer Lampe, auf der noch die alte Klyqa-Firmware laeuft | | \`flash/\` | \`ninlight__gz.img\` | serielle Erstbespielung ueber die Platine | | \`bruno/\` | \`ninlight.yml\` | REST-Schnittstelle als Bruno-Collection | \`SHA256SUMS\` deckt jede Datei ab: \`sha256sum -c SHA256SUMS\` (macOS: \`shasum -a 256 -c SHA256SUMS\`). $MIGRATE_NOTE ## Alte Klyqa-Lampe migrieren Die alte Firmware nimmt nur signierte Images an; welche Signatur (\`_prod\` oder \`_dev\`) gilt, haengt an Produkt-ID und Version der Lampe. Der Weg laeuft ueber die Migrationsstation (\`tools/station/\` im Repo, ein Raspberry Pi mit zwei Zugangspunkten): 1. Station einschalten und \`ninlight-station migrate on\` (oder den Taster druecken). 2. Lampe einschalten - sie waehlt sich in den Klyqa-Zugangspunkt ein und holt das passende Image aus \`migrate/\`. 3. Nach dem Neustart laeuft ninlight; die Lampe spannt ihren Onboarding-AP auf. ## Frisch geflashte Lampe onboarden ./onboard.sh "MeinWLAN" "meinpasswort" Vorher von Hand: Lampe einschalten (sie pulsiert warmweiss), diesen Rechner mit dem WLAN \`ninlight-\` verbinden (Passwort \`ninlight\`). \`./onboard.sh --help\` zeigt die Schritte und die Optionen (\`--host\`, \`--port\`, \`--token\`). Im unkonfigurierten Onboarding-AP ist der Zugang offen, im Fallback-AP einer bereits onboardeten Lampe ist das Token noetig. Serielle Erstbespielung: \`flash/ninlight__gz.img\` mit \`tools/flash.sh\` aus dem Repo bzw. \`wm_tool\` auf die Platine schreiben. ## Vertrauensgrenze Der 512-Byte-Trailer der \`_ota.img\` prueft Geraetetyp und SHA256 - das ist eine Integritaets- und Geraetetyppruefung, **kein kryptografisches Signaturverfahren**. Der Download laeuft ueber \`http://\` ohne TLS, und der Rettungskanal ist nur durch ein WLAN- Passwort geschuetzt, das mit einem oeffentlichen Default im Repo steht. Wer im selben WLAN ist und das API-Token kennt (bzw. beim Recovery in Funkreichweite ist und das Passwort kennt), kann der Lampe beliebige Firmware unterschieben. Das ist eine bewusste Entscheidung dieses Projekts: Ziel ist, die Signaturpruefung der alten, abgekuendigten Original-Firmware zu umgehen, damit die Lampen ueberhaupt weiter nutzbar bleiben - nicht, ein neues, gehaertetes Sicherheitsmodell zu bauen. Die RSA-Signatur der \`migrate/\`-Images ist die der ALTEN Firmware und existiert nur, damit die alte Firmware das Image ueberhaupt annimmt. Die privaten Schluessel liegen nie in diesem Archiv. README echo "== Auf Zugangsdaten pruefen ==" # Doppelter Boden zum Beiseitelegen von config.local.cmake: bevor irgendetwas gepackt # wird, wird JEDE Datei des Archivs binaer gegen die echten Werte aus # config.local.cmake geprueft. Lieber ein abgebrochener Release als ein Archiv mit # Michaels WLAN-Passwort bei Dritten. LOCAL="$ROOT/config.local.cmake" if [ -f "$LOCAL" ]; then python3 - "$OUT" "$LOCAL" <<'PY' || exit 1 import os import re import sys stage, local = sys.argv[1], sys.argv[2] 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"} 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())) hits = [] for root, _dirs, files in os.walk(stage): for fn in sorted(files): path = os.path.join(root, fn) with open(path, "rb") as fh: data = fh.read() for name, needle in needles: if needle in data: hits.append("%s in %s" % (name, os.path.relpath(path, stage))) if hits: sys.stderr.write( "Fehler: Zugangsdaten aus config.local.cmake im Archiv gefunden, nichts gepackt:\n " + "\n ".join(hits) + "\n") sys.exit(1) print(" sauber (%d geprueft)" % len(needles)) PY fi echo "== SHA256SUMS ==" ( cd "$OUT" # $SHA_CMD absichtlich ohne Anfuehrungszeichen: "shasum -a 256" sind drei Woerter. # shellcheck disable=SC2086 find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort | xargs $SHA_CMD >SHA256SUMS ) echo "== packen ==" mkdir -p "$ROOT/dist" tar czf "$TGZ" -C "$STAGE" "$NAME" echo "fertig: $TGZ"