Files
Michael Sauer 7fd279721f chore: HA-Kopie und die von der Station abgedeckten Werkzeuge wieder heraus
Der Shield-Zweig brachte zwei Verzeichnisse mit, die nichts mit dem Board zu
tun haben. Nach Ruecksprache mit Michael bleibt davon nur, was es sonst
nirgends gibt.

ha-integration/ faellt ganz weg: die Home-Assistant-Integration hat ihr
eigenes Repo (ha-ninlight, privat im Gitea) und laeuft dort in Michaels
Produktivinstanz. Eine zweite Kopie hier wuerde unweigerlich auseinanderlaufen,
und dann waere unklar, welche gilt.

Aus qconnex-listen/ fliegen die Teile, die tools/station betriebsfertig kann:
hostapd.conf, dnsmasq-qcx.conf, up.sh, down.sh und otaserve.py bauen von Hand
den Zugangspunkt "qconnex-firmware" und liefern Abbilder aus - genau der
Migrationsmodus der Pi-Station, dort als Dienst, mit Produkt-ID-Tabelle und
Signaturpruefung. Ihre eigene hostapd.conf nennt sich im Kopf selbst
"temporary experiment, do not enable as a service".

Es bleibt, wofuer es bei uns nichts gibt: onboard.py spricht die ALTE Firmware
ueber ihren TLS-Socket auf Port 2223 an (unser onboard.sh richtet dagegen eine
bereits migrierte ninlight-Lampe ein - anderer Zweck), dazu die
Analysewerkzeuge tlssniff.py, raw3333.py und logserver.py sowie bulb-map.txt.

Ausserdem in diesem Commit: die erwarteten Zahlen in test_release.sh von 35/32
auf 39/36. Der Merge lief konfliktfrei durch und liess sie trotzdem falsch
stehen - der Zweig zweigte vor der Reparatur der Zugangsdatenpruefung ab und
konnte ihre neue Meldung nicht kennen, waehrend sie sein neuntes Ziel nicht
kennen konnte. Woran die Zahlen haengen, steht jetzt im Kommentar daneben.
2026-09-26 18:01:56 +02:00

664 lines
28 KiB
Bash
Executable File

