diff --git a/CHANGELOG.md b/CHANGELOG.md index d30f4af..569bdd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,23 @@ nicht als freigegeben: - **Der SSH-Zugriffsweg auf Proxmox-Knoten.** Die Fingerabdruckprüfung ist getestet, eine echte Verbindung gab es nie. +### Einrichtung, Aktualisierung, Entfernung + +Das Linux-Paket bringt drei Skripte mit: + +- **`setup.sh`** richtet eine Anlage vollständig ein — PostgreSQL auf Wunsch + mit, Dienstkonto, Schlüssel, Schema, erster Administrator, gehärtetes + Repository, systemd-Einheit. Bricht ein Schritt ab, wird zurückgebaut, was + dieser Lauf angelegt hat; Vorgefundenes bleibt unangetastet. +- **`update.sh`** sichert **zuerst** Datenbank und Konfiguration, hält den + Dienst an, tauscht die Programme, migriert mit der neuen Fassung und startet. + Kommt der Dienst danach nicht hoch, holt es die vorige Fassung zurück. + Repository und Verschlüsselungsschlüssel werden nie angefasst. +- **`uninstall.sh`** entfernt standardmäßig **nur** Dienst und Programme. + Datenbank, Repository und Konfiguration bleiben liegen; jede dieser drei + Löschungen verlangt ein wörtlich getipptes Bestätigungswort an einem + Terminal. + ### Eingefrorene Verträge Ab dieser Fassung sind API (102 Endpunkte), Migrationen, Backup-Format und diff --git a/README.md b/README.md index 663fece..6275ae9 100644 --- a/README.md +++ b/README.md @@ -340,11 +340,13 @@ Erzeugt je Zielplattform einen Verzeichnisbaum und ein Archiv — Programme, Obe | Paket | Inhalt | | --- | --- | -| `linux-amd64`, `linux-arm64` | vollständiger Server, Oberfläche, Agent, alle Werkzeuge | +| `linux-amd64`, `linux-arm64` | vollständiger Server, Oberfläche, Agent, alle Werkzeuge, `setup.sh`/`update.sh`/`uninstall.sh` | | `windows-amd64` | Agent und `syncova-repo` | macOS und ein vollständiger Windows-Server werden bewusst nicht ausgeliefert: Eine Plattform ohne Betriebskonzept weckt Erwartungen, die niemand einlöst. +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. Für den Einstieg: [Installation](docs/installation.md) · [Wiederherstellung im Ernstfall](docs/recovery-runbook.md) · [Sicherheitsleitfaden](docs/security-guide.md) · [API](docs/api.md) · [Störungen](docs/troubleshooting.md) diff --git a/docs/installation.md b/docs/installation.md index 480ec3d..fcc7e2e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -17,6 +17,33 @@ wiederherstellt. Sie beschreibt den Serverteil; die Agenten stehen in Datenträgerausfall nähme sonst Original und Sicherung gemeinsam mit. Das ist keine Feinheit der Einrichtung, sondern der Zweck der Übung. +## Der kurze Weg: setup.sh + +Das Paket bringt ein Einrichtungsskript mit. Es geht genau die Schritte dieser +Anleitung, und was es angelegt hat, baut es bei einem Abbruch wieder zurück. + +```bash +tar -xzf syncova--linux-amd64.tar.gz +cd syncova--linux-amd64 +sudo ./setup.sh +``` + +Es fragt nach Datenbank, Repository und dem ersten Administrator, installiert +PostgreSQL auf Wunsch mit, erzeugt den Verschlüsselungsschlüssel und richtet den +Dienst ein. Für einen unbeaufsichtigten Lauf: `sudo ./setup.sh --unbeaufsichtigt` +mit den Werten aus der Umgebung (`./setup.sh --hilfe` zeigt sie). + +Später aktualisieren: `sudo ./update.sh` — es sichert vorher Datenbank und +Konfiguration und nimmt sich selbst zurück, wenn der Dienst danach nicht +hochkommt. Entfernen: `sudo ./uninstall.sh` — es lässt Datenbank, Repository +und Konfiguration liegen, sofern man nicht ausdrücklich etwas anderes verlangt. + +**Eine bestehende Installation überschreibt `setup.sh` nicht.** Es bricht ab und +verweist auf `update.sh`; der Unterschied ist, dass ein Update vorher sichert. + +Der Rest dieses Dokuments beschreibt dieselben Schritte von Hand — für alle, die +wissen wollen, was das Skript tut, oder die davon abweichen müssen. + ## 1. Paket auspacken ```bash @@ -117,7 +144,17 @@ davor, dass eine neue Fassung auf ein altes Schema schreibt. ## 6. Ersten Administrator anlegen ```bash -SYNCOVA_ADMIN_PASSWORD='' ./bin/syncova-admin create-admin --username admin +./bin/syncova-admin create-admin --username admin +``` + +Das Passwort wird abgefragt und wiederholt — es steht **nicht** in einer +Umgebungsvariablen und nicht in den Aufrufparametern. Beides landete sonst in +der Prozessliste und in der Shell-Historie. + +In einem Skript geht es über die Standardeingabe: + +```bash +printf '%s\n%s\n' "$PASSWORD" "$PASSWORD" | ./bin/syncova-admin create-admin --username admin ``` **Es gibt kein Standardkonto im Programm.** Ein ausgeliefertes Kennwort wäre diff --git a/docs/release-howto.md b/docs/release-howto.md index bfb2069..25764af 100644 --- a/docs/release-howto.md +++ b/docs/release-howto.md @@ -173,7 +173,7 @@ set -a; . /tmp/syncova.env; set +a # 4. Schema und erster Administrator ./bin/syncova-migrate up ./bin/syncova-migrate status -SYNCOVA_ADMIN_PASSWORD='' ./bin/syncova-admin create-admin --username admin +./bin/syncova-admin create-admin --username admin # fragt das Passwort ab # 5. Starten ./bin/syncova-api & diff --git a/scripts/build-release.sh b/scripts/build-release.sh index f6f7cb9..b2459f9 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -137,6 +137,13 @@ for platformEntry in "${targetPlatforms[@]}"; do cp -R docs/. "${packageRoot}/docs/" cp README.md CHANGELOG.md "${packageRoot}/" + + # Die Betriebsskripte gehoeren neben die Programme, nicht in einen + # 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}/" + 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/release_test.go b/scripts/release_test.go index d93832d..62fc092 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -8,6 +8,7 @@ package scripts import ( "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -138,3 +139,89 @@ func readReleaseScript(testInstance *testing.T) string { return string(scriptContent) } + +// TestOperationScriptsAgreeOnPaths haelt die drei Betriebsskripte deckungsgleich. +// +// setup.sh, update.sh und uninstall.sh teilen die Orte, an denen die Anlage +// liegt. Waeren sie sich uneinig, entstuende der schlimmste Fall dieser drei +// Skripte: eine Installation, die sich nicht mehr entfernen laesst, weil das +// Deinstallationsskript woanders sucht — oder ein Update, das neben die +// vorhandene Fassung installiert. +// +// Geprueft wird die **Schnittmenge**: Wo zwei Skripte dieselbe Konstante +// 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"} + + // valuesByConstant sammelt je Konstante die Werte samt Herkunft. + valuesByConstant := make(map[string]map[string]string) + + constantPattern := regexp.MustCompile(`^readonly\s+(\w+)="([^"]*)"`) + + for _, scriptName := range scriptNames { + scriptContent, readError := os.ReadFile(filepath.Join(repositoryRoot, "scripts", scriptName)) + if readError != nil { + testInstance.Fatalf("%s ließ sich nicht lesen: %v", scriptName, readError) + } + + for _, currentLine := range strings.Split(string(scriptContent), "\n") { + matchedGroups := constantPattern.FindStringSubmatch(strings.TrimSpace(currentLine)) + if matchedGroups == nil { + continue + } + + constantName, constantValue := matchedGroups[1], matchedGroups[2] + + // Farben und dergleichen sind Ausgabe, kein Ort. + if strings.HasPrefix(constantName, "color") { + continue + } + + if valuesByConstant[constantName] == nil { + valuesByConstant[constantName] = make(map[string]string) + } + + valuesByConstant[constantName][scriptName] = constantValue + } + } + + if len(valuesByConstant) == 0 { + testInstance.Fatal("in den Betriebsskripten wurde keine einzige Konstante gefunden") + } + + for constantName, valuesByScript := range valuesByConstant { + if len(valuesByScript) < 2 { + continue + } + + var referenceValue, referenceScript string + + for scriptName, constantValue := range valuesByScript { + if referenceScript == "" { + referenceValue, referenceScript = constantValue, scriptName + + continue + } + + if constantValue != referenceValue { + testInstance.Errorf("%s ist uneinheitlich:\n %s: %q\n %s: %q\n"+ + " Drei Skripte, die sich über den Installationsort uneinig sind, ergeben eine\n"+ + " Anlage, die sich nicht mehr entfernen lässt.", + constantName, referenceScript, referenceValue, scriptName, constantValue) + } + } + } +} + +// TestOperationScriptsAreShipped prueft, dass die Skripte im Paket landen. +func TestOperationScriptsAreShipped(testInstance *testing.T) { + buildScript := readReleaseScript(testInstance) + + for _, scriptName := range []string{"setup.sh", "update.sh", "uninstall.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 new file mode 100755 index 0000000..6cbc1bd --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,821 @@ +#!/usr/bin/env bash +# +# Richtet Syncova Backups auf einem Linux-Server ein. +# +# Der Ablauf folgt docs/installation.md. Das Skript nimmt niemandem eine +# Entscheidung ab, die er selbst treffen muss — es fragt, erklaert, und legt +# nichts an, was es nicht auch wieder zuruecknehmen koennte. +# +# ./setup.sh fragt nach +# ./setup.sh --unbeaufsichtigt liest alles aus der Umgebung +# ./setup.sh --hilfe zeigt alle Schalter +# +# Bei einem Abbruch wird zurueckgebaut, was dieser Lauf angelegt hat. Eine halb +# eingerichtete Anlage sieht arbeitsfaehig aus und scheitert beim ersten Lauf. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Feste Orte +# +# Sie stehen gleichlautend in update.sh und uninstall.sh; ein Test haelt die +# drei Skripte deckungsgleich. Drei Skripte, die sich ueber den Installationsort +# uneinig sind, ergeben eine Anlage, die sich nicht mehr entfernen laesst. +# --------------------------------------------------------------------------- + +# installationRoot ist das Programmverzeichnis. +readonly installationRoot="/opt/syncova" +# configurationDirectory enthaelt die Umgebungsdatei. +readonly configurationDirectory="/etc/syncova" +# configurationFile ist die Umgebungsdatei des Dienstes. +readonly configurationFile="${configurationDirectory}/syncova.env" +# serviceName ist die systemd-Einheit. +readonly serviceName="syncova-api" +# serviceUnitFile ist deren Datei. +readonly serviceUnitFile="/etc/systemd/system/${serviceName}.service" +# serviceAccount ist das Dienstkonto. +readonly serviceAccount="syncova" +# defaultRepositoryPath ist der vorgeschlagene Ort des Repositorys. +readonly defaultRepositoryPath="/srv/syncova-repository" +# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf. +readonly backupDirectory="/var/backups/syncova" + +# --------------------------------------------------------------------------- +# 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 meldet einen Arbeitsschritt. +writeStep() { printf '%s==>%s %s\n' "${colorBold}" "${colorReset}" "$1"; } + +# writeDetail meldet eine Einzelheit. +writeDetail() { printf ' %s\n' "$1"; } + +# writeSuccess meldet einen gelungenen Schritt. +writeSuccess() { printf ' %s✓%s %s\n' "${colorGreen}" "${colorReset}" "$1"; } + +# writeWarning meldet etwas, das Beachtung verlangt. +writeWarning() { printf ' %s!%s %s\n' "${colorYellow}" "${colorReset}" "$1" >&2; } + +# abortWithMessage bricht mit einer Begruendung ab. +abortWithMessage() { + printf '\n%sAbbruch:%s %s\n' "${colorRed}${colorBold}" "${colorReset}" "$1" >&2 + exit 1 +} + +# --------------------------------------------------------------------------- +# Rueckbau bei Abbruch +# +# Jeder Schritt vermerkt, was er angelegt hat. Bricht das Skript ab, wird genau +# das wieder entfernt — und nichts sonst. Eine vorgefundene Datenbank oder ein +# vorgefundenes Repository bleiben unangetastet, auch wenn der Lauf scheitert. +# --------------------------------------------------------------------------- + +# createdArtifacts sammelt die Dinge, die dieser Lauf angelegt hat. +createdArtifacts=() + +# noteCreated vermerkt ein angelegtes Objekt fuer den Rueckbau. +noteCreated() { createdArtifacts+=("$1"); } + +# rollbackOnFailure baut zurueck, was dieser Lauf angelegt hat. +rollbackOnFailure() { + local exitCode=$? + + if [[ ${exitCode} -eq 0 ]] || [[ ${#createdArtifacts[@]} -eq 0 ]]; then + exit "${exitCode}" + fi + + printf '\n%sDer Lauf wird zurueckgebaut.%s\n' "${colorYellow}${colorBold}" "${colorReset}" >&2 + + # Rueckwaerts: zuerst der Dienst, zuletzt die Datenbank. + local artifactIndex + for (( artifactIndex=${#createdArtifacts[@]}-1; artifactIndex>=0; artifactIndex-- )); do + local artifact="${createdArtifacts[artifactIndex]}" + local artifactKind="${artifact%%:*}" + local artifactValue="${artifact#*:}" + + case "${artifactKind}" in + service) + systemctl stop "${serviceName}" 2>/dev/null || true + systemctl disable "${serviceName}" 2>/dev/null || true + rm -f "${serviceUnitFile}" + systemctl daemon-reload 2>/dev/null || true + writeDetail "Dienst entfernt" + ;; + directory) + rm -rf "${artifactValue}" + writeDetail "Verzeichnis entfernt: ${artifactValue}" + ;; + file) + rm -f "${artifactValue}" + writeDetail "Datei entfernt: ${artifactValue}" + ;; + account) + userdel "${artifactValue}" 2>/dev/null || true + writeDetail "Dienstkonto entfernt: ${artifactValue}" + ;; + database) + # Nur eine Datenbank, die dieser Lauf angelegt hat. + runAsPostgres "DROP DATABASE IF EXISTS ${artifactValue}" || true + writeDetail "Datenbank entfernt: ${artifactValue}" + ;; + dbuser) + runAsPostgres "DROP ROLE IF EXISTS ${artifactValue}" || true + writeDetail "Datenbankkonto entfernt: ${artifactValue}" + ;; + esac + done + + printf '\n%sEs wurde nichts angetastet, was vorher schon da war.%s\n' \ + "${colorYellow}" "${colorReset}" >&2 + + exit "${exitCode}" +} + +trap rollbackOnFailure EXIT + +# --------------------------------------------------------------------------- +# Schalter +# --------------------------------------------------------------------------- + +# unattendedMode laeuft ohne Rueckfragen. +unattendedMode="nein" +# packageDirectory ist das entpackte Auslieferungspaket. +packageDirectory="" +# repositoryPath ist der Ort des Repositorys. +repositoryPath="${SYNCOVA_SETUP_REPOSITORY_PATH:-${defaultRepositoryPath}}" +# databaseMode ist "lokal" oder "vorhanden". +databaseMode="${SYNCOVA_SETUP_DATABASE_MODE:-}" +# databaseHost, -Port, -Name, -User, -Password beschreiben die Datenbank. +databaseHost="${SYNCOVA_SETUP_DB_HOST:-127.0.0.1}" +databasePort="${SYNCOVA_SETUP_DB_PORT:-5432}" +databaseName="${SYNCOVA_SETUP_DB_NAME:-syncova}" +databaseUser="${SYNCOVA_SETUP_DB_USER:-syncova}" +databasePassword="${SYNCOVA_SETUP_DB_PASSWORD:-}" +databaseSSLMode="${SYNCOVA_SETUP_DB_SSLMODE:-}" +# listenAddress ist die Adresse des Dienstes. +listenAddress="${SYNCOVA_SETUP_LISTEN_ADDRESS:-127.0.0.1:8080}" +# administratorName ist der erste Administrator. +administratorName="${SYNCOVA_SETUP_ADMIN_USERNAME:-admin}" +# administratorPassword ist dessen Passwort. +administratorPassword="${SYNCOVA_SETUP_ADMIN_PASSWORD:-}" +# createHardenedRepository legt ein gehaertetes Repository an. +createHardenedRepository="${SYNCOVA_SETUP_HARDENED:-ja}" +# encryptionKeys ist ein vorgegebener Schluessel; leer erzeugt einen neuen. +encryptionKeys="${SYNCOVA_SETUP_ENCRYPTION_KEYS:-}" + +showUsage() { + cat <<'USAGE' +Verwendung: + setup.sh [--paket ] [--unbeaufsichtigt] [weitere Schalter] + +Schalter: + --paket Entpacktes Auslieferungspaket (Vorgabe: Verzeichnis des Skripts) + --repository Ort des Repositorys (Vorgabe: /srv/syncova-repository) + --adresse Lauschadresse (Vorgabe: 127.0.0.1:8080) + --admin Anmeldename des ersten Administrators (Vorgabe: admin) + --ohne-haertung Repository ohne Aufbewahrungsschutz anlegen + --unbeaufsichtigt Keine Rueckfragen; alle Werte aus der Umgebung + --hilfe Diese Uebersicht + +Umgebung fuer den unbeaufsichtigten Lauf: + SYNCOVA_SETUP_DATABASE_MODE lokal | vorhanden + SYNCOVA_SETUP_DB_HOST/_PORT/_NAME/_USER/_PASSWORD/_SSLMODE + SYNCOVA_SETUP_ADMIN_USERNAME Anmeldename (Vorgabe: admin) + SYNCOVA_SETUP_ADMIN_PASSWORD Passwort des ersten Administrators + SYNCOVA_SETUP_ENCRYPTION_KEYS Vorhandener Schluessel; leer erzeugt einen neuen + SYNCOVA_SETUP_REPOSITORY_PATH Ort des Repositorys + SYNCOVA_SETUP_LISTEN_ADDRESS Lauschadresse + +Ist bereits eine Installation vorhanden, bricht das Skript ab und verweist auf +update.sh. Eine bestehende Anlage zu ueberschreiben ist kein Setup. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --paket) packageDirectory="${2:-}"; shift 2 ;; + --repository) repositoryPath="${2:-}"; shift 2 ;; + --adresse) listenAddress="${2:-}"; shift 2 ;; + --admin) administratorName="${2:-}"; shift 2 ;; + --ohne-haertung) createHardenedRepository="nein"; shift ;; + --unbeaufsichtigt) unattendedMode="ja"; shift ;; + --hilfe|-h|--help) showUsage; trap - EXIT; exit 0 ;; + *) showUsage >&2; abortWithMessage "unbekannter Schalter: $1" ;; + esac +done + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + +# askQuestion stellt eine Frage mit Vorgabewert. +askQuestion() { + local questionText="$1" defaultValue="${2:-}" answerVariable="$3" enteredValue="" + + if [[ "${unattendedMode}" == "ja" ]]; then + printf -v "${answerVariable}" '%s' "${defaultValue}" + return 0 + fi + + if [[ -n "${defaultValue}" ]]; then + read -r -p " ${questionText} [${defaultValue}]: " enteredValue + else + read -r -p " ${questionText}: " enteredValue + fi + + printf -v "${answerVariable}" '%s' "${enteredValue:-${defaultValue}}" +} + +# askPassword liest ein Passwort verdeckt und laesst es wiederholen. +askPassword() { + local questionText="$1" answerVariable="$2" firstEntry="" secondEntry="" + + while true; do + read -r -s -p " ${questionText}: " firstEntry; printf '\n' + read -r -s -p " ${questionText} wiederholen: " secondEntry; printf '\n' + + if [[ -z "${firstEntry}" ]]; then + writeWarning "Ein leeres Passwort wird nicht angenommen." + continue + fi + + if [[ "${firstEntry}" != "${secondEntry}" ]]; then + writeWarning "Die beiden Eingaben stimmen nicht ueberein." + continue + fi + + printf -v "${answerVariable}" '%s' "${firstEntry}" + return 0 + done +} + +# confirmQuestion stellt eine Ja/Nein-Frage. +confirmQuestion() { + local questionText="$1" defaultAnswer="${2:-n}" enteredValue="" + + if [[ "${unattendedMode}" == "ja" ]]; then + [[ "${defaultAnswer}" == "j" ]] + return $? + fi + + read -r -p " ${questionText} [$( [[ "${defaultAnswer}" == "j" ]] && echo 'J/n' || echo 'j/N' )]: " enteredValue + enteredValue="${enteredValue:-${defaultAnswer}}" + + [[ "${enteredValue}" =~ ^[jJyY] ]] +} + +# generatePassword erzeugt ein zufaelliges Passwort. +generatePassword() { + # Aus /dev/urandom und nicht aus $RANDOM: Letzteres ist vorhersagbar und + # hat 15 Bit Zufall — fuer ein Datenbankpasswort zu wenig. + # + # Die Reihenfolge der Pipe ist wesentlich: Erst wird eine **begrenzte** + # Menge gelesen, am Ende steht "cut". Mit "tr < /dev/urandom | head -c 32" + # schliesst head den Pipe nach 32 Zeichen, tr bekommt SIGPIPE, und mit + # "set -o pipefail" bricht das Skript ab — mitten in der Einrichtung und + # ohne erkennbaren Grund. + head -c 512 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | cut -c1-32 +} + +# runAsPostgres fuehrt SQL als Datenbankverwalter aus. +runAsPostgres() { + su - postgres -c "psql -v ON_ERROR_STOP=1 -c \"$1\"" >/dev/null 2>&1 +} + +# queryAsPostgres liefert das Ergebnis einer Abfrage als Text. +# +# Bewusst ohne Pipe zu grep: "psql | grep -q" schliesst den Pipe nach dem ersten +# Treffer, psql bekommt SIGPIPE, und mit "set -o pipefail" bricht das ganze +# Skript ab — mitten in der Einrichtung, ohne erkennbaren Grund. +queryAsPostgres() { + su - postgres -c "psql -tAc \"$1\"" 2>/dev/null || true +} + +# detectPackageManager ermittelt das Paketwerkzeug der Distribution. +detectPackageManager() { + if command -v apt-get >/dev/null 2>&1; then echo "apt"; return; fi + if command -v dnf >/dev/null 2>&1; then echo "dnf"; return; fi + if command -v yum >/dev/null 2>&1; then echo "yum"; return; fi + if command -v zypper >/dev/null 2>&1; then echo "zypper"; return; fi + if command -v pacman >/dev/null 2>&1; then echo "pacman"; return; fi + echo "unbekannt" +} + +# --------------------------------------------------------------------------- +# 1. Voraussetzungen +# --------------------------------------------------------------------------- + +printf '\n%sSyncova Backups — Einrichtung%s\n\n' "${colorBold}" "${colorReset}" + +writeStep "Voraussetzungen" + +if [[ "$(id -u)" -ne 0 ]]; then + abortWithMessage "Dieses Skript braucht root-Rechte (sudo ./setup.sh)." +fi + +if [[ "$(uname -s)" != "Linux" ]]; then + abortWithMessage "Der Syncova-Server wird ausschliesslich fuer Linux ausgeliefert." +fi + +if ! command -v systemctl >/dev/null 2>&1; then + abortWithMessage "Es wurde kein systemd gefunden. Der Dienst laesst sich sonst nicht einrichten." +fi + +# Eine bestehende Installation wird nicht ueberschrieben. Dafuer gibt es +# update.sh — und der Unterschied ist wesentlich: Ein Update sichert vorher. +if [[ -e "${installationRoot}" ]] || [[ -f "${configurationFile}" ]] || [[ -f "${serviceUnitFile}" ]]; then + printf '\n' + writeWarning "Es ist bereits eine Installation vorhanden:" + [[ -e "${installationRoot}" ]] && writeDetail " ${installationRoot}" + [[ -f "${configurationFile}" ]] && writeDetail " ${configurationFile}" + [[ -f "${serviceUnitFile}" ]] && writeDetail " ${serviceUnitFile}" + printf '\n' + abortWithMessage "Zum Aktualisieren: ./update.sh — es sichert vorher Datenbank und Konfiguration." +fi + +writeSuccess "root, Linux, systemd" + +# Das Paket finden. +if [[ -z "${packageDirectory}" ]]; then + packageDirectory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +if [[ ! -x "${packageDirectory}/bin/syncova-api" ]]; then + abortWithMessage "Unter ${packageDirectory} liegt kein Auslieferungspaket (bin/syncova-api fehlt). + Geben Sie es mit --paket an." +fi + +packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')" +writeSuccess "Paket gefunden: ${packageVersion}" + +# Pruefsummen kontrollieren, sofern das Paket welche mitbringt. +if [[ -f "${packageDirectory}/SHA256SUMS" ]] && command -v sha256sum >/dev/null 2>&1; then + if ( cd "${packageDirectory}" && sha256sum --quiet -c SHA256SUMS >/dev/null 2>&1 ); then + writeSuccess "Pruefsummen des Pakets stimmen" + else + writeWarning "Die Pruefsummen des Pakets stimmen nicht. Wurde es vollstaendig uebertragen?" + confirmQuestion "Trotzdem fortfahren?" "n" || abortWithMessage "abgebrochen" + fi +fi + +# --------------------------------------------------------------------------- +# 2. Datenbank +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Datenbank" + +if [[ -z "${databaseMode}" ]]; then + if [[ "${unattendedMode}" == "ja" ]]; then + databaseMode="lokal" + else + writeDetail "PostgreSQL 17 oder neuer wird gebraucht." + writeDetail "" + writeDetail " 1) auf diesem Server installieren" + writeDetail " 2) eine vorhandene Datenbank verwenden" + writeDetail "" + + local_choice="" + askQuestion "Auswahl" "1" local_choice + [[ "${local_choice}" == "2" ]] && databaseMode="vorhanden" || databaseMode="lokal" + fi +fi + +if [[ "${databaseMode}" == "lokal" ]]; then + if command -v psql >/dev/null 2>&1 && systemctl is-active --quiet postgresql 2>/dev/null; then + writeSuccess "PostgreSQL laeuft bereits" + else + packageManager="$(detectPackageManager)" + writeDetail "PostgreSQL wird installiert (${packageManager})" + + case "${packageManager}" in + apt) + DEBIAN_FRONTEND=noninteractive apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql postgresql-contrib >/dev/null + ;; + dnf|yum) + "${packageManager}" install -y -q postgresql-server postgresql-contrib >/dev/null + # Auf Red-Hat-Systemen muss der Datenbankbereich zuerst angelegt werden. + [[ -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >/dev/null 2>&1 || true + ;; + zypper) + zypper --non-interactive install -y postgresql-server postgresql-contrib >/dev/null + ;; + pacman) + pacman -Sy --noconfirm postgresql >/dev/null + [[ -f /var/lib/postgres/data/PG_VERSION ]] || \ + su - postgres -c "initdb -D /var/lib/postgres/data" >/dev/null 2>&1 || true + ;; + *) + abortWithMessage "Die Distribution wurde nicht erkannt. Installieren Sie PostgreSQL von Hand + und starten Sie das Skript mit SYNCOVA_SETUP_DATABASE_MODE=vorhanden erneut." + ;; + esac + + systemctl enable --now postgresql >/dev/null 2>&1 || \ + abortWithMessage "PostgreSQL liess sich nicht starten. Pruefen Sie 'systemctl status postgresql'." + + writeSuccess "PostgreSQL installiert und gestartet" + 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" + databaseSSLMode="${databaseSSLMode:-disable}" + + if [[ -z "${databasePassword}" ]]; then + databasePassword="$(generatePassword)" # secretscan:erlaubt: erzeugter Zufallswert, kein hinterlegtes Geheimnis + writeDetail "Ein Datenbankpasswort wurde erzeugt." + fi + + # Rolle und Datenbank anlegen — beides nur, wenn es sie nicht schon gibt. + existingRole="$(queryAsPostgres "SELECT 1 FROM pg_roles WHERE rolname='${databaseUser}'")" + + if [[ "${existingRole}" == "1" ]]; then + writeDetail "Das Datenbankkonto ${databaseUser} ist bereits vorhanden; das Passwort wird gesetzt." + runAsPostgres "ALTER ROLE ${databaseUser} WITH LOGIN PASSWORD '${databasePassword}'" || \ + abortWithMessage "Das Datenbankpasswort liess sich nicht setzen." + else + runAsPostgres "CREATE ROLE ${databaseUser} WITH LOGIN PASSWORD '${databasePassword}'" || \ + abortWithMessage "Das Datenbankkonto liess sich nicht anlegen." + noteCreated "dbuser:${databaseUser}" + writeSuccess "Datenbankkonto ${databaseUser} angelegt" + fi + + existingDatabase="$(queryAsPostgres "SELECT 1 FROM pg_database WHERE datname='${databaseName}'")" + + if [[ "${existingDatabase}" == "1" ]]; then + writeDetail "Die Datenbank ${databaseName} ist bereits vorhanden und wird verwendet." + else + su - postgres -c "createdb -O ${databaseUser} ${databaseName}" >/dev/null 2>&1 || \ + abortWithMessage "Die Datenbank liess sich nicht anlegen." + noteCreated "database:${databaseName}" + writeSuccess "Datenbank ${databaseName} angelegt" + fi +else + askQuestion "Datenbankserver" "${databaseHost}" databaseHost + askQuestion "Port" "${databasePort}" databasePort + askQuestion "Datenbankname" "${databaseName}" databaseName + askQuestion "Datenbankkonto" "${databaseUser}" databaseUser + + if [[ -z "${databasePassword}" ]]; then + askPassword "Passwort des Datenbankkontos" databasePassword + fi + + # Bei einer entfernten Datenbank ist Verschluesselung der Standard: Ohne sie + # ginge das Passwort im Klartext durch das Netz. + databaseSSLMode="${databaseSSLMode:-require}" + + writeDetail "Die Verbindung wird geprueft." + + if ! PGPASSWORD="${databasePassword}" psql -h "${databaseHost}" -p "${databasePort}" \ + -U "${databaseUser}" -d "${databaseName}" -tAc "SELECT 1" >/dev/null 2>&1; then + abortWithMessage "Die Datenbank ist nicht erreichbar oder die Anmeldung schlug fehl. + Geprueft wurde: ${databaseUser}@${databaseHost}:${databasePort}/${databaseName}" + fi + + writeSuccess "Datenbank erreichbar" +fi + +# --------------------------------------------------------------------------- +# 3. Dienstkonto und Verzeichnisse +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Dienstkonto und Verzeichnisse" + +if id "${serviceAccount}" >/dev/null 2>&1; then + writeDetail "Das Dienstkonto ${serviceAccount} ist bereits vorhanden." +else + useradd --system --no-create-home --shell /usr/sbin/nologin "${serviceAccount}" 2>/dev/null || \ + useradd --system --no-create-home --shell /sbin/nologin "${serviceAccount}" || \ + abortWithMessage "Das Dienstkonto liess sich nicht anlegen." + noteCreated "account:${serviceAccount}" + writeSuccess "Dienstkonto ${serviceAccount} angelegt" +fi + +install -d -m 0755 "${installationRoot}" +noteCreated "directory:${installationRoot}" + +install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${configurationDirectory}" +noteCreated "directory:${configurationDirectory}" + +install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}" + +cp -R "${packageDirectory}/bin" "${installationRoot}/" +[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/" +[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/" +[[ -d "${packageDirectory}/docs" ]] && cp -R "${packageDirectory}/docs" "${installationRoot}/" +[[ -d "${packageDirectory}/deployment" ]] && cp -R "${packageDirectory}/deployment" "${installationRoot}/" +chmod -R 0755 "${installationRoot}/bin" + +# 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" + +writeSuccess "Programme unter ${installationRoot}" + +# --------------------------------------------------------------------------- +# 4. Verschluesselungsschluessel +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Verschluesselungsschluessel" + +if [[ -z "${encryptionKeys}" ]]; then + # "|| true", damit die eigene Fehlermeldung unten greift: Findet grep + # nichts, endet es mit 1, und "set -e" braeche vorher ab — mit einer + # Meldung, die niemandem sagt, was fehlt. + generatedKeyLine="$("${installationRoot}/bin/syncova-admin" generate-key 2>/dev/null \ + | grep '^SYNCOVA_ENCRYPTION_KEYS=' || true)" + encryptionKeys="${generatedKeyLine#SYNCOVA_ENCRYPTION_KEYS=}" + + [[ -n "${encryptionKeys}" ]] || abortWithMessage "Der Schluessel liess sich nicht erzeugen." + + printf '\n' + printf ' %s┌─────────────────────────────────────────────────────────────────┐%s\n' "${colorYellow}${colorBold}" "${colorReset}" + printf ' %s│ DIESER SCHLUESSEL EXISTIERT NUR HIER │%s\n' "${colorYellow}${colorBold}" "${colorReset}" + printf ' %s└─────────────────────────────────────────────────────────────────┘%s\n' "${colorYellow}${colorBold}" "${colorReset}" + printf '\n' + printf ' SYNCOVA_ENCRYPTION_KEYS=%s\n' "${encryptionKeys}" + printf '\n' + writeDetail "Ohne ihn sind MFA-Geheimnisse, die Zugangsdaten der" + writeDetail "Virtualisierungsverbuende und die Datenschluessel der Repositories" + writeDetail "verloren — und damit die verschluesselten Backups." + writeDetail "" + writeDetail "Er gehoert an einen Ort ausserhalb dieser Anlage: Passwortspeicher" + writeDetail "oder Tresor. In ein Backup, das Syncova erzeugt, gehoert er" + writeDetail "ausdruecklich nicht — man braeuchte ihn, um an ihn heranzukommen." + printf '\n' + + if [[ "${unattendedMode}" != "ja" ]]; then + acknowledgement="" + while [[ "${acknowledgement}" != "gesichert" ]]; do + read -r -p " Tippen Sie 'gesichert', wenn der Schluessel anderswo liegt: " acknowledgement + done + else + writeWarning "Unbeaufsichtigter Lauf: Sichern Sie den Schluessel aus ${configurationFile}." + fi +else + writeSuccess "Ein vorgegebener Schluessel wird uebernommen" +fi + +# --------------------------------------------------------------------------- +# 5. Konfiguration +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Konfiguration" + +# Erst mit strengen Rechten anlegen, dann fuellen. Andersherum stuende das +# Passwort fuer einen Augenblick weltlesbar auf der Platte. +install -m 0600 -o "${serviceAccount}" -g "${serviceAccount}" /dev/null "${configurationFile}" +noteCreated "file:${configurationFile}" + +cat > "${configurationFile}" <&1 | tail -1; then + abortWithMessage "Die Migrationen liessen sich nicht anwenden." +fi + +writeSuccess "Schema angelegt" + +# --------------------------------------------------------------------------- +# 7. Erster Administrator +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Erster Administrator" + +if [[ -z "${administratorPassword}" ]]; then + if [[ "${unattendedMode}" == "ja" ]]; then + abortWithMessage "Im unbeaufsichtigten Lauf muss SYNCOVA_SETUP_ADMIN_PASSWORD gesetzt sein." + fi + + askQuestion "Anmeldename" "${administratorName}" administratorName + askPassword "Passwort fuer ${administratorName}" administratorPassword +fi + +# Das Passwort geht ueber die Standardeingabe, nicht als Aufrufparameter: +# Parameter stehen in der Prozessliste und in der Shell-Historie. Das Programm +# fragt zweimal, deshalb zwei Zeilen. +if ! printf '%s\n%s\n' "${administratorPassword}" "${administratorPassword}" \ + | "${installationRoot}/bin/syncova-admin" create-admin --username "${administratorName}" >/dev/null 2>&1; then + abortWithMessage "Der Administrator liess sich nicht anlegen. + Haeufigste Ursache: Das Passwort erfuellt die Mindestanforderung nicht + (12 Zeichen, mindestens 5 verschiedene, Buchstaben plus ein weiteres Zeichen)." +fi + +writeSuccess "Administrator ${administratorName} angelegt" + +# --------------------------------------------------------------------------- +# 8. Repository +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Repository" + +if [[ "${unattendedMode}" != "ja" ]]; then + writeDetail "Das Repository gehoert nicht auf denselben Datentraeger wie die" + writeDetail "zu sichernden Daten. Ein Datentraegerausfall naehme sonst Original" + writeDetail "und Sicherung gemeinsam mit." + printf '\n' + askQuestion "Ort des Repositorys" "${repositoryPath}" repositoryPath +fi + +if [[ -e "${repositoryPath}/format/repository.json" ]]; then + writeDetail "Unter ${repositoryPath} liegt bereits ein Repository; es wird verwendet." +else + install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${repositoryPath}" + + hardenedFlag=() + [[ "${createHardenedRepository}" == "ja" ]] && hardenedFlag=(--hardened) + + if ! su -s /bin/bash "${serviceAccount}" -c \ + "'${installationRoot}/bin/syncova-repo' create --path '${repositoryPath}' --name 'Hauptziel' ${hardenedFlag[*]:-}" \ + >/dev/null 2>&1; then + abortWithMessage "Das Repository liess sich nicht anlegen." + fi + + noteCreated "directory:${repositoryPath}" + writeSuccess "Repository unter ${repositoryPath}$( [[ "${createHardenedRepository}" == "ja" ]] && echo ' (gehaertet)' )" +fi + +# --------------------------------------------------------------------------- +# 9. Dienst +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Dienst" + +cat > "${serviceUnitFile}" </dev/null 2>&1 +systemctl start "${serviceName}" + +writeDetail "Der Dienst wird geprueft." + +serviceIsReady="nein" +for _attempt in $(seq 1 15); do + if curl -fsS "http://${listenAddress}/health/ready" >/dev/null 2>&1; then + serviceIsReady="ja" + break + fi + sleep 1 +done + +if [[ "${serviceIsReady}" != "ja" ]]; then + printf '\n' + writeWarning "Der Dienst meldet sich nicht als betriebsbereit." + writeDetail " systemctl status ${serviceName}" + writeDetail " journalctl -u ${serviceName} -n 50 --no-pager" + abortWithMessage "Die Einrichtung wird zurueckgebaut." +fi + +writeSuccess "Dienst laeuft und ist betriebsbereit" + +# Ab hier ist die Einrichtung gelungen; der Rueckbau entfaellt. +trap - EXIT + +# --------------------------------------------------------------------------- +# Abschluss +# --------------------------------------------------------------------------- + +printf '\n%sDie Einrichtung ist abgeschlossen.%s\n\n' "${colorGreen}${colorBold}" "${colorReset}" + +printf ' Version %s\n' "${packageVersion}" +printf ' Programme %s\n' "${installationRoot}" +printf ' Konfiguration %s\n' "${configurationFile}" +printf ' Repository %s\n' "${repositoryPath}" +printf ' Dienst %s (systemctl status %s)\n' "${serviceName}" "${serviceName}" +printf ' Oberflaeche %s/web — braucht einen Webserver mit SPA-Fallback\n' "${installationRoot}" +printf '\n' + +printf '%sWas jetzt zu tun bleibt:%s\n\n' "${colorBold}" "${colorReset}" +cat <<'NEXT' + 1. Repository in der Control Plane eintragen und die Durchsetzungsstufe + MESSEN — sie wird gemessen, nicht behauptet: + + curl -X POST http:///api/v1/repositories \ + -H "Authorization: Bearer " -H 'Content-Type: application/json' \ + -d '{"name":"Hauptziel","location":""}' + + curl -X POST http:///api/v1/repositories//enforcement/measure \ + -H "Authorization: Bearer " + + 2. Oberflaeche ausliefern (nginx, SPA-Fallback try_files $uri /index.html) + und TLS einrichten — oder den Dienst an 127.0.0.1 binden. + + 3. Zweiten Faktor einrichten, mindestens fuer Konten mit Loeschrecht. + + 4. Benachrichtigungsweg einrichten. Ohne ihn erfaehrt niemand von einem + Ausfall. + + 5. Eine Wiederherstellung durchfuehren, bevor Sie eine brauchen. Erst danach + steigt ein Wiederherstellungspunkt auf "recoverable" — alles davor ist ein + Indiz, kein Nachweis. +NEXT + +printf '\n Ausfuehrlich: %s/docs/installation.md\n' "${installationRoot}" +printf ' Im Ernstfall: %s/docs/recovery-runbook.md\n\n' "${installationRoot}" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..4babf25 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,459 @@ +#!/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 diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..b0de4bb --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# +# Aktualisiert eine vorhandene Syncova-Installation. +# +# Die Reihenfolge ist nicht verhandelbar und der Grund fuer dieses Skript: +# +# sichern -> anhalten -> Programme tauschen -> migrieren -> starten -> pruefen +# +# Laeuft der Dienst noch, waehrend sich das Schema aendert, arbeitet er auf +# einem Stand, den er nicht kennt. Und ohne die Sicherung davor gibt es keinen +# Weg zurueck: Ein Rollback nimmt genau eine Migration zuruck, und Daten in +# Tabellen, die es vorher nicht gab, sind danach weg. +# +# Scheitert ein Schritt, wird die vorige Fassung zurueckgeholt. +# +# ./update.sh prueft, fragt, aktualisiert +# ./update.sh --pruefen sieht nur nach und aendert nichts +# ./update.sh --ja ohne Rueckfrage +# +# Das Repository wird nicht angefasst. Der Verschluesselungsschluessel auch +# nicht — er bleibt, wie er ist, sonst waeren alle Geheimnisse unlesbar. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Feste Orte — gleichlautend in setup.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" + +# previousReleaseRoot nimmt die abgeloeste Fassung auf. +# +# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte +# Fassung sofort loescht, hat keinen Rueckweg. +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 +# --------------------------------------------------------------------------- + +checkOnly="nein" +assumeYes="nein" +packageDirectory="" + +showUsage() { + cat <<'USAGE' +Verwendung: + update.sh [--paket ] [--pruefen] [--ja] + +Schalter: + --paket Entpacktes neues Paket (Vorgabe: Verzeichnis des Skripts) + --pruefen Nur nachsehen: installierte und neue Fassung vergleichen + --ja Ohne Rueckfrage aktualisieren + --hilfe Diese Uebersicht + +Vor dem Tausch werden Datenbank und Konfiguration gesichert nach: + /var/backups/syncova + +Nicht angetastet werden das Repository und der Verschluesselungsschluessel. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --paket) packageDirectory="${2:-}"; shift 2 ;; + --pruefen) checkOnly="ja"; shift ;; + --ja) assumeYes="ja"; shift ;; + --hilfe|-h|--help) showUsage; exit 0 ;; + *) showUsage >&2; abortWithMessage "unbekannter Schalter: $1" ;; + esac +done + +# --------------------------------------------------------------------------- +# 1. Was ist installiert? +# --------------------------------------------------------------------------- + +printf '\n%sSyncova Backups — Aktualisierung%s\n\n' "${colorBold}" "${colorReset}" + +writeStep "Vorhandene Installation" + +if [[ "$(id -u)" -ne 0 ]]; then + abortWithMessage "Dieses Skript braucht root-Rechte (sudo ./update.sh)." +fi + +if [[ ! -x "${installationRoot}/bin/syncova-api" ]]; then + abortWithMessage "Unter ${installationRoot} ist keine Installation zu finden. + Zum Einrichten: ./setup.sh" +fi + +if [[ ! -f "${configurationFile}" ]]; then + abortWithMessage "Die Konfiguration ${configurationFile} fehlt. + Ohne sie laesst sich weder sichern noch migrieren." +fi + +installedVersion="$("${installationRoot}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')" +[[ -n "${installedVersion}" ]] || installedVersion="unbekannt" + +writeSuccess "Installiert: ${installedVersion}" + +# Der Schemastand gehoert zur Bestandsaufnahme: Steht er auf "unsauber", ist +# eine fruehere Migration abgebrochen, und ein Update darauf waere fahrlaessig. +set -a +# shellcheck disable=SC1090 +. "${configurationFile}" +set +a + +schemaState="$("${installationRoot}/bin/syncova-migrate" status 2>&1 | tail -2 | tr '\n' ' ')" +writeDetail "Schema: ${schemaState}" + +if printf '%s' "${schemaState}" | grep -qi "unsauber\|dirty"; then + printf '\n' + writeWarning "Der Schemastand ist als unsauber vermerkt." + writeDetail "Eine fruehere Migration ist abgebrochen. Bringen Sie das Schema" + writeDetail "zuerst in Ordnung ('syncova-migrate force ' nach Ruecksprache)," + writeDetail "bevor Sie aktualisieren." + abortWithMessage "Ein Update auf einem unsauberen Schema wird nicht durchgefuehrt." +fi + +serviceWasRunning="nein" +if systemctl is-active --quiet "${serviceName}" 2>/dev/null; then + serviceWasRunning="ja" + writeSuccess "Dienst laeuft" +else + writeDetail "Der Dienst laeuft derzeit nicht." +fi + +# --------------------------------------------------------------------------- +# 2. Was ist neu? +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Neues Paket" + +if [[ -z "${packageDirectory}" ]]; then + packageDirectory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +if [[ ! -x "${packageDirectory}/bin/syncova-api" ]]; then + abortWithMessage "Unter ${packageDirectory} liegt kein Auslieferungspaket. + Geben Sie es mit --paket an." +fi + +packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')" +writeSuccess "Neu: ${packageVersion}" + +if [[ -f "${packageDirectory}/SHA256SUMS" ]] && command -v sha256sum >/dev/null 2>&1; then + if ( cd "${packageDirectory}" && sha256sum --quiet -c SHA256SUMS >/dev/null 2>&1 ); then + writeSuccess "Pruefsummen stimmen" + else + abortWithMessage "Die Pruefsummen des neuen Pakets stimmen nicht. + Ein unvollstaendig uebertragenes Paket wird nicht eingespielt." + fi +fi + +if [[ "${installedVersion}" == "${packageVersion}" ]]; then + printf '\n' + writeDetail "Installiert und neu sind dieselbe Fassung (${packageVersion})." + + if [[ "${checkOnly}" == "ja" ]]; then + exit 0 + fi + + if [[ "${assumeYes}" != "ja" ]]; then + read -r -p " Trotzdem neu einspielen? [j/N]: " reinstallAnswer + [[ "${reinstallAnswer}" =~ ^[jJyY] ]] || { printf '\n Nichts zu tun.\n\n'; exit 0; } + else + printf '\n Nichts zu tun.\n\n' + exit 0 + fi +fi + +if [[ "${checkOnly}" == "ja" ]]; then + printf '\n' + printf ' %s%s%s → %s%s%s\n\n' "${colorBold}" "${installedVersion}" "${colorReset}" \ + "${colorBold}${colorGreen}" "${packageVersion}" "${colorReset}" + printf ' Zum Aktualisieren: %s\n\n' "$0" + exit 0 +fi + +# --------------------------------------------------------------------------- +# 3. Rueckfrage +# --------------------------------------------------------------------------- + +if [[ "${assumeYes}" != "ja" ]]; then + printf '\n' + printf ' %s%s%s → %s%s%s\n\n' "${colorBold}" "${installedVersion}" "${colorReset}" \ + "${colorBold}${colorGreen}" "${packageVersion}" "${colorReset}" + writeDetail "Vor dem Tausch werden Datenbank und Konfiguration gesichert." + writeDetail "Repository und Verschluesselungsschluessel bleiben unberuehrt." + writeDetail "Der Dienst ist waehrend des Updates kurz nicht erreichbar." + printf '\n' + + read -r -p " Jetzt aktualisieren? [j/N]: " updateAnswer + [[ "${updateAnswer}" =~ ^[jJyY] ]] || { printf '\n Abgebrochen. Es wurde nichts geaendert.\n\n'; exit 0; } +fi + +# --------------------------------------------------------------------------- +# 4. Sichern +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Sicherung vor dem Update" + +install -d -m 0700 "${backupDirectory}" + +backupTimestamp="$(date -u '+%Y%m%d-%H%M%S')" +backupPrefix="${backupDirectory}/${backupTimestamp}-vor-${packageVersion}" + +# Die Konfiguration zuerst: Sie ist klein und enthaelt den Schluessel. +install -m 0600 "${configurationFile}" "${backupPrefix}.env" +writeSuccess "Konfiguration: ${backupPrefix}.env" + +# Die Datenbank. Ohne pg_dump gibt es keinen Weg zurueck — deshalb bricht das +# Skript hier ab, statt "auf gut Glueck" weiterzumachen. +if ! command -v pg_dump >/dev/null 2>&1; then + printf '\n' + writeWarning "pg_dump ist nicht vorhanden." + writeDetail "Ohne Datenbanksicherung gibt es nach einer misslungenen Migration" + writeDetail "keinen Weg zurueck: Ein Rollback nimmt genau eine Migration" + writeDetail "zurueck, und Daten in neuen Tabellen sind danach weg." + printf '\n' + writeDetail "Installieren Sie postgresql-client, oder sichern Sie von Hand und" + writeDetail "starten Sie danach mit --ja erneut." + abortWithMessage "Ohne Datenbanksicherung wird nicht aktualisiert." +fi + +databaseDumpFile="${backupPrefix}.dump" + +if ! PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" pg_dump \ + --host "${SYNCOVA_DB_HOST:-127.0.0.1}" \ + --port "${SYNCOVA_DB_PORT:-5432}" \ + --username "${SYNCOVA_DB_USER:-syncova}" \ + --dbname "${SYNCOVA_DB_NAME:-syncova}" \ + --format=custom --file "${databaseDumpFile}" 2>/tmp/syncova-pgdump.log; then + printf '\n' + writeWarning "Die Datenbanksicherung schlug fehl:" + head -5 /tmp/syncova-pgdump.log | sed 's/^/ /' >&2 + rm -f /tmp/syncova-pgdump.log + abortWithMessage "Ohne Sicherung wird nicht aktualisiert." +fi + +rm -f /tmp/syncova-pgdump.log +chmod 0600 "${databaseDumpFile}" + +dumpSize="$(du -h "${databaseDumpFile}" | awk '{print $1}')" +writeSuccess "Datenbank: ${databaseDumpFile} (${dumpSize})" + +# --------------------------------------------------------------------------- +# 5. Tauschen +# --------------------------------------------------------------------------- + +# updateFailed holt die vorige Fassung zurueck. +# +# Aufgerufen aus dem trap. Die Reihenfolge ist die Umkehrung des Updates: erst +# den Dienst anhalten, dann die Programme zuruecktauschen, dann starten. +updateFailed() { + local exitCode=$? + [[ ${exitCode} -eq 0 ]] && exit 0 + + printf '\n%sDas Update wird zurueckgenommen.%s\n' "${colorYellow}${colorBold}" "${colorReset}" >&2 + + systemctl stop "${serviceName}" 2>/dev/null || true + + if [[ -d "${previousReleaseRoot}" ]]; then + rm -rf "${installationRoot}" + mv "${previousReleaseRoot}" "${installationRoot}" + writeDetail "Die vorige Fassung ${installedVersion} liegt wieder an ihrem Platz." + fi + + if [[ "${serviceWasRunning}" == "ja" ]]; then + systemctl start "${serviceName}" 2>/dev/null || \ + writeWarning "Der Dienst liess sich nicht wieder starten: systemctl status ${serviceName}" + fi + + printf '\n' >&2 + writeDetail "Die Datenbank wurde NICHT zurueckgespielt — das geht nur von Hand:" + writeDetail "" + writeDetail " systemctl stop ${serviceName}" + writeDetail " pg_restore --clean --if-exists -d ${SYNCOVA_DB_NAME:-syncova} ${databaseDumpFile}" + writeDetail " systemctl start ${serviceName}" + writeDetail "" + writeDetail "Notwendig ist das nur, wenn die Migration bereits gelaufen war." + writeDetail "Der Schemastand sagt es: ${installationRoot}/bin/syncova-migrate status" + + exit "${exitCode}" +} + +trap updateFailed EXIT + +printf '\n' +writeStep "Programme tauschen" + +if [[ "${serviceWasRunning}" == "ja" ]]; then + systemctl stop "${serviceName}" + writeSuccess "Dienst angehalten" +fi + +rm -rf "${previousReleaseRoot}" +mv "${installationRoot}" "${previousReleaseRoot}" + +install -d -m 0755 "${installationRoot}" +cp -R "${packageDirectory}/bin" "${installationRoot}/" +[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/" +[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/" +[[ -d "${packageDirectory}/docs" ]] && cp -R "${packageDirectory}/docs" "${installationRoot}/" +[[ -d "${packageDirectory}/deployment" ]] && cp -R "${packageDirectory}/deployment" "${installationRoot}/" +chmod -R 0755 "${installationRoot}/bin" + +printf '%s\n' "${packageVersion}" > "${installationRoot}/VERSION" + +writeSuccess "Programme ausgetauscht" + +# --------------------------------------------------------------------------- +# 6. Migrieren +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Datenbankschema" + +# Mit dem NEUEN Programm und bei angehaltenem Dienst. Beides gehoert zusammen: +# Die neue Fassung bringt ihre Migrationen mit, und ein laufender Dienst +# arbeitete waehrenddessen auf einem Stand, den er nicht kennt. +migrationOutput="$("${installationRoot}/bin/syncova-migrate" up 2>&1 | tail -1)" +writeDetail "${migrationOutput}" + +if printf '%s' "${migrationOutput}" | grep -qi "error\|fehler"; then + abortWithMessage "Die Migration schlug fehl." +fi + +writeSuccess "Schema aktuell" + +# --------------------------------------------------------------------------- +# 7. Starten und pruefen +# --------------------------------------------------------------------------- + +printf '\n' +writeStep "Dienst" + +systemctl daemon-reload +systemctl start "${serviceName}" + +healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}" +serviceIsReady="nein" + +for _attempt in $(seq 1 20); do + if curl -fsS "http://${healthAddress}/health/ready" >/dev/null 2>&1; then + serviceIsReady="ja" + break + fi + sleep 1 +done + +if [[ "${serviceIsReady}" != "ja" ]]; then + printf '\n' + writeWarning "Der Dienst meldet sich nach dem Update nicht als betriebsbereit." + journalctl -u "${serviceName}" -n 15 --no-pager 2>/dev/null | sed 's/^/ /' >&2 || true + abortWithMessage "Das Update wird zurueckgenommen." +fi + +# Die laufende Fassung ist eine Beigabe, kein Pruefkriterium — sie wird aus dem +# Programm gelesen, nicht aus der Gesundheitsantwort. +# +# Der erste Anlauf holte sie mit "grep -o" aus der Antwort von /health/ready. +# Die enthaelt aber gar kein Versionsfeld; grep endet dann mit 1, und mit +# "set -o pipefail" nahm das Skript ein **gelungenes** Update wieder zurueck. +# Ein Rollback wegen einer Zeile, die nur der Ausgabe dient. +runningVersion="$("${installationRoot}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}' || true)" + +writeSuccess "Dienst laeuft und ist betriebsbereit${runningVersion:+ (${runningVersion})}" + +# Ab hier ist das Update gelungen. +trap - EXIT + +# --------------------------------------------------------------------------- +# Abschluss +# --------------------------------------------------------------------------- + +printf '\n%sDie Aktualisierung ist abgeschlossen.%s\n\n' "${colorGreen}${colorBold}" "${colorReset}" + +printf ' %s → %s\n\n' "${installedVersion}" "${packageVersion}" +printf ' Sicherung %s.dump\n' "${backupPrefix}" +printf ' Vorige Fassung %s\n' "${previousReleaseRoot}" +printf '\n' + +writeDetail "Die vorige Fassung bleibt liegen, bis Sie sie entfernen:" +writeDetail " rm -rf ${previousReleaseRoot}" +printf '\n' +writeDetail "Sehen Sie vorher nach, ob die neue Fassung im Betrieb traegt —" +writeDetail "ein Update, das man am selben Tag entsorgt, hat keinen Rueckweg." +printf '\n'