syncova-backup/scripts/uninstall.sh
Jerrit Fritzsche 0c5a106a83
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 33s
CI / Sicherheitsprüfungen (push) Successful in 24s
setup.sh, update.sh und uninstall.sh
Drei Betriebsskripte fuer Linux, im Auslieferungspaket neben den Programmen.

setup.sh richtet eine Anlage vollstaendig ein: PostgreSQL auf Wunsch mit
(apt/dnf/yum/zypper/pacman), Dienstkonto, Verschluesselungsschluessel, Schema,
erster Administrator, gehaertetes Repository, gehaertete systemd-Einheit. Jeder
Schritt vermerkt, was er angelegt hat; bricht der Lauf ab, wird genau das
zurueckgebaut und nichts sonst. Eine bestehende Installation wird nicht
ueberschrieben — dafuer gibt es update.sh, und der Unterschied ist, dass ein
Update vorher sichert.

update.sh haelt die Reihenfolge ein, um die es geht: sichern, anhalten,
tauschen, migrieren, starten, pruefen. Kommt der Dienst danach nicht hoch, holt
es die vorige Fassung zurueck. Ohne pg_dump wird gar nicht erst begonnen — ohne
Sicherung gibt es nach einer misslungenen Migration keinen Weg zurueck.
Repository und Verschluesselungsschluessel bleiben unberuehrt.

uninstall.sh entfernt standardmaessig NUR Dienst und Programme. Datenbank,
Repository und Konfiguration bleiben liegen; jede dieser Loeschungen verlangt
ein woertlich getipptes Bestaetigungswort an einem Terminal. Ein
Deinstallationsskript, das nebenbei die Backups mitnimmt, vernichtet genau das,
wofuer jemand jahrelang Speicher bezahlt hat.

Gegen Debian 12 im Container gefahren — Installation, Anmeldung, Repository
eingetragen, Loeschschutz gemessen (advisory auf overlayfs, richtig), echter
Sicherungslauf, Update mit unveraendertem Bestand, beide Abbauarten.

Drei Fehler dabei gefunden und behoben, alle derselben Art:

- "tr </dev/urandom | head -c 32" und "psql | grep -q": Der frueh geschlossene
  Pipe schickt dem Schreiber SIGPIPE, und mit "set -o pipefail" bricht das
  Skript mitten in der Einrichtung ab, ohne erkennbaren Grund.
- update.sh las die laufende Fassung mit "grep -o" aus der Antwort von
  /health/ready. Die enthaelt gar kein Versionsfeld; grep endet mit 1, und das
  Skript nahm ein GELUNGENES Update wieder zurueck — wegen einer Zeile, die nur
  der Ausgabe dient.

Dazu: SYNCOVA_ADMIN_PASSWORD stand in der Doku und existiert nicht — das
Passwort kommt ueber die Standardeingabe. Und "syncova-repo break-lock" fehlte
zwar nicht mehr, aber der Test auf die Uebereinstimmung der drei Skripte ist
neu: Drei Skripte, die sich ueber den Installationsort uneinig sind, ergeben
eine Anlage, die sich nicht mehr entfernen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:33:41 +02:00

