Files
ninlight-w600/tools/release.sh
T
Michael Sauer e10147b70f Merge remote-tracking branch 'origin/montana-black-shield' into merge-probe
* origin/montana-black-shield:
  docs(bulb-map): Montana-Black-Shield (f960) ergaenzt
  chore(tools): qconnex-Migrationswerkzeug und HA-Integration ins Repo
  feat(station): Montana-Black-Produkt-ID -> Ziel ledshield_rgb
  feat(board): ledshield_rgb — Montana Black RGB-LED-Shield (analoge RGB-Streifen)
  build: tools/podman.sh — docker.sh-Pendant für podman (gleiches Image, gleicher Mount)
  Überträgt lokale Änderungen (nl_crc8 unsigned-shift, docker.sh cd -P) in neuen Top-Level-Tree und entfernt altes ninlight-w600/
  Initial commit
2026-09-26 17:18:27 +02:00

368 lines
16 KiB
Bash
Executable File

#!/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 <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 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 <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, ledshield_rgb.
## 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 gegen die echten Werte aus config.local.cmake
# geprueft. Lieber ein abgebrochener Release als ein Archiv mit Michaels WLAN-Passwort
# bei Dritten - dieses Archiv geht an Fremde, und wiederholen laesst sich das nicht.
#
# Gesucht wird in ZWEI Sichten:
# roh - Kopf, Trailer und die Nicht-Abbilder (README.md, onboard.sh, bruno/) sind
# unkomprimiert; eine versehentlich notierte Zeile faellt nur so auf.
# entpackt- jedes Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann (beim _ota.img
# bzw. den signierten migrate/-Bildern) 512 Byte Trailer bzw. Signatur. Im
# komprimierten Strom steht kein Klartext. Die rohe Suche allein meldete
# deshalb IMMER "sauber" - auch fuer Abzuege aus build/, die die
# Zugangsdaten nachweislich tragen. Sie hat nie etwas geprueft.
#
# Entpackt wird ab Byte 56 OHNE das Dateiende abzuschneiden: die drei Bildarten im
# Archiv haben unterschiedliche Enden (flash/_gz.img traegt gar keinen Trailer,
# ota/_ota.img einen 512-Byte-Trailer, migrate/*.img eine 512-Byte-Signatur). Ein
# festes raw[56:-512] wie in tools/station/deploy.sh - wo nur Abbilder MIT Trailer
# vorkommen - wuerde hier jedes _gz.img fuer unentpackbar halten und jeden Release
# abbrechen. decompressobj hoert am Ende des gzip-Stroms von selbst auf; was danach
# kommt, ist ohnehin schon roh geprueft.
#
# Ein Abbild, das sich nicht entpacken laesst, ist ein FEHLER und bricht ab - nicht
# "dann eben ungeprueft durchlassen". Das gilt auch fuer einen abgeschnittenen
# gzip-Strom, den decompressobj sonst stillschweigend annimmt.
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$OUT" "$LOCAL" <<'PY' || exit 1
import os
import re
import sys
import zlib
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"}
# Kopflaenge des vom wm_tool erzeugten Abbilds; danach beginnt der gzip-Strom.
HEADER_LEN = 56
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:
raise ValueError("Datei ist kuerzer als der Kopf")
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
body = dec.decompress(raw[HEADER_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 = []
checked = 0
images = 0
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
rel = os.path.relpath(path, stage)
with open(path, "rb") as fh:
data = fh.read()
checked += 1
views = [data]
if fn.endswith(".img"):
try:
views.append(unpack(data))
except Exception as exc:
broken.append("%s (%s)" % (rel, exc))
continue
images += 1
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 laesst sich nicht entpacken, also auch nicht auf Zugangsdaten "
"pruefen - nichts gepackt:\n " + "\n ".join(broken) + "\n")
sys.exit(1)
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 Dateien geprueft, davon %d Abbilder entpackt)" % (checked, images))
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"