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.
310 lines
13 KiB
Bash
Executable File
310 lines
13 KiB
Bash
Executable File
#!/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"
|