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
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
# im Onboarding-AP (ninlight-<mac4>), statt sich in ein fremdes WLAN einzuwaehlen.
#
# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle acht Ziele)
#
# Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten
# (ninlight_<ziel>_gz.img, ninlight_<ziel>_ota.img, ...). Bewusst NICHT build/: die
# dortigen Abzuege sind die des Entwicklers - mit seinem WLAN - und bleiben unangetastet.
#
# Dieses Skript ist ausdruecklich zum Wiederverwenden da: tools/release.sh baut damit das
# Auslieferungsarchiv, und die Migrationsstation wird damit die Wahl "Lampe startet im
# Onboarding-AP" bedienen. Deshalb steht die Logik hier und nicht in release.sh.
#
# Umgebungsvariablen:
# NINLIGHT_DOCKER Build-Vorspann (Vorgabe tools/docker.sh); Tests stubben ihn
# NINLIGHT_NOCREDS_BUILD_REL Ausgabeverzeichnis relativ zur Repo-Wurzel
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
if [ $# -gt 0 ]; then
TARGETS=("$@")
else
TARGETS=("${DEVICES[@]}")
fi
for t in "${TARGETS[@]}"; do
ok=0
for d in "${DEVICES[@]}"; do [ "$t" = "$d" ] && ok=1; done
[ "$ok" -eq 1 ] || { echo "Fehler: unbekanntes Ziel '$t' (bekannt: ${DEVICES[*]})" >&2; exit 1; }
done
# config.local.cmake wird in CMakeLists.txt mit gewoehnlichem set() eingebunden und
# SCHLAEGT damit jedes -D auf der Kommandozeile. Ein Bau ohne Zugangsdaten ist deshalb
# nur moeglich, wenn die Datei fuer die Dauer des Baus beiseite liegt. In ihr stehen die
# echte SSID, das WLAN-Passwort und das API-Token - sie muss auch dann zurueckkommen,
# wenn der Bau abbricht oder jemand Strg-C drueckt. Darum ein trap auf EXIT UND auf
# INT/TERM (EXIT allein laeuft bei einem Signal zwar auch, aber der Exit-Code soll das
# Signal widerspiegeln, statt als Erfolg durchzugehen).
LOCAL="$ROOT/config.local.cmake"
STASH_DIR=""
restore_local() {
[ -n "$STASH_DIR" ] || return 0
if [ -f "$STASH_DIR/config.local.cmake" ]; then
cp -p "$STASH_DIR/config.local.cmake" "$LOCAL" || {
echo "ACHTUNG: config.local.cmake konnte nicht zurueckgelegt werden - Kopie liegt unter $STASH_DIR" >&2
STASH_DIR=""
return 1
}
fi
rm -rf "$STASH_DIR"
STASH_DIR=""
}
if [ -f "$LOCAL" ]; then
STASH_DIR=$(mktemp -d)
cp -p "$LOCAL" "$STASH_DIR/config.local.cmake"
trap 'restore_local' EXIT
trap 'restore_local; trap - INT; kill -INT $$' INT
trap 'restore_local; exit 143' TERM
rm -f "$LOCAL"
echo "config.local.cmake liegt fuer diesen Bau beiseite (keine WLAN-Zugangsdaten im Abzug)"
fi
for t in "${TARGETS[@]}"; do
echo "== $t (ohne Zugangsdaten)"
# Relative Pfade, weil der Bau ueblicherweise im Container laeuft, der das Repo unter
# /work einhaengt - absolute Hostpfade gaebe es dort nicht.
"$DOCKER" cmake -S . -B "$BUILD_REL/$t" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE="$t" >/dev/null
"$DOCKER" cmake --build "$BUILD_REL/$t"
done
echo "fertig - Abzuege ohne Zugangsdaten unter $ROOT/$BUILD_REL"
+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"
+1
View File
@@ -38,6 +38,7 @@ SHELL_SUITES=(
"$ROOT/tools/station/tests/test_cli.sh" "$ROOT/tools/station/tests/test_cli.sh"
"$ROOT/tools/station/tests/test_deploy.sh" "$ROOT/tools/station/tests/test_deploy.sh"
"$ROOT/tools/station/tests/test_setup_pi.sh" "$ROOT/tools/station/tests/test_setup_pi.sh"
"$ROOT/tools/tests/test_release.sh"
) )
for suite in "${SHELL_SUITES[@]}"; do for suite in "${SHELL_SUITES[@]}"; do
"$suite" "$suite"
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
# Stub-Tests fuer tools/release.sh (und tools/build_nocreds.sh) - ohne Docker, ohne
# echten Firmwarebau ausfuehrbar.
#
# Gestubbt wird nur die UNTERSTE Ebene: NINLIGHT_DOCKER zeigt auf ein Skript, das sich
# wie der Bau im Container verhaelt (relative Pfade ab Repo-Wurzel, legt _gz.img und
# _ota.img ab). Alles darueber - das Beiseitelegen von config.local.cmake, das
# Signieren, README/SHA256SUMS, das Packen - laeuft echt. Wichtig dabei: der Stub
# verhaelt sich wie ein Compiler und SCHREIBT die Werte aus config.local.cmake in die
# Images, wenn die Datei zum Bauzeitpunkt noch da ist. Nur so pruefen die Faelle 5/6
# wirklich das Skript und nicht den Stub: vergisst release.sh/build_nocreds.sh das
# Beiseitelegen, landen die Zugangsdaten tatsaechlich im Abzug.
#
# Aufruf: tools/tests/test_release.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
TOOLS_DIR=$(cd "$HERE/.." && pwd)
REPO=$(cd "$TOOLS_DIR/.." && pwd)
# Sicherheitsnetz (wie in tools/station/tests/test_deploy.sh): NINLIGHT_SIGN_DIR zeigt
# auf diesem Rechner auf ECHTE Signierschluessel. Sie darf nicht aus der Umgebung in
# diese Suite durchsickern - jedes Szenario setzt sie hart, Testschluessel kommen ueber
# die separate Variable TEST_SIGN_DIR.
unset NINLIGHT_SIGN_DIR
unset TEST_SIGN_DIR
unset NINLIGHT_NOCREDS_BUILD_REL
unset NINLIGHT_DOCKER
# GNU vor BSD: auf dem Pi/Debian gibt es sha256sum, auf dem Mac nur shasum.
if command -v sha256sum >/dev/null 2>&1; then
SHA_CHECK=(sha256sum -c)
else
SHA_CHECK=(shasum -a 256 -c)
fi
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
FAILED=0
pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
# Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
SECRET_SSID="TestWLAN-4b7f-geheim"
SECRET_PASS="testpasswort-4b7f"
SECRET_TOKEN="0badc0de4b7f0badc0de4b7f"
# --- Sandbox-Repo: release.sh, build_nocreds.sh, nl_sign.py, bruno/, config.local.cmake ---
setup_sandbox() {
local sandbox=$1
rm -rf "$sandbox"
mkdir -p "$sandbox/tools/mkimage" "$sandbox/bruno" "$sandbox/dist"
cp "$TOOLS_DIR/release.sh" "$sandbox/tools/release.sh"
cp "$TOOLS_DIR/build_nocreds.sh" "$sandbox/tools/build_nocreds.sh"
chmod +x "$sandbox/tools/release.sh" "$sandbox/tools/build_nocreds.sh"
# Das ECHTE Signierwerkzeug, nur die Schluessel sind Wegwerf-Testschluessel.
cp "$TOOLS_DIR/mkimage/nl_sign.py" "$sandbox/tools/mkimage/nl_sign.py"
# Die ECHTE Bruno-Collection: die Zusage ist, dass sie im Archiv liegt - mit einer
# erfundenen Attrappe wuerde der Test nur sich selbst pruefen.
cp "$REPO/bruno/ninlight.yml" "$sandbox/bruno/ninlight.yml"
cat >"$sandbox/config.local.cmake" <<EOF
set(NINLIGHT_WIFI_SSID "$SECRET_SSID")
set(NINLIGHT_WIFI_PASSWORD "$SECRET_PASS")
set(NINLIGHT_TOKEN "$SECRET_TOKEN")
EOF
}
# --- Docker-Stub: verhaelt sich wie der Bau im Container ---
# Steuerbar ueber:
# STUB_FAIL_TARGET bricht beim Bau dieses Ziels mit Exit 1 ab
# STUB_TERM_TARGET schickt beim Bau dieses Ziels SIGTERM an den Aufrufer
# (build_nocreds.sh) - erzwungener Abbruch mitten im Lauf
# STUB_LEAK=1 schreibt die Zugangsdaten IMMER ins Image, auch wenn
# config.local.cmake beiseite liegt (prueft den doppelten Boden)
setup_stub_docker() {
local stub=$1 sandbox=$2
cat >"$stub" <<'STUB'
#!/usr/bin/env bash
# Erwartet "cmake -S . -B <rel>/<ziel> ..." bzw. "cmake --build <rel>/<ziel>".
set -euo pipefail
cd "$NINLIGHT_STUB_ROOT"
mode=""
outdir=""
args=("$@")
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[$i]}" in
--build) mode=build; outdir=${args[$((i + 1))]} ;;
-B) mode=${mode:-configure}; outdir=${args[$((i + 1))]} ;;
esac
done
[ -n "$outdir" ] || { echo "Stub: kein Build-Verzeichnis in '$*'" >&2; exit 1; }
target=$(basename "$outdir")
mkdir -p "$outdir"
[ "$mode" = "build" ] || exit 0
if [ "${STUB_TERM_TARGET:-}" = "$target" ]; then
kill -TERM "$PPID"
sleep 5
exit 1
fi
if [ "${STUB_FAIL_TARGET:-}" = "$target" ]; then
echo "Stub: Bau von $target fehlgeschlagen" >&2
exit 1
fi
# "Compiler": die Zugangsdaten aus config.local.cmake landen im Abzug - genau wie beim
# echten Bau, wo CMakeLists.txt sie per set() einkompiliert.
creds=""
if [ -f config.local.cmake ]; then
creds=$(sed -n 's/.*set(NINLIGHT_[A-Z_]* "\(.*\)").*/\1/p' config.local.cmake | tr '\n' ' ')
fi
if [ "${STUB_LEAK:-}" = "1" ]; then
creds="$STUB_LEAK_VALUE"
fi
CREDS="$creds" TARGET="$target" OUTDIR="$outdir" python3 - <<'PY'
import os
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
body = bytearray(("firmware-%s-" % target).encode() * 200)
body = body[:4096] + b"\0" * (4096 - len(body[:4096]))
blob = creds.encode()
if blob:
body[100:100 + len(blob)] = blob
gz = bytes(body)
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
fh.write(gz) # roh, ohne NINL-Trailer - das signiert nl_sign.py
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
fh.write(gz + bytes(trailer))
PY
STUB
chmod +x "$stub"
}
# Wegwerf-Testschluessel ueber nl_sign.generate_test_key() - nie echte Schluessel.
make_test_keys() {
local keydir=$1 with_prod=$2 with_dev=$3
mkdir -p "$keydir/QCX_Device_CA"
PYTHONPATH="$TOOLS_DIR/mkimage" python3 - "$keydir" "$with_prod" "$with_dev" <<'PY'
import sys
import nl_sign
keydir, with_prod, with_dev = sys.argv[1], sys.argv[2] == "1", sys.argv[3] == "1"
if with_prod:
k = nl_sign.generate_test_key()
with open(keydir + "/QCX_Device_CA/qcx_firmware_signing_key.pem", "wb") as fh:
fh.write(k.private_pem)
with open(keydir + "/prod_pub.der", "wb") as fh:
fh.write(k.public_der)
if with_dev:
k = nl_sign.generate_test_key()
with open(keydir + "/fw_sign.pem", "wb") as fh:
fh.write(k.private_pem)
with open(keydir + "/dev_pub.der", "wb") as fh:
fh.write(k.public_der)
PY
}
# Ruft release.sh in der Sandbox auf. NINLIGHT_SIGN_DIR wird hier HART gesetzt, nie aus
# der geerbten Umgebung uebernommen (siehe Sicherheitsnetz oben).
run_release() {
local sandbox=$1 version=$2
local sign_dir="${TEST_SIGN_DIR:-$sandbox/kein-schluesselverzeichnis}"
env NINLIGHT_SIGN_DIR="$sign_dir" \
NINLIGHT_DOCKER="$sandbox/stub-docker" \
NINLIGHT_STUB_ROOT="$sandbox" \
STUB_FAIL_TARGET="${STUB_FAIL_TARGET:-}" \
STUB_TERM_TARGET="${STUB_TERM_TARGET:-}" \
STUB_LEAK="${STUB_LEAK:-}" \
STUB_LEAK_VALUE="$SECRET_SSID" \
"$sandbox/tools/release.sh" "$version"
}
# Sucht die Zugangsdaten BINAER in jeder Datei unterhalb eines Verzeichnisses.
grep_secrets() {
local dir=$1
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" python3 - "$dir" <<'PY'
import os
import sys
root = sys.argv[1]
needles = {k: os.environ[k].encode() for k in ("SSID", "PASS", "TOKEN")}
for dirpath, _dirs, files in os.walk(root):
for fn in sorted(files):
p = os.path.join(dirpath, fn)
if os.path.islink(p):
continue
with open(p, "rb") as fh:
data = fh.read()
for name, needle in needles.items():
if needle in data:
print("%s in %s" % (name, os.path.relpath(p, root)))
PY
}
sha_of() { python3 -c "
import hashlib,sys
print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
echo "=== Szenario 1: Erfolgslauf mit beiden Schluesseln ==="
SANDBOX="$TMPROOT/s1"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
KEYS_FULL="$TMPROOT/keys-full"
make_test_keys "$KEYS_FULL" 1 1
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC: $OUT"; fi
TGZ="$SANDBOX/dist/ninlight-0.9.9.tar.gz"
if [ -f "$TGZ" ]; then pass "Archiv entstanden"; else fail "kein Archiv unter $TGZ"; fi
EXTRACT="$TMPROOT/x1"
mkdir -p "$EXTRACT"
tar xzf "$TGZ" -C "$EXTRACT"
A="$EXTRACT/ninlight-0.9.9"
if [ -d "$A" ]; then pass "Archiv entpackt nach ninlight-0.9.9/"; else fail "Verzeichnis ninlight-0.9.9/ fehlt"; fi
echo "--- Fall 1/2/3: Verzeichnisse und Dateizahlen ---"
for d in ota migrate flash bruno; do
if [ -d "$A/$d" ]; then pass "$d/ vorhanden"; else fail "$d/ fehlt"; fi
done
for f in SHA256SUMS README.md onboard.sh bruno/ninlight.yml; do
if [ -f "$A/$f" ]; then pass "$f vorhanden"; else fail "$f fehlt"; fi
done
if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "onboard.sh ist nicht ausfuehrbar"; fi
N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ')
N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ')
[ "$N_OTA" = "8" ] && pass "ota/ enthaelt 8 Dateien" || fail "ota/ enthaelt $N_OTA statt 8"
[ "$N_FLASH" = "8" ] && pass "flash/ enthaelt 8 Dateien" || fail "flash/ enthaelt $N_FLASH statt 8"
[ "$N_MIG" = "16" ] && pass "migrate/ enthaelt 16 Dateien" || fail "migrate/ enthaelt $N_MIG statt 16"
MISS=""
for t in "${TARGETS[@]}"; do
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t"
[ -f "$A/flash/ninlight_${t}_gz.img" ] || MISS="$MISS flash/$t"
[ -f "$A/migrate/${t}_prod.img" ] || MISS="$MISS migrate/${t}_prod"
[ -f "$A/migrate/${t}_dev.img" ] || MISS="$MISS migrate/${t}_dev"
done
[ -z "$MISS" ] && pass "je Ziel genau die erwarteten Dateinamen" || fail "fehlende Dateien:$MISS"
echo "--- Migrationsimages sind echt signiert (und zwar das _gz.img) ---"
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/prod_pub.der" \
--in "$A/migrate/evk_prod.img" >/dev/null 2>&1; then
pass "evk_prod.img traegt eine gueltige Produktionssignatur"
else
fail "evk_prod.img besteht die Signaturpruefung nicht"
fi
if python3 "$TOOLS_DIR/mkimage/nl_sign.py" verify --pub "$KEYS_FULL/dev_pub.der" \
--in "$A/migrate/g95_cw_dev.img" >/dev/null 2>&1; then
pass "g95_cw_dev.img traegt eine gueltige Entwicklungssignatur"
else
fail "g95_cw_dev.img besteht die Signaturpruefung nicht"
fi
# Die Signatur belegt denselben 512-Byte-Block wie der Trailer: signiert werden muss das
# rohe _gz.img. Ein versehentlich signiertes _ota.img waere 512 Byte laenger.
GZ_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/flash/ninlight_evk_gz.img")
MIG_SIZE=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$A/migrate/evk_prod.img")
if [ "$MIG_SIZE" -eq $((GZ_SIZE + 512)) ]; then
pass "Migrationsimage = _gz.img + 512 Byte Signatur (nicht das _ota.img signiert)"
else
fail "Migrationsimage hat $MIG_SIZE Byte, erwartet $((GZ_SIZE + 512))"
fi
echo "--- Fall 4: SHA256SUMS deckt jede Datei ab und stimmt ---"
LISTED=$(awk '{print $2}' "$A/SHA256SUMS" | sed 's|^\*||' | sort)
PRESENT=$( (cd "$A" && find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) )
if [ "$LISTED" = "$PRESENT" ]; then
pass "SHA256SUMS listet genau die Dateien des Archivs ($(echo "$PRESENT" | wc -l | tr -d ' '))"
else
fail "SHA256SUMS und Archivinhalt weichen ab:
$(diff <(echo "$LISTED") <(echo "$PRESENT") || true)"
fi
if (cd "$A" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
pass "Pruefsummen stimmen"
else
fail "Pruefsummenpruefung fehlgeschlagen"
fi
echo "--- Fall 5: keine Zugangsdaten im Archiv (binaer gesucht) ---"
HITS=$(grep_secrets "$A")
if [ -z "$HITS" ]; then
pass "weder SSID noch Passwort noch Token in irgendeiner Datei des Archivs"
else
fail "Zugangsdaten im Archiv gefunden:
$HITS"
fi
# Auch im gepackten Archiv selbst (tar speichert Inhalte ungefiltert, gzip ist keine
# Verschleierung - aber eine unkomprimierbare Kopie wuerde hier auffallen).
if python3 -c "
import gzip,sys
data = gzip.open(sys.argv[1],'rb').read()
sys.exit(0 if not any(s.encode() in data for s in sys.argv[2:]) else 1)" \
"$TGZ" "$SECRET_SSID" "$SECRET_PASS" "$SECRET_TOKEN"; then
pass "auch im entpackten tar-Strom keine Zugangsdaten"
else
fail "Zugangsdaten im tar-Strom des Archivs"
fi
# Der Stub verhaelt sich nur dann wie ein Compiler, wenn er die Werte ueberhaupt
# einbauen KANN - sonst waere Fall 5 wertlos. Gegenprobe: mit vorhandener
# config.local.cmake landen sie im Abzug.
mkdir -p "$TMPROOT/probe"
env NINLIGHT_STUB_ROOT="$SANDBOX" "$SANDBOX/stub-docker" cmake --build "$TMPROOT/probe/evk" >/dev/null
if grep_secrets "$TMPROOT/probe" | grep -q SSID; then
pass "Gegenprobe: der Stub baut die Zugangsdaten ein, solange config.local.cmake da ist"
else
fail "Gegenprobe fehlgeschlagen - der Stub baut nie Zugangsdaten ein, Fall 5 waere wertlos"
fi
echo "--- Fall 6a: config.local.cmake nach dem Erfolgslauf unveraendert ---"
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake liegt unveraendert an seinem Platz"
else
fail "config.local.cmake fehlt oder wurde veraendert"
fi
echo "--- README nennt die Vertrauensgrenze und die Verzeichnisse ---"
for phrase in "kein kryptografisches Signaturverfahren" "ohne TLS" "onboard.sh" "migrate/" "flash/"; do
if grep -qF "$phrase" "$A/README.md"; then pass "README nennt '$phrase'"; else fail "README nennt '$phrase' nicht"; fi
done
echo "=== Szenario 2: Bau bricht mittendrin ab (Fall 6) ==="
SANDBOX="$TMPROOT/s2"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(STUB_FAIL_TARGET=e27_cw TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein halbes Archiv zurueckgelassen"; else fail "Archiv trotz Abbruch entstanden"; fi
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake nach dem Abbruch unveraendert an seinem Platz"
else
fail "config.local.cmake nach dem Abbruch verschwunden oder veraendert"
fi
echo "=== Szenario 3: Abbruch per Signal (SIGTERM mitten im Bau) ==="
SANDBOX="$TMPROOT/s3"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
CONF_BEFORE=$(sha_of "$SANDBOX/config.local.cmake")
set +e
OUT=$(STUB_TERM_TARGET=gu10_rgb TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if [ -f "$SANDBOX/config.local.cmake" ] && [ "$(sha_of "$SANDBOX/config.local.cmake")" = "$CONF_BEFORE" ]; then
pass "config.local.cmake nach dem Signalabbruch unveraendert an seinem Platz"
else
fail "config.local.cmake nach dem Signalabbruch verschwunden oder veraendert"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv nach dem Signalabbruch"; else fail "Archiv trotz Signalabbruch entstanden"; fi
echo "=== Szenario 4: ohne Schluesselverzeichnis (Fall 3) ==="
SANDBOX="$TMPROOT/s4"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1) # kein TEST_SIGN_DIR -> Verzeichnis gibt es nicht
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0 - das Archiv entsteht trotzdem"; else fail "Exit-Code $RC: $OUT"; fi
if echo "$OUT" | grep -q "Schluesselverzeichnis"; then pass "deutliche Meldung zum fehlenden Schluesselverzeichnis"; else fail "keine Meldung zum fehlenden Schluesselverzeichnis"; fi
EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT"
tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
A4="$EXTRACT/ninlight-0.9.9"
if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "8" ]; then
pass "ota/ trotzdem vollstaendig"
else
fail "ota/ unvollstaendig"
fi
if grep -q "migrate/\` fehlt in diesem Archiv" "$A4/README.md"; then
pass "README.md sagt, warum migrate/ fehlt"
else
fail "README.md erklaert das fehlende migrate/ nicht"
fi
if (cd "$A4" && "${SHA_CHECK[@]}" SHA256SUMS >/dev/null 2>&1); then
pass "SHA256SUMS stimmt auch ohne migrate/"
else
fail "SHA256SUMS stimmt ohne migrate/ nicht"
fi
echo "=== Szenario 5: zweiter Lauf mit derselben Version bricht ab (Fall 7) ==="
BEFORE=$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")
set +e
OUT=$(run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "zweiter Lauf war erfolgreich"; fi
if echo "$OUT" | grep -q "existiert bereits"; then pass "Meldung 'existiert bereits'"; else fail "keine Meldung zum vorhandenen Archiv: $OUT"; fi
if [ "$(sha_of "$SANDBOX/dist/ninlight-0.9.9.tar.gz")" = "$BEFORE" ]; then
pass "vorhandenes Archiv unveraendert"
else
fail "vorhandenes Archiv wurde ueberschrieben"
fi
# Und eine andere Version laeuft weiterhin durch.
set +e
OUT=$(run_release "$SANDBOX" 0.9.10 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ] && [ -f "$SANDBOX/dist/ninlight-0.9.10.tar.gz" ]; then
pass "andere Version laeuft normal durch"
else
fail "andere Version schlaegt fehl: $OUT"
fi
echo "=== Szenario 6: doppelter Boden - Zugangsdaten im Abzug brechen den Lauf ab ==="
SANDBOX="$TMPROOT/s6"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(STUB_LEAK=1 TEST_SIGN_DIR="$KEYS_FULL" run_release "$SANDBOX" 0.9.9 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Lauf war trotz Zugangsdaten im Abzug erfolgreich"; fi
if echo "$OUT" | grep -q "Zugangsdaten aus config.local.cmake im Archiv gefunden"; then
pass "Meldung benennt den Fund"
else
fail "keine Meldung zum Fund: $OUT"
fi
if echo "$OUT" | grep -qF "$SECRET_SSID"; then
fail "die Meldung druckt den geheimen Wert selbst"
else
pass "die Meldung druckt den Wert nicht, nur den Namen"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz Fund gepackt"; fi
echo "=== Szenario 7: onboard.sh spricht die richtige Schnittstelle an ==="
ONB="$TMPROOT/x1/ninlight-0.9.9/onboard.sh"
STUBBIN="$TMPROOT/onb-bin"
mkdir -p "$STUBBIN"
CURLLOG="$TMPROOT/curl.log"
cat >"$STUBBIN/curl" <<'CURL'
#!/usr/bin/env bash
{ for a in "$@"; do printf '%s\n' "$a"; done; echo "--"; } >>"$CURL_LOG"
echo '{}'
CURL
chmod +x "$STUBBIN/curl"
: >"$CURLLOG"
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" "MeinWLAN" "geheim123" >/dev/null
if grep -q "^http://192.168.4.1:3333/api/v1/system/settings$" "$CURLLOG"; then
pass "PUT geht an system/settings im Onboarding-AP"
else
fail "falsche URL: $(cat "$CURLLOG")"
fi
if grep -q '^PUT$' "$CURLLOG"; then pass "Methode PUT"; else fail "keine PUT-Methode"; fi
if grep -qF '{"type": "wifi", "ssid": "MeinWLAN", "password": "geheim123"}' "$CURLLOG"; then
pass "Rumpf entspricht der Bruno-Anfrage 'Set WiFi'"
else
fail "falscher Rumpf: $(cat "$CURLLOG")"
fi
if grep -q "^http://192.168.4.1:3333/api/v1/system/info$" "$CURLLOG"; then
pass "danach wird system/info abgefragt"
else
fail "system/info wird nicht abgefragt"
fi
if grep -q "^Authorization:" "$CURLLOG"; then
fail "ohne --token wird trotzdem ein Authorization-Header geschickt"
else
pass "ohne --token kein Authorization-Header (offener Onboarding-AP)"
fi
: >"$CURLLOG"
CURL_LOG="$CURLLOG" PATH="$STUBBIN:$PATH" NINLIGHT_ONBOARD_WAIT=0 "$ONB" --token abc12345 "MeinWLAN" "geheim123" >/dev/null
if grep -q "^Authorization: abc12345$" "$CURLLOG"; then
pass "mit --token wird der Header geschickt (Fallback-AP)"
else
fail "--token kommt nicht als Authorization-Header an"
fi
HELP=$("$ONB" --help)
if echo "$HELP" | grep -qi "von hand" && echo "$HELP" | grep -q "ninlight-<mac4>"; then
pass "Hilfetext nennt die manuellen Schritte und den AP-Namen"
else
fail "Hilfetext erklaert die manuellen Schritte nicht: $HELP"
fi
set +e
"$ONB" >/dev/null 2>&1
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "ohne SSID bricht onboard.sh ab"; else fail "ohne SSID lief onboard.sh durch"; fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "alle Zusagen erfuellt"
else
echo "es gab Fehler" >&2
fi
exit "$FAILED"