From 4762fa29c34b2a9a518ceaeea6641462847fd8d8 Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Mon, 17 Aug 2026 15:58:00 +0200 Subject: [PATCH] Fehlervorlage und ein Diagnoseskript, das sie fuellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Vorlage allein bringt wenig — sie stellt Fragen, die der Meldende meist nicht beantworten kann. Deshalb zwei Teile: diagnose.sh sammelt in einem Zug, was zur Analyse gebraucht wird: Fassungen aller acht Programme, Betriebssystem, Container ja/nein, Dateisystem des Repositorys, PostgreSQL-Fassung, Schemastand, Dienstzustand, Gesundheitsbericht (der auch bei 503 den vollstaendigen Bericht traegt), Bestand, die letzten nicht erfolgreichen Laeufe mit Fehlercode UND Fehlerklasse, die gemessene Durchsetzungsstufe und die letzten Fehlerzeilen. Es liest nur. Geheimnisse kommen nicht hinein, und der Weg dahin ist umgekehrt: Es gibt eine Liste der Werte, die gezeigt werden duerfen. Eine Sperrliste vergaesse den naechsten neuen Wert. Zusaetzlich werden die tatsaechlichen Geheimnisse gelesen und aus JEDER Ausgabe entfernt — auch aus Protokollzeilen, in die sie auf einem unvorhergesehenen Weg geraten sind. Real geprueft: weder Datenbankpasswort noch Schluessel noch Administratorpasswort stehen im Bericht. Die Vorlage beginnt mit sieben Faellen, die wie ein Fehler aussehen und gewolltes Verhalten sind — "advisory" statt "filesystem", ein Teilfehler, "geloescht aber nichts frei", 503 mit vollstaendigem Bericht. Das ist keine Abwehr, sondern spart beiden Seiten einen halben Tag. Pflichtfelder sind Beobachtung, Erwartung, Schritte, Bereich, Datenrisiko, Haeufigkeit und der Diagnosebericht; Fehlercode und request_id stehen eigens da, weil sie die beiden wertvollsten Angaben sind. Beim Erproben zwei Funde: - Die Installationsanleitung verlangte PostgreSQL 17. setup.sh installiert auf Debian 12 aber 15 — und alles lief, bis hin zu einem echten Sicherungslauf. Die Anforderung lautet jetzt 15 (geprueft gegen 17 in CI und Entwicklung, gegen 15 auf Debian 12), und setup.sh lehnt aeltere Fassungen ab statt sie stillschweigend zu nehmen. - Ein Repository auf der Platte, das nicht in der Control Plane eingetragen ist, faellt niemandem auf: Die Sicherung laeuft nie, weil der Server das Ziel nicht kennt. Der Bericht benennt diesen Fall jetzt ausdruecklich. Gegen das echte v1.0.0-rc1-Paket gefahren: Installation, erzeugte Stoerung (ALL_SOURCES_FAILED / source), Bericht zeigt Code, Klasse, "overlayfs" und "nie gemessen". Co-Authored-By: Claude Opus 5 --- .gitea/ISSUE_TEMPLATE/config.yaml | 23 ++ .gitea/ISSUE_TEMPLATE/fehler.yaml | 199 +++++++++++++++ README.md | 2 + docs/installation.md | 2 +- docs/troubleshooting.md | 18 ++ scripts/build-release.sh | 3 +- scripts/diagnose.sh | 397 ++++++++++++++++++++++++++++++ scripts/release_test.go | 4 +- scripts/setup.sh | 21 ++ scripts/update.sh | 3 + 10 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/fehler.yaml create mode 100755 scripts/diagnose.sh diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..f2f302a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,23 @@ +# Leere Issues bleiben moeglich. +# +# Eine Vorlage soll fuehren, nicht sperren: Wer etwas melden will, das in keine +# Form passt, soll es trotzdem tun koennen. Die Alternative waere, dass er es +# gar nicht meldet. +blank_issues_enabled: true + +contact_links: + - name: Störung nach Symptom nachschlagen + url: https://git.jfritzsche.de/jf/syncova-backup/src/branch/main/docs/troubleshooting.md + about: Die häufigsten Fälle samt Ursache — oft schneller als eine Rückfrage. + + - name: Wiederherstellung im Ernstfall + url: https://git.jfritzsche.de/jf/syncova-backup/src/branch/main/docs/recovery-runbook.md + about: Wenn gerade etwas weg ist. Vier Lagen, von der harmlosen zur schwersten. + + - name: Was diese Fassung ausdrücklich nicht kann + url: https://git.jfritzsche.de/jf/syncova-backup/src/branch/main/CHANGELOG.md + about: Kapazitätsprognose, Backup Copy, ACLs und weitere benannte Auslassungen. + + - name: Was gebaut, aber nie auf echter Hardware gefahren wurde + url: https://git.jfritzsche.de/jf/syncova-backup/src/branch/main/docs/release-candidate.md + about: Windows-Dienst, systemd-Einheit und der Proxmox-Meilenstein. diff --git a/.gitea/ISSUE_TEMPLATE/fehler.yaml b/.gitea/ISSUE_TEMPLATE/fehler.yaml new file mode 100644 index 0000000..037ad48 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/fehler.yaml @@ -0,0 +1,199 @@ +name: Fehler melden +about: Etwas verhält sich anders, als es soll +title: "[Fehler] " +body: + - type: markdown + attributes: + value: | + ## Bevor Sie schreiben: ist es wirklich ein Fehler? + + Sieben Dinge sehen wie ein Fehler aus und sind gewolltes Verhalten. Ein + Blick darauf spart Ihnen und mir einen halben Tag: + + | Beobachtung | Warum es richtig ist | + | --- | --- | + | Durchsetzungsstufe `advisory` statt `filesystem` | Ihr Dateisystem setzt den Löschschutz nicht durch — overlayfs, NFS oder fehlendes `CAP_LINUX_IMMUTABLE`. Die Stufe wird **gemessen**, nicht behauptet | + | `PARTIAL FAILURE` bei nicht lesbarer Datei | Dort fehlen Daten. Ein Teilfehler ist kein Erfolg und wird bewusst nicht wiederholt | + | Sockets, Pipes, Gerätedateien werden übergangen | Sie haben keinen sicherbaren Inhalt. Das ist ein Vermerk, kein Fehler | + | „Gelöscht, aber nichts frei geworden" | Deduplizierung: Die Blöcke werden von einem anderen Backup gebraucht | + | Erfolgsquote meldet `warning` bei 100 % | Es gab keine Läufe. Ohne Lauf gibt es keine Quote | + | `503` mit vollständigem `data`-Bericht | Absicht: Monitoring schlägt an, die Oberfläche kann trotzdem zeigen, *was* kaputt ist | + | Punkt bleibt `unverified` trotz sauberem Scan | Nur ein durchgeführter Wiederherstellungstest hebt auf `recoverable`. Alles davor ist ein Indiz | + + Weitere Fälle nach Symptom: [`docs/troubleshooting.md`](../src/branch/main/docs/troubleshooting.md) + + --- + + ## Keine Geheimnisse einfügen + + **Niemals** in ein Issue: `SYNCOVA_ENCRYPTION_KEYS`, `SYNCOVA_DB_PASSWORD`, + API-Tokens, Zugangsdaten der Virtualisierungsverbünde. + + Das Diagnoseskript unten entfernt sie selbsttätig. Sehen Sie den Bericht + trotzdem durch, bevor Sie ihn einfügen — er ist danach öffentlich. + + - type: textarea + id: beobachtung + attributes: + label: Was ist passiert? + description: Was haben Sie gesehen — möglichst mit der genauen Meldung. + placeholder: | + Beim nächtlichen Lauf des Auftrags "Dateiserver täglich" bricht die + Sicherung nach etwa zwei Minuten ab. Die Oberfläche zeigt den Lauf als + "failed" mit dem Code REPOSITORY_FULL, obwohl auf dem Ziel 400 GB frei sind. + validations: + required: true + + - type: textarea + id: erwartung + attributes: + label: Was haben Sie erwartet? + description: | + Bitte ausfüllen, auch wenn es offensichtlich scheint. Oft liegt genau + hier der Unterschied zwischen einem Fehler und einer Erwartung, die die + Anlage bewusst nicht erfüllt. + placeholder: Der Lauf sollte durchlaufen; es ist genug Platz vorhanden. + validations: + required: true + + - type: textarea + id: schritte + attributes: + label: Wie lässt es sich auslösen? + description: | + Schritt für Schritt. Wenn es nur sporadisch auftritt, schreiben Sie das + — auch „nur nachts, etwa jeder dritte Lauf" ist eine brauchbare Angabe. + placeholder: | + 1. Auftrag mit Quelle /srv/daten und Repository /mnt/backup anlegen + 2. Lauf über POST /api/v1/jobs//run anstoßen + 3. Nach ~2 Minuten steht der Lauf auf failed + validations: + required: true + + - type: input + id: fehlercode + attributes: + label: Fehlercode und request_id + description: | + **Die beiden wertvollsten Angaben überhaupt.** Der Code steht in + `error.code` jeder API-Antwort und im Lauf; die `request_id` steht in + **jeder** Antwort — auch der erfolgreichen. Ohne sie bleibt „es hat + nicht funktioniert". + placeholder: "REPOSITORY_FULL, request_id 7f900328-a7db-4c54-ae6e-9ace38353224" + + - type: dropdown + id: bereich + attributes: + label: Welcher Bereich? + description: Grobe Zuordnung genügt — sie grenzt die Suche stark ein. + options: + - Weiß ich nicht + - Sicherung (Scheduler, Backup Engine) + - Wiederherstellung + - Repository (Integrität, Katalog, Löschschutz) + - Agent (Windows oder Linux) + - Proxmox + - Oberfläche + - API + - Anmeldung, Rollen, zweiter Faktor + - Meldungen und Benachrichtigungen + - Kennzahlen und Berichte + - Installation, Update, Deinstallation + validations: + required: true + + - type: dropdown + id: datenrisiko + attributes: + label: Sind Daten in Gefahr? + description: | + Bestimmt die Reihenfolge der Bearbeitung. Bitte ehrlich einschätzen — + „weiß ich nicht" ist eine zulässige und nützliche Antwort. + options: + - Nein — es ist unschön, aber nichts geht verloren + - Weiß ich nicht + - Ja — eine Sicherung fehlt oder ist unvollständig + - Ja — eine Wiederherstellung liefert falsche oder fehlende Daten + - Ja — Daten wurden gelöscht oder sind unlesbar + validations: + required: true + + - type: dropdown + id: haeufigkeit + attributes: + label: Wie oft tritt es auf? + options: + - Jedes Mal + - Häufig, aber nicht immer + - Selten + - Genau einmal + validations: + required: true + + - type: textarea + id: diagnose + attributes: + label: Diagnosebericht + description: | + Auf dem betroffenen Server ausführen und die **vollständige** Ausgabe + hier einfügen: + + ```bash + sudo /opt/syncova/diagnose.sh + ``` + + Liegt das Skript nicht dort, steht es im entpackten Paket neben + `setup.sh`. Es liest nur und **verändert nichts**; Geheimnisse entfernt + es selbsttätig. + + Es sammelt in einem Zug: Fassungen aller Programme, Betriebssystem, + Container ja/nein, **Dateisystem des Repositorys**, PostgreSQL-Fassung, + Schemastand, Dienstzustand, Gesundheitsbericht, Bestand, die letzten + nicht erfolgreichen Läufe mit Fehlercode, gemessene Durchsetzungsstufe + und die letzten Fehlerzeilen. + + Ohne diesen Bericht folgen als Erstes Rückfragen nach genau diesen Werten. + render: text + validations: + required: true + + - type: textarea + id: protokoll + attributes: + label: Protokollzeilen zum Vorfall + description: | + Wenn Sie die `request_id` haben, ist das die kürzeste Suche: + + ```bash + journalctl -u syncova-api --no-pager | grep + ``` + + Sonst die Zeilen um den Zeitpunkt herum: + + ```bash + journalctl -u syncova-api --since "2026-08-17 02:00" --until "2026-08-17 02:10" --no-pager + ``` + + Prüfen Sie die Zeilen auf Pfade und Namen, die nicht öffentlich werden sollen. + render: text + + - type: textarea + id: sonstiges + attributes: + label: Sonstiges + description: | + Was Ihnen sonst auffiel. Besonders nützlich: Hat es früher funktioniert? + Wurde etwas geändert — Update, neue Platte, neues Netz, Umzug des + Repositorys? + + - type: checkboxes + id: bestaetigung + attributes: + label: Vor dem Absenden + options: + - label: Ich habe die Tabelle oben durchgesehen; es ist keiner dieser Fälle + required: true + - label: Der Bericht enthält keine Passwörter, Schlüssel oder Tokens + required: true + - label: Ich verwende die Fassung, die im Diagnosebericht steht + required: true diff --git a/README.md b/README.md index 6275ae9..574a98f 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,8 @@ Erzeugt je Zielplattform einen Verzeichnisbaum und ein Archiv — Programme, Obe macOS und ein vollständiger Windows-Server werden bewusst nicht ausgeliefert: Eine Plattform ohne Betriebskonzept weckt Erwartungen, die niemand einlöst. +Einen Fehler melden: `sudo /opt/syncova/diagnose.sh` ausführen und die Ausgabe ins Issue einfügen — sie enthält alles, was zur Analyse gebraucht wird, und keine Geheimnisse. + Installieren: `sudo ./setup.sh` aus dem entpackten Paket. Aktualisieren: `sudo ./update.sh` — es sichert vorher und nimmt sich zurück, wenn der Dienst danach nicht hochkommt. Entfernen: `sudo ./uninstall.sh` — Datenbank, Repository und Konfiguration bleiben liegen, sofern man nicht ausdrücklich etwas anderes verlangt. **Release veröffentlichen und das System durchtesten:** [docs/release-howto.md](docs/release-howto.md) — vier Stufen vom Rauchtest bis zum offenen Proxmox-Meilenstein, jeweils mit Gegenprobe. diff --git a/docs/installation.md b/docs/installation.md index fcc7e2e..8fe86ce 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -9,7 +9,7 @@ wiederherstellt. Sie beschreibt den Serverteil; die Agenten stehen in | | | | --- | --- | | Betriebssystem | Linux (amd64 oder arm64). Der Server wird für Windows **nicht** ausgeliefert | -| PostgreSQL | 17 oder neuer, erreichbar vom Server | +| PostgreSQL | **15 oder neuer**, erreichbar vom Server. Geprüft gegen 17 (Entwicklung und CI) und 15 (Debian 12) | | Speicher für das Repository | eigener Datenträger oder eigene Freigabe — nicht dasselbe Gerät wie die zu sichernden Daten | | Reverse Proxy | optional, aber empfohlen, sofern der Dienst nicht selbst TLS bedient | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2b982f1..21aebff 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,6 +2,24 @@ Nach Symptom geordnet — danach sucht man, wenn etwas nicht geht. +## Alles auf einmal: diagnose.sh + +```bash +sudo /opt/syncova/diagnose.sh +``` + +Sammelt in einem Zug, was für eine Fehlersuche gebraucht wird: Fassungen aller +Programme, Betriebssystem, Container ja/nein, **Dateisystem des Repositorys**, +PostgreSQL-Fassung, Schemastand, Dienstzustand, Gesundheitsbericht, Bestand, +die letzten nicht erfolgreichen Läufe mit Fehlercode und Fehlerklasse, die +gemessene Durchsetzungsstufe und die letzten Fehlerzeilen. + +Es **liest nur** und verändert nichts. Geheimnisse entfernt es selbsttätig — +sehen Sie den Bericht trotzdem durch, bevor Sie ihn weitergeben. + +Für eine Fehlermeldung gehört seine Ausgabe vollständig ins Issue; ohne sie +folgen als Erstes Rückfragen nach genau diesen Werten. + ## Zuerst: die drei Auskünfte ```bash diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b2459f9..3345687 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -142,7 +142,8 @@ for platformEntry in "${targetPlatforms[@]}"; do # Unterordner: Wer ein Paket auspackt, soll setup.sh sehen, ohne zu suchen. # Fuer Windows entfallen sie — dort gibt es weder systemd noch apt. if [[ "${targetOS}" != "windows" ]]; then - install -m 0755 scripts/setup.sh scripts/update.sh scripts/uninstall.sh "${packageRoot}/" + install -m 0755 scripts/setup.sh scripts/update.sh scripts/uninstall.sh \ + scripts/diagnose.sh "${packageRoot}/" fi cp deployment/docker-compose.yml "${packageRoot}/deployment/" 2>/dev/null || true cp deployment/syncova-agent.service "${packageRoot}/deployment/" 2>/dev/null || true diff --git a/scripts/diagnose.sh b/scripts/diagnose.sh new file mode 100755 index 0000000..d9b21a4 --- /dev/null +++ b/scripts/diagnose.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# +# Sammelt den Zustand einer Syncova-Installation fuer eine Fehlermeldung. +# +# Der Sinn: **eine** Ausgabe statt zwoelf Rueckfragen. Was hier steht, ist genau +# das, wonach man sonst einzeln fragen muesste — und was ohne Nachfrage meist +# fehlt: Fassung, Schemastand, Dateisystem des Repositorys, gemessene +# Durchsetzungsstufe, die letzten Fehlerzeilen. +# +# sudo ./diagnose.sh Ausgabe auf den Bildschirm +# sudo ./diagnose.sh > bericht.md In eine Datei +# +# Das Skript **veraendert nichts**. Es liest, mehr nicht. +# +# Geheimnisse werden nicht ausgegeben. Der Weg dahin ist bewusst umgekehrt: Es +# gibt eine Liste der Werte, die gezeigt werden **duerfen**; alles andere bleibt +# draussen. Eine Sperrliste vergaesse den naechsten neuen Wert. + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Feste Orte — gleichlautend in setup.sh, update.sh und uninstall.sh +# --------------------------------------------------------------------------- + +readonly installationRoot="/opt/syncova" +readonly configurationDirectory="/etc/syncova" +readonly configurationFile="${configurationDirectory}/syncova.env" +readonly serviceName="syncova-api" +readonly backupDirectory="/var/backups/syncova" + +# publicSettings sind die Werte, die in einer Fehlermeldung stehen duerfen. +# +# Eine Erlaubnisliste und keine Sperrliste: Kommt morgen eine neue Variable mit +# einem Geheimnis hinzu, faellt sie hier automatisch heraus. Bei einer Sperrliste +# stuende sie im naechsten Bericht. +readonly publicSettings=( + SYNCOVA_ENV + SYNCOVA_DB_HOST SYNCOVA_DB_PORT SYNCOVA_DB_NAME SYNCOVA_DB_USER SYNCOVA_DB_SSLMODE + SYNCOVA_DB_MAX_OPEN_CONNECTIONS + SYNCOVA_ENCRYPTION_CURRENT_KEY + SYNCOVA_HTTP_LISTEN_ADDRESS SYNCOVA_HTTP_REQUESTS_PER_MINUTE + SYNCOVA_HTTP_TLS_CERT_FILE SYNCOVA_HTTP_TLS_KEY_FILE + SYNCOVA_LOG_LEVEL SYNCOVA_LOG_FORMAT + SYNCOVA_AUTH_REQUIRE_MFA_FOR_PRIVILEGED_USERS + SYNCOVA_AUTH_MAX_FAILED_LOGIN_ATTEMPTS SYNCOVA_AUTH_LOCKOUT_DURATION + SYNCOVA_RESTORE_ALLOWED_ROOTS + SYNCOVA_ALLOW_INTERNAL_NOTIFICATION_TARGETS +) + +# secretValues sind die tatsaechlichen Geheimnisse dieser Anlage. +# +# Sie werden gelesen, um sie aus **jeder** Ausgabe zu entfernen — auch aus +# Protokollzeilen, in die sie auf einem Weg geraten sind, den niemand vorhergesehen +# hat. Die Erlaubnisliste oben schuetzt die Konfiguration; das hier schuetzt alles +# uebrige. +secretValues=() + +# writeHeading schreibt eine Ueberschrift. +writeHeading() { printf '\n## %s\n\n' "$1"; } + +# writeBlock rahmt einen Ausgabeblock. +writeBlock() { printf '```text\n%s\n```\n' "$1"; } + +# redactSecrets entfernt bekannte Geheimnisse aus einem Text. +redactSecrets() { + local text="$1" secretValue + + for secretValue in "${secretValues[@]}"; do + [[ ${#secretValue} -lt 8 ]] && continue + text="${text//${secretValue}/***}" + done + + printf '%s' "${text}" +} + +# runAndCapture fuehrt ein Kommando aus und gibt seine Ausgabe redigiert zurueck. +runAndCapture() { + local commandOutput + commandOutput="$("$@" 2>&1)" || true + + [[ -z "${commandOutput}" ]] && commandOutput="(keine Ausgabe)" + + redactSecrets "${commandOutput}" +} + +# --------------------------------------------------------------------------- +# Konfiguration einlesen +# --------------------------------------------------------------------------- + +configurationIsReadable="nein" + +if [[ -r "${configurationFile}" ]]; then + configurationIsReadable="ja" + + set -a + # shellcheck disable=SC1090 + . "${configurationFile}" + set +a + + [[ -n "${SYNCOVA_DB_PASSWORD:-}" ]] && secretValues+=("${SYNCOVA_DB_PASSWORD}") + [[ -n "${SYNCOVA_ENCRYPTION_KEYS:-}" ]] && secretValues+=("${SYNCOVA_ENCRYPTION_KEYS}") + + # Auch der reine Schluesselwert ohne Versionspraefix, falls er einzeln + # irgendwo auftaucht. + if [[ "${SYNCOVA_ENCRYPTION_KEYS:-}" == *:* ]]; then + secretValues+=("${SYNCOVA_ENCRYPTION_KEYS#*:}") + fi +fi + +# --------------------------------------------------------------------------- +# Kopf +# --------------------------------------------------------------------------- + +printf '# Syncova — Diagnosebericht\n\n' +printf 'Erstellt: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + +if [[ "$(id -u)" -ne 0 ]]; then + printf '\n> **Hinweis:** ohne root-Rechte erstellt. Konfiguration, Dienstprotokoll\n' + printf '> und Datenbankangaben fehlen deshalb. Vollstaendig: `sudo ./diagnose.sh`\n' +fi + +# --------------------------------------------------------------------------- +# 1. Fassungen +# --------------------------------------------------------------------------- + +writeHeading "Fassungen" + +versionReport="" + +if [[ -d "${installationRoot}/bin" ]]; then + for programPath in "${installationRoot}"/bin/*; do + [[ -x "${programPath}" ]] || continue + versionReport+="$("${programPath}" --version 2>/dev/null || echo "$(basename "${programPath}") (keine Versionsangabe)")"$'\n' + done +else + versionReport="Unter ${installationRoot}/bin liegt kein Programm."$'\n' +fi + +[[ -f "${installationRoot}/VERSION" ]] && \ + versionReport+="VERSION-Datei: $(cat "${installationRoot}/VERSION")"$'\n' + +[[ -d "${installationRoot}.vorherige" ]] && \ + versionReport+="Vorige Fassung liegt noch: ${installationRoot}.vorherige"$'\n' + +writeBlock "${versionReport%$'\n'}" + +# --------------------------------------------------------------------------- +# 2. System +# --------------------------------------------------------------------------- + +writeHeading "System" + +systemReport="Betriebssystem: $(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-unbekannt}" || echo unbekannt) +Kern: $(uname -sr) +Architektur: $(uname -m)" + +# Container oder nicht: Er entscheidet ueber den Loeschschutz. In einem Container +# auf overlayfs gibt es kein Unveraenderlich-Kennzeichen, und ein "advisory" ist +# dann die richtige Auskunft und kein Fehler. +if [[ -f /.dockerenv ]] || grep -qa 'container=' /proc/1/environ 2>/dev/null; then + systemReport+=$'\n'"Umgebung: Container" +else + systemReport+=$'\n'"Umgebung: kein Container erkannt" +fi + +systemReport+=$'\n'"systemd: $(command -v systemctl >/dev/null 2>&1 && echo vorhanden || echo 'nicht vorhanden')" + +writeBlock "${systemReport}" + +# --------------------------------------------------------------------------- +# 3. Dienst +# --------------------------------------------------------------------------- + +writeHeading "Dienst" + +serviceReport="" + +if command -v systemctl >/dev/null 2>&1; then + serviceState="$(systemctl is-active "${serviceName}" 2>/dev/null)" + serviceReport+="Zustand: ${serviceState:-nicht aktiv}"$'\n' + + serviceEnabled="$(systemctl is-enabled "${serviceName}" 2>/dev/null)" + serviceReport+="Autostart: ${serviceEnabled:-nicht eingerichtet}"$'\n' + serviceReport+=$'\n'"$(runAndCapture systemctl status "${serviceName}" --no-pager -n 0)" +else + serviceReport="systemd ist nicht vorhanden." +fi + +writeBlock "$(redactSecrets "${serviceReport}")" + +# Die Gesundheitsantwort traegt auch bei 503 den vollstaendigen Bericht — sie +# sagt, welche Komponente fehlt, nicht nur dass etwas fehlt. +writeHeading "Gesundheit" + +healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}" +healthReport="" + +if command -v curl >/dev/null 2>&1; then + for endpointPath in /health/live /health/ready /api/v1/health; do + responseBody="$(curl -s --max-time 10 -w $'\n(HTTP %{http_code})' \ + "http://${healthAddress}${endpointPath}" 2>&1 || echo '(nicht erreichbar)')" + healthReport+="${endpointPath}: ${responseBody}"$'\n\n' + done +else + healthReport="curl ist nicht vorhanden." +fi + +writeBlock "$(redactSecrets "${healthReport%$'\n\n'}")" + +# --------------------------------------------------------------------------- +# 4. Datenbank +# --------------------------------------------------------------------------- + +writeHeading "Datenbank und Schema" + +databaseReport="" + +if [[ "${configurationIsReadable}" == "ja" ]] && [[ -x "${installationRoot}/bin/syncova-migrate" ]]; then + databaseReport+="$(runAndCapture "${installationRoot}/bin/syncova-migrate" status)"$'\n' +else + databaseReport+="Der Schemastand ist ohne lesbare Konfiguration nicht zu ermitteln."$'\n' +fi + +if [[ "${configurationIsReadable}" == "ja" ]] && command -v psql >/dev/null 2>&1; then + databaseReport+=$'\n'"PostgreSQL: $(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql -h "${SYNCOVA_DB_HOST:-127.0.0.1}" \ + -p "${SYNCOVA_DB_PORT:-5432}" -U "${SYNCOVA_DB_USER:-syncova}" -d "${SYNCOVA_DB_NAME:-syncova}" \ + -tAc 'SHOW server_version' 2>&1 | head -1)" +fi + +writeBlock "$(redactSecrets "${databaseReport%$'\n'}")" + +# Der Bestand sagt mehr als jede Beschreibung: Eine Anlage ohne einen einzigen +# erfolgreichen Lauf hat ein anderes Problem als eine mit tausend. +if [[ "${configurationIsReadable}" == "ja" ]] && command -v psql >/dev/null 2>&1; then + writeHeading "Bestand" + + inventoryQuery=" +SELECT 'Repositories: ' || count(*) FROM repositories +UNION ALL SELECT 'Auftraege: ' || count(*) FROM backup_jobs +UNION ALL SELECT 'Laeufe gesamt: ' || count(*) FROM backup_job_runs +UNION ALL SELECT 'davon erfolgreich: ' || count(*) FROM backup_job_runs WHERE status = 'succeeded' +UNION ALL SELECT 'davon Teilfehler: ' || count(*) FROM backup_job_runs WHERE status = 'partial_failure' +UNION ALL SELECT 'davon gescheitert: ' || count(*) FROM backup_job_runs WHERE status = 'failed' +UNION ALL SELECT 'Wiederherstellungsp.: ' || count(*) FROM backups WHERE deleted_at IS NULL +UNION ALL SELECT 'Agenten: ' || count(*) FROM agents +UNION ALL SELECT 'Konten (aktiv): ' || count(*) FROM users WHERE status = 'active';" + + writeBlock "$(redactSecrets "$(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql \ + -h "${SYNCOVA_DB_HOST:-127.0.0.1}" -p "${SYNCOVA_DB_PORT:-5432}" \ + -U "${SYNCOVA_DB_USER:-syncova}" -d "${SYNCOVA_DB_NAME:-syncova}" \ + -tA -c "${inventoryQuery}" 2>&1)")" + + # Die letzten gescheiterten Laeufe samt Fehlercode und Klasse. Der Code ist + # der Vertrag; der Meldungstext ist fuer Menschen und darf sich aendern. + writeHeading "Letzte nicht erfolgreiche Laeufe" + + failureQuery=" +SELECT to_char(coalesce(completed_at, started_at), 'YYYY-MM-DD HH24:MI') || ' ' || + rpad(status, 16) || ' ' || + rpad(coalesce(error_code, '-'), 26) || ' ' || + coalesce(failure_class, '-') +FROM backup_job_runs +WHERE status <> 'succeeded' +ORDER BY coalesce(completed_at, started_at) DESC NULLS LAST +LIMIT 10;" + + failureOutput="$(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql \ + -h "${SYNCOVA_DB_HOST:-127.0.0.1}" -p "${SYNCOVA_DB_PORT:-5432}" \ + -U "${SYNCOVA_DB_USER:-syncova}" -d "${SYNCOVA_DB_NAME:-syncova}" \ + -tA -c "${failureQuery}" 2>&1)" + + [[ -z "${failureOutput}" ]] && failureOutput="(keine — alle Laeufe waren erfolgreich)" + + writeBlock "$(redactSecrets "${failureOutput}")" +fi + +# --------------------------------------------------------------------------- +# 5. Repositories +# --------------------------------------------------------------------------- + +writeHeading "Repositories" + +repositoryReport="" + +if [[ "${configurationIsReadable}" == "ja" ]] && command -v psql >/dev/null 2>&1; then + while IFS='|' read -r repositoryLocation repositoryHardened repositoryLevel; do + [[ -n "${repositoryLocation}" ]] || continue + + repositoryReport+="Ort: ${repositoryLocation}"$'\n' + # PostgreSQL liefert "t"/"f" — das liest sich in einem Bericht wie ein + # Tippfehler. + [[ "${repositoryHardened}" == "t" ]] && repositoryHardened="ja" || repositoryHardened="nein" + repositoryReport+="Gehaertet: ${repositoryHardened}"$'\n' + repositoryReport+="Durchsetzung: ${repositoryLevel:-nie gemessen}"$'\n' + + if [[ -d "${repositoryLocation}" ]]; then + # Das Dateisystem entscheidet ueber den Loeschschutz. Auf overlayfs, + # NFS oder ohne CAP_LINUX_IMMUTABLE gibt es kein + # Unveraenderlich-Kennzeichen — "advisory" ist dann richtig. + repositoryReport+="Dateisystem: $(stat -f -c '%T' "${repositoryLocation}" 2>/dev/null || echo unbekannt)"$'\n' + repositoryReport+="Belegt: $(du -sh "${repositoryLocation}" 2>/dev/null | cut -f1)"$'\n' + repositoryReport+="Frei: $(df -h "${repositoryLocation}" 2>/dev/null | awk 'NR==2 {print $4" von "$2}')"$'\n' + + if [[ -x "${installationRoot}/bin/syncova-repo" ]]; then + repositoryReport+=$'\n'"$(runAndCapture "${installationRoot}/bin/syncova-repo" health --path "${repositoryLocation}")"$'\n' + fi + else + repositoryReport+="Zustand: NICHT ERREICHBAR"$'\n' + fi + + repositoryReport+=$'\n' + done < <(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql -h "${SYNCOVA_DB_HOST:-127.0.0.1}" \ + -p "${SYNCOVA_DB_PORT:-5432}" -U "${SYNCOVA_DB_USER:-syncova}" -d "${SYNCOVA_DB_NAME:-syncova}" \ + -tA -F'|' -c "SELECT location, hardened, coalesce(enforcement_level,'') FROM repositories ORDER BY name" 2>/dev/null) +fi + +# Ein Repository auf der Platte, das nicht in der Control Plane steht, ist eine +# haeufige und leicht zu uebersehende Lage: Die Sicherung laeuft nie, weil das +# Ziel dem Server unbekannt ist. Sie gehoert in den Bericht. +for candidatePath in /srv/syncova-repository "${SYNCOVA_SETUP_REPOSITORY_PATH:-}"; do + [[ -n "${candidatePath}" ]] || continue + [[ -f "${candidatePath}/format/repository.json" ]] || continue + [[ "${repositoryReport}" == *"${candidatePath}"* ]] && continue + + repositoryReport+="Ort: ${candidatePath}"$'\n' + repositoryReport+="Eingetragen: NEIN — die Control Plane kennt dieses Repository nicht."$'\n' + repositoryReport+=" Ohne Eintrag laeuft keine Sicherung hinein."$'\n' + repositoryReport+="Dateisystem: $(stat -f -c '%T' "${candidatePath}" 2>/dev/null || echo unbekannt)"$'\n\n' +done + +[[ -z "${repositoryReport}" ]] && repositoryReport="(keines gefunden oder nicht ermittelbar)" + +writeBlock "$(redactSecrets "${repositoryReport%$'\n'}")" + +# --------------------------------------------------------------------------- +# 6. Konfiguration +# --------------------------------------------------------------------------- + +writeHeading "Konfiguration" + +configurationReport="" + +if [[ "${configurationIsReadable}" == "ja" ]]; then + for settingName in "${publicSettings[@]}"; do + settingValue="${!settingName:-}" + [[ -n "${settingValue}" ]] && configurationReport+="${settingName}=${settingValue}"$'\n' + done + + # Nur ob gesetzt, niemals der Wert. + configurationReport+=$'\n'"SYNCOVA_DB_PASSWORD: $( [[ -n "${SYNCOVA_DB_PASSWORD:-}" ]] && echo gesetzt || echo 'NICHT GESETZT' )"$'\n' + configurationReport+="SYNCOVA_ENCRYPTION_KEYS: $( [[ -n "${SYNCOVA_ENCRYPTION_KEYS:-}" ]] && echo gesetzt || echo 'NICHT GESETZT' )"$'\n' + configurationReport+=$'\n'"Rechte: $(stat -c '%a %U:%G' "${configurationFile}" 2>/dev/null || echo unbekannt) (erwartet: 600 syncova:syncova)" +else + configurationReport="${configurationFile} ist nicht lesbar (root-Rechte noetig?)." +fi + +writeBlock "${configurationReport}" + +# --------------------------------------------------------------------------- +# 7. Protokoll +# --------------------------------------------------------------------------- + +writeHeading "Letzte Fehler- und Warnzeilen" + +logReport="" + +if command -v journalctl >/dev/null 2>&1; then + logReport="$(journalctl -u "${serviceName}" --no-pager -n 400 2>/dev/null \ + | grep -iE '"level":"(ERROR|WARN)"|error|fehler|panic' | tail -30)" +fi + +[[ -z "${logReport}" ]] && logReport="(keine Fehler- oder Warnzeilen in den letzten 400 Eintraegen)" + +writeBlock "$(redactSecrets "${logReport}")" + +writeHeading "Die letzten Protokollzeilen" + +recentLog="" +command -v journalctl >/dev/null 2>&1 && \ + recentLog="$(journalctl -u "${serviceName}" --no-pager -n 25 2>/dev/null)" + +[[ -z "${recentLog}" ]] && recentLog="(kein Journal verfuegbar)" + +writeBlock "$(redactSecrets "${recentLog}")" + +# --------------------------------------------------------------------------- +# 8. Sicherungen der Aktualisierungen +# --------------------------------------------------------------------------- + +if [[ -d "${backupDirectory}" ]]; then + writeHeading "Sicherungen frueherer Aktualisierungen" + writeBlock "$(ls -lh "${backupDirectory}" 2>/dev/null | tail -8)" +fi + +printf '\n---\n\n' +printf 'Erstellt mit `diagnose.sh`. Geheimnisse sind entfernt; sehen Sie den\n' +printf 'Bericht trotzdem durch, bevor Sie ihn veroeffentlichen.\n' diff --git a/scripts/release_test.go b/scripts/release_test.go index 62fc092..0eaf03c 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -152,7 +152,7 @@ func readReleaseScript(testInstance *testing.T) string { // deklarieren, muessen die Werte uebereinstimmen. Eine Konstante nur deshalb // mitzufuehren, damit dieser Test etwas zu vergleichen hat, waere verkehrt. func TestOperationScriptsAgreeOnPaths(testInstance *testing.T) { - scriptNames := []string{"setup.sh", "update.sh", "uninstall.sh"} + scriptNames := []string{"setup.sh", "update.sh", "uninstall.sh", "diagnose.sh"} // valuesByConstant sammelt je Konstante die Werte samt Herkunft. valuesByConstant := make(map[string]map[string]string) @@ -218,7 +218,7 @@ func TestOperationScriptsAgreeOnPaths(testInstance *testing.T) { func TestOperationScriptsAreShipped(testInstance *testing.T) { buildScript := readReleaseScript(testInstance) - for _, scriptName := range []string{"setup.sh", "update.sh", "uninstall.sh"} { + for _, scriptName := range []string{"setup.sh", "update.sh", "uninstall.sh", "diagnose.sh"} { if !strings.Contains(buildScript, scriptName) { testInstance.Errorf("%s wird nicht ausgeliefert — dann steht ein Betreiber "+ "mit einem Paket da und ohne den Weg hinein", scriptName) diff --git a/scripts/setup.sh b/scripts/setup.sh index 6cbc1bd..74125d6 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -428,6 +428,22 @@ if [[ "${databaseMode}" == "lokal" ]]; then writeSuccess "PostgreSQL installiert und gestartet" fi + # Welche Fassung hat die Distribution mitgebracht? + # + # Debian 12 liefert 15, nicht 17. Geprueft ist Syncova gegen 17 (Entwicklung + # und CI) und 15 (Einrichtung und Sicherungslauf). Aelter wird abgelehnt: Die + # Migrationen setzen unter anderem gen_random_uuid() und DROP DATABASE ... + # WITH (FORCE) voraus. + postgresVersion="$(queryAsPostgres 'SHOW server_version' | awk '{print $1}' | cut -d. -f1)" + + if [[ -n "${postgresVersion}" ]]; then + if [[ "${postgresVersion}" -lt 15 ]]; then + abortWithMessage "PostgreSQL ${postgresVersion} ist zu alt. Gebraucht wird 15 oder neuer." + fi + + writeSuccess "PostgreSQL ${postgresVersion}" + fi + # Eine lokale Datenbank spricht ueber den Unix-Socket; TLS ist dort ohne # Belang und wuerde den Verbindungsaufbau nur scheitern lassen. databaseHost="127.0.0.1" @@ -519,6 +535,11 @@ cp -R "${packageDirectory}/bin" "${installationRoot}/" [[ -d "${packageDirectory}/deployment" ]] && cp -R "${packageDirectory}/deployment" "${installationRoot}/" chmod -R 0755 "${installationRoot}/bin" +# Das Diagnoseskript kommt neben die Programme. Im Ernstfall sucht niemand das +# ausgepackte Paket von vor drei Monaten. +[[ -f "${packageDirectory}/diagnose.sh" ]] && \ + install -m 0755 "${packageDirectory}/diagnose.sh" "${installationRoot}/diagnose.sh" + # Die Version wird festgehalten. update.sh und uninstall.sh lesen sie; ohne sie # muesste man ein Binary aufrufen, um zu wissen, was installiert ist. printf '%s\n' "${packageVersion}" > "${installationRoot}/VERSION" diff --git a/scripts/update.sh b/scripts/update.sh index b0de4bb..763aefd 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -334,6 +334,9 @@ cp -R "${packageDirectory}/bin" "${installationRoot}/" [[ -d "${packageDirectory}/deployment" ]] && cp -R "${packageDirectory}/deployment" "${installationRoot}/" chmod -R 0755 "${installationRoot}/bin" +[[ -f "${packageDirectory}/diagnose.sh" ]] && \ + install -m 0755 "${packageDirectory}/diagnose.sh" "${installationRoot}/diagnose.sh" + printf '%s\n' "${packageVersion}" > "${installationRoot}/VERSION" writeSuccess "Programme ausgetauscht"