#!/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.
#
# Und zwar an der Stelle, an der sie beim echten Bau auch landen: IM KOMPRIMIERTEN
# RUMPF. Ein Abbild ist 56 Byte Kopf, dann ein gzip-Strom, dann - beim _ota.img - 512
# Byte Trailer. Frueher schrieb der Stub reinen Klartext in eine Datei, die er nur
# "..._ota.img" nannte; damit fand die rohe Bytesuche in release.sh die Werte, und Fall
# 5 war gruen, obwohl der Pruefkoerper an echten Abbildern nie irgendetwas gefunden
# haette. Ein Testartefakt, das dem Pruefling recht gibt, ist schlimmer als kein Test.
#
# 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 ledshield_rgb)
# 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)
# STUB_TRUNC_TARGET schneidet beim _ota.img dieses Ziels den gzip-Strom ab - das
# Abbild laesst sich dann nicht entpacken. Das _gz.img bleibt heil,
# damit das Signieren davor nicht schon abbricht.
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" TRUNC="${STUB_TRUNC_TARGET:-}" python3 - <<'PY'
import gzip
import io
import os
target, outdir, creds = os.environ["TARGET"], os.environ["OUTDIR"], os.environ["CREDS"]
# Der Rumpf ist ~100 kB gleichfoermiger Text - so wie echte Firmware gross genug, dass
# deflate ihn WIRKLICH komprimiert. Zwei Klippen liegen hier dicht nebeneinander:
# zu kleiner Rumpf -> gzip-Strom unter 1024 Byte, und nl_sign.py lehnt ab;
# unkomprimierbarer Rumpf (etwa Zufallsbytes) -> deflate legt ihn als "stored block"
# UNVERAENDERT ab, der Klartext stuende wieder roh in der Datei und der Test prueft
# erneut eine Welt, die es nicht gibt.
body = bytearray(
b"".join(b"ninlight-%s-block-%06d\n" % (target.encode(), i) for i in range(4000)))
tag = ("firmware-%s-" % target).encode()
body[0:len(tag)] = tag
blob = creds.encode()
if blob:
# Die Zugangsdaten liegen IM Rumpf - also nach dem Packen im komprimierten Strom
# und nicht im Klartext in der Datei. Genau wie beim echten Compiler.
body[100:100 + len(blob)] = blob
# Aufbau wie beim echten Bau: 56 Byte Kopf (wm_tool), dann der gzip-Strom.
header = bytearray(b"\x9f\xff\xff\xa0" + b"\x00" * 52)
header[4:8] = b"STUB"
buf = io.BytesIO()
# mtime=0: sonst haengt der Dateiinhalt an der Uhrzeit und Vergleiche werden wacklig.
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gf:
gf.write(bytes(body))
stream = buf.getvalue()
raw = bytes(header) + stream
# nl_sign.py verlangt eine durch 4 teilbare Laenge von mindestens 1024 Byte. Die
# Fuellbytes stehen hinter dem gzip-Strom und stoeren das Entpacken nicht.
raw += b"\x00" * (-len(raw) % 4)
assert len(raw) >= 1024, "Stub-Abbild zu kurz fuer nl_sign.py"
# Selbstkontrolle gegen genau den Fehler, der diese Suite jahrelang wertlos machte.
assert not blob or blob not in raw, \
"Zugangsdaten stehen roh in der Datei - der Rumpf wurde nicht wirklich komprimiert"
with open(os.path.join(outdir, "ninlight_%s_gz.img" % target), "wb") as fh:
fh.write(raw) # roh, ohne NINL-Trailer - das signiert nl_sign.py
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
ota_body = raw
if os.environ.get("TRUNC") == target:
# Abgeschnittener gzip-Strom: decompressobj liefert dafuer stillschweigend nur den
# lesbaren Teil, wenn niemand dec.eof abfragt.
ota_body = bytes(header) + stream[:len(stream) // 2]
with open(os.path.join(outdir, "ninlight_%s_ota.img" % target), "wb") as fh:
fh.write(ota_body + 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_TRUNC_TARGET="${STUB_TRUNC_TARGET:-}" \
STUB_LEAK_VALUE="$SECRET_SSID" \
"$sandbox/tools/release.sh" "$version"
}
# Sucht die Zugangsdaten in jeder Datei unterhalb eines Verzeichnisses - in den
# Rohbytes UND im entpackten Rumpf jedes Abbilds. Nur roh zu suchen hiesse, die
# Zusage "keine Zugangsdaten im Archiv" gar nicht zu pruefen: im gzip-Strom steht
# kein Klartext. Ein Abbild, das sich nicht entpacken laesst, wird hier gemeldet und
# nicht verschwiegen - sonst haette die Gegenprobe ein stilles Loch.
# $2 (optional) = "roh": nur die Rohbytes durchsuchen, nichts entpacken.
grep_secrets() {
local dir=$1 mode=${2:-entpackt}
SSID="$SECRET_SSID" PASS="$SECRET_PASS" TOKEN="$SECRET_TOKEN" \
python3 - "$dir" "$mode" <<'PY'
import os
import sys
import zlib
HEADER_LEN = 56
root, mode = sys.argv[1], sys.argv[2]
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)
rel = os.path.relpath(p, root)
if os.path.islink(p):
continue
with open(p, "rb") as fh:
data = fh.read()
views = [data]
if mode != "roh" and fn.endswith(".img"):
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
try:
plain = dec.decompress(data[HEADER_LEN:])
if not dec.eof:
raise ValueError("gzip-Strom unvollstaendig")
views.append(plain)
except Exception as exc:
print("NICHT ENTPACKBAR %s (%s)" % (rel, exc))
for name, needle in needles.items():
if any(needle in view for view in views):
print("%s in %s" % (name, rel))
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" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
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 (roh UND im entpackten Rumpf 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 (oder ein Abbild liess sich nicht entpacken):
$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
# Und zwar realistisch: IM komprimierten Rumpf, nicht als Klartext in der Datei. Faende
# die rohe Suche sie, prueften alle Faelle oben eine Welt, die es nicht gibt - an echten
# Abbildern findet eine rohe Suche nie etwas.
if grep_secrets "$TMPROOT/probe" roh | grep -q .; then
fail "Gegenprobe: der Stub legt die Zugangsdaten im Klartext ab - unrealistisches Abbild"
else
pass "Gegenprobe: im Rohbyteinhalt des Stub-Abbilds steht kein Klartext (nur im gzip-Rumpf)"
fi
# Und das Abbild hat wirklich den Aufbau des echten: 56 Byte Kopf, gzip, 512 Byte
# Trailer mit NINL am Anfang.
if python3 - "$TMPROOT/probe/evk" <<'PY'
import os
import sys
import zlib
d = sys.argv[1]
ota = open(os.path.join(d, "ninlight_evk_ota.img"), "rb").read()
gz = open(os.path.join(d, "ninlight_evk_gz.img"), "rb").read()
assert ota[-512:][:4] == b"NINL", "kein NINL am Trailer-Anfang"
assert ota[:-512] == gz, "_ota.img ist nicht _gz.img + 512 Byte Trailer"
assert gz[56:58] == b"\x1f\x8b", "kein gzip-Strom ab Byte 56"
for raw in (ota, gz):
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
dec.decompress(raw[56:])
assert dec.eof, "gzip-Strom unvollstaendig"
PY
then
pass "Gegenprobe: Stub-Abbild hat den echten Aufbau (56 Byte Kopf, gzip, NINL-Trailer)"
else
fail "Gegenprobe: Stub-Abbild hat nicht den Aufbau eines echten Abbilds"
fi
echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---"
# 9 ota + 9 flash + 18 migrate + bruno/ninlight.yml + README.md + onboard.sh = 39,
# davon 36 Abbilder. SHA256SUMS entsteht erst nach der Pruefung.
#
# Die Zahlen haengen an der Laenge von TARGETS: mit ledshield_rgb kamen vier
# Dateien dazu (je ein _ota, ein _gz und zwei signierte Migrationsabzuege), alle
# vier sind Abbilder. Wer ein Ziel ergaenzt, passt sie hier mit an - der Merge
# des Shield-Zweigs lief konfliktfrei durch und liess trotzdem 35/32 stehen.
if echo "$OUT" | grep -qF "sauber (39 Dateien geprueft, davon 36 Abbilder entpackt)"; then
pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder"
else
fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')"
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 ' ')" = "${#TARGETS[@]}" ]; 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 KOMPRIMIERTEN Rumpf brechen den Lauf ab ==="
# Das ist der Kern: der Stub legt die SSID im gzip-Rumpf jedes Abbilds ab, wie es der
# echte Compiler taete. Ein Pruefkoerper, der nur die Rohbytes durchsucht, laesst diesen
# Lauf anstandslos durch und packt Michaels WLAN-Daten ins Archiv.
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
if echo "$OUT" | grep -q "NINLIGHT_WIFI_SSID in ota/ninlight_evk_ota.img"; then
pass "Meldung nennt den Namen des Wertes und die Datei"
else
fail "Meldung nennt Name und Datei nicht: $OUT"
fi
echo "=== Szenario 6b: Klartext in einer Nicht-Abbild-Datei bricht den Lauf ab ==="
# Nicht jede Datei im Archiv ist ein Abbild: README, onboard.sh und die Bruno-Collection
# sind Text. Fuer sie bleibt die rohe Bytesuche die einzige Pruefung - sie darf durch das
# Entpacken nicht verlorengehen.
SANDBOX="$TMPROOT/s6b"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
printf '\n# versehentlich notiert: %s\n' "$SECRET_PASS" >>"$SANDBOX/bruno/ninlight.yml"
set +e
OUT=$(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 Klartext in bruno/ninlight.yml erfolgreich"; fi
if echo "$OUT" | grep -q "NINLIGHT_WIFI_PASSWORD in bruno/ninlight.yml"; then
pass "Meldung nennt NINLIGHT_WIFI_PASSWORD und die Datei"
else
fail "Meldung nennt den Fund in bruno/ninlight.yml nicht: $OUT"
fi
if echo "$OUT" | grep -qF "$SECRET_PASS"; then
fail "die Meldung druckt das Passwort selbst"
else
pass "die Meldung druckt das Passwort 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 6c: ein nicht entpackbares Abbild bricht den Lauf ab ==="
# Ein abgeschnittener gzip-Strom ist der heimtueckische Fall: decompressobj liefert
# stillschweigend nur den lesbaren Teil. Was dahinter steht, bliebe ungeprueft - und
# genau dort koennte das Passwort liegen. Ungeprueft durchlassen ist keine Option.
SANDBOX="$TMPROOT/s6c"
setup_sandbox "$SANDBOX"
setup_stub_docker "$SANDBOX/stub-docker" "$SANDBOX"
set +e
OUT=$(STUB_TRUNC_TARGET=e14_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 "Lauf mit unentpackbarem Abbild war erfolgreich"; fi
if echo "$OUT" | grep -q "ota/ninlight_e14_cw_ota.img"; then
pass "Meldung nennt die betroffene Datei"
else
fail "Meldung nennt die betroffene Datei nicht: $OUT"
fi
if echo "$OUT" | grep -q "laesst sich nicht entpacken"; then
pass "Meldung sagt, dass das Abbild nicht entpackbar ist"
else
fail "Meldung erklaert den Grund nicht: $OUT"
fi
if [ ! -e "$SANDBOX/dist/ninlight-0.9.9.tar.gz" ]; then pass "kein Archiv gepackt"; else fail "Archiv trotz unentpackbarem Abbild 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"