#!/usr/bin/env bash set -Eeuo pipefail # ============================================================================= # Invoice Ninja - Remote Deployment Bootstrap # # Phase 5: # - Deployment-Profil festlegen # - Sicherheits-Preflight durchführen # - Kundenkonfiguration bereitstellen und validieren # - versioniertes Release von Gitea herunterladen # - SHA256-Prüfsumme verifizieren # - Archivinhalt gegen feste Allowlist prüfen # - Installationsdateien sicher bereitstellen # - technische .env kontrolliert erzeugen und prüfen # - Installationsstatus sicher markieren # - prepare_invoiceninja_environment.sh ausführen # - APP_KEY / DB- / Redis-Passwörter erzeugen # - Runtime-Verzeichnisse db/ und storage/ vorbereiten # - Ergebnis nach der Vorbereitung erneut validieren # # Noch NICHT enthalten: # - Ausführen von install-invoiceninja.sh # - Starten von Docker Compose # - Starten oder Stoppen des Invoice-Ninja-Stacks # - Löschen bestehender Docker-Ressourcen # # Hinweis: # Für die Erzeugung des APP_KEY verwendet das Prepare-Skript einmalig # einen temporären "docker run --rm"-Container. Der Compose-Stack wird # dabei nicht gestartet. # # Aufruf: # # Produktion: # bash bootstrap-invoiceninja.sh production # # Test: # bash bootstrap-invoiceninja.sh test # # ============================================================================= # ----------------------------------------------------------------------------- # Grundkonfiguration # ----------------------------------------------------------------------------- DEPLOYMENT_MODE="${1:-}" PRODUCTION_TARGET="/volume1/docker/invoiceninja" TEST_TARGET="/volume1/docker/invoiceninja_test" PRODUCTION_PROJECT="invoiceninja" TEST_PROJECT="invoiceninja_test" PRODUCTION_PORT="5485" TEST_PORT="5486" CONFIG_FILENAME="kundenkonfiguration.env" ENV_FILENAME=".env" STATE_FILENAME=".bootstrap-state" STATE_VERSION="1" SYNOACLTOOL="/usr/syno/bin/synoacltool" EXIT_CONFIG=2 EXIT_SAFETY=3 # ----------------------------------------------------------------------------- # Release-Konfiguration # ----------------------------------------------------------------------------- RELEASE_VERSION="v0.1.1-test" RELEASE_BASE_URL="https://git.myduvancloud.de/duvan-it/invoiceninja-deployment/releases/download/${RELEASE_VERSION}" ARCHIVE_NAME="invoiceninja-deployment-${RELEASE_VERSION}.tar.gz" CHECKSUM_NAME="${ARCHIVE_NAME}.sha256" ARCHIVE_URL="${RELEASE_BASE_URL}/${ARCHIVE_NAME}" CHECKSUM_URL="${RELEASE_BASE_URL}/${CHECKSUM_NAME}" PACKAGE_ROOT="invoiceninja-deployment" DEPLOYMENT_FILES=( ".env.example" "docker-compose.yml" "in-vhost.conf" "install-invoiceninja.sh" "prepare_invoiceninja_environment.sh" ) # ----------------------------------------------------------------------------- # Erwartete technische .env-Struktur # # Damit verhindern wir, dass sich .env.example und Bootstrap unbemerkt # auseinanderentwickeln. # ----------------------------------------------------------------------------- ENV_KEYS=( "TIME_ZONE" "WEB_BIND_ADDRESS" "WEB_PORT" "APP_NAME" "APP_ENV" "APP_KEY" "APP_DEBUG" "APP_URL" "REQUIRE_HTTPS" "TRUSTED_PROXIES" "PDF_GENERATOR" "IN_USER_EMAIL" "IN_PASSWORD" "DB_DATABASE" "DB_USERNAME" "DB_PASSWORD" "DB_ROOT_PASSWORD" "REDIS_PASSWORD" "MAIL_MAILER" "MAIL_HOST" "MAIL_PORT" "MAIL_USERNAME" "MAIL_PASSWORD" "MAIL_ENCRYPTION" "MAIL_FROM_ADDRESS" "MAIL_FROM_NAME" ) # ----------------------------------------------------------------------------- # Temporäre Release-Pfade # ----------------------------------------------------------------------------- TEMP_DIR="" EXTRACT_DIR="" EXTRACT_PACKAGE_DIR="" ARCHIVE_PATH="" CHECKSUM_PATH="" ENV_BUILD_FILE="" ENV_EXPECTED_FILE="" STATE_BUILD_FILE="" # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- log() { printf '[INFO] %s\n' "$1" } warn() { printf '[WARN] %s\n' "$1" >&2 } error() { printf '[ERROR] %s\n' "$1" >&2 } die() { error "$1" exit 1 } # ----------------------------------------------------------------------------- # Aufruf prüfen # ----------------------------------------------------------------------------- if (( $# > 1 )); then error "Zu viele Argumente angegeben." printf '[ERROR] Verwendung: %s production|test\n' \ "$(basename "$0")" >&2 exit "$EXIT_CONFIG" fi # ----------------------------------------------------------------------------- # Deployment-Profil # ----------------------------------------------------------------------------- case "$DEPLOYMENT_MODE" in production) ACCESS_MODE="reverse_proxy" TARGET_DIR="$PRODUCTION_TARGET" PROJECT_NAME="$PRODUCTION_PROJECT" WEB_BIND_ADDRESS="127.0.0.1" WEB_PORT="$PRODUCTION_PORT" REQUIRE_HTTPS="true" ;; test) ACCESS_MODE="direct" TARGET_DIR="$TEST_TARGET" PROJECT_NAME="$TEST_PROJECT" WEB_BIND_ADDRESS="0.0.0.0" WEB_PORT="$TEST_PORT" REQUIRE_HTTPS="false" ;; "") error "Kein Installationsmodus angegeben." printf '[ERROR] Verwendung: %s production|test\n' \ "$(basename "$0")" >&2 exit "$EXIT_CONFIG" ;; *) error "Ungültiger Installationsmodus: $DEPLOYMENT_MODE" error "Erlaubt sind ausschließlich: production oder test" exit "$EXIT_CONFIG" ;; esac CONFIG_FILE="$TARGET_DIR/$CONFIG_FILENAME" ENV_FILE="$TARGET_DIR/$ENV_FILENAME" STATE_FILE="$TARGET_DIR/$STATE_FILENAME" ENV_TEMPLATE_FILE="$TARGET_DIR/.env.example" TARGET_PARENT="$(dirname -- "$TARGET_DIR")" # ----------------------------------------------------------------------------- # Kundenkonfiguration erzeugen # ----------------------------------------------------------------------------- write_customer_config_template() { cat > "$CONFIG_FILE" <<'EOF' # ============================================================================= # Invoice Ninja - Installationskonfiguration # ============================================================================= # # Diese Datei enthält ausschließlich Angaben, die für die Installation # kundenspezifisch festgelegt werden müssen. # # Werte mit müssen angepasst werden. # # Technische Einstellungen und Passwörter für Docker, MariaDB, Redis und # Invoice Ninja werden automatisch festgelegt bzw. erzeugt. # # Diese Datei wird nach erfolgreicher Installation wieder entfernt. # # ============================================================================= # ----------------------------------------------------------------------------- # Öffentliche Adresse # # Produktiv: # APP_URL=https://rechnung.kunde.de # # Interner Test: # APP_URL=http://192.168.1.100:5486 # ----------------------------------------------------------------------------- APP_URL= # ----------------------------------------------------------------------------- # Initialer Invoice-Ninja-Administrator # ----------------------------------------------------------------------------- IN_USER_EMAIL= IN_PASSWORD= # ----------------------------------------------------------------------------- # E-Mail-Versand # # log # Invoice Ninja versendet zunächst keine echten E-Mails. # # smtp # Invoice Ninja verwendet einen SMTP-Server. # # Bei MAIL_MAILER=log werden die SMTP-Felder darunter ignoriert. # ----------------------------------------------------------------------------- MAIL_MAILER=log # ----------------------------------------------------------------------------- # SMTP # # Nur erforderlich, wenn MAIL_MAILER=smtp verwendet wird. # ----------------------------------------------------------------------------- MAIL_HOST= MAIL_PORT=587 MAIL_USERNAME= MAIL_PASSWORD= MAIL_ENCRYPTION=STARTTLS MAIL_FROM_ADDRESS= MAIL_FROM_NAME="Invoice Ninja" EOF } # ----------------------------------------------------------------------------- # Schlüssel/Wert aus Kundenkonfiguration lesen # # Die Datei wird absichtlich NICHT mit "source" geladen. # ----------------------------------------------------------------------------- get_config_value() { local key="$1" awk -F= -v key="$key" ' $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) print value exit } ' "$CONFIG_FILE" } value_is_missing() { local value="$1" [[ -z "$value" || "$value" == "" ]] } # ----------------------------------------------------------------------------- # Wert aus einer technischen .env lesen # # Auch die technische .env wird absichtlich NICHT mit "source" geladen. # ----------------------------------------------------------------------------- get_env_value_from_file() { local file="$1" local key="$2" awk -F= -v key="$key" ' $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) print value exit } ' "$file" } # ----------------------------------------------------------------------------- # Erwarteten Installationsstatus ausgeben # # Der Status bindet eine begonnene Installation fest an: # - Bootstrap-State-Version # - Installationsmodus # - Release-Version # - Compose-Projekt # - Zielverzeichnis # # Dadurch dürfen vorhandene Runtime-Verzeichnisse nicht allein aufgrund # ihres Namens als zu diesem Deployment gehörig angenommen werden. # ----------------------------------------------------------------------------- print_expected_state() { printf 'STATE_VERSION=%s\n' "$STATE_VERSION" printf 'DEPLOYMENT_MODE=%s\n' "$DEPLOYMENT_MODE" printf 'RELEASE_VERSION=%s\n' "$RELEASE_VERSION" printf 'PROJECT_NAME=%s\n' "$PROJECT_NAME" printf 'TARGET_DIR=%s\n' "$TARGET_DIR" } state_file_matches_expected() { local expected_state local actual_state local state_meta [[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || return 1 state_meta="$(stat -c '%u:%g:%a' "$STATE_FILE" 2>/dev/null)" || return 1 [[ "$state_meta" == "0:0:600" ]] || return 1 has_synology_acl "$STATE_FILE" && return 1 expected_state="$(print_expected_state)" actual_state="$(cat "$STATE_FILE")" || return 1 [[ "$actual_state" == "$expected_state" ]] } # ----------------------------------------------------------------------------- # Wert sicher für Docker-Compose-.env quoten # # Kundeneingaben werden in doppelte Anführungszeichen gesetzt. # # Dabei werden: # \ -> \\ # " -> \" # $ -> $$ # # Dadurch werden Sonderzeichen nicht unbeabsichtigt als Compose- # Variableninterpolation interpretiert. # ----------------------------------------------------------------------------- dotenv_quote() { local value="$1" local escaped if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then return 1 fi escaped="$( printf '%s' "$value" | sed \ -e 's/\\/\\\\/g' \ -e 's/"/\\"/g' \ -e 's/\$/$$/g' )" printf '"%s"' "$escaped" } # ----------------------------------------------------------------------------- # Synology-ACL prüfen # ----------------------------------------------------------------------------- has_synology_acl() { local path="$1" local acl_output acl_output="$( "$SYNOACLTOOL" -get "$path" 2>&1 || true )" [[ "$acl_output" == *"ACL version:"* ]] } # ----------------------------------------------------------------------------- # Bekannte Deployment-Datei prüfen # ----------------------------------------------------------------------------- is_known_deployment_file() { local filename="$1" local allowed for allowed in "${DEPLOYMENT_FILES[@]}"; do if [[ "$filename" == "$allowed" ]]; then return 0 fi done return 1 } # ----------------------------------------------------------------------------- # Temporäre Release-Dateien entfernen # ----------------------------------------------------------------------------- cleanup_release_temp() { if [[ -n "$STATE_BUILD_FILE" ]]; then rm -f -- "$STATE_BUILD_FILE" 2>/dev/null || true fi if [[ -n "$ENV_EXPECTED_FILE" ]]; then rm -f -- "$ENV_EXPECTED_FILE" 2>/dev/null || true fi if [[ -n "$ENV_BUILD_FILE" ]]; then rm -f -- "$ENV_BUILD_FILE" 2>/dev/null || true fi if [[ -n "$EXTRACT_PACKAGE_DIR" && -d "$EXTRACT_PACKAGE_DIR" ]]; then local filename for filename in "${DEPLOYMENT_FILES[@]}"; do rm -f -- "$EXTRACT_PACKAGE_DIR/$filename" 2>/dev/null || true done rmdir -- "$EXTRACT_PACKAGE_DIR" 2>/dev/null || true fi if [[ -n "$EXTRACT_DIR" && -d "$EXTRACT_DIR" ]]; then rmdir -- "$EXTRACT_DIR" 2>/dev/null || true fi if [[ -n "$ARCHIVE_PATH" ]]; then rm -f -- "$ARCHIVE_PATH" 2>/dev/null || true fi if [[ -n "$CHECKSUM_PATH" ]]; then rm -f -- "$CHECKSUM_PATH" 2>/dev/null || true fi if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then rmdir -- "$TEMP_DIR" 2>/dev/null || true fi } trap cleanup_release_temp EXIT # ----------------------------------------------------------------------------- # Voraussetzungen # ----------------------------------------------------------------------------- if [[ "${EUID}" -ne 0 ]]; then die "Das Skript muss als root ausgeführt werden." fi command -v awk >/dev/null 2>&1 \ || die "awk wurde nicht gefunden." command -v cat >/dev/null 2>&1 \ || die "cat wurde nicht gefunden." command -v chmod >/dev/null 2>&1 \ || die "chmod wurde nicht gefunden." command -v chown >/dev/null 2>&1 \ || die "chown wurde nicht gefunden." command -v cmp >/dev/null 2>&1 \ || die "cmp wurde nicht gefunden." command -v curl >/dev/null 2>&1 \ || die "curl wurde nicht gefunden." command -v docker >/dev/null 2>&1 \ || die "Docker wurde nicht gefunden." command -v find >/dev/null 2>&1 \ || die "find wurde nicht gefunden." command -v grep >/dev/null 2>&1 \ || die "grep wurde nicht gefunden." command -v mktemp >/dev/null 2>&1 \ || die "mktemp wurde nicht gefunden." command -v mv >/dev/null 2>&1 \ || die "mv wurde nicht gefunden." command -v sed >/dev/null 2>&1 \ || die "sed wurde nicht gefunden." command -v stat >/dev/null 2>&1 \ || die "stat wurde nicht gefunden." command -v sha256sum >/dev/null 2>&1 \ || die "sha256sum wurde nicht gefunden." command -v sort >/dev/null 2>&1 \ || die "sort wurde nicht gefunden." command -v tar >/dev/null 2>&1 \ || die "tar wurde nicht gefunden." [[ -x "$SYNOACLTOOL" ]] \ || die "Das Synology-ACL-Werkzeug wurde nicht gefunden: $SYNOACLTOOL" docker info >/dev/null 2>&1 \ || die "Docker ist nicht erreichbar." docker compose version >/dev/null 2>&1 \ || die "Docker Compose v2 wurde nicht gefunden." [[ -d "$TARGET_PARENT" ]] \ || die "Das übergeordnete Zielverzeichnis wurde nicht gefunden: $TARGET_PARENT" has_synology_acl "$TARGET_PARENT" \ || die "Das übergeordnete Zielverzeichnis besitzt keine verwendbare Synology-ACL: $TARGET_PARENT" # ----------------------------------------------------------------------------- # Interne Sicherheitsregeln # ----------------------------------------------------------------------------- if [[ "$DEPLOYMENT_MODE" == "production" ]]; then [[ "$ACCESS_MODE" == "reverse_proxy" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-Zugriffsart." [[ "$TARGET_DIR" == "$PRODUCTION_TARGET" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Zielverzeichnis." [[ "$PROJECT_NAME" == "$PRODUCTION_PROJECT" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Projektnamen." [[ "$WEB_BIND_ADDRESS" == "127.0.0.1" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-Bind-Adresse." [[ "$WEB_PORT" == "$PRODUCTION_PORT" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Port." [[ "$REQUIRE_HTTPS" == "true" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-HTTPS-Konfiguration." fi if [[ "$DEPLOYMENT_MODE" == "test" ]]; then [[ "$ACCESS_MODE" == "direct" ]] \ || die "Interner Sicherheitsfehler bei der Test-Zugriffsart." [[ "$TARGET_DIR" == "$TEST_TARGET" ]] \ || die "Interner Sicherheitsfehler beim Test-Zielverzeichnis." [[ "$PROJECT_NAME" == "$TEST_PROJECT" ]] \ || die "Interner Sicherheitsfehler beim Test-Projektnamen." [[ "$WEB_BIND_ADDRESS" == "0.0.0.0" ]] \ || die "Interner Sicherheitsfehler bei der Test-Bind-Adresse." [[ "$WEB_PORT" == "$TEST_PORT" ]] \ || die "Interner Sicherheitsfehler beim Test-Port." [[ "$REQUIRE_HTTPS" == "false" ]] \ || die "Interner Sicherheitsfehler bei der Test-HTTPS-Konfiguration." [[ "$TARGET_DIR" != "$PRODUCTION_TARGET" ]] \ || die "Eine Testinstallation darf niemals das Produktivverzeichnis verwenden." [[ "$PROJECT_NAME" != "$PRODUCTION_PROJECT" ]] \ || die "Eine Testinstallation darf niemals den Produktiv-Projektnamen verwenden." [[ "$WEB_PORT" != "$PRODUCTION_PORT" ]] \ || die "Eine Testinstallation darf niemals den Produktiv-Port verwenden." fi # ----------------------------------------------------------------------------- # Sicherheitsprüfung des Zielsystems # ----------------------------------------------------------------------------- SAFETY_ERRORS=() add_safety_error() { SAFETY_ERRORS+=("$1") } if [[ -L "$TARGET_DIR" ]]; then add_safety_error \ "Das Zielverzeichnis ist ein symbolischer Link: $TARGET_DIR" fi if [[ -e "$TARGET_DIR" && ! -d "$TARGET_DIR" ]]; then add_safety_error \ "Der Zielpfad existiert bereits, ist aber kein Verzeichnis: $TARGET_DIR" fi if [[ -L "$CONFIG_FILE" ]]; then add_safety_error \ "Die Kundenkonfiguration ist ein symbolischer Link: $CONFIG_FILE" fi if [[ -L "$ENV_FILE" ]]; then add_safety_error \ "Die technische .env ist ein symbolischer Link: $ENV_FILE" fi if [[ -e "$ENV_FILE" && ! -f "$ENV_FILE" ]]; then add_safety_error \ "Die technische .env ist keine reguläre Datei." fi if [[ -f "$ENV_FILE" && ! -L "$ENV_FILE" ]]; then ENV_META="$(stat -c '%u:%g:%a' "$ENV_FILE" 2>/dev/null || true)" if [[ "$ENV_META" != "0:0:600" ]]; then add_safety_error \ "Die technische .env muss root:root gehören und Dateimodus 600 besitzen." fi if has_synology_acl "$ENV_FILE"; then add_safety_error \ "Die technische .env darf keine Synology-ACL besitzen." fi fi # ----------------------------------------------------------------------------- # Installationsstatus prüfen # ----------------------------------------------------------------------------- STATE_VALID="false" if [[ -L "$STATE_FILE" ]]; then add_safety_error \ "Der Installationsstatus ist ein symbolischer Link: $STATE_FILE" elif [[ -e "$STATE_FILE" ]]; then if [[ ! -f "$STATE_FILE" ]]; then add_safety_error \ "Der Installationsstatus ist keine reguläre Datei: $STATE_FILE" elif state_file_matches_expected; then STATE_VALID="true" else add_safety_error \ "Der vorhandene Installationsstatus passt nicht zu diesem Deployment." fi fi # ----------------------------------------------------------------------------- # Bestehende Laufzeitdaten erkennen # # db/ und storage/ sind nur dann zulässig, wenn ein gültiger, rootgeschützter # Installationsstatus für exakt dieses Deployment vorhanden ist. # ----------------------------------------------------------------------------- DEPLOYMENT_FILE_COUNT=0 if [[ -d "$TARGET_DIR" ]]; then for runtime_name in db storage; do runtime_path="$TARGET_DIR/$runtime_name" if [[ -L "$runtime_path" ]]; then add_safety_error \ "Runtime-Pfad ist ein symbolischer Link: $runtime_name" elif [[ -e "$runtime_path" && ! -d "$runtime_path" ]]; then add_safety_error \ "Runtime-Pfad ist kein Verzeichnis: $runtime_name" elif [[ -d "$runtime_path" && "$STATE_VALID" != "true" ]]; then add_safety_error \ "Runtime-Verzeichnis '$runtime_name/' existiert ohne gültigen Installationsstatus." fi done # ------------------------------------------------------------------------- # Nur bekannte Inhalte im Ziel zulassen # ------------------------------------------------------------------------- while IFS= read -r existing_path; do existing_name="${existing_path##*/}" case "$existing_name" in "$CONFIG_FILENAME"|"$ENV_FILENAME"|"$STATE_FILENAME"|db|storage) continue ;; esac if is_known_deployment_file "$existing_name"; then continue fi add_safety_error \ "Unbekannter Inhalt im Zielverzeichnis erkannt: $existing_name" done < <( find "$TARGET_DIR" \ -mindepth 1 \ -maxdepth 1 \ -print 2>/dev/null ) # ------------------------------------------------------------------------- # Vorhandene Deployment-Dateien prüfen # ------------------------------------------------------------------------- for filename in "${DEPLOYMENT_FILES[@]}"; do target_file="$TARGET_DIR/$filename" if [[ -L "$target_file" ]]; then add_safety_error \ "Deployment-Datei ist ein symbolischer Link: $filename" continue fi if [[ -e "$target_file" ]]; then if [[ ! -f "$target_file" ]]; then add_safety_error \ "Deployment-Pfad ist keine reguläre Datei: $filename" continue fi DEPLOYMENT_FILE_COUNT=$((DEPLOYMENT_FILE_COUNT + 1)) fi done if (( DEPLOYMENT_FILE_COUNT > 0 && DEPLOYMENT_FILE_COUNT < ${#DEPLOYMENT_FILES[@]} )); then add_safety_error \ "Die Deployment-Dateien sind nur teilweise vorhanden." fi fi # ----------------------------------------------------------------------------- # Bestehendes Docker-Compose-Projekt prüfen # ----------------------------------------------------------------------------- EXISTING_PROJECT_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Names}}' )" if [[ -n "$EXISTING_PROJECT_CONTAINERS" ]]; then add_safety_error \ "Das Docker-Compose-Projekt '$PROJECT_NAME' besitzt bereits Container." fi # ----------------------------------------------------------------------------- # Docker-Port prüfen # ----------------------------------------------------------------------------- PORT_CONTAINERS="$( docker ps -a \ --filter "publish=${WEB_PORT}" \ --format '{{.Names}}' )" if [[ -n "$PORT_CONTAINERS" ]]; then add_safety_error \ "Der vorgesehene Docker-Port ${WEB_PORT} wird bereits von einem Container verwendet." fi # ----------------------------------------------------------------------------- # Zusätzlich auf lokal belegten TCP-Port prüfen # ----------------------------------------------------------------------------- PORT_LISTENER="" if command -v ss >/dev/null 2>&1; then PORT_LISTENER="$( ss -ltn 2>/dev/null | awk -v port=":${WEB_PORT}" ' $4 ~ port "$" { listener = $4 } END { if (listener != "") { print listener } } ' )" elif command -v netstat >/dev/null 2>&1; then PORT_LISTENER="$( netstat -ltn 2>/dev/null | awk -v port=":${WEB_PORT}" ' $4 ~ port "$" { listener = $4 } END { if (listener != "") { print listener } } ' )" fi if [[ -n "$PORT_LISTENER" && -z "$PORT_CONTAINERS" ]]; then add_safety_error \ "Der TCP-Port ${WEB_PORT} wird bereits auf dem NAS verwendet." fi # ----------------------------------------------------------------------------- # Ergebnis der Sicherheitsprüfung # ----------------------------------------------------------------------------- if (( ${#SAFETY_ERRORS[@]} > 0 )); then printf '\n' >&2 warn "Die Installation wurde aus Sicherheitsgründen blockiert:" for safety_error in "${SAFETY_ERRORS[@]}"; do printf ' - %s\n' "$safety_error" >&2 done printf '\n' >&2 warn "Bestehende Systeme wurden nicht verändert." warn "Es wurden keine Docker-Ressourcen gelöscht oder gestoppt." exit "$EXIT_SAFETY" fi # ----------------------------------------------------------------------------- # Erster Lauf: Kundenkonfiguration erzeugen # ----------------------------------------------------------------------------- if [[ ! -e "$CONFIG_FILE" ]]; then log "Es wurde noch keine Kundenkonfiguration gefunden." log "Die Installationskonfiguration wird vorbereitet." TARGET_CREATED="false" if [[ ! -d "$TARGET_DIR" ]]; then mkdir -- "$TARGET_DIR" TARGET_CREATED="true" if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$TARGET_DIR" >/dev/null 2>&1; then rmdir -- "$TARGET_DIR" 2>/dev/null || true die "Die Synology-ACL konnte für das Zielverzeichnis nicht eingerichtet werden." fi if ! has_synology_acl "$TARGET_DIR"; then rmdir -- "$TARGET_DIR" 2>/dev/null || true die "Das Zielverzeichnis wurde nicht korrekt in den Synology-ACL-Modus übernommen." fi fi if ! write_customer_config_template; then rm -f -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Kundenkonfiguration konnte nicht erstellt werden." fi if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$CONFIG_FILE" >/dev/null 2>&1; then rm -f -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Synology-ACL konnte für die Kundenkonfiguration nicht eingerichtet werden." fi if ! has_synology_acl "$CONFIG_FILE"; then rm -f -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Kundenkonfiguration wurde nicht korrekt in den Synology-ACL-Modus übernommen." fi printf '\n' log "Die Kundenkonfiguration wurde erstellt:" printf '\n' printf ' %s\n' "$CONFIG_FILE" printf '\n' log "Bitte öffnen Sie diese Datei über DSM bzw. die Docker-Freigabe" log "und ersetzen Sie alle erforderlichen Werte mit ." log "Anschließend dieselbe Installationsaufgabe erneut ausführen." printf '\n' log "Hinweis: Die Kundenkonfiguration ist nur für die Installation vorgesehen" log "und wird nach erfolgreicher Installation wieder entfernt." printf '\n' log "Es wurden noch keine Docker-Container oder Docker-Ressourcen erstellt." exit "$EXIT_CONFIG" fi # ----------------------------------------------------------------------------- # Kundenkonfiguration prüfen # ----------------------------------------------------------------------------- if [[ ! -f "$CONFIG_FILE" ]]; then die "Die Kundenkonfiguration ist keine reguläre Datei: $CONFIG_FILE" fi if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$CONFIG_FILE" >/dev/null 2>&1; then die "Die Synology-ACL der Kundenkonfiguration konnte nicht aktualisiert werden." fi if ! has_synology_acl "$CONFIG_FILE"; then die "Die Kundenkonfiguration besitzt keine gültige Synology-ACL." fi # ----------------------------------------------------------------------------- # Kundenkonfiguration einlesen # ----------------------------------------------------------------------------- APP_URL="$(get_config_value APP_URL)" IN_USER_EMAIL="$(get_config_value IN_USER_EMAIL)" IN_PASSWORD="$(get_config_value IN_PASSWORD)" MAIL_MAILER="$(get_config_value MAIL_MAILER)" MAIL_HOST="$(get_config_value MAIL_HOST)" MAIL_PORT="$(get_config_value MAIL_PORT)" MAIL_USERNAME="$(get_config_value MAIL_USERNAME)" MAIL_PASSWORD="$(get_config_value MAIL_PASSWORD)" MAIL_ENCRYPTION="$(get_config_value MAIL_ENCRYPTION)" MAIL_FROM_ADDRESS="$(get_config_value MAIL_FROM_ADDRESS)" MAIL_FROM_NAME="$(get_config_value MAIL_FROM_NAME)" # ----------------------------------------------------------------------------- # Kundenkonfiguration validieren # ----------------------------------------------------------------------------- CONFIG_ERRORS=() add_config_error() { CONFIG_ERRORS+=("$1") } require_value() { local key="$1" local value="$2" if value_is_missing "$value"; then add_config_error "$key ist nicht gesetzt." fi } require_value "APP_URL" "$APP_URL" require_value "IN_USER_EMAIL" "$IN_USER_EMAIL" require_value "IN_PASSWORD" "$IN_PASSWORD" require_value "MAIL_MAILER" "$MAIL_MAILER" # ----------------------------------------------------------------------------- # Mail-Konfiguration prüfen # ----------------------------------------------------------------------------- if ! value_is_missing "$MAIL_MAILER"; then case "$MAIL_MAILER" in log) ;; smtp) require_value "MAIL_HOST" "$MAIL_HOST" require_value "MAIL_PORT" "$MAIL_PORT" require_value "MAIL_USERNAME" "$MAIL_USERNAME" require_value "MAIL_PASSWORD" "$MAIL_PASSWORD" require_value "MAIL_ENCRYPTION" "$MAIL_ENCRYPTION" require_value "MAIL_FROM_ADDRESS" "$MAIL_FROM_ADDRESS" require_value "MAIL_FROM_NAME" "$MAIL_FROM_NAME" if ! value_is_missing "$MAIL_PORT"; then if [[ ! "$MAIL_PORT" =~ ^[0-9]+$ ]]; then add_config_error \ "MAIL_PORT muss eine gültige Portnummer sein." elif (( MAIL_PORT < 1 || MAIL_PORT > 65535 )); then add_config_error \ "MAIL_PORT muss zwischen 1 und 65535 liegen." fi fi ;; *) add_config_error \ "MAIL_MAILER muss 'log' oder 'smtp' sein." ;; esac fi # ----------------------------------------------------------------------------- # APP_URL prüfen # # Ist APP_URL noch nicht gesetzt, werden keine zusätzlichen Folgefehler # ausgegeben. # ----------------------------------------------------------------------------- if ! value_is_missing "$APP_URL"; then case "$DEPLOYMENT_MODE" in production) if [[ "$APP_URL" != https://* ]]; then add_config_error \ "Eine produktive Installation benötigt eine HTTPS-APP_URL." fi ;; test) if [[ "$APP_URL" != http://* ]]; then add_config_error \ "Eine Testinstallation benötigt eine HTTP-APP_URL." fi if [[ "$APP_URL" != *":${WEB_PORT}"* ]]; then add_config_error \ "Bei einer Testinstallation muss APP_URL den Port ${WEB_PORT} enthalten." fi ;; esac fi if (( ${#CONFIG_ERRORS[@]} > 0 )); then printf '\n' >&2 warn "Die Kundenkonfiguration ist noch nicht vollständig oder ungültig:" for config_error in "${CONFIG_ERRORS[@]}"; do printf ' - %s\n' "$config_error" >&2 done printf '\n' >&2 warn "Bitte bearbeiten Sie:" printf ' %s\n' "$CONFIG_FILE" >&2 printf '\n' >&2 warn "Es wurden keine Docker-Ressourcen erstellt, gelöscht oder gestoppt." exit "$EXIT_CONFIG" fi # ============================================================================= # RELEASE-DOWNLOAD # ============================================================================= printf '\n' log "Kundenkonfiguration erfolgreich geprüft." log "Deployment-Release wird vorbereitet." log "Release-Version: $RELEASE_VERSION" TEMP_DIR="$(mktemp -d /tmp/invoiceninja-deployment.XXXXXX)" [[ "$TEMP_DIR" == /tmp/invoiceninja-deployment.* ]] \ || die "Unsicheres temporäres Verzeichnis erkannt." EXTRACT_DIR="$TEMP_DIR/extracted" EXTRACT_PACKAGE_DIR="$EXTRACT_DIR/$PACKAGE_ROOT" ARCHIVE_PATH="$TEMP_DIR/$ARCHIVE_NAME" CHECKSUM_PATH="$TEMP_DIR/$CHECKSUM_NAME" ENV_BUILD_FILE="$TEMP_DIR/generated.env" ENV_EXPECTED_FILE="$TEMP_DIR/expected.env" STATE_BUILD_FILE="$TEMP_DIR/bootstrap-state" mkdir -- "$EXTRACT_DIR" # ----------------------------------------------------------------------------- # Download # ----------------------------------------------------------------------------- log "Installationspaket wird heruntergeladen." curl \ --fail \ --location \ --silent \ --show-error \ --connect-timeout 15 \ --max-time 300 \ --retry 3 \ --retry-delay 2 \ "$ARCHIVE_URL" \ -o "$ARCHIVE_PATH" \ || die "Das Installationspaket konnte nicht heruntergeladen werden." log "SHA256-Prüfsumme wird heruntergeladen." curl \ --fail \ --location \ --silent \ --show-error \ --connect-timeout 15 \ --max-time 60 \ --retry 3 \ --retry-delay 2 \ "$CHECKSUM_URL" \ -o "$CHECKSUM_PATH" \ || die "Die SHA256-Prüfsumme konnte nicht heruntergeladen werden." # ----------------------------------------------------------------------------- # SHA256-Datei sicher validieren # ----------------------------------------------------------------------------- EXPECTED_HASH="$( awk -v expected_file="$ARCHIVE_NAME" ' NF { line_count++ if (NF == 2 && $2 == expected_file && length($1) == 64 && $1 !~ /[^0-9A-Fa-f]/) { hash = $1 } } END { if (line_count == 1 && hash != "") { print hash } else { exit 1 } } ' "$CHECKSUM_PATH" )" || die "Die SHA256-Datei besitzt ein unerwartetes Format." EXPECTED_HASH="$( printf '%s' "$EXPECTED_HASH" | tr '[:upper:]' '[:lower:]' )" ACTUAL_HASH="$( sha256sum "$ARCHIVE_PATH" | awk '{print $1}' )" if [[ "$ACTUAL_HASH" != "$EXPECTED_HASH" ]]; then error "SHA256-Prüfung fehlgeschlagen." error "Das heruntergeladene Installationspaket wird nicht verwendet." exit 1 fi log "SHA256-Prüfung erfolgreich." # ----------------------------------------------------------------------------- # Archivinhalt gegen feste Allowlist prüfen # ----------------------------------------------------------------------------- EXPECTED_ARCHIVE_CONTENT="$( { printf '%s/\n' "$PACKAGE_ROOT" for filename in "${DEPLOYMENT_FILES[@]}"; do printf '%s/%s\n' "$PACKAGE_ROOT" "$filename" done } | LC_ALL=C sort )" ACTUAL_ARCHIVE_CONTENT="$( tar -tzf "$ARCHIVE_PATH" | LC_ALL=C sort )" || die "Der Inhalt des Installationspakets konnte nicht gelesen werden." if [[ "$ACTUAL_ARCHIVE_CONTENT" != "$EXPECTED_ARCHIVE_CONTENT" ]]; then error "Das Installationspaket enthält unerwartete Dateien oder Pfade." error "Das Paket wird nicht entpackt." exit 1 fi # ----------------------------------------------------------------------------- # Dateitypen im Archiv prüfen # ----------------------------------------------------------------------------- EXPECTED_ARCHIVE_TYPES="$( { printf 'd %s/\n' "$PACKAGE_ROOT" for filename in "${DEPLOYMENT_FILES[@]}"; do printf -- '- %s/%s\n' "$PACKAGE_ROOT" "$filename" done } | LC_ALL=C sort )" ACTUAL_ARCHIVE_TYPES="$( tar -tvzf "$ARCHIVE_PATH" | awk '{ type = substr($1, 1, 1) path = $NF print type " " path }' | LC_ALL=C sort )" || die "Die Dateitypen im Installationspaket konnten nicht geprüft werden." if [[ "$ACTUAL_ARCHIVE_TYPES" != "$EXPECTED_ARCHIVE_TYPES" ]]; then error "Das Installationspaket enthält unerlaubte Dateitypen." error "Symbolische Links oder andere Sonderdateien sind nicht zulässig." exit 1 fi log "Archivinhalt erfolgreich geprüft." # ----------------------------------------------------------------------------- # Sicher entpacken # ----------------------------------------------------------------------------- tar -xzf "$ARCHIVE_PATH" \ -C "$EXTRACT_DIR" \ || die "Das Installationspaket konnte nicht entpackt werden." [[ -d "$EXTRACT_PACKAGE_DIR" ]] \ || die "Das erwartete Paketverzeichnis wurde nicht gefunden." for filename in "${DEPLOYMENT_FILES[@]}"; do extracted_file="$EXTRACT_PACKAGE_DIR/$filename" [[ -f "$extracted_file" && ! -L "$extracted_file" ]] \ || die "Ungültige Datei im entpackten Paket: $filename" done # ----------------------------------------------------------------------------- # Vorhandenes Deployment prüfen oder frisch bereitstellen # ----------------------------------------------------------------------------- if (( DEPLOYMENT_FILE_COUNT == ${#DEPLOYMENT_FILES[@]} )); then log "Deployment-Dateien sind bereits vollständig vorhanden." log "Vorhandene Dateien werden gegen Release $RELEASE_VERSION geprüft." FILE_MISMATCH="false" for filename in "${DEPLOYMENT_FILES[@]}"; do if ! cmp -s \ "$EXTRACT_PACKAGE_DIR/$filename" \ "$TARGET_DIR/$filename"; then error "Vorhandene Datei weicht vom Release ab: $filename" FILE_MISMATCH="true" fi done if [[ "$FILE_MISMATCH" == "true" ]]; then error "Vorhandene Deployment-Dateien werden nicht automatisch überschrieben." exit "$EXIT_SAFETY" fi log "Vorhandene Deployment-Dateien entsprechen exakt dem Release." else log "Installationsdateien werden bereitgestellt." COPIED_FILES=() rollback_copied_files() { local copied for copied in "${COPIED_FILES[@]}"; do rm -f -- "$copied" 2>/dev/null || true done } for filename in "${DEPLOYMENT_FILES[@]}"; do source_file="$EXTRACT_PACKAGE_DIR/$filename" target_file="$TARGET_DIR/$filename" if [[ -e "$target_file" || -L "$target_file" ]]; then rollback_copied_files die "Zieldatei ist unerwartet bereits vorhanden: $filename" fi if ! cp -- "$source_file" "$target_file"; then rollback_copied_files die "Datei konnte nicht bereitgestellt werden: $filename" fi COPIED_FILES+=("$target_file") if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$target_file" >/dev/null 2>&1; then rollback_copied_files die "Synology-ACL konnte nicht gesetzt werden: $filename" fi done for filename in "${DEPLOYMENT_FILES[@]}"; do if ! cmp -s \ "$EXTRACT_PACKAGE_DIR/$filename" \ "$TARGET_DIR/$filename"; then rollback_copied_files die "Integritätsprüfung der bereitgestellten Datei fehlgeschlagen: $filename" fi done log "Installationsdateien erfolgreich bereitgestellt." fi # ============================================================================= # TECHNISCHE .env # ============================================================================= # ----------------------------------------------------------------------------- # .env.example-Struktur prüfen # ----------------------------------------------------------------------------- [[ -f "$ENV_TEMPLATE_FILE" && ! -L "$ENV_TEMPLATE_FILE" ]] \ || die "Die bereitgestellte .env.example ist ungültig." if grep -q '^COMPOSE_PROJECT_NAME=' "$ENV_TEMPLATE_FILE"; then die "Die .env.example darf COMPOSE_PROJECT_NAME nicht enthalten." fi EXPECTED_ENV_KEYS="$( printf '%s\n' "${ENV_KEYS[@]}" | LC_ALL=C sort )" ACTUAL_ENV_KEYS="$( awk -F= ' /^[A-Za-z_][A-Za-z0-9_]*=/ { print $1 } ' "$ENV_TEMPLATE_FILE" | LC_ALL=C sort )" if [[ "$ACTUAL_ENV_KEYS" != "$EXPECTED_ENV_KEYS" ]]; then error "Die Struktur der .env.example entspricht nicht der erwarteten Version." error "Die technische .env wird nicht erzeugt." exit 1 fi log "Struktur der .env.example erfolgreich geprüft." # ----------------------------------------------------------------------------- # SMTP-Werte für technische .env ableiten # # Bei MAIL_MAILER=log werden nicht benötigte SMTP-Zugangsdaten bewusst leer # gelassen. # ----------------------------------------------------------------------------- ENV_MAIL_HOST="" ENV_MAIL_PORT="587" ENV_MAIL_USERNAME="" ENV_MAIL_PASSWORD="" ENV_MAIL_ENCRYPTION="STARTTLS" ENV_MAIL_FROM_ADDRESS="" ENV_MAIL_FROM_NAME="$MAIL_FROM_NAME" if [[ "$MAIL_MAILER" == "smtp" ]]; then ENV_MAIL_HOST="$MAIL_HOST" ENV_MAIL_PORT="$MAIL_PORT" ENV_MAIL_USERNAME="$MAIL_USERNAME" ENV_MAIL_PASSWORD="$MAIL_PASSWORD" ENV_MAIL_ENCRYPTION="$MAIL_ENCRYPTION" ENV_MAIL_FROM_ADDRESS="$MAIL_FROM_ADDRESS" fi # ----------------------------------------------------------------------------- # Kundeneingaben für .env sicher quoten # ----------------------------------------------------------------------------- Q_APP_NAME="$(dotenv_quote "Invoice Ninja")" \ || die "APP_NAME konnte nicht sicher verarbeitet werden." Q_APP_URL="$(dotenv_quote "$APP_URL")" \ || die "APP_URL enthält unzulässige Zeilenumbrüche." Q_IN_USER_EMAIL="$(dotenv_quote "$IN_USER_EMAIL")" \ || die "IN_USER_EMAIL enthält unzulässige Zeilenumbrüche." Q_IN_PASSWORD="$(dotenv_quote "$IN_PASSWORD")" \ || die "IN_PASSWORD enthält unzulässige Zeilenumbrüche." Q_MAIL_HOST="$(dotenv_quote "$ENV_MAIL_HOST")" \ || die "MAIL_HOST enthält unzulässige Zeilenumbrüche." Q_MAIL_USERNAME="$(dotenv_quote "$ENV_MAIL_USERNAME")" \ || die "MAIL_USERNAME enthält unzulässige Zeilenumbrüche." Q_MAIL_PASSWORD="$(dotenv_quote "$ENV_MAIL_PASSWORD")" \ || die "MAIL_PASSWORD enthält unzulässige Zeilenumbrüche." Q_MAIL_FROM_ADDRESS="$(dotenv_quote "$ENV_MAIL_FROM_ADDRESS")" \ || die "MAIL_FROM_ADDRESS enthält unzulässige Zeilenumbrüche." Q_MAIL_FROM_NAME="$(dotenv_quote "$ENV_MAIL_FROM_NAME")" \ || die "MAIL_FROM_NAME enthält unzulässige Zeilenumbrüche." # ----------------------------------------------------------------------------- # Erwartete technische .env in temporärer Datei aufbauen # # APP_KEY, DB_PASSWORD, DB_ROOT_PASSWORD und REDIS_PASSWORD bleiben bewusst # leer. Sie werden erst in der nächsten Phase durch das bereits getestete # prepare_invoiceninja_environment.sh erzeugt. # ----------------------------------------------------------------------------- ( umask 077 { printf 'TIME_ZONE=Europe/Berlin\n' printf 'WEB_BIND_ADDRESS=%s\n' "$WEB_BIND_ADDRESS" printf 'WEB_PORT=%s\n' "$WEB_PORT" printf 'APP_NAME=%s\n' "$Q_APP_NAME" printf 'APP_ENV=production\n' printf 'APP_KEY=\n' printf 'APP_DEBUG=false\n' printf 'APP_URL=%s\n' "$Q_APP_URL" printf 'REQUIRE_HTTPS=%s\n' "$REQUIRE_HTTPS" printf 'TRUSTED_PROXIES=*\n' printf 'PDF_GENERATOR=snappdf\n' printf 'IN_USER_EMAIL=%s\n' "$Q_IN_USER_EMAIL" printf 'IN_PASSWORD=%s\n' "$Q_IN_PASSWORD" printf 'DB_DATABASE=invoiceninja\n' printf 'DB_USERNAME=ninjauser\n' printf 'DB_PASSWORD=\n' printf 'DB_ROOT_PASSWORD=\n' printf 'REDIS_PASSWORD=\n' printf 'MAIL_MAILER=%s\n' "$MAIL_MAILER" printf 'MAIL_HOST=%s\n' "$Q_MAIL_HOST" printf 'MAIL_PORT=%s\n' "$ENV_MAIL_PORT" printf 'MAIL_USERNAME=%s\n' "$Q_MAIL_USERNAME" printf 'MAIL_PASSWORD=%s\n' "$Q_MAIL_PASSWORD" printf 'MAIL_ENCRYPTION=%s\n' "$ENV_MAIL_ENCRYPTION" printf 'MAIL_FROM_ADDRESS=%s\n' "$Q_MAIL_FROM_ADDRESS" printf 'MAIL_FROM_NAME=%s\n' "$Q_MAIL_FROM_NAME" } > "$ENV_BUILD_FILE" ) chmod 0600 "$ENV_BUILD_FILE" chown root:root "$ENV_BUILD_FILE" cp -- "$ENV_BUILD_FILE" "$ENV_EXPECTED_FILE" chmod 0600 "$ENV_EXPECTED_FILE" chown root:root "$ENV_EXPECTED_FILE" # ----------------------------------------------------------------------------- # COMPOSE_PROJECT_NAME nochmals explizit ausschließen # ----------------------------------------------------------------------------- if grep -q '^COMPOSE_PROJECT_NAME=' "$ENV_BUILD_FILE"; then die "Interner Sicherheitsfehler: COMPOSE_PROJECT_NAME wurde in .env erzeugt." fi # ----------------------------------------------------------------------------- # Technische .env bereitstellen / Idempotenz prüfen # # Vier Werte werden vom Prepare-Skript verwaltet und deshalb beim Vergleich # bewusst ausgeblendet: # # APP_KEY # DB_PASSWORD # DB_ROOT_PASSWORD # REDIS_PASSWORD # # Alle anderen Zeilen müssen weiterhin bytegenau dem vom Bootstrap erwarteten # Zustand entsprechen. # ----------------------------------------------------------------------------- if [[ -e "$ENV_FILE" ]]; then log "Technische .env ist bereits vorhanden." log "Vorhandene .env wird nicht überschrieben." if grep -q '^COMPOSE_PROJECT_NAME=' "$ENV_FILE"; then error "Die vorhandene .env enthält COMPOSE_PROJECT_NAME." exit "$EXIT_SAFETY" fi ACTUAL_ENV_FILE_KEYS="$( awk -F= ' /^[A-Za-z_][A-Za-z0-9_]*=/ { print $1 } ' "$ENV_FILE" | LC_ALL=C sort )" if [[ "$ACTUAL_ENV_FILE_KEYS" != "$EXPECTED_ENV_KEYS" ]]; then error "Die Schlüsselstruktur der vorhandenen technischen .env ist unerwartet." exit "$EXIT_SAFETY" fi EXPECTED_CONTROLLED_ENV="$( awk -F= ' $1 != "APP_KEY" && $1 != "DB_PASSWORD" && $1 != "DB_ROOT_PASSWORD" && $1 != "REDIS_PASSWORD" { print } ' "$ENV_EXPECTED_FILE" )" ACTUAL_CONTROLLED_ENV="$( awk -F= ' $1 != "APP_KEY" && $1 != "DB_PASSWORD" && $1 != "DB_ROOT_PASSWORD" && $1 != "REDIS_PASSWORD" { print } ' "$ENV_FILE" )" if [[ "$ACTUAL_CONTROLLED_ENV" != "$EXPECTED_CONTROLLED_ENV" ]]; then error "Die vorhandene technische .env weicht bei kontrollierten Werten ab." error "Die Datei wird aus Sicherheitsgründen nicht verändert." exit "$EXIT_SAFETY" fi CURRENT_APP_KEY="$(get_env_value_from_file "$ENV_FILE" APP_KEY)" CURRENT_DB_PASSWORD="$(get_env_value_from_file "$ENV_FILE" DB_PASSWORD)" CURRENT_DB_ROOT_PASSWORD="$(get_env_value_from_file "$ENV_FILE" DB_ROOT_PASSWORD)" CURRENT_REDIS_PASSWORD="$(get_env_value_from_file "$ENV_FILE" REDIS_PASSWORD)" if [[ "$STATE_VALID" != "true" ]]; then if [[ -n "$CURRENT_APP_KEY" || -n "$CURRENT_DB_PASSWORD" || -n "$CURRENT_DB_ROOT_PASSWORD" || -n "$CURRENT_REDIS_PASSWORD" ]]; then error "Generierte Secrets sind vorhanden, aber ein gültiger Installationsstatus fehlt." error "Die vorhandene .env wird aus Sicherheitsgründen nicht verwendet." exit "$EXIT_SAFETY" fi else if [[ -n "$CURRENT_APP_KEY" && ! "$CURRENT_APP_KEY" =~ ^base64:[A-Za-z0-9+/=]+$ ]]; then error "Der vorhandene APP_KEY besitzt ein unerwartetes Format." exit "$EXIT_SAFETY" fi for secret_value in \ "$CURRENT_DB_PASSWORD" \ "$CURRENT_DB_ROOT_PASSWORD" \ "$CURRENT_REDIS_PASSWORD" do if [[ -n "$secret_value" && ! "$secret_value" =~ ^[0-9a-f]{64}$ ]]; then error "Ein vorhandenes automatisch erzeugtes Secret besitzt ein unerwartetes Format." exit "$EXIT_SAFETY" fi done fi log "Vorhandene technische .env entspricht dem erwarteten Deployment-Zustand." else log "Technische .env wird erzeugt." if [[ -e "$ENV_FILE" || -L "$ENV_FILE" ]]; then die "Die technische .env ist unerwartet bereits vorhanden." fi mv -- "$ENV_BUILD_FILE" "$ENV_FILE" ENV_BUILD_FILE="" chmod 0600 "$ENV_FILE" chown root:root "$ENV_FILE" if has_synology_acl "$ENV_FILE"; then die "Die technische .env wurde unerwartet mit einer Synology-ACL angelegt." fi log "Technische .env erfolgreich erzeugt." fi # ============================================================================= # INSTALLATIONSSTATUS # ============================================================================= if [[ "$STATE_VALID" == "true" ]]; then log "Gültiger Installationsstatus ist bereits vorhanden." else log "Installationsstatus wird angelegt." ( umask 077 print_expected_state > "$STATE_BUILD_FILE" ) chmod 0600 "$STATE_BUILD_FILE" chown root:root "$STATE_BUILD_FILE" if [[ -e "$STATE_FILE" || -L "$STATE_FILE" ]]; then die "Der Installationsstatus ist unerwartet bereits vorhanden." fi mv -- "$STATE_BUILD_FILE" "$STATE_FILE" STATE_BUILD_FILE="" chmod 0600 "$STATE_FILE" chown root:root "$STATE_FILE" if ! state_file_matches_expected; then die "Der Installationsstatus konnte nicht sicher angelegt werden." fi STATE_VALID="true" log "Installationsstatus erfolgreich angelegt." fi # ============================================================================= # PREPARE-SKRIPT # ============================================================================= PREPARE_SCRIPT="$TARGET_DIR/prepare_invoiceninja_environment.sh" [[ -f "$PREPARE_SCRIPT" && ! -L "$PREPARE_SCRIPT" ]] \ || die "Das Prepare-Skript ist ungültig: $PREPARE_SCRIPT" printf '\n' log "Technische Invoice-Ninja-Vorbereitung wird ausgeführt." log "Dabei werden Runtime-Verzeichnisse und technische Secrets erzeugt." log "Für den APP_KEY kann einmalig ein temporärer Docker-Container verwendet werden." log "Der Docker-Compose-Stack wird dabei NICHT gestartet." if ! bash "$PREPARE_SCRIPT"; then error "Die technische Invoice-Ninja-Vorbereitung ist fehlgeschlagen." error "Der Installationsstatus bleibt für eine sichere Fortsetzung erhalten." exit 1 fi # ============================================================================= # POST-VALIDIERUNG # ============================================================================= log "Ergebnis der technischen Vorbereitung wird geprüft." # ----------------------------------------------------------------------------- # Runtime-Verzeichnisse # ----------------------------------------------------------------------------- for runtime_name in db storage; do runtime_path="$TARGET_DIR/$runtime_name" if [[ -L "$runtime_path" || ! -d "$runtime_path" ]]; then die "Runtime-Verzeichnis wurde nicht korrekt vorbereitet: $runtime_name" fi done # ----------------------------------------------------------------------------- # Installationsstatus # ----------------------------------------------------------------------------- if ! state_file_matches_expected; then die "Der Installationsstatus ist nach der Vorbereitung ungültig." fi # ----------------------------------------------------------------------------- # .env-Sicherheit # ----------------------------------------------------------------------------- [[ -f "$ENV_FILE" && ! -L "$ENV_FILE" ]] \ || die "Die technische .env ist nach der Vorbereitung ungültig." ENV_META="$(stat -c '%u:%g:%a' "$ENV_FILE" 2>/dev/null || true)" [[ "$ENV_META" == "0:0:600" ]] \ || die "Die technische .env besitzt nach der Vorbereitung nicht root:root mit Modus 600." if has_synology_acl "$ENV_FILE"; then die "Die technische .env besitzt nach der Vorbereitung unerwartet eine Synology-ACL." fi if grep -q '^COMPOSE_PROJECT_NAME=' "$ENV_FILE"; then die "Die technische .env enthält nach der Vorbereitung COMPOSE_PROJECT_NAME." fi POST_ENV_KEYS="$( awk -F= ' /^[A-Za-z_][A-Za-z0-9_]*=/ { print $1 } ' "$ENV_FILE" | LC_ALL=C sort )" if [[ "$POST_ENV_KEYS" != "$EXPECTED_ENV_KEYS" ]]; then die "Die Schlüsselstruktur der technischen .env wurde unerwartet verändert." fi POST_CONTROLLED_ENV="$( awk -F= ' $1 != "APP_KEY" && $1 != "DB_PASSWORD" && $1 != "DB_ROOT_PASSWORD" && $1 != "REDIS_PASSWORD" { print } ' "$ENV_FILE" )" EXPECTED_CONTROLLED_ENV="$( awk -F= ' $1 != "APP_KEY" && $1 != "DB_PASSWORD" && $1 != "DB_ROOT_PASSWORD" && $1 != "REDIS_PASSWORD" { print } ' "$ENV_EXPECTED_FILE" )" if [[ "$POST_CONTROLLED_ENV" != "$EXPECTED_CONTROLLED_ENV" ]]; then die "Kontrollierte Werte der technischen .env wurden unerwartet verändert." fi POST_APP_KEY="$(get_env_value_from_file "$ENV_FILE" APP_KEY)" POST_DB_PASSWORD="$(get_env_value_from_file "$ENV_FILE" DB_PASSWORD)" POST_DB_ROOT_PASSWORD="$(get_env_value_from_file "$ENV_FILE" DB_ROOT_PASSWORD)" POST_REDIS_PASSWORD="$(get_env_value_from_file "$ENV_FILE" REDIS_PASSWORD)" [[ "$POST_APP_KEY" =~ ^base64:[A-Za-z0-9+/=]+$ ]] \ || die "APP_KEY wurde nicht im erwarteten Format erzeugt." [[ "$POST_DB_PASSWORD" =~ ^[0-9a-f]{64}$ ]] \ || die "DB_PASSWORD wurde nicht im erwarteten Format erzeugt." [[ "$POST_DB_ROOT_PASSWORD" =~ ^[0-9a-f]{64}$ ]] \ || die "DB_ROOT_PASSWORD wurde nicht im erwarteten Format erzeugt." [[ "$POST_REDIS_PASSWORD" =~ ^[0-9a-f]{64}$ ]] \ || die "REDIS_PASSWORD wurde nicht im erwarteten Format erzeugt." # ----------------------------------------------------------------------------- # Sicherstellen, dass der Compose-Stack weiterhin nicht existiert # ----------------------------------------------------------------------------- POST_PROJECT_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Names}}' )" if [[ -n "$POST_PROJECT_CONTAINERS" ]]; then error "Nach der Vorbereitung wurden unerwartet Container des Compose-Projekts erkannt:" printf '%s\n' "$POST_PROJECT_CONTAINERS" >&2 exit "$EXIT_SAFETY" fi # ----------------------------------------------------------------------------- # Docker-Compose-Konfiguration nochmals explizit validieren # ----------------------------------------------------------------------------- docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ config >/dev/null \ || die "Die Docker-Compose-Konfiguration ist nach der Vorbereitung ungültig." # ----------------------------------------------------------------------------- # Abschluss Phase 5 # ----------------------------------------------------------------------------- printf '\n' log "Technische Deployment-Vorbereitung erfolgreich abgeschlossen." printf '\n' printf ' Installationsmodus : %s\n' "$DEPLOYMENT_MODE" printf ' Release-Version : %s\n' "$RELEASE_VERSION" printf ' Zielverzeichnis : %s\n' "$TARGET_DIR" printf ' Compose-Projekt : %s\n' "$PROJECT_NAME" printf ' Web-Bind-Adresse : %s\n' "$WEB_BIND_ADDRESS" printf ' Web-Port : %s\n' "$WEB_PORT" printf ' HTTPS erforderlich : %s\n' "$REQUIRE_HTTPS" printf ' APP_URL : %s\n' "$APP_URL" printf ' Mail-Modus : %s\n' "$MAIL_MAILER" printf '\n' if [[ "$DEPLOYMENT_MODE" == "test" ]]; then PROD_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PRODUCTION_PROJECT}" \ --format '{{.Names}}' )" if [[ -n "$PROD_CONTAINERS" ]]; then log "Bestehendes Produktivprojekt '$PRODUCTION_PROJECT' wurde erkannt." log "Das Produktivprojekt wurde vom Test nicht angesprochen." fi fi printf '\n' log "Release wurde heruntergeladen und verifiziert." log "Die Installationsdateien wurden vorbereitet." log "Die technische .env wurde geprüft." log "APP_KEY, DB_PASSWORD, DB_ROOT_PASSWORD und REDIS_PASSWORD sind gesetzt." log "db/ und storage/ sind vorbereitet." log "Der Installationsstatus wurde geprüft." log "install-invoiceninja.sh wurde NICHT ausgeführt." log "Docker Compose wurde NICHT gestartet." log "Es wurden keine Container des Compose-Projekts erstellt."