#!/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=() # serviceIsReachable prueft, ob eine Adresse mit HTTP 200 antwortet. # # Drei Wege, weil keiner ueberall vorhanden ist. Genau daran ist die Einrichtung # in Issue #2 gescheitert: Der Dienst lief einwandfrei — Protokoll und # Zeitstempel belegen es —, aber auf dem System war kein curl installiert. Die # Bereitschaftspruefung hielt das fuer einen gescheiterten Start und baute eine # funktionierende Anlage wieder zurueck. # # Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt. serviceIsReachable() { local requestURL="$1" if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 5 "${requestURL}" >/dev/null 2>&1 return $? fi # wget endet bei 4xx und 5xx mit einem Fehler — dasselbe Verhalten wie # "curl -f". if command -v wget >/dev/null 2>&1; then wget -q -O /dev/null --timeout=5 "${requestURL}" >/dev/null 2>&1 return $? fi bashHTTPStatusIsOK "${requestURL}" } # bashHTTPStatusIsOK spricht HTTP ohne jedes Zusatzwerkzeug. # # Der letzte Ausweg: /dev/tcp gehoert zur Bash selbst. Damit laesst sich die # Bereitschaft auch auf einem System pruefen, auf dem weder curl noch wget # liegen — und das ist bei einem schlanken Serverabbild der Normalfall, nicht # die Ausnahme. bashHTTPStatusIsOK() { local strippedURL="${1#http://}" local hostAndPort="${strippedURL%%/*}" local requestPath="/${strippedURL#*/}" # Ohne Pfadanteil liefert die Ersetzung oben die Adresse zurueck. [[ "${requestPath}" == "/${hostAndPort}" ]] && requestPath="/" local targetHost="${hostAndPort%%:*}" local targetPort="${hostAndPort##*:}" [[ "${targetPort}" == "${targetHost}" ]] && targetPort=80 exec 3<>"/dev/tcp/${targetHost}/${targetPort}" 2>/dev/null || return 1 printf 'GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n' \ "${requestPath}" "${hostAndPort}" >&3 local statusLine="" read -r -t 5 statusLine <&3 exec 3<&- 3>&- 2>/dev/null [[ "${statusLine}" == *" 200 "* ]] } # 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}" } # httpToolInUse benennt das verwendete Werkzeug. # # Es gehoert in den Bericht: Steht dort der Notweg ueber /dev/tcp, weiss der # Leser, dass auf diesem System weder curl noch wget liegt — und genau daran ist # in Issue #2 eine Einrichtung gescheitert. if command -v curl >/dev/null 2>&1; then httpToolInUse="curl" elif command -v wget >/dev/null 2>&1; then httpToolInUse="wget (curl ist nicht vorhanden)" else httpToolInUse="Bash /dev/tcp (weder curl noch wget vorhanden)" fi # fetchHTTPBody holt eine Antwort mit dem vorhandenen Werkzeug. fetchHTTPBody() { local requestURL="$1" if command -v curl >/dev/null 2>&1; then curl -s --max-time 10 -w $'\n(HTTP %{http_code})' "${requestURL}" 2>&1 || echo '(nicht erreichbar)' return fi if command -v wget >/dev/null 2>&1; then wget -q -O - --timeout=10 "${requestURL}" 2>&1 || echo '(nicht erreichbar)' return fi if bashHTTPStatusIsOK "${requestURL}"; then echo "(HTTP 200 — Inhalt ohne curl oder wget nicht lesbar)" else echo "(nicht erreichbar oder kein HTTP 200)" fi } # 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="" for endpointPath in /health/live /health/ready /api/v1/health; do responseBody="$(fetchHTTPBody "http://${healthAddress}${endpointPath}")" healthReport+="${endpointPath}: ${responseBody}"$'\n\n' done healthReport+="Abgefragt mit: ${httpToolInUse}" 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'