deploy.sh legt Rettungsabzuege jetzt in images/rescue-wlan/ (aus build/) und images/rescue-nocreds/ (aus build-nocreds/) ab, statt sie flach in images/ abzulegen - dasselbe Muster wie migrate-wlan/migrate-nocreds, inklusive derselben --delete-Vorsicht (nur bei vollstaendigem Satz). Alter flacher Bestand wird bei jedem Deploy aufgeraeumt, auch bei einem gezielten. README beschreibt den Umschalter jetzt fuer beide Betriebsarten.
654 lines
33 KiB
Bash
Executable File
654 lines
33 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Liefert Firmware-Images und den Stationscode an den Pi aus.
|
|
#
|
|
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
|
|
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
|
|
set -euo pipefail
|
|
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
|
|
shift || true
|
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
ROOT=$(cd "$HERE/../.." && pwd)
|
|
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
|
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
|
|
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
|
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
|
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
|
# dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst
|
|
# beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die
|
|
# RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt
|
|
# _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten.
|
|
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
|
|
|
declare -a FILES=()
|
|
declare -a RESCUE_NOCREDS_FILES=()
|
|
if [ $# -gt 0 ]; then
|
|
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
|
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
|
# unten).
|
|
FULL_SYNC=0
|
|
for t in "$@"; do
|
|
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
|
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
|
FILES+=("$f")
|
|
# build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf
|
|
# nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht
|
|
# gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU
|
|
# dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete,
|
|
# wenn diese Liste nicht jedes Ziel aus FILES abdeckt).
|
|
nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img"
|
|
if [ -f "$nf" ]; then
|
|
RESCUE_NOCREDS_FILES+=("$nf")
|
|
else
|
|
echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2
|
|
fi
|
|
done
|
|
else
|
|
FULL_SYNC=1
|
|
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
|
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
|
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
|
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
|
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
|
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
|
# kein Zufall.
|
|
declare -A SEEN_AT=()
|
|
while IFS= read -r f; do
|
|
base=$(basename "$f")
|
|
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
|
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
|
exit 1
|
|
fi
|
|
SEEN_AT[$base]=$f
|
|
FILES+=("$f")
|
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
|
# Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy
|
|
# oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt
|
|
# RESCUE_NOCREDS_FILES einfach leer, kein Abbruch.
|
|
if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then
|
|
declare -A SEEN_AT_NOCREDS=()
|
|
while IFS= read -r f; do
|
|
base=$(basename "$f")
|
|
if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then
|
|
echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2
|
|
exit 1
|
|
fi
|
|
SEEN_AT_NOCREDS[$base]=$f
|
|
RESCUE_NOCREDS_FILES+=("$f")
|
|
done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
|
fi
|
|
fi
|
|
|
|
echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
|
|
STAGE=$(mktemp -d)
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
|
declare -A NINL_VERSION=()
|
|
for f in "${FILES[@]}"; do
|
|
base=$(basename "$f")
|
|
target=${base#ninlight_}; target=${target%_ota.img}
|
|
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
|
|
# das faellt sonst erst am Geraet auf.
|
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
|
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
|
# shellcheck disable=SC2086
|
|
set -- $out
|
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
|
NINL_VERSION[$target]="$2"
|
|
# Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE -
|
|
# server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter
|
|
# wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort).
|
|
mkdir -p "$STAGE/rescue-wlan"
|
|
cp "$f" "$STAGE/rescue-wlan/$base"
|
|
done
|
|
|
|
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
|
|
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
|
|
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
|
|
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
|
|
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
|
|
# jedes Ziel gelaufen" (siehe oben).
|
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
|
|
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
|
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
|
base=$(basename "$f")
|
|
target=${base#ninlight_}; target=${target%_ota.img}
|
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
|
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
|
# shellcheck disable=SC2086
|
|
set -- $out
|
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
|
mkdir -p "$STAGE/rescue-nocreds"
|
|
cp "$f" "$STAGE/rescue-nocreds/$base"
|
|
done
|
|
else
|
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
|
fi
|
|
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
|
|
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
|
|
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
|
|
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
|
|
RESCUE_NOCREDS_COMPLETE=1
|
|
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
|
|
RESCUE_NOCREDS_COMPLETE=0
|
|
else
|
|
declare -A RESCUE_NOCREDS_TARGETS=()
|
|
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
|
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
|
RESCUE_NOCREDS_TARGETS[$t]=1
|
|
done
|
|
for f in "${FILES[@]}"; do
|
|
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
|
|
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
|
|
done
|
|
fi
|
|
|
|
echo "== Migrationsimages signieren =="
|
|
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
|
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
|
# 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).
|
|
# ${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
|
|
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
|
|
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
|
# einer, darf --delete auf dem Pi nicht laufen.
|
|
NOCREDS_COMPLETE=0
|
|
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
|
|
|
|
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
|
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
|
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
|
|
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
|
|
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
|
|
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
|
|
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
|
|
sign_set() {
|
|
local src_root=$1 stage_sub=$2 strictness=$3
|
|
local complete=1 produced=0
|
|
local f base target gz
|
|
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="$src_root/$target/ninlight_${target}_gz.img"
|
|
if [ ! -f "$gz" ]; then
|
|
if [ "$strictness" = "strict" ]; then
|
|
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
|
|
fi
|
|
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
|
|
complete=0
|
|
continue
|
|
fi
|
|
mkdir -p "$STAGE/$stage_sub"
|
|
if [ "$HAVE_PROD" -eq 1 ]; then
|
|
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|
|
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
|
produced=1
|
|
fi
|
|
if [ "$HAVE_DEV" -eq 1 ]; then
|
|
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|
|
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
|
produced=1
|
|
fi
|
|
done
|
|
[ "$produced" -eq 1 ] || return 1
|
|
local VERSION_ARGS=()
|
|
local t
|
|
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. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
|
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
|
|
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
|
|
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
|
|
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
|
|
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
|
|
python3 - "$STAGE/$stage_sub" "${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:])
|
|
entries = []
|
|
for name in sorted(os.listdir(migrate_dir)):
|
|
if name.endswith("_prod.img"):
|
|
target, variant = name[: -len("_prod.img")], "prod"
|
|
elif name.endswith("_dev.img"):
|
|
target, variant = name[: -len("_dev.img")], "dev"
|
|
else:
|
|
continue
|
|
with open(os.path.join(migrate_dir, name), "rb") as fh:
|
|
data = fh.read()
|
|
entries.append({
|
|
"target": target,
|
|
"variant": variant,
|
|
"file": name,
|
|
"bytes": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"ninlight_version": version_map.get(target, ""),
|
|
})
|
|
|
|
manifest = {
|
|
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"images": entries,
|
|
}
|
|
versions = {e["ninlight_version"] for e in entries}
|
|
if len(versions) == 1:
|
|
(version,) = versions
|
|
if version:
|
|
manifest["ninlight_version"] = version
|
|
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
PY
|
|
[ "$complete" -eq 1 ]
|
|
}
|
|
|
|
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-Migrationsimages" >&2
|
|
fi
|
|
if [ -f "$KEY_DEV" ]; then
|
|
HAVE_DEV=1
|
|
else
|
|
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
|
fi
|
|
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
|
BOTH_KEYS=0
|
|
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
|
|
|
|
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
|
|
# und die Station beide vorhalten soll (der Bediener schaltet per
|
|
# "ninlight-station bildsatz" bzw. am Taster um):
|
|
#
|
|
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
|
|
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
|
|
#
|
|
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
|
|
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
|
|
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
|
|
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
|
|
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
|
|
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
|
|
echo " Bildsatz migrate-wlan (aus build/)"
|
|
if sign_set "$ROOT/build" migrate-wlan strict; then
|
|
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
|
|
fi
|
|
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
|
|
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
|
|
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
|
|
else
|
|
if [ ! -d "$STAGE/migrate-nocreds" ]; then
|
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
|
fi
|
|
fi
|
|
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
|
|
|
|
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
|
|
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
|
|
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
|
|
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
|
|
# dagegen ungeprueft, was in build-nocreds/ lag.
|
|
#
|
|
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
|
|
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
|
|
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
|
|
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
|
# Fehler. Lieber ein abgebrochener Deploy.
|
|
#
|
|
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
|
|
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
|
|
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
|
|
if [ -d "$STAGE/migrate-nocreds" ]; then
|
|
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
|
|
LOCAL="$ROOT/config.local.cmake"
|
|
if [ -f "$LOCAL" ]; then
|
|
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || 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 Bildsatz 'ohne-wlan' gefunden, "
|
|
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
|
sys.exit(1)
|
|
print(" sauber (%d geprueft)" % len(needles))
|
|
CHECKPY
|
|
else
|
|
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
|
fi
|
|
fi
|
|
|
|
echo "== uebertragen =="
|
|
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
|
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
|
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
|
|
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
|
|
# rsync ein solches Update dann stillschweigend ueberspringen.
|
|
#
|
|
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
|
|
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
|
|
# ausgelieferte Verzeichnis.
|
|
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
|
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" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
|
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
|
"$HOST:/tmp/"
|
|
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
|
|
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
|
|
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
|
|
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
|
|
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
|
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
|
|
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
|
|
# __pycache__ dieses Rechners.
|
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
|
# 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" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
|
|
set -e
|
|
FULL_SYNC=$1
|
|
MIGRATE_COMPLETE=$2
|
|
NOCREDS_COMPLETE=$3
|
|
RESCUE_NOCREDS_COMPLETE=$4
|
|
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}
|
|
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
|
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
|
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
|
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
|
|
|
sudo install -d -m 0755 "$ROOT_DIR/images"
|
|
if [ "$FULL_SYNC" -eq 1 ]; then
|
|
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
|
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
|
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
|
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
|
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
|
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
|
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
|
REMOVED=""
|
|
if [ -n "$OLD_LIST" ]; then
|
|
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
|
fi
|
|
if [ -n "$REMOVED" ]; then
|
|
echo " voller Abgleich - folgende Images werden entfernt:"
|
|
echo "$REMOVED" | sed 's/^/ /'
|
|
else
|
|
echo " voller Abgleich - keine Images werden entfernt"
|
|
fi
|
|
# migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
|
|
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
|
|
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
|
|
# unter /tmp/ninlight-images deshalb gar nicht existiert.
|
|
#
|
|
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
|
|
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
|
|
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
|
|
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
|
|
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
|
|
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
|
|
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
|
else
|
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
|
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
|
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
|
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds \
|
|
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
|
fi
|
|
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
|
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
|
|
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
|
|
#
|
|
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
|
|
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
|
|
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
|
|
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
|
|
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
|
|
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
|
|
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
|
|
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
|
|
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
|
|
# greift dort also einfach nicht.
|
|
sync_image_set() {
|
|
set=$1
|
|
complete=$2
|
|
if [ -d "/tmp/ninlight-images/$set" ]; then
|
|
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
|
|
if [ "$FULL_SYNC" -eq 1 ] && [ "$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/$set/" "$ROOT_DIR/images/$set/"
|
|
else
|
|
# 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/$set/" "$ROOT_DIR/images/$set/"
|
|
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
|
|
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/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)
|
|
if not isinstance(new_manifest, dict):
|
|
new_manifest = {}
|
|
try:
|
|
with open(manifest_path) as fh:
|
|
old_manifest = json.load(fh)
|
|
except (OSError, ValueError):
|
|
old_manifest = {}
|
|
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
|
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
|
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
|
|
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
|
|
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
|
|
# Stationscode zurueck.
|
|
if not isinstance(old_manifest, dict):
|
|
old_manifest = {}
|
|
old_images = old_manifest.get("images")
|
|
if not isinstance(old_images, list):
|
|
old_images = []
|
|
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
|
|
new_images = new_manifest.get("images")
|
|
if not isinstance(new_images, list):
|
|
new_images = []
|
|
for e in new_images:
|
|
if isinstance(e, dict):
|
|
by_key[(e.get("target"), e.get("variant"))] = e
|
|
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
|
|
merged = {
|
|
"built": new_manifest.get("built"),
|
|
"images": merged_images,
|
|
}
|
|
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
|
|
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
|
|
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
|
|
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
|
|
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
|
|
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
|
|
# Fall, dass wirklich alle Eintraege uebereinstimmen.
|
|
versions = {e.get("ninlight_version") for e in merged_images}
|
|
if len(versions) == 1:
|
|
(version,) = versions
|
|
if isinstance(version, str) and version:
|
|
merged["ninlight_version"] = version
|
|
with open(manifest_path, "w") as fh:
|
|
json.dump(merged, fh, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
MERGE
|
|
fi
|
|
fi
|
|
else
|
|
# Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
|
|
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
|
|
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
|
|
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
|
|
# (Fixrunde 2, Kleinigkeit).
|
|
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
|
fi
|
|
}
|
|
|
|
# Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
|
|
# Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
|
|
# andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
|
|
# fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
|
|
sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
|
|
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
|
|
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
|
|
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
|
|
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
|
|
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
|
|
# Schluesseln.
|
|
sync_image_set rescue-wlan 1
|
|
sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE"
|
|
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
|
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
|
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
|
if [ -d "$ROOT_DIR/images/migrate" ]; then
|
|
sudo rm -rf "$ROOT_DIR/images/migrate"
|
|
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
|
fi
|
|
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
|
|
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
|
|
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
|
|
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
|
|
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
|
|
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
|
|
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
|
|
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
|
|
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
|
|
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
|
|
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
|
|
if [ -n "$OLD_FLAT_RESCUE" ]; then
|
|
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
|
|
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
|
|
fi
|
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
|
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
|
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
|
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
|
|
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
|
|
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
|
|
sudo install -d -m 0755 "$ROOT_DIR/display"
|
|
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
|
|
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
|
|
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
|
|
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
|
|
sudo install -d -m 0755 "$ROOT_DIR/button"
|
|
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
|
|
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
|
|
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
|
|
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
|
|
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
|
|
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
|
|
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
|
|
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
|
|
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
|
|
sudo systemctl daemon-reload
|
|
if systemctl is-active --quiet ninlight-station; then
|
|
if sudo systemctl restart ninlight-station; then
|
|
echo " Dienst neu gestartet"
|
|
else
|
|
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo " Dienst laeuft nicht (Station ist aus)"
|
|
fi
|
|
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
|
|
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
|
|
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
|
|
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
|
|
# uebersehen.
|
|
if systemctl is-active --quiet ninlight-display; then
|
|
if sudo systemctl restart ninlight-display; then
|
|
echo " Anzeigedienst neu gestartet"
|
|
else
|
|
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
|
|
fi
|
|
else
|
|
echo " Anzeigedienst laeuft nicht"
|
|
fi
|
|
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
|
|
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
|
|
if systemctl is-active --quiet ninlight-button; then
|
|
if sudo systemctl restart ninlight-button; then
|
|
echo " Tasterdienst neu gestartet"
|
|
else
|
|
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
|
|
fi
|
|
else
|
|
echo " Tasterdienst laeuft nicht"
|
|
fi
|
|
REMOTE
|
|
|
|
echo "fertig."
|