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"