# shellcheck shell=sh # # Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte. # # Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich # `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER # Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere, # und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu # schreiben. # # Zwei Regeln, aus denen sich alles andere ergibt: # # 1. `report` schreibt IMMER zuerst lokal, dann erst der Sendeversuch. Vor # `wireguard_joined` gibt es keinen Weg zu CluPilot (Spec §5) — die Zeilen # sammeln sich und werden von `flush_reports` nachgereicht. # 2. Ein gescheiterter Sendeversuch ist NORMAL und darf den Lauf nie abbrechen. # Das Skript läuft unter `set -e`; jede Netzoperation hier ist deshalb # ausdrücklich abgefangen. # # Der Zeitstempel ist der des Ereignisses, nicht der der Ankunft. Sonst sieht # eine zwanzigminütige Installation in der Konsole aus wie eine Sekunde, und # niemand kann erkennen, welcher Abschnitt lange gedauert hat. # # --------------------------------------------------------------------------- # Der Umschlag, gegen den die Plattform schreibt # --------------------------------------------------------------------------- # # Die Abschnittsschlüssel sind laut Plan die einzige Absprache zwischen den zwei # Plänen. Der Umschlag darum war nirgends festgelegt — er wird hier festgelegt, # damit die Plattformseite EINE Stelle zum Nachlesen hat: # # POST {api}/host/progress # Content-Type: application/json # # {"code": "", "events": [ # {"section": "rescue_checked", "state": "done", # "message": "...", "occurred_at": "2026-07-30T18:04:11Z"} # ]} # # `state` ist `done` oder `failed`. Einen Zustand für „läuft gerade" gibt es # absichtlich nicht: die Konsole leitet „offen" aus dem Fehlen einer Meldung ab # (Spec §7), und ein dritter Zustand wäre eine zweite Wahrheit über dieselbe # Sache. # # `message` ist immer vorhanden, notfalls leer — ein Feld, das mal da ist und # mal nicht, ist die teurere Variante. # Überschreibbar, damit sich die Melde-Mechanik ohne root und ohne echte # Maschine durchspielen lässt. Im Betrieb setzt niemand die Variable: der # Adminbereich schreibt sie nicht in die kopierte Zeile, und das Skript läuft # ohnehin als root. CLUPILOT_STATE_DIR="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}" CLUPILOT_PROGRESS_LOG="${CLUPILOT_STATE_DIR}/progress.jsonl" CLUPILOT_PROGRESS_SENT="${CLUPILOT_STATE_DIR}/progress.sent" # Kurz gehalten, weil das hier im Regelfall gegen eine Adresse läuft, die es # noch nicht gibt. Ein langer Zeitablauf je Abschnitt summiert sich zu Minuten, # in denen nichts passiert und niemand sieht warum. CLUPILOT_HTTP_CONNECT_TIMEOUT=5 CLUPILOT_HTTP_MAX_TIME=15 # Legt das Zustandsverzeichnis an. 0700, weil hier später auch die # Aufrufargumente liegen und der WireGuard-Schlüssel darin steht. init_reporting() { mkdir -p "$CLUPILOT_STATE_DIR" chmod 700 "$CLUPILOT_STATE_DIR" [ -f "$CLUPILOT_PROGRESS_LOG" ] || : > "$CLUPILOT_PROGRESS_LOG" [ -f "$CLUPILOT_PROGRESS_SENT" ] || echo 0 > "$CLUPILOT_PROGRESS_SENT" } # Macht aus beliebigem Text den Rumpf eines JSON-Strings. # # Der Text kommt oft aus der Ausgabe eines fehlgeschlagenen Befehls, ist also # nicht unter unserer Kontrolle. Reihenfolge zählt: der Backslash zuerst, sonst # verdoppelt der Lauf danach die gerade gesetzten Fluchtzeichen wieder mit. # Zeilenumbrüche und Tabulatoren werden zu Leerzeichen — eine Meldung ist eine # Zeile, und eine eingebettete Zeile zerlegte die JSONL-Datei. json_escape() { printf '%s' "$1" \ | tr '\n\r\t' ' ' \ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' } # Der einzige Zeitstempel-Erzeuger im ganzen Skript. UTC mit `Z`, weil das # Skript auf Maschinen mit jeder denkbaren Zonen-Einstellung läuft und die # Konsole nach R19 ohnehin in UTC speichert und erst bei der Anzeige umrechnet. now_iso() { date -u '+%Y-%m-%dT%H:%M:%SZ' } # report
[message] # # Gibt immer 0 zurück. Ein Melden, das den Lauf abbricht, wäre die Diagnose, die # den Patienten umbringt. report() { _section="$1" _state="$2" _message="${3:-}" printf '{"section":"%s","state":"%s","message":"%s","occurred_at":"%s"}\n' \ "$(json_escape "$_section")" \ "$(json_escape "$_state")" \ "$(json_escape "$_message")" \ "$(now_iso)" \ >> "$CLUPILOT_PROGRESS_LOG" log "[${_state}] ${_section}${_message:+ — ${_message}}" flush_reports return 0 } # Reicht alles nach, was noch nicht bestätigt gesendet wurde. # # Der Stand steht als Zeilenzahl in einer eigenen Datei, statt die Zeilen in der # Protokolldatei umzuschreiben: `progress.jsonl` ist damit reines Anhängen und # übersteht einen Stromausfall mitten im Schreiben ohne Sonderbehandlung. # # Gibt immer 0 zurück. flush_reports() { [ -f "$CLUPILOT_PROGRESS_LOG" ] || return 0 _sent="$(cat "$CLUPILOT_PROGRESS_SENT" 2>/dev/null || echo 0)" _total="$(wc -l < "$CLUPILOT_PROGRESS_LOG")" _sent="$(printf '%s' "$_sent" | tr -cd '0-9')" [ -n "$_sent" ] || _sent=0 [ "$_total" -gt "$_sent" ] || return 0 _pending="$(sed -n "$((_sent + 1)),\$p" "$CLUPILOT_PROGRESS_LOG")" [ -n "$_pending" ] || return 0 _events="$(printf '%s' "$_pending" | tr '\n' ',' | sed -e 's/,$//')" _body="$(printf '{"code":"%s","events":[%s]}' "$(json_escape "$CLUPILOT_CODE")" "$_events")" if post_json "${CLUPILOT_API}/host/progress" "$_body"; then # Erst nach der bestätigten Antwort. Andersherum ginge genau der # Abschnitt verloren, dessen Zustellung gescheitert ist. echo "$_total" > "$CLUPILOT_PROGRESS_SENT" log "nachgereicht: $((_total - _sent)) Meldung(en)" fi return 0 } # section_done
— 0, wenn dieser Abschnitt schon einmal `done` gemeldet # hat. # # Die Fortschrittsdatei IST der Zustand. Es gibt keine zweite Buchführung # darüber, was schon lief — eine zweite wäre eine zweite Wahrheit, und nach dem # Neustart in Task 3 wäre genau sie diejenige, die fehlt. # # Bewusst gegen den festen Rumpf geprüft, den `report` schreibt, statt gegen ein # lose passendes Muster: `grep rescue_checked` fände auch eine Meldung, in deren # `message` der Name zufällig vorkommt. section_done() { [ -f "$CLUPILOT_PROGRESS_LOG" ] || return 1 grep -q "^{\"section\":\"$1\",\"state\":\"done\"," "$CLUPILOT_PROGRESS_LOG" } # run_section
— führt einen Abschnitt aus, wenn er noch # nicht erledigt ist, und meldet sein Ergebnis. # # Das `if` statt `&&` ist Absicht: unter `set -e` bricht ein `&&`-Ausdruck den # Lauf ab, bevor `report ... failed` je liefe — und dann stünde in der Konsole # nichts, obwohl genau dieser Fall der ist, für den sie gebaut wurde. run_section() { _key="$1" _fn="$2" if section_done "$_key"; then log "übersprungen: ${_key} (schon erledigt)" return 0 fi if "$_fn"; then report "$_key" done "${CLUPILOT_SECTION_NOTE:-}" CLUPILOT_SECTION_NOTE='' return 0 fi report "$_key" failed "${CLUPILOT_SECTION_NOTE:-abgebrochen in ${_key}}" CLUPILOT_SECTION_NOTE='' return 1 } # post_json — 0 bei 2xx, sonst 1. Schreibt nichts nach stdout. # # Der Rückfall auf wget ist kein Luxus: Rettungssysteme sind unterschiedlich # bestückt, und genau hier ist noch keine Gelegenheit gewesen, etwas # nachzuinstallieren. post_json() { _url="$1" _payload="$2" if command -v curl >/dev/null 2>&1; then # `-f` lässt curl bei 4xx/5xx mit Fehler enden, statt die Fehlerseite # als Erfolg zu behandeln. curl -sS -f \ --connect-timeout "$CLUPILOT_HTTP_CONNECT_TIMEOUT" \ --max-time "$CLUPILOT_HTTP_MAX_TIME" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -X POST --data "$_payload" \ "$_url" >/dev/null 2>&1 return $? fi if command -v wget >/dev/null 2>&1; then wget -q -O /dev/null \ --timeout="$CLUPILOT_HTTP_MAX_TIME" \ --header='Content-Type: application/json' \ --header='Accept: application/json' \ --post-data="$_payload" \ "$_url" return $? fi return 1 } # http_get — Rumpf nach stdout, 0 bei Erfolg. # # Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs; # dafür gibt es `http_download`, das nicht durch den Speicher geht. http_get() { if command -v curl >/dev/null 2>&1; then curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null return $? fi if command -v wget >/dev/null 2>&1; then wget -q -O - --timeout=120 "$1" 2>/dev/null return $? fi return 1 } # http_download — 0 bei Erfolg. # # Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf # fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt, # wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme # fängt es zwar auch ab, aber erst nach dem Herunterladen. http_download() { _url="$1" _target="$2" rm -f "$_target" if command -v curl >/dev/null 2>&1; then curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null return $? fi if command -v wget >/dev/null 2>&1; then wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null return $? fi return 1 }