**Warum keine Wiederherstellung funktionierte.** Der Dienst laeuft mit
`ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner — alles andere ist fuer ihn schreibgeschuetzt. Jedes Ziel
ausserhalb endete mit "mkdir: permission denied", und zwar **nach** der
Vorabpruefung. `/tmp` scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein
eigenes /tmp, und was dort landet, sieht man von aussen gar nicht.
`setup.sh` legt jetzt `/srv/syncova-restore` an und traegt es in
`ReadWritePaths` ein; `--wiederherstellungsziel` ergaenzt weitere. Der Ort liegt
unter /srv und nicht unter /var/lib — Letzteres steht auf der Sperrliste des
Zielschutzes. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig; das
ist mir erst aufgefallen, nachdem ich die Flaeche zunaechst falsch gelegt hatte
und der eigene Zielschutz sie ablehnte.
**Zwei neue Endpunkte** (Vertrag entsprechend erweitert):
- `GET /filesystem/browse` — Verzeichnisse mit der Angabe, ob der **Dienst**
dort schreiben darf. **Gemessen** durch eine Probedatei, nicht aus den
Rechtebits geraten: Unter ProtectSystem=strict sagen die Bits nichts ueber
das aus, was der Namensraum zulaesst. Gesperrte Orte werden gezeigt, nicht
versteckt — sonst bliebe offen, warum ein Pfad fehlt.
- `GET /backups/{id}/contents` — das Manifest als Ebene eines Baums. Der Baum
entsteht aus den **Pfaden**, nicht aus Verzeichniseintraegen: Ein Manifest
kann eine Datei enthalten, deren Elternverzeichnis nicht als eigener Eintrag
vorliegt, und wer nur `directory`-Eintraege auflistet, verliert ganze
Teilbaeume. Durch Mutation bestaetigt.
**Auswahl statt Textfeld.** Der Assistent hat jetzt einen Ordnerbaum fuer das
Ziel und einen Browser fuer den Backup-Inhalt. Ordner **und** einzelne Dateien
lassen sich waehlen; beides geht als `path_prefix` in die Anfrage, weil der
Server auf Gleichheit oder Praefix mit Verzeichnisgrenze vergleicht. Bewusste
Grenze: eine Auswahl je Lauf — eine Liste kennt die API nicht, und mehrere
Laeufe vorzutaeuschen ergaebe mehrere Ausgaenge, die niemand mehr erklaeren
kann.
**Integritaetslauf.** "can't access property toLocaleString, chunks_checked is
undefined" — die Ergebnisse liegen unter `details`, und die Felder heissen
`missing_chunks`/`corrupted_chunks`, nicht umgekehrt. Betrifft alle vier
Pruefendpunkte; sie tragen dieselbe Huelle. Derselbe Fehler wie bei
/retention-policies: die Antwortform angenommen statt geprueft.
**Geist Mono liegt jetzt im Paket** (drei Schnitte, 128 KB, OFL-Lizenz dabei).
Ausgeliefert vom eigenen Ursprung — das verlangt die CSP, und ein
Backup-Server, dessen Oberflaeche von einem CDN abhaengt, waere auch ohne CSP
falsch. `font-display: swap`, damit der Text sofort steht.
Nachgewiesen gegen Debian 12: Vollwiederherstellung (5 Dateien), nur ein Ordner
(2 Dateien), nur eine Datei (1 Datei) — alle drei bitgenau. Der Server nennt
/srv/syncova-restore als beschreibbar und /etc, /usr, /var als gesperrt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1477 lines
59 KiB
Bash
Executable File
1477 lines
59 KiB
Bash
Executable File
#!/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"
|
|
|
|
# additionalRestoreRoots sind weitere Ziele fuer Wiederherstellungen.
|
|
#
|
|
# Wer nach /srv/wiederherstellung oder auf eine Freigabe zurueckschreiben will,
|
|
# nennt sie beim Einrichten mit --wiederherstellungsziel. Sie landen in
|
|
# ReadWritePaths der Einheit; ohne diesen Eintrag nuetzen die Rechte des
|
|
# Verzeichnisses nichts.
|
|
additionalRestoreRoots=""
|
|
|
|
# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf.
|
|
#
|
|
# Sie ist noetig, weil der Dienst mit ProtectSystem=strict laeuft: Ausserhalb
|
|
# der in ReadWritePaths genannten Pfade ist das Dateisystem fuer ihn
|
|
# schreibgeschuetzt. Ohne eine solche Flaeche endet **jede** Wiederherstellung
|
|
# mit "mkdir: permission denied" — und zwar erst nach der Vorabpruefung, also
|
|
# an der unangenehmsten Stelle.
|
|
#
|
|
# Der Ort liegt unter /srv und nicht unter /var/lib: Letzteres steht auf der
|
|
# Sperrliste des Zielschutzes (packages/recovery/targetguard.go), weil eine
|
|
# Wiederherstellung dorthin den Zustand der Anlage selbst ueberschreiben
|
|
# koennte. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig.
|
|
readonly restoreDirectory="/srv/syncova-restore"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:-}"
|
|
# setupWebServer richtet nginx samt Zertifikat ein.
|
|
setupWebServer="${SYNCOVA_SETUP_WEB:-}"
|
|
# webOnlyMode richtet ausschliesslich die Weboberflaeche ein.
|
|
webOnlyMode="nein"
|
|
|
|
showUsage() {
|
|
cat <<'USAGE'
|
|
Verwendung:
|
|
setup.sh [--paket <verzeichnis>] [--unbeaufsichtigt] [weitere Schalter]
|
|
|
|
Schalter:
|
|
--paket <verzeichnis> Entpacktes Auslieferungspaket (Vorgabe: Verzeichnis des Skripts)
|
|
--repository <pfad> Ort des Repositorys (Vorgabe: /srv/syncova-repository)
|
|
--adresse <host:port> Lauschadresse (Vorgabe: 127.0.0.1:8080)
|
|
--admin <name> Anmeldename des ersten Administrators (Vorgabe: admin)
|
|
--ohne-haertung Repository ohne Aufbewahrungsschutz anlegen
|
|
--weboberflaeche Nur die Weboberflaeche einrichten (bestehende Installation)
|
|
--ohne-weboberflaeche Weboberflaeche nicht einrichten
|
|
--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 des API-Dienstes
|
|
SYNCOVA_SETUP_WEB ja | nein — nginx samt selbst ausgestelltem Zertifikat
|
|
|
|
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 ;;
|
|
--wiederherstellungsziel)
|
|
# Mehrfach angebbar. Die Pfade kommen in ReadWritePaths der Einheit;
|
|
# ohne diesen Eintrag nuetzen die Rechte des Verzeichnisses nichts.
|
|
additionalRestoreRoots="${additionalRestoreRoots} ${2:-}"; shift 2 ;;
|
|
--adresse) listenAddress="${2:-}"; shift 2 ;;
|
|
--admin) administratorName="${2:-}"; shift 2 ;;
|
|
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
|
--unbeaufsichtigt) unattendedMode="ja"; shift ;;
|
|
--weboberflaeche) webOnlyMode="ja"; shift ;;
|
|
--ohne-weboberflaeche) setupWebServer="nein"; 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
|
|
}
|
|
|
|
# volatileRepositoryOverride laesst einen fluechtigen Ort ausdruecklich zu.
|
|
readonly volatileRepositoryOverride="${SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY:-nein}"
|
|
|
|
# repositoryIsUnderPrivateTmp meldet einen Ort unterhalb von /tmp.
|
|
#
|
|
# Der Dienst laeuft mit PrivateTmp=yes und bekommt damit ein **eigenes** /tmp.
|
|
# Ein Repository unter /tmp existiert in seiner Sicht nicht; systemd bricht den
|
|
# Start mit 226/NAMESPACE ab, noch bevor das Programm laeuft.
|
|
repositoryIsUnderPrivateTmp() {
|
|
[[ "$1" == /tmp/* || "$1" == "/tmp" ]]
|
|
}
|
|
|
|
# validateRepositoryPath weist einen fluechtigen Ablageort zurueck.
|
|
#
|
|
# **Der wichtigste Punkt dieser ganzen Datei.** Ein Backup-Repository unter /tmp,
|
|
# /var/tmp oder auf einem tmpfs ist nicht unbequem, sondern falsch:
|
|
#
|
|
# - systemd-tmpfiles raeumt /tmp und /var/tmp regelmaessig auf. Die Sicherungen
|
|
# verschwinden dann von selbst, ohne Meldung und ohne dass jemand es merkt —
|
|
# bis er sie braucht.
|
|
# - Ein tmpfs liegt im Arbeitsspeicher. Nach einem Neustart ist es leer.
|
|
#
|
|
# Fuer ein Programm, dessen einziger Zweck die Aufbewahrung von Daten ist, waere
|
|
# das die schlimmste denkbare Voreinstellung: Es meldete jede Nacht erfolgreiche
|
|
# Sicherungen und haette keine.
|
|
validateRepositoryPath() {
|
|
local candidatePath="$1" refusalReason=""
|
|
|
|
case "${candidatePath}" in
|
|
/tmp|/tmp/*) refusalReason="/tmp wird von systemd-tmpfiles regelmaessig aufgeraeumt" ;;
|
|
/var/tmp|/var/tmp/*) refusalReason="/var/tmp wird von systemd-tmpfiles regelmaessig aufgeraeumt" ;;
|
|
/dev/shm|/dev/shm/*) refusalReason="/dev/shm liegt im Arbeitsspeicher" ;;
|
|
/run|/run/*) refusalReason="/run liegt im Arbeitsspeicher" ;;
|
|
esac
|
|
|
|
# Der allgemeine Fall: Was auf einem tmpfs liegt, ueberlebt keinen Neustart —
|
|
# gleich wie das Verzeichnis heisst.
|
|
if [[ -z "${refusalReason}" ]] && [[ -d "${candidatePath}" ]]; then
|
|
local filesystemType
|
|
filesystemType="$(stat -f -c '%T' "${candidatePath}" 2>/dev/null || echo unbekannt)"
|
|
|
|
case "${filesystemType}" in
|
|
tmpfs|ramfs) refusalReason="der Ort liegt auf einem ${filesystemType} (Arbeitsspeicher)" ;;
|
|
esac
|
|
fi
|
|
|
|
[[ -z "${refusalReason}" ]] && return 0
|
|
|
|
if [[ "${volatileRepositoryOverride}" == "ja" ]]; then
|
|
writeWarning "Fluechtiger Ablageort ausdruecklich zugelassen: ${refusalReason}."
|
|
writeDetail "Die Sicherungen dort ueberleben weder Aufraeumlaeufe noch einen Neustart."
|
|
writeDetail "Das ist ausschliesslich fuer Wegwerf-Umgebungen vertretbar."
|
|
|
|
return 0
|
|
fi
|
|
|
|
abortWithMessage "Der Ort ${candidatePath} taugt nicht als Repository:
|
|
${refusalReason}.
|
|
|
|
Die Sicherungen wuerden dort verschwinden, ohne dass es jemandem
|
|
auffaellt — und ein Backupsystem, das jede Nacht Erfolg meldet und
|
|
keine Daten hat, ist schlimmer als gar keines.
|
|
|
|
Nehmen Sie ein dauerhaftes Verzeichnis, etwa /srv/syncova-repository,
|
|
und moeglichst einen anderen Datentraeger als den der Quelldaten.
|
|
|
|
Nur fuer Wegwerf-Umgebungen: SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja"
|
|
}
|
|
|
|
# serviceIsReachable prueft, ob eine Adresse mit HTTP 200 antwortet.
|
|
#
|
|
# Drei Wege, weil keiner ueberall vorhanden ist. Genau daran ist die Einrichtung
|
|
# in Issue #2 gescheitert: Der Dienst lief einwandfrei — Protokoll und
|
|
# Zeitstempel belegen es —, aber auf dem System war kein curl installiert. Die
|
|
# Bereitschaftspruefung hielt das fuer einen gescheiterten Start und baute eine
|
|
# funktionierende Anlage wieder zurueck.
|
|
#
|
|
# Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.
|
|
serviceIsReachable() {
|
|
local requestURL="$1"
|
|
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -fsS --max-time 5 "${requestURL}" >/dev/null 2>&1
|
|
return $?
|
|
fi
|
|
|
|
# wget endet bei 4xx und 5xx mit einem Fehler — dasselbe Verhalten wie
|
|
# "curl -f".
|
|
if command -v wget >/dev/null 2>&1; then
|
|
wget -q -O /dev/null --timeout=5 "${requestURL}" >/dev/null 2>&1
|
|
return $?
|
|
fi
|
|
|
|
bashHTTPStatusIsOK "${requestURL}"
|
|
}
|
|
|
|
# bashHTTPStatusIsOK spricht HTTP ohne jedes Zusatzwerkzeug.
|
|
#
|
|
# Der letzte Ausweg: /dev/tcp gehoert zur Bash selbst. Damit laesst sich die
|
|
# Bereitschaft auch auf einem System pruefen, auf dem weder curl noch wget
|
|
# liegen — und das ist bei einem schlanken Serverabbild der Normalfall, nicht
|
|
# die Ausnahme.
|
|
bashHTTPStatusIsOK() {
|
|
local strippedURL="${1#http://}"
|
|
local hostAndPort="${strippedURL%%/*}"
|
|
local requestPath="/${strippedURL#*/}"
|
|
|
|
# Ohne Pfadanteil liefert die Ersetzung oben die Adresse zurueck.
|
|
[[ "${requestPath}" == "/${hostAndPort}" ]] && requestPath="/"
|
|
|
|
local targetHost="${hostAndPort%%:*}"
|
|
local targetPort="${hostAndPort##*:}"
|
|
[[ "${targetPort}" == "${targetHost}" ]] && targetPort=80
|
|
|
|
exec 3<>"/dev/tcp/${targetHost}/${targetPort}" 2>/dev/null || return 1
|
|
|
|
printf 'GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n' \
|
|
"${requestPath}" "${hostAndPort}" >&3
|
|
|
|
local statusLine=""
|
|
read -r -t 5 statusLine <&3
|
|
|
|
exec 3<&- 3>&- 2>/dev/null
|
|
|
|
[[ "${statusLine}" == *" 200 "* ]]
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weboberflaeche
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Die Oberflaeche ist ein Satz statischer Dateien. Sie braucht einen Webserver,
|
|
# und zwar aus zwei Gruenden:
|
|
#
|
|
# 1. **SPA-Fallback.** Ohne "try_files $uri /index.html" ergibt ein Neuladen
|
|
# auf /recovery-points einen 404 — die Oberflaeche kennt ihre Adressen
|
|
# selbst, der Webserver kennt sie nicht.
|
|
# 2. **Gleiche Herkunft.** Der Client ruft die API relativ unter /api/v1 auf.
|
|
# Liefert derselbe Server Oberflaeche und API aus, entfaellt CORS
|
|
# vollstaendig — und damit die haeufigste Fehlkonfiguration ueberhaupt.
|
|
#
|
|
# Der API-Dienst bleibt dabei an 127.0.0.1 gebunden. Erreichbar ist nur der
|
|
# Webserver; die API liegt hinter ihm. Das ist ausdruecklich sicherer, als den
|
|
# Dienst selbst ins Netz zu haengen.
|
|
|
|
# webServerRootLink ist der Ort, an dem nginx die Oberflaeche erwartet.
|
|
readonly webServerRootLink="${installationRoot}/web"
|
|
|
|
# tlsDirectory nimmt das selbst ausgestellte Zertifikat auf.
|
|
readonly tlsDirectory="${configurationDirectory}/tls"
|
|
|
|
# certificateValidityDays ist die Gueltigkeit des selbst ausgestellten Zertifikats.
|
|
#
|
|
# Zehn Jahre. Ein selbst ausgestelltes Zertifikat wird von niemandem erneuert —
|
|
# es gibt keine Automatik dafuer. Eine kurze Frist ergaebe deshalb keinen
|
|
# Sicherheitsgewinn, sondern eine Oberflaeche, die eines Tages mit einer
|
|
# Fehlermeldung stehenbleibt, die niemand einordnen kann.
|
|
readonly certificateValidityDays=3650
|
|
|
|
# collectServerAddresses sammelt die Namen und Adressen dieses Servers.
|
|
#
|
|
# Sie gehoeren als subjectAltName ins Zertifikat. Moderne Browser sehen den
|
|
# Common Name gar nicht mehr an; ohne SAN ist das Zertifikat fuer sie
|
|
# unbrauchbar, gleich was daraufsteht.
|
|
collectServerAddresses() {
|
|
local shortName fullName addressList=""
|
|
|
|
shortName="$(hostname -s 2>/dev/null || hostname)"
|
|
fullName="$(hostname -f 2>/dev/null || echo "${shortName}")"
|
|
|
|
addressList="DNS:${shortName}"
|
|
[[ "${fullName}" != "${shortName}" ]] && addressList+=",DNS:${fullName}"
|
|
addressList+=",DNS:localhost,IP:127.0.0.1"
|
|
|
|
# Jede erreichbare Adresse dieses Servers. Ohne sie zeigt der Browser beim
|
|
# Aufruf ueber die IP einen Namensfehler an — und genau so ruft man einen
|
|
# frisch eingerichteten Server auf, bevor er einen Namen hat.
|
|
local singleAddress
|
|
while read -r singleAddress; do
|
|
[[ -n "${singleAddress}" ]] || continue
|
|
[[ "${singleAddress}" == "127.0.0.1" ]] && continue
|
|
addressList+=",IP:${singleAddress}"
|
|
done < <(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}')
|
|
|
|
printf '%s' "${addressList}"
|
|
}
|
|
|
|
# createSelfSignedCertificate stellt ein Zertifikat fuer diesen Server aus.
|
|
createSelfSignedCertificate() {
|
|
local certificateFile="${tlsDirectory}/server.crt"
|
|
local keyFile="${tlsDirectory}/server.key"
|
|
|
|
if [[ -f "${certificateFile}" && -f "${keyFile}" ]]; then
|
|
writeDetail "Ein Zertifikat ist bereits vorhanden und wird verwendet."
|
|
|
|
return 0
|
|
fi
|
|
|
|
install -d -m 0750 -o root -g "${serviceAccount}" "${tlsDirectory}"
|
|
|
|
local subjectAltNames
|
|
subjectAltNames="$(collectServerAddresses)"
|
|
|
|
local certificateSubject
|
|
certificateSubject="/CN=$(hostname -f 2>/dev/null || hostname)/O=Syncova Backups"
|
|
|
|
if ! openssl req -x509 -newkey rsa:4096 -sha256 -nodes \
|
|
-days "${certificateValidityDays}" \
|
|
-keyout "${keyFile}" -out "${certificateFile}" \
|
|
-subj "${certificateSubject}" \
|
|
-addext "subjectAltName=${subjectAltNames}" \
|
|
-addext "basicConstraints=critical,CA:FALSE" \
|
|
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \
|
|
-addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1; then
|
|
abortWithMessage "Das Zertifikat liess sich nicht ausstellen."
|
|
fi
|
|
|
|
chmod 0640 "${keyFile}"
|
|
chmod 0644 "${certificateFile}"
|
|
chown root:"${serviceAccount}" "${keyFile}" "${certificateFile}"
|
|
|
|
writeSuccess "Zertifikat ausgestellt (${certificateValidityDays} Tage)"
|
|
writeDetail "Gilt fuer: ${subjectAltNames//,/, }"
|
|
}
|
|
|
|
# http2DirectiveFor liefert die zur nginx-Fassung passende HTTP/2-Schreibweise.
|
|
#
|
|
# Ab 1.25.1 heisst es "http2 on;" als eigene Anweisung; davor war es ein
|
|
# Parameter von "listen". Die neue Schreibweise auf einer aelteren Fassung ergibt
|
|
# "unknown directive http2" und damit einen Webserver, der nicht startet —
|
|
# Debian 12 liefert 1.22, und daran ist genau das aufgefallen.
|
|
http2DirectiveFor() {
|
|
local versionText majorVersion minorVersion patchVersion
|
|
|
|
versionText="$(nginx -v 2>&1 | sed -n 's|.*nginx/\([0-9.]*\).*|\1|p')"
|
|
|
|
IFS='.' read -r majorVersion minorVersion patchVersion <<< "${versionText}"
|
|
|
|
majorVersion="${majorVersion:-0}"
|
|
minorVersion="${minorVersion:-0}"
|
|
patchVersion="${patchVersion:-0}"
|
|
|
|
if (( majorVersion > 1 )) || \
|
|
(( majorVersion == 1 && minorVersion > 25 )) || \
|
|
(( majorVersion == 1 && minorVersion == 25 && patchVersion >= 1 )); then
|
|
printf 'eigene-anweisung'
|
|
else
|
|
printf 'listen-parameter'
|
|
fi
|
|
}
|
|
|
|
# writeWebServerSite schreibt die nginx-Konfiguration.
|
|
writeWebServerSite() {
|
|
local siteFile="$1"
|
|
|
|
local http2Style listenSuffix http2Line
|
|
http2Style="$(http2DirectiveFor)"
|
|
|
|
if [[ "${http2Style}" == "eigene-anweisung" ]]; then
|
|
listenSuffix=""
|
|
http2Line=" http2 on;"
|
|
else
|
|
listenSuffix=" http2"
|
|
http2Line=" # HTTP/2 steht bei dieser nginx-Fassung als Parameter von listen."
|
|
fi
|
|
|
|
cat > "${siteFile}" <<SITE
|
|
# Syncova Backups — Weboberflaeche und API.
|
|
#
|
|
# Angelegt von setup.sh. Aenderungen bleiben bei einem Update erhalten; die
|
|
# Datei wird nicht ueberschrieben, solange sie vorhanden ist.
|
|
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name _;
|
|
|
|
# Alles auf HTTPS. Ein Anmeldeformular ueber HTTP schickt das Passwort im
|
|
# Klartext durch das Netz.
|
|
return 301 https://\$host\$request_uri;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl${listenSuffix};
|
|
listen [::]:443 ssl${listenSuffix};
|
|
${http2Line}
|
|
server_name _;
|
|
|
|
ssl_certificate ${tlsDirectory}/server.crt;
|
|
ssl_certificate_key ${tlsDirectory}/server.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_prefer_server_ciphers off;
|
|
|
|
root ${webServerRootLink};
|
|
index index.html;
|
|
|
|
# Die Oberflaeche kennt ihre Adressen selbst. Ohne diese Zeile ergibt ein
|
|
# Neuladen auf /recovery-points einen 404.
|
|
location / {
|
|
try_files \$uri \$uri/ /index.html;
|
|
}
|
|
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:8080;
|
|
proxy_set_header Host \$host;
|
|
proxy_set_header X-Real-IP \$remote_addr;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
|
|
# Ein Integritaetslauf oder eine Wiederherstellung laeuft lange. Die
|
|
# Vorgabe von 60 Sekunden braeche sie mit einem 504 ab, waehrend die
|
|
# Anlage einwandfrei weiterarbeitet.
|
|
proxy_read_timeout 600s;
|
|
proxy_send_timeout 600s;
|
|
proxy_request_buffering off;
|
|
client_max_body_size 64m;
|
|
}
|
|
|
|
location = /health/live { proxy_pass http://127.0.0.1:8080; }
|
|
location = /health/ready { proxy_pass http://127.0.0.1:8080; }
|
|
}
|
|
SITE
|
|
}
|
|
|
|
# setupWebInterface richtet Webserver, Zertifikat und Weiterleitung ein.
|
|
setupWebInterface() {
|
|
printf '\n'
|
|
writeStep "Weboberflaeche"
|
|
|
|
# openssl und nginx nachinstallieren, falls noetig.
|
|
local packageManager
|
|
packageManager="$(detectPackageManager)"
|
|
|
|
local missingPackages=()
|
|
command -v openssl >/dev/null 2>&1 || missingPackages+=("openssl")
|
|
command -v nginx >/dev/null 2>&1 || missingPackages+=("nginx")
|
|
|
|
if [[ ${#missingPackages[@]} -gt 0 ]]; then
|
|
writeDetail "Es wird installiert: ${missingPackages[*]}"
|
|
|
|
case "${packageManager}" in
|
|
apt)
|
|
DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missingPackages[@]}" >/dev/null
|
|
;;
|
|
dnf|yum) "${packageManager}" install -y -q "${missingPackages[@]}" >/dev/null ;;
|
|
zypper) zypper --non-interactive install -y "${missingPackages[@]}" >/dev/null ;;
|
|
pacman) pacman -Sy --noconfirm "${missingPackages[@]}" >/dev/null ;;
|
|
*)
|
|
writeWarning "Die Distribution wurde nicht erkannt."
|
|
writeDetail "Installieren Sie ${missingPackages[*]} von Hand und rufen Sie"
|
|
writeDetail "'setup.sh --weboberflaeche' erneut auf."
|
|
|
|
return 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Belegte Ports melden statt eine fremde Anlage zu ueberschreiben.
|
|
local occupyingProcess
|
|
occupyingProcess="$(ss -tlnp 2>/dev/null | awk '$4 ~ /:443$/ {print $NF; exit}')"
|
|
|
|
if [[ -n "${occupyingProcess}" ]] && ! printf '%s' "${occupyingProcess}" | grep -q nginx; then
|
|
writeWarning "Auf Port 443 lauscht bereits etwas anderes: ${occupyingProcess}"
|
|
writeDetail "Die Weboberflaeche wird nicht eingerichtet, um nichts zu zerstoeren."
|
|
|
|
return 1
|
|
fi
|
|
|
|
createSelfSignedCertificate
|
|
|
|
# nginx darf die Oberflaeche lesen.
|
|
chmod 0755 "${installationRoot}"
|
|
[[ -d "${webServerRootLink}" ]] && chmod -R a+rX "${webServerRootLink}"
|
|
|
|
# Der Ablageort der Konfiguration unterscheidet sich je Distribution.
|
|
local siteFile=""
|
|
|
|
if [[ -d /etc/nginx/sites-available ]]; then
|
|
siteFile="/etc/nginx/sites-available/syncova"
|
|
writeWebServerSite "${siteFile}"
|
|
ln -sf "${siteFile}" /etc/nginx/sites-enabled/syncova
|
|
|
|
# Die mitgelieferte Standardseite beansprucht Port 80 und 443.
|
|
[[ -e /etc/nginx/sites-enabled/default ]] && rm -f /etc/nginx/sites-enabled/default
|
|
elif [[ -d /etc/nginx/conf.d ]]; then
|
|
siteFile="/etc/nginx/conf.d/syncova.conf"
|
|
writeWebServerSite "${siteFile}"
|
|
else
|
|
writeWarning "Es wurde kein nginx-Konfigurationsverzeichnis gefunden."
|
|
|
|
return 1
|
|
fi
|
|
|
|
if ! nginx -t >/dev/null 2>&1; then
|
|
writeWarning "Die nginx-Konfiguration wird abgelehnt:"
|
|
nginx -t 2>&1 | sed 's/^/ /' >&2
|
|
rm -f "${siteFile}" /etc/nginx/sites-enabled/syncova
|
|
|
|
return 1
|
|
fi
|
|
|
|
systemctl enable nginx >/dev/null 2>&1 || true
|
|
systemctl restart nginx >/dev/null 2>&1 || systemctl start nginx >/dev/null 2>&1
|
|
|
|
if ! systemctl is-active --quiet nginx 2>/dev/null; then
|
|
writeWarning "nginx laeuft nicht: systemctl status nginx"
|
|
|
|
return 1
|
|
fi
|
|
|
|
writeSuccess "nginx liefert die Oberflaeche aus"
|
|
|
|
reportFirewallState
|
|
|
|
return 0
|
|
}
|
|
|
|
# reportFirewallState sagt, ob der Zugang tatsaechlich offen ist.
|
|
#
|
|
# Geoeffnet wird nichts von selbst: Eine Firewallregel ist eine Entscheidung
|
|
# ueber die Erreichbarkeit eines Servers und gehoert nicht als Nebenwirkung
|
|
# einer Installation gesetzt. Gesagt wird es aber — sonst sucht der Betreiber
|
|
# den Fehler in der Anlage, waehrend die Firewall blockt.
|
|
reportFirewallState() {
|
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then
|
|
if ufw status 2>/dev/null | grep -qE "^443|Nginx|https"; then
|
|
writeSuccess "ufw laesst Port 443 durch"
|
|
else
|
|
writeWarning "ufw ist aktiv und Port 443 ist nicht freigegeben:"
|
|
writeDetail " ufw allow 443/tcp && ufw allow 80/tcp"
|
|
fi
|
|
|
|
return
|
|
fi
|
|
|
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
if firewall-cmd --list-services 2>/dev/null | grep -q https; then
|
|
writeSuccess "firewalld laesst https durch"
|
|
else
|
|
writeWarning "firewalld ist aktiv und https ist nicht freigegeben:"
|
|
writeDetail " firewall-cmd --permanent --add-service=https --add-service=http"
|
|
writeDetail " firewall-cmd --reload"
|
|
fi
|
|
|
|
return
|
|
fi
|
|
|
|
writeDetail "Es wurde keine aktive Firewall erkannt; der Zugang haengt am Netz davor."
|
|
}
|
|
|
|
# printWebAccessSummary nennt Adresse und Fingerabdruck.
|
|
printWebAccessSummary() {
|
|
local certificateFile="${tlsDirectory}/server.crt"
|
|
|
|
[[ -f "${certificateFile}" ]] || return 0
|
|
|
|
printf '\n%sDie Oberflaeche ist erreichbar unter:%s\n\n' "${colorBold}" "${colorReset}"
|
|
|
|
local singleAddress
|
|
while read -r singleAddress; do
|
|
[[ -n "${singleAddress}" ]] && printf ' https://%s/\n' "${singleAddress}"
|
|
done < <(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}')
|
|
|
|
printf ' https://%s/\n' "$(hostname -f 2>/dev/null || hostname)"
|
|
|
|
local certificateFingerprint
|
|
certificateFingerprint="$(openssl x509 -in "${certificateFile}" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)"
|
|
|
|
printf '\n%sDas Zertifikat ist selbst ausgestellt.%s Der Browser warnt deshalb — zu Recht:\n' \
|
|
"${colorYellow}${colorBold}" "${colorReset}"
|
|
printf 'Niemand bestaetigt, dass es zu diesem Server gehoert. Es schuetzt gegen\n'
|
|
printf 'Mitlesen, nicht gegen einen Mittelsmann.\n\n'
|
|
printf 'Vergleichen Sie beim ersten Aufruf den Fingerabdruck im Browser mit diesem:\n\n'
|
|
printf ' %s\n\n' "${certificateFingerprint}"
|
|
printf 'Danach ist die Warnung unbedenklich. Fuer den Dauerbetrieb gehoert ein\n'
|
|
printf 'Zertifikat einer Zertifizierungsstelle hierher — eigene CA oder Let'"'"'s Encrypt.\n'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Voraussetzungen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
printf '\n%sSyncova Backups — Einrichtung%s\n\n' "${colorBold}" "${colorReset}"
|
|
|
|
# Nur die Oberflaeche, auf einer bereits eingerichteten Anlage.
|
|
#
|
|
# Sie ist der Teil, den man am ehesten nachtraegt: Erst laeuft die Sicherung,
|
|
# dann will jemand sie auch ansehen. Dafuer eine vollstaendige Neuinstallation
|
|
# zu verlangen waere unangemessen.
|
|
if [[ "${webOnlyMode}" == "ja" ]]; then
|
|
trap - EXIT
|
|
|
|
if [[ "$(id -u)" -ne 0 ]]; then
|
|
abortWithMessage "Dieses Skript braucht root-Rechte (sudo ./setup.sh --weboberflaeche)."
|
|
fi
|
|
|
|
if [[ ! -d "${installationRoot}/web" ]]; then
|
|
abortWithMessage "Unter ${installationRoot}/web liegt keine Oberflaeche.
|
|
Richten Sie zuerst die Anlage ein: ./setup.sh"
|
|
fi
|
|
|
|
if setupWebInterface; then
|
|
printWebAccessSummary
|
|
printf '\n'
|
|
exit 0
|
|
fi
|
|
|
|
abortWithMessage "Die Weboberflaeche liess sich nicht einrichten."
|
|
fi
|
|
|
|
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 <verzeichnis> an."
|
|
fi
|
|
|
|
packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')"
|
|
writeSuccess "Paket gefunden: ${packageVersion}"
|
|
|
|
# Den vorgesehenen Ablageort sofort pruefen. Im unbeaufsichtigten Lauf soll das
|
|
# in Sekunden scheitern und nicht erst, nachdem PostgreSQL installiert, das
|
|
# Schema angelegt und ein Administrator eingerichtet wurde.
|
|
validateRepositoryPath "${repositoryPath}"
|
|
|
|
# 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
|
|
|
|
# Welche Fassung hat die Distribution mitgebracht?
|
|
#
|
|
# Debian 12 liefert 15, nicht 17. Geprueft ist Syncova gegen 17 (Entwicklung
|
|
# und CI) und 15 (Einrichtung und Sicherungslauf). Aelter wird abgelehnt: Die
|
|
# Migrationen setzen unter anderem gen_random_uuid() und DROP DATABASE ...
|
|
# WITH (FORCE) voraus.
|
|
postgresVersion="$(queryAsPostgres 'SHOW server_version' | awk '{print $1}' | cut -d. -f1)"
|
|
|
|
if [[ -n "${postgresVersion}" ]]; then
|
|
if [[ "${postgresVersion}" -lt 15 ]]; then
|
|
abortWithMessage "PostgreSQL ${postgresVersion} ist zu alt. Gebraucht wird 15 oder neuer."
|
|
fi
|
|
|
|
writeSuccess "PostgreSQL ${postgresVersion}"
|
|
fi
|
|
|
|
# Eine lokale Datenbank spricht ueber den Unix-Socket; TLS ist dort ohne
|
|
# Belang und wuerde den Verbindungsaufbau nur scheitern lassen.
|
|
databaseHost="127.0.0.1"
|
|
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}"
|
|
|
|
# Die Wiederherstellungsflaeche. Ohne sie scheitert jede Wiederherstellung an
|
|
# ProtectSystem=strict — und der Betreiber sucht den Fehler bei den Rechten des
|
|
# Zielverzeichnisses statt bei der Haertung des Dienstes.
|
|
install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}"
|
|
|
|
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"
|
|
|
|
# Alle vier Betriebsskripte kommen neben die Programme. Im Ernstfall sucht
|
|
# niemand das ausgepackte Paket von vor drei Monaten — und wer es geloescht hat,
|
|
# koennte die Anlage sonst nie wieder deinstallieren oder die Oberflaeche
|
|
# nachtraeglich einrichten. Dass hier zuvor nur diagnose.sh stand, ist beim
|
|
# Erproben aufgefallen: Der Hinweis "Spaeter nachholen: /opt/syncova/setup.sh"
|
|
# verwies auf eine Datei, die es nicht gab.
|
|
for operationScript in setup.sh update.sh uninstall.sh diagnose.sh; do
|
|
[[ -f "${packageDirectory}/${operationScript}" ]] && \
|
|
install -m 0755 "${packageDirectory}/${operationScript}" \
|
|
"${installationRoot}/${operationScript}"
|
|
done
|
|
|
|
# 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}" <<CONFIG
|
|
# Konfiguration von Syncova Backups.
|
|
#
|
|
# Angelegt von setup.sh am $(date -u '+%Y-%m-%d %H:%M:%S UTC').
|
|
# Diese Datei enthaelt Geheimnisse: root, Modus 0600.
|
|
|
|
SYNCOVA_ENV=production
|
|
|
|
SYNCOVA_DB_HOST=${databaseHost}
|
|
SYNCOVA_DB_PORT=${databasePort}
|
|
SYNCOVA_DB_NAME=${databaseName}
|
|
SYNCOVA_DB_USER=${databaseUser}
|
|
SYNCOVA_DB_PASSWORD=${databasePassword}
|
|
SYNCOVA_DB_SSLMODE=${databaseSSLMode}
|
|
|
|
# Ohne diesen Schluessel sind die abgelegten Geheimnisse verloren.
|
|
SYNCOVA_ENCRYPTION_KEYS=${encryptionKeys}
|
|
SYNCOVA_ENCRYPTION_CURRENT_KEY=v1
|
|
|
|
SYNCOVA_HTTP_LISTEN_ADDRESS=${listenAddress}
|
|
SYNCOVA_LOG_LEVEL=info
|
|
SYNCOVA_LOG_FORMAT=json
|
|
|
|
# TLS im Dienst selbst. Nur eines von beiden zu setzen verweigert den Start —
|
|
# sonst liefe der Dienst im Klartext, obwohl Verschluesselung eingerichtet
|
|
# aussieht. Hinter einem Reverse Proxy bleiben beide leer und die Lauschadresse
|
|
# steht auf 127.0.0.1.
|
|
# SYNCOVA_HTTP_TLS_CERT_FILE=
|
|
# SYNCOVA_HTTP_TLS_KEY_FILE=
|
|
|
|
# Begrenzt Wiederherstellungsziele. Ohne diese Angabe kann jeder mit dem Recht
|
|
# restores.execute in jedes beschreibbare Verzeichnis schreiben.
|
|
# SYNCOVA_RESTORE_ALLOWED_ROOTS=/srv/restore
|
|
|
|
# Zweiter Faktor fuer Konten mit Benutzer-, Rollen- oder Sicherheitsrechten.
|
|
# Zuerst einrichten, dann einschalten — sonst sperren Sie sich aus.
|
|
# SYNCOVA_AUTH_REQUIRE_MFA_FOR_PRIVILEGED_USERS=true
|
|
CONFIG
|
|
|
|
chmod 0600 "${configurationFile}"
|
|
chown "${serviceAccount}:${serviceAccount}" "${configurationFile}"
|
|
|
|
writeSuccess "${configurationFile} (0600, ${serviceAccount})"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
printf '\n'
|
|
writeStep "Datenbankschema"
|
|
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
. "${configurationFile}"
|
|
set +a
|
|
|
|
if ! "${installationRoot}/bin/syncova-migrate" up 2>&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
|
|
|
|
validateRepositoryPath "${repositoryPath}"
|
|
|
|
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"
|
|
|
|
# PrivateTmp und ein Repository unter /tmp schliessen einander aus.
|
|
#
|
|
# Mit PrivateTmp=yes bekommt der Dienst ein eigenes /tmp; der Ablageort
|
|
# existiert in seiner Sicht dann nicht, und systemd bricht den Start mit
|
|
# 226/NAMESPACE ab — bevor das Programm ueberhaupt laeuft. Der Fall tritt nur
|
|
# noch mit ausdruecklicher Freigabe auf; dann muss die Haertung an dieser einen
|
|
# Stelle weichen, sonst startet der Dienst nie.
|
|
privateTmpSetting="yes"
|
|
|
|
if repositoryIsUnderPrivateTmp "${repositoryPath}"; then
|
|
privateTmpSetting="no"
|
|
writeWarning "PrivateTmp wird abgeschaltet, weil das Repository unter /tmp liegt."
|
|
fi
|
|
|
|
cat > "${serviceUnitFile}" <<UNIT
|
|
[Unit]
|
|
Description=Syncova Control Plane
|
|
Documentation=file://${installationRoot}/docs/installation.md
|
|
After=network-online.target postgresql.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${serviceAccount}
|
|
Group=${serviceAccount}
|
|
EnvironmentFile=${configurationFile}
|
|
ExecStart=${installationRoot}/bin/syncova-api
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
# Haertung: Der Dienst braucht Netz und sein Repository, sonst nichts.
|
|
NoNewPrivileges=yes
|
|
PrivateTmp=${privateTmpSetting}
|
|
ProtectSystem=strict
|
|
ProtectHome=yes
|
|
ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots}
|
|
ProtectKernelTunables=yes
|
|
ProtectKernelModules=yes
|
|
ProtectControlGroups=yes
|
|
RestrictSUIDSGID=yes
|
|
# AF_UNIX gehoert in die Liste: Mit systemd-resolved laeuft die Namensaufloesung
|
|
# ueber einen Unix-Socket. Ohne diesen Eintrag findet der Dienst seine Datenbank
|
|
# nicht und meldet einen Netzfehler, waehrend das Netz einwandfrei arbeitet.
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
MemoryDenyWriteExecute=yes
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
|
|
noteCreated "service:${serviceName}"
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable "${serviceName}" >/dev/null 2>&1
|
|
systemctl start "${serviceName}"
|
|
|
|
writeDetail "Der Dienst wird geprueft."
|
|
|
|
serviceIsReady="nein"
|
|
for _attempt in $(seq 1 15); do
|
|
if serviceIsReachable "http://${listenAddress}/health/ready"; then
|
|
serviceIsReady="ja"
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [[ "${serviceIsReady}" != "ja" ]]; then
|
|
printf '\n'
|
|
writeWarning "Der Dienst meldet sich nicht als betriebsbereit."
|
|
|
|
# Die Zeilen gleich mitliefern statt auf sie zu verweisen.
|
|
#
|
|
# Ein Abbruch, der nur sagt "sehen Sie ins Journal", verlangt einen zweiten
|
|
# Anlauf, um ueberhaupt zu erfahren, was los war — und beim Rueckbau ist der
|
|
# Dienst dann schon weg.
|
|
if command -v journalctl >/dev/null 2>&1; then
|
|
printf '\n'
|
|
writeDetail "Die letzten Zeilen des Dienstes:"
|
|
printf '\n'
|
|
journalctl -u "${serviceName}" -n 20 --no-pager 2>/dev/null | sed 's/^/ /' >&2
|
|
printf '\n'
|
|
|
|
# 226/NAMESPACE ist der haeufigste Fall und ohne Erklaerung nicht zu
|
|
# deuten: Er bedeutet fast immer einen Pfad in ReadWritePaths, den der
|
|
# Dienst in seiner eigenen Namensraumsicht nicht sieht.
|
|
if journalctl -u "${serviceName}" -n 20 --no-pager 2>/dev/null | grep -q "226/NAMESPACE"; then
|
|
writeDetail "226/NAMESPACE bedeutet: Ein Pfad der Diensteinheit ist aus Sicht"
|
|
writeDetail "des Dienstes nicht vorhanden. Pruefen Sie ReadWritePaths in"
|
|
writeDetail "${serviceUnitFile} — insbesondere den Ort des Repositorys."
|
|
printf '\n'
|
|
fi
|
|
fi
|
|
|
|
abortWithMessage "Die Einrichtung wird zurueckgebaut."
|
|
fi
|
|
|
|
writeSuccess "Dienst laeuft und ist betriebsbereit"
|
|
|
|
# Ab hier ist die Einrichtung gelungen; der Rueckbau entfaellt.
|
|
#
|
|
# Was danach kommt, ist Zugabe: Scheitert die Weboberflaeche, steht die Anlage
|
|
# trotzdem. Sie deshalb wieder abzureissen waere unverhaeltnismaessig — die
|
|
# Sicherungen laufen auch ohne Oberflaeche.
|
|
trap - EXIT
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Weboberflaeche
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if [[ -z "${setupWebServer}" ]]; then
|
|
if [[ "${unattendedMode}" == "ja" ]]; then
|
|
setupWebServer="ja"
|
|
else
|
|
printf '\n'
|
|
writeStep "Weboberflaeche"
|
|
writeDetail "Die Oberflaeche braucht einen Webserver. Eingerichtet wird nginx mit"
|
|
writeDetail "einem selbst ausgestellten Zertifikat; der API-Dienst bleibt dabei an"
|
|
writeDetail "127.0.0.1 gebunden und liegt hinter dem Webserver."
|
|
printf '\n'
|
|
|
|
confirmQuestion "Weboberflaeche jetzt einrichten?" "j" && setupWebServer="ja" || setupWebServer="nein"
|
|
fi
|
|
fi
|
|
|
|
webInterfaceReady="nein"
|
|
|
|
if [[ "${setupWebServer}" == "ja" ]]; then
|
|
if setupWebInterface; then
|
|
webInterfaceReady="ja"
|
|
else
|
|
printf '\n'
|
|
writeWarning "Die Weboberflaeche wurde nicht eingerichtet."
|
|
writeDetail "Die Anlage laeuft trotzdem; die API ist unter ${listenAddress} erreichbar."
|
|
writeDetail "Spaeter nachholen: ${installationRoot}/setup.sh --weboberflaeche"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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}"
|
|
if [[ "${webInterfaceReady}" == "ja" ]]; then
|
|
printf ' Oberflaeche ueber nginx, siehe unten\n'
|
|
else
|
|
printf ' Oberflaeche %s/web — braucht einen Webserver mit SPA-Fallback\n' "${installationRoot}"
|
|
fi
|
|
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://<adresse>/api/v1/repositories \
|
|
-H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
|
|
-d '{"name":"Hauptziel","location":"<repository>"}'
|
|
|
|
curl -X POST http://<adresse>/api/v1/repositories/<id>/enforcement/measure \
|
|
-H "Authorization: Bearer <token>"
|
|
|
|
2. Zweiten Faktor einrichten, mindestens fuer Konten mit Loeschrecht.
|
|
|
|
3. Benachrichtigungsweg einrichten. Ohne ihn erfaehrt niemand von einem
|
|
Ausfall.
|
|
|
|
4. Eine Wiederherstellung durchfuehren, bevor Sie eine brauchen. Erst danach
|
|
steigt ein Wiederherstellungspunkt auf "recoverable" — alles davor ist ein
|
|
Indiz, kein Nachweis.
|
|
NEXT
|
|
|
|
[[ "${webInterfaceReady}" == "ja" ]] && printWebAccessSummary
|
|
|
|
printf '\n Ausfuehrlich: %s/docs/installation.md\n' "${installationRoot}"
|
|
printf ' Im Ernstfall: %s/docs/recovery-runbook.md\n\n' "${installationRoot}"
|