feat(tools): Auslieferungsarchiv ohne WLAN-Zugangsdaten

Baut alle acht Ziele ueber den wiederverwendbaren Bauweg tools/build_nocreds.sh
(config.local.cmake liegt per trap beiseite, damit sie nicht als set() jedes -D
schlaegt) und packt ota/, migrate/ (prod+dev signiert), flash/, bruno/,
onboard.sh, README.md und SHA256SUMS nach dist/ninlight-<version>.tar.gz.

Vor dem Packen wird jede Datei binaer gegen SSID, WLAN-Passwort und Token aus
config.local.cmake geprueft; ein Fund bricht den Lauf ab.
This commit is contained in:
Michael Sauer
2026-09-15 01:07:44 +02:00
parent 6325ce073f
commit 2fe2f7d9f4
4 changed files with 883 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# Baut ein Auslieferungsarchiv: alle acht 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 <version>
# Ergebnis: dist/ninlight-<version>.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 <version>}
ROOT=$(cd "$(dirname "$0")/.." && pwd)
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
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 <ssid> [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-<mac4>" 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-<mac4>.local, Port 3333) erreichbar.
#
# Optionen:
# --host <ip> Adresse der Lampe im AP (Vorgabe 192.168.4.1)
# --port <port> REST-Port (Vorgabe 3333)
# --token <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 <token> mitgeben." >&2
echo " keine Verbindung -> bist du wirklich im WLAN ninlight-<mac4>?" >&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" <<README
# ninlight $VERSION
Ersatzfirmware fuer die abgekuendigten Klyqa-Lampen (WinnerMicro W600). Alle Images in
diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit bespielte
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (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.
## Was liegt wo
| Verzeichnis | Inhalt | wofuer |
|---|---|---|
| \`ota/\` | \`ninlight_<ziel>_ota.img\` | Update einer Lampe, auf der ninlight schon laeuft (REST \`fw_update\`), und die Auslieferung an die Rettungsstation |
| \`migrate/\` | \`<ziel>_prod.img\`, \`<ziel>_dev.img\` | Erstmigration einer Lampe, auf der noch die alte Klyqa-Firmware laeuft |
| \`flash/\` | \`ninlight_<ziel>_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-<mac4>\` 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_<ziel>_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"