460 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Entfernt Syncova Backups von einem Server.
#
# **Standardmaessig bleiben die Daten liegen.** Entfernt werden Dienst und
# Programme; Datenbank, Repository und Konfiguration bleiben, wo sie sind.
#
# Der Grund ist der Gegenstand dieser Software: Ein Deinstallationsskript, das
# nebenbei die Backups mitnimmt, vernichtet genau das, wofuer jemand jahrelang
# Speicher bezahlt hat — und zwar in dem Moment, in dem er am wenigsten damit
# rechnet.
#
# ./uninstall.sh Dienst und Programme
# ./uninstall.sh --mit-konfiguration zusaetzlich /etc/syncova
# ./uninstall.sh --mit-datenbank zusaetzlich die Datenbank
# ./uninstall.sh --mit-repository zusaetzlich das Repository
# ./uninstall.sh --alles alle drei
#
# Jeder der drei Schalter verlangt eine getippte Bestaetigung. Ein Kennzeichen
# in einem Skript reicht nicht aus.
set -euo pipefail
# ---------------------------------------------------------------------------
# Feste Orte — gleichlautend in setup.sh und update.sh
# ---------------------------------------------------------------------------
readonly installationRoot="/opt/syncova"
readonly configurationDirectory="/etc/syncova"
readonly configurationFile="${configurationDirectory}/syncova.env"
readonly serviceName="syncova-api"
readonly serviceUnitFile="/etc/systemd/system/${serviceName}.service"
readonly serviceAccount="syncova"
readonly backupDirectory="/var/backups/syncova"
readonly previousReleaseRoot="${installationRoot}.vorherige"
# ---------------------------------------------------------------------------
# Ausgabe
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
readonly colorReset=$'\033[0m'
readonly colorBold=$'\033[1m'
readonly colorRed=$'\033[31m'
readonly colorGreen=$'\033[32m'
readonly colorYellow=$'\033[33m'
else
readonly colorReset='' colorBold='' colorRed='' colorGreen='' colorYellow=''
fi
writeStep() { printf '%s==>%s %s\n' "${colorBold}" "${colorReset}" "$1"; }
writeDetail() { printf ' %s\n' "$1"; }
writeSuccess() { printf ' %s✓%s %s\n' "${colorGreen}" "${colorReset}" "$1"; }
writeWarning() { printf ' %s!%s %s\n' "${colorYellow}" "${colorReset}" "$1" >&2; }
abortWithMessage() {
printf '\n%sAbbruch:%s %s\n' "${colorRed}${colorBold}" "${colorReset}" "$1" >&2
exit 1
}
# ---------------------------------------------------------------------------
# Schalter
# ---------------------------------------------------------------------------
removeConfiguration="nein"
removeDatabase="nein"
removeRepository="nein"
removeServiceAccount="nein"
showUsage() {
cat <<'USAGE'
Verwendung:
uninstall.sh [Schalter]
Ohne Schalter werden entfernt:
- der Dienst syncova-api
- die Programme unter /opt/syncova
Es bleiben liegen:
- die Datenbank mit Auftraegen, Laeufen und Wiederherstellungspunkten
- das Repository mit den Sicherungen selbst
- die Konfiguration samt Verschluesselungsschluessel
Zusaetzliche Schalter (jeder verlangt eine getippte Bestaetigung):
--mit-konfiguration Entfernt /etc/syncova — DARIN LIEGT DER SCHLUESSEL
--mit-datenbank Entfernt die Datenbank
--mit-repository Entfernt das Repository — DAS SIND DIE BACKUPS
--mit-dienstkonto Entfernt das Dienstkonto syncova
--alles Alle vier
--hilfe Diese Uebersicht
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mit-konfiguration) removeConfiguration="ja"; shift ;;
--mit-datenbank) removeDatabase="ja"; shift ;;
--mit-repository) removeRepository="ja"; shift ;;
--mit-dienstkonto) removeServiceAccount="ja"; shift ;;
--alles)
removeConfiguration="ja"; removeDatabase="ja"
removeRepository="ja"; removeServiceAccount="ja"; shift ;;
--hilfe|-h|--help) showUsage; exit 0 ;;
*) showUsage >&2; abortWithMessage "unbekannter Schalter: $1" ;;
esac
done
# ---------------------------------------------------------------------------
# Bestandsaufnahme
# ---------------------------------------------------------------------------
printf '\n%sSyncova Backups — Deinstallation%s\n\n' "${colorBold}" "${colorReset}"
if [[ "$(id -u)" -ne 0 ]]; then
abortWithMessage "Dieses Skript braucht root-Rechte (sudo ./uninstall.sh)."
fi
writeStep "Was ist vorhanden?"
foundAnything="nein"
installedVersion=""
if [[ -x "${installationRoot}/bin/syncova-api" ]]; then
installedVersion="$("${installationRoot}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')"
writeDetail "Programme ${installationRoot} (${installedVersion:-Version unbekannt})"
foundAnything="ja"
fi
if [[ -f "${serviceUnitFile}" ]]; then
serviceState="$(systemctl is-active "${serviceName}" 2>/dev/null || echo 'gestoppt')"
writeDetail "Dienst ${serviceName} (${serviceState})"
foundAnything="ja"
fi
# Die Konfiguration wird gelesen, um Datenbank und Repository zu finden. Sie
# enthaelt Geheimnisse; ausgegeben wird nichts davon.
databaseName="" ; databaseUser="" ; databaseHost="" ; databasePort=""
if [[ -f "${configurationFile}" ]]; then
set -a
# shellcheck disable=SC1090
. "${configurationFile}"
set +a
databaseName="${SYNCOVA_DB_NAME:-}"
databaseUser="${SYNCOVA_DB_USER:-}"
databaseHost="${SYNCOVA_DB_HOST:-127.0.0.1}"
databasePort="${SYNCOVA_DB_PORT:-5432}"
writeDetail "Konfiguration ${configurationFile}"
foundAnything="ja"
fi
# Repositories aus der Datenbank ermitteln — es koennen mehrere sein, und ihr
# Ort steht nicht in der Konfiguration.
knownRepositoryPaths=()
if [[ -n "${databaseName}" ]] && command -v psql >/dev/null 2>&1; then
while IFS= read -r repositoryLocation; do
[[ -n "${repositoryLocation}" ]] && knownRepositoryPaths+=("${repositoryLocation}")
done < <(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql -h "${databaseHost}" -p "${databasePort}" \
-U "${databaseUser}" -d "${databaseName}" -tAc \
"SELECT location FROM repositories ORDER BY name" 2>/dev/null || true)
fi
if [[ -n "${databaseName}" ]]; then
backupCount=""
if command -v psql >/dev/null 2>&1; then
backupCount="$(PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" psql -h "${databaseHost}" -p "${databasePort}" \
-U "${databaseUser}" -d "${databaseName}" -tAc \
"SELECT COUNT(*) FROM backups WHERE deleted_at IS NULL" 2>/dev/null || true)"
fi
writeDetail "Datenbank ${databaseName}@${databaseHost}:${databasePort}${backupCount:+ (${backupCount} Wiederherstellungspunkte)}"
foundAnything="ja"
fi
for repositoryLocation in "${knownRepositoryPaths[@]}"; do
repositorySize=""
[[ -d "${repositoryLocation}" ]] && repositorySize="$(du -sh "${repositoryLocation}" 2>/dev/null | awk '{print $1}')"
writeDetail "Repository ${repositoryLocation}${repositorySize:+ (${repositorySize})}"
foundAnything="ja"
done
if [[ -d "${backupDirectory}" ]]; then
writeDetail "Sicherungen ${backupDirectory} (Datenbankabzuege der Updates)"
fi
if [[ "${foundAnything}" != "ja" ]]; then
printf '\n Es ist keine Installation zu finden.\n\n'
exit 0
fi
# ---------------------------------------------------------------------------
# Was passiert
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Was entfernt wird"
writeDetail "Dienst und Programme"
[[ "${removeConfiguration}" == "ja" ]] && writeDetail "Konfiguration ${configurationDirectory}"
[[ "${removeDatabase}" == "ja" ]] && writeDetail "Datenbank ${databaseName}"
[[ "${removeRepository}" == "ja" ]] && writeDetail "Repositories (siehe oben)"
[[ "${removeServiceAccount}" == "ja" ]] && writeDetail "Dienstkonto ${serviceAccount}"
printf '\n'
writeStep "Was liegen bleibt"
[[ "${removeConfiguration}" != "ja" ]] && writeDetail "Konfiguration ${configurationDirectory} — enthaelt den Verschluesselungsschluessel"
[[ "${removeDatabase}" != "ja" ]] && writeDetail "Datenbank ${databaseName:-(keine gefunden)}"
[[ "${removeRepository}" != "ja" ]] && {
if [[ ${#knownRepositoryPaths[@]} -eq 0 ]]; then
writeDetail "Repositories — keine gefunden"
else
for repositoryLocation in "${knownRepositoryPaths[@]}"; do
writeDetail "Repository ${repositoryLocation} — die Sicherungen selbst"
done
fi
}
[[ -d "${backupDirectory}" ]] && writeDetail "Sicherungen ${backupDirectory}"
# ---------------------------------------------------------------------------
# Bestaetigungen
#
# Je gefaehrlicher, desto laenger das zu tippende Wort. Ein Kennzeichen in einem
# Skript soll nicht genuegen, um Backups zu vernichten.
# ---------------------------------------------------------------------------
# requireTypedConfirmation verlangt ein woertlich getipptes Wort.
requireTypedConfirmation() {
local confirmationWord="$1" questionText="$2" enteredValue=""
if [[ ! -t 0 ]]; then
abortWithMessage "Diese Handlung verlangt eine Bestaetigung an einem Terminal.
In einem Skript ist sie bewusst nicht moeglich."
fi
printf '\n'
printf ' %s%s%s\n' "${colorRed}${colorBold}" "${questionText}" "${colorReset}"
read -r -p " Tippen Sie '${confirmationWord}' zum Bestaetigen: " enteredValue
if [[ "${enteredValue}" != "${confirmationWord}" ]]; then
abortWithMessage "Nicht bestaetigt. Es wurde nichts entfernt."
fi
}
if [[ "${removeRepository}" == "ja" ]]; then
printf '\n'
printf ' %s┌──────────────────────────────────────────────────────────────┐%s\n' "${colorRed}${colorBold}" "${colorReset}"
printf ' %s│ DAS REPOSITORY ENTHAELT DIE SICHERUNGEN SELBST │%s\n' "${colorRed}${colorBold}" "${colorReset}"
printf ' %s└──────────────────────────────────────────────────────────────┘%s\n' "${colorRed}${colorBold}" "${colorReset}"
printf '\n'
writeDetail "Nach dem Entfernen gibt es keinen Wiederherstellungspunkt mehr."
writeDetail "Kein Rueckweg, keine zweite Kopie, kein Papierkorb."
writeDetail ""
writeDetail "Ein gehaertetes Repository laesst sich nicht einfach loeschen; das"
writeDetail "Skript hebt dafuer den Aufbewahrungsschutz auf. Genau davor schuetzt"
writeDetail "dieser Schutz normalerweise."
requireTypedConfirmation "SICHERUNGEN VERNICHTEN" \
"Sollen die Sicherungen unwiederbringlich entfernt werden?"
fi
if [[ "${removeDatabase}" == "ja" ]]; then
requireTypedConfirmation "DATENBANK LOESCHEN" \
"Auftraege, Laeufe, Wiederherstellungspunkte und das Auditprotokoll gehen verloren."
fi
if [[ "${removeConfiguration}" == "ja" ]]; then
printf '\n'
writeDetail "In ${configurationFile} steht der Verschluesselungsschluessel."
writeDetail "Ohne ihn sind verschluesselte Sicherungen nicht mehr lesbar — auch"
writeDetail "die, die anderswo liegen."
writeDetail ""
writeDetail "Sichern Sie ihn jetzt, falls noch nicht geschehen:"
writeDetail " grep SYNCOVA_ENCRYPTION_KEYS ${configurationFile}"
requireTypedConfirmation "SCHLUESSEL VERWERFEN" \
"Soll die Konfiguration samt Schluessel entfernt werden?"
fi
if [[ "${removeConfiguration}" != "ja" && "${removeDatabase}" != "ja" && \
"${removeRepository}" != "ja" ]]; then
if [[ -t 0 ]]; then
printf '\n'
read -r -p " Dienst und Programme entfernen? [j/N]: " proceedAnswer
[[ "${proceedAnswer}" =~ ^[jJyY] ]] || { printf '\n Abgebrochen.\n\n'; exit 0; }
fi
fi
# ---------------------------------------------------------------------------
# 1. Dienst
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Dienst"
if systemctl is-active --quiet "${serviceName}" 2>/dev/null; then
systemctl stop "${serviceName}"
writeSuccess "Dienst angehalten"
fi
if systemctl is-enabled --quiet "${serviceName}" 2>/dev/null; then
systemctl disable "${serviceName}" >/dev/null 2>&1 || true
fi
if [[ -f "${serviceUnitFile}" ]]; then
rm -f "${serviceUnitFile}"
systemctl daemon-reload
writeSuccess "Diensteinheit entfernt"
fi
# ---------------------------------------------------------------------------
# 2. Repositories
#
# Vor den Programmen, weil das Aufheben des Aufbewahrungsschutzes das Werkzeug
# aus dem Paket braucht.
# ---------------------------------------------------------------------------
if [[ "${removeRepository}" == "ja" ]] && [[ ${#knownRepositoryPaths[@]} -gt 0 ]]; then
printf '\n'
writeStep "Repositories"
for repositoryLocation in "${knownRepositoryPaths[@]}"; do
[[ -d "${repositoryLocation}" ]] || continue
# Der Aufbewahrungsschutz muss Datei fuer Datei aufgehoben werden. Eine
# rekursive Aufhebungsfunktion gibt es im Produktivcode ausdruecklich
# nicht — sie waere genau das Werkzeug, das ein Angreifer sucht.
if command -v chattr >/dev/null 2>&1; then
find "${repositoryLocation}" -type f -exec chattr -i {} + 2>/dev/null || true
fi
if rm -rf "${repositoryLocation}" 2>/dev/null; then
writeSuccess "Entfernt: ${repositoryLocation}"
else
writeWarning "Nicht vollstaendig entfernt: ${repositoryLocation}"
writeDetail " Der Aufbewahrungsschutz greift noch. Von Hand:"
writeDetail " chattr -R -i '${repositoryLocation}' && rm -rf '${repositoryLocation}'"
fi
done
fi
# ---------------------------------------------------------------------------
# 3. Programme
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Programme"
for programRoot in "${installationRoot}" "${previousReleaseRoot}"; do
if [[ -d "${programRoot}" ]]; then
rm -rf "${programRoot}"
writeSuccess "Entfernt: ${programRoot}"
fi
done
# ---------------------------------------------------------------------------
# 4. Datenbank
# ---------------------------------------------------------------------------
if [[ "${removeDatabase}" == "ja" ]] && [[ -n "${databaseName}" ]]; then
printf '\n'
writeStep "Datenbank"
# Ohne Pipe zu grep: Der frueh geschlossene Pipe schickt psql ein SIGPIPE,
# und mit "set -o pipefail" braeche das Skript mitten im Abraeumen ab.
existingDatabase="$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='${databaseName}'\"" 2>/dev/null || true)"
if [[ "${existingDatabase}" == "1" ]]; then
# WITH (FORCE) trennt bestehende Verbindungen. Ohne den Zusatz
# scheitert das Loeschen an einer einzigen offenen Sitzung.
if su - postgres -c "psql -v ON_ERROR_STOP=1 -c \"DROP DATABASE IF EXISTS ${databaseName} WITH (FORCE)\"" >/dev/null 2>&1; then
writeSuccess "Datenbank ${databaseName} entfernt"
else
writeWarning "Die Datenbank liess sich nicht entfernen."
writeDetail " Von Hand: sudo -u postgres dropdb --force ${databaseName}"
fi
if [[ -n "${databaseUser}" ]]; then
# Kein "A && B || C": Scheitert B, liefe auch C — und der Betreiber
# bekaeme beide Meldungen zugleich.
if su - postgres -c "psql -c \"DROP ROLE IF EXISTS ${databaseUser}\"" >/dev/null 2>&1; then
writeSuccess "Datenbankkonto ${databaseUser} entfernt"
else
writeDetail "Das Datenbankkonto ${databaseUser} blieb (es gehoert womoeglich anderen Datenbanken)."
fi
fi
else
writeDetail "Die Datenbank ${databaseName} ist von hier aus nicht erreichbar."
writeDetail "Bei einer entfernten Datenbank entfernen Sie sie dort."
fi
fi
# ---------------------------------------------------------------------------
# 5. Konfiguration
# ---------------------------------------------------------------------------
if [[ "${removeConfiguration}" == "ja" ]] && [[ -d "${configurationDirectory}" ]]; then
printf '\n'
writeStep "Konfiguration"
rm -rf "${configurationDirectory}"
writeSuccess "Entfernt: ${configurationDirectory}"
fi
# ---------------------------------------------------------------------------
# 6. Dienstkonto
# ---------------------------------------------------------------------------
if [[ "${removeServiceAccount}" == "ja" ]] && id "${serviceAccount}" >/dev/null 2>&1; then
printf '\n'
writeStep "Dienstkonto"
if userdel "${serviceAccount}" 2>/dev/null; then
writeSuccess "Dienstkonto ${serviceAccount} entfernt"
else
writeWarning "Das Dienstkonto liess sich nicht entfernen (gehoeren ihm noch Dateien?)."
fi
fi
# ---------------------------------------------------------------------------
# Abschluss
# ---------------------------------------------------------------------------
printf '\n%sDie Deinstallation ist abgeschlossen.%s\n\n' "${colorGreen}${colorBold}" "${colorReset}"
remainingItems=()
[[ "${removeConfiguration}" != "ja" && -d "${configurationDirectory}" ]] && \
remainingItems+=("${configurationDirectory} — Konfiguration samt Verschluesselungsschluessel")
[[ "${removeDatabase}" != "ja" && -n "${databaseName}" ]] && \
remainingItems+=("Datenbank ${databaseName} auf ${databaseHost}:${databasePort}")
if [[ "${removeRepository}" != "ja" ]]; then
for repositoryLocation in "${knownRepositoryPaths[@]}"; do
[[ -d "${repositoryLocation}" ]] && remainingItems+=("${repositoryLocation} — die Sicherungen")
done
fi
[[ -d "${backupDirectory}" ]] && remainingItems+=("${backupDirectory} — Datenbankabzuege frueherer Updates")
if [[ ${#remainingItems[@]} -gt 0 ]]; then
printf '%sEs bleibt liegen:%s\n\n' "${colorBold}" "${colorReset}"
for remainingItem in "${remainingItems[@]}"; do
printf ' %s\n' "${remainingItem}"
done
printf '\n'
printf 'Das ist Absicht. Eine spaetere Neuinstallation findet diese Daten wieder;\n'
printf 'ein Skript, das sie nebenbei mitnimmt, vernichtet genau das, wofuer\n'
printf 'jemand jahrelang Speicher bezahlt hat.\n\n'
if [[ "${removeConfiguration}" != "ja" && -f "${configurationFile}" ]]; then
printf '%sSichern Sie den Verschluesselungsschluessel, bevor Sie den Server%s\n' \
"${colorYellow}${colorBold}" "${colorReset}"
printf '%sabraeumen — ohne ihn sind verschluesselte Sicherungen unlesbar:%s\n\n' \
"${colorYellow}${colorBold}" "${colorReset}"
printf ' grep SYNCOVA_ENCRYPTION_KEYS %s\n\n' "${configurationFile}"
fi
else
printf 'Es wurde alles entfernt.\n\n'
fi