fix(station): Manifest-Form, Teilschluessel-Faelle und Schluessel-Testisolation
Fixrunde 1 zu "Migrationsimages beim Deploy signieren":
- manifest.json traegt ninlight_version und ein neues built jetzt auf oberster
Ebene statt pro Eintrag, dazu "file" statt der Redundanz einer Version pro
Bild - genau die Form aus der Spec (Abschnitt 4) und aus server.py's
Station.manifest_version(), die vorher immer "unbekannt" gelesen hat, weil
sie ausschliesslich auf oberster Ebene sucht (C1). Ein Testfall schickt das
Manifest jetzt zusaetzlich durch genau diese Funktion statt nur durch
json.load.
- Ein Schluesselverzeichnis, das existiert, aber weder Produktions- noch
Entwicklungsschluessel enthaelt, legt kein STAGE/migrate mehr an - das
vorherige Verhalten liess dafuer trotzdem ein (leeres) migrate/ mit
Manifest entstehen, das der volle rsync-Abgleich dann mit --delete gegen
ein vorhandenes migrate/ auf dem Pi durchgesetzt und es geleert hat (C2).
Aus demselben Grund war die _gz.img-Pflichtpruefung faelschlich
unconditional: fehlte sie, brach sogar ein Deploy ab, der gar nicht
signieren wollte, und nahm die Rettungsimages gleich mit (C3).
- Ein neues MIGRATE_COMPLETE-Flag (nur 1, wenn dieser Lauf mit BEIDEN
Schluesseln signiert hat) entscheidet jetzt, ob der Abgleich auf dem Pi
--delete benutzen und das Manifest ersetzen darf. Bei nur einem der beiden
Schluessel oder einem gezielten Deploy synct migrate/ ohne --delete und
merged das Manifest serverseitig mit dem vorhandenen zusammen, statt es zu
ueberschreiben - vorher raeumte ein Voll-Deploy mit nur dem dev-Schluessel
saemtliche _prod.img weg (C2, Teilfall), und ein gezielter Deploy liess ein
Manifest zurueck, das die unveraendert gebliebenen Ziele nicht mehr
erwaehnte (I1).
- ${HOME:-} statt $HOME beim Ermitteln von SIGN_DIR, damit ein Aufruf unter
`set -u` ohne gesetztes HOME (cron, env -i) nicht unnoetig abbricht, obwohl
nur die Rettungsimages gebraucht werden.
- tests/test_deploy.sh: die Sicherheitsabsicherung aus dem letzten Durchgang
hatte selbst eine Luecke (C4) - "${NINLIGHT_SIGN_DIR:-default}" in
run_deploy() greift nur, wenn die Variable NICHT gesetzt ist, und genau die
ist es, mit der man auf ~/workspace/fw-sign (das ECHTE Schluesselverzeichnis
auf diesem Rechner) zeigt. run_deploy() setzt NINLIGHT_SIGN_DIR jetzt hart
und liest dafuer nur noch die eigene Testvariable TEST_SIGN_DIR, die
deploy.sh nicht kennt und die deshalb nie von aussen vorbelegt sein kann;
am Dateikopf werden zur Sicherheit beide Variablen ge-unset. Verifiziert:
die Suite bleibt gruen, wenn NINLIGHT_SIGN_DIR vorher exportiert ist wie in
einem echten Entwicklerprofil.
- Szenario 15 vergleicht jetzt ALLE Zeilen beider privater Testschluessel
gegen Ausgabe UND Log, nicht nur eine einzelne Zeile des Produktions-
schluessels gegen die Ausgabe.
Vier neue/erweiterte Testfaelle (18-20) plus die Ergaenzung an Szenario 14:
Verzeichnis vorhanden aber leer, Voll-Deploy mit nur dev-Schluessel, echter
Server-Leser fuer das Manifest, gezielter Deploy fuehrt das Manifest zusammen
statt es zu verkuerzen. Jeder der vier Critical-Befunde wurde vor dem Fix per
Rot-Probe am kaputten Code bestaetigt.
This commit is contained in:
+117
-36
@@ -72,13 +72,21 @@ echo "== Migrationsimages signieren =="
|
||||
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
|
||||
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
|
||||
# ausgeliefert (Exit-Code bleibt 0).
|
||||
SIGN_DIR=${NINLIGHT_SIGN_DIR:-$HOME/workspace/fw-sign}
|
||||
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
|
||||
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
|
||||
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
|
||||
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_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
|
||||
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
|
||||
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
|
||||
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
|
||||
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
|
||||
MIGRATE_COMPLETE=0
|
||||
|
||||
if [ -d "$SIGN_DIR" ]; then
|
||||
mkdir -p "$STAGE/migrate"
|
||||
HAVE_PROD=0
|
||||
HAVE_DEV=0
|
||||
if [ -f "$KEY_PROD" ]; then
|
||||
@@ -91,36 +99,51 @@ if [ -d "$SIGN_DIR" ]; then
|
||||
else
|
||||
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
||||
fi
|
||||
for f in "${FILES[@]}"; do
|
||||
base=$(basename "$f")
|
||||
target=${base#ninlight_}; target=${target%_ota.img}
|
||||
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen
|
||||
# denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein signiertes
|
||||
# _ota.img waere ein Image, das die alte Lampe ablehnt oder halb annimmt.
|
||||
gz="$ROOT/build/$target/ninlight_${target}_gz.img"
|
||||
[ -f "$gz" ] || {
|
||||
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; }
|
||||
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/migrate/${target}_prod.img" \
|
||||
|| { echo "Fehler: Signieren von $target (prod) fehlgeschlagen" >&2; exit 1; }
|
||||
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
||||
# Erst HIER, nachdem feststeht dass ueberhaupt signiert wird, STAGE/migrate anlegen
|
||||
# und das fehlende _gz.img zum Abbruch machen (Fixrunde 1, C2+C3): ein
|
||||
# Schluesselverzeichnis ohne jeden Schluessel darf weder ein leeres migrate/
|
||||
# erzeugen (das wuerde ein vorhandenes migrate/ auf dem Pi mit --delete leerraeumen)
|
||||
# noch am Fehlen des _gz.img scheitern, wenn gar nicht signiert werden soll.
|
||||
mkdir -p "$STAGE/migrate"
|
||||
for f in "${FILES[@]}"; do
|
||||
base=$(basename "$f")
|
||||
target=${base#ninlight_}; target=${target%_ota.img}
|
||||
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
|
||||
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
|
||||
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
|
||||
# annimmt.
|
||||
gz="$ROOT/build/$target/ninlight_${target}_gz.img"
|
||||
[ -f "$gz" ] || {
|
||||
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; }
|
||||
if [ "$HAVE_PROD" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/migrate/${target}_prod.img" \
|
||||
|| { echo "Fehler: Signieren von $target (prod) fehlgeschlagen" >&2; exit 1; }
|
||||
fi
|
||||
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/migrate/${target}_dev.img" \
|
||||
|| { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; }
|
||||
fi
|
||||
done
|
||||
if [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ]; then
|
||||
MIGRATE_COMPLETE=1
|
||||
fi
|
||||
if [ "$HAVE_DEV" -eq 1 ]; then
|
||||
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/migrate/${target}_dev.img" \
|
||||
|| { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; }
|
||||
fi
|
||||
done
|
||||
VERSION_ARGS=()
|
||||
for t in "${!NINL_VERSION[@]}"; do
|
||||
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
||||
done
|
||||
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
||||
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
||||
# ueber das tatsaechlich abgelegte Image sagt.
|
||||
python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY'
|
||||
VERSION_ARGS=()
|
||||
for t in "${!NINL_VERSION[@]}"; do
|
||||
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
||||
done
|
||||
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
||||
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
||||
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
|
||||
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
|
||||
# liest: "ninlight_version" auf oberster Ebene, nicht pro Eintrag (Fixrunde 1, C1 -
|
||||
# der Server las vorher immer "unbekannt").
|
||||
python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
migrate_dir = sys.argv[1]
|
||||
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
|
||||
@@ -137,14 +160,33 @@ for name in sorted(os.listdir(migrate_dir)):
|
||||
entries.append({
|
||||
"target": target,
|
||||
"variant": variant,
|
||||
"file": name,
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"ninlight_version": version_map.get(target, ""),
|
||||
})
|
||||
|
||||
# Die Spec kennt nur EINE Firmware-Version pro Manifest, nicht eine pro Ziel. Im
|
||||
# Regelfall bauen alle Ziele eines Deploys aus demselben Firmware-Stand, weichen die
|
||||
# Versionen trotzdem ab, ist das kein Grund zum Abbrechen - nur eine Warnung, und die
|
||||
# alphabetisch erste Version gewinnt (deterministisch, nachvollziehbar).
|
||||
versions = sorted({version_map.get(e["target"], "") for e in entries})
|
||||
if len(versions) > 1:
|
||||
print(f"Warnung: unterschiedliche Firmware-Versionen in diesem Deploy {versions} "
|
||||
f"- manifest.json verwendet {versions[0]}", file=sys.stderr)
|
||||
ninlight_version = versions[0] if versions else ""
|
||||
|
||||
manifest = {
|
||||
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"ninlight_version": ninlight_version,
|
||||
"images": entries,
|
||||
}
|
||||
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
||||
json.dump({"images": entries}, fh, indent=2, sort_keys=True)
|
||||
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
PY
|
||||
else
|
||||
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
|
||||
fi
|
||||
else
|
||||
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
||||
fi
|
||||
@@ -164,9 +206,10 @@ rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
||||
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
|
||||
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
||||
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
|
||||
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" <<'REMOTE'
|
||||
set -e
|
||||
FULL_SYNC=$1
|
||||
MIGRATE_COMPLETE=$2
|
||||
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
||||
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
||||
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
||||
@@ -202,16 +245,54 @@ else
|
||||
sudo rsync -a -c --exclude=/migrate /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
||||
fi
|
||||
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
|
||||
# dem Entwicklungsrechner kein Schluesselverzeichnis vorhanden war), darf ein
|
||||
# vorhandenes migrate/ auf dem Pi NICHT geleert werden - sonst zerstoert ein Deploy
|
||||
# ohne Schluesselzugriff die Bestueckung frueherer Deploys (derselbe Fehler wie
|
||||
# frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen statt beim Ersetzen).
|
||||
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
|
||||
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
|
||||
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
|
||||
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
|
||||
# statt beim Ersetzen).
|
||||
if [ -d /tmp/ninlight-images/migrate ]; then
|
||||
sudo install -d -m 0755 "$ROOT_DIR/images/migrate"
|
||||
if [ "$FULL_SYNC" -eq 1 ]; then
|
||||
if [ "$FULL_SYNC" -eq 1 ] && [ "$MIGRATE_COMPLETE" -eq 1 ]; then
|
||||
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
|
||||
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
|
||||
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
|
||||
sudo rsync -a -c --delete /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/"
|
||||
else
|
||||
sudo rsync -a -c /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/"
|
||||
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
|
||||
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
|
||||
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
|
||||
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
|
||||
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
|
||||
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
||||
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
||||
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
||||
sudo rsync -a -c --exclude=manifest.json /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/"
|
||||
if [ -f /tmp/ninlight-images/migrate/manifest.json ]; then
|
||||
sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/manifest.json <<'MERGE'
|
||||
import json
|
||||
import sys
|
||||
|
||||
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
||||
with open(new_manifest_path) as fh:
|
||||
new_manifest = json.load(fh)
|
||||
try:
|
||||
with open(manifest_path) as fh:
|
||||
old_manifest = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
old_manifest = {"images": []}
|
||||
by_key = {(e.get("target"), e.get("variant")): e for e in old_manifest.get("images", [])}
|
||||
for e in new_manifest.get("images", []):
|
||||
by_key[(e.get("target"), e.get("variant"))] = e
|
||||
merged = {
|
||||
"built": new_manifest.get("built"),
|
||||
"ninlight_version": new_manifest.get("ninlight_version"),
|
||||
"images": sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or "")),
|
||||
}
|
||||
with open(manifest_path, "w") as fh:
|
||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
MERGE
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " kein Schluesselverzeichnis auf dem Entwicklungsrechner - migrate/ auf dem Pi bleibt unangetastet"
|
||||
|
||||
Reference in New Issue
Block a user