#!/usr/bin/env bash set -Eeuo pipefail # ============================================================================= # Invoice Ninja - Remote Deployment Bootstrap # # Phase 7: # - 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 # - install-invoiceninja.sh ausführen # - Docker-Images laden und Compose-Stack starten # - Container- und Health-Status nach der Installation prüfen # - temporäre Kunden-/Installationsdateien sicher entfernen # - Bootstrap-Status nach erfolgreicher Finalisierung entfernen # - finalisierte Runtime bei späteren Aufrufen ohne Marker erkennen # # Noch NICHT enthalten: # - automatische Konfiguration von DSM Reverse Proxy / Zertifikaten # - automatische Upgrades bestehender Installationen # - 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 eigentliche Compose-Stack wird durch # install-invoiceninja.sh 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" INSTALLED_FILENAME=".bootstrap-installed" STATE_VERSION="1" SYNOACLTOOL="/usr/syno/bin/synoacltool" EXIT_CONFIG=2 EXIT_SAFETY=3 # ----------------------------------------------------------------------------- # Release-Konfiguration # ----------------------------------------------------------------------------- RELEASE_VERSION="v0.1.3-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="" INSTALLED_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" INSTALLED_FILE="$TARGET_DIR/$INSTALLED_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" ]] } # ----------------------------------------------------------------------------- # Erfolgreichen Installationsabschluss prüfen # # Der Abschlussmarker wird erst nach einem erfolgreichen Installer-Lauf und # einer zusätzlichen Container-/Health-Prüfung erzeugt. # ----------------------------------------------------------------------------- print_expected_installed_state() { print_expected_state printf 'INSTALLATION_STATUS=installed\n' } installed_file_matches_expected() { local expected_installed_state local actual_installed_state local installed_meta [[ -f "$INSTALLED_FILE" && ! -L "$INSTALLED_FILE" ]] || return 1 installed_meta="$(stat -c '%u:%g:%a' "$INSTALLED_FILE" 2>/dev/null)" || return 1 [[ "$installed_meta" == "0:0:600" ]] || return 1 has_synology_acl "$INSTALLED_FILE" && return 1 expected_installed_state="$(print_expected_installed_state)" actual_installed_state="$(cat "$INSTALLED_FILE")" || return 1 [[ "$actual_installed_state" == "$expected_installed_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 } # ----------------------------------------------------------------------------- # Finalisierte Runtime validieren # # Nach erfolgreicher Phase 7 bleiben im Ziel ausschließlich: # .env # docker-compose.yml # in-vhost.conf # db/ # storage/ # # Die beiden Bootstrap-Marker werden bewusst NICHT dauerhaft benötigt. # ----------------------------------------------------------------------------- runtime_env_matches_profile() { local env_meta local app_url local app_key local db_password local db_root_password local redis_password local mail_mailer local mail_port [[ -f "$ENV_FILE" && ! -L "$ENV_FILE" ]] || return 1 env_meta="$(stat -c '%u:%g:%a' "$ENV_FILE" 2>/dev/null)" || return 1 [[ "$env_meta" == "0:0:600" ]] || return 1 has_synology_acl "$ENV_FILE" && return 1 if grep -q '^COMPOSE_PROJECT_NAME=' "$ENV_FILE"; then return 1 fi [[ "$(get_env_value_from_file "$ENV_FILE" TIME_ZONE)" == "Europe/Berlin" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" WEB_BIND_ADDRESS)" == "$WEB_BIND_ADDRESS" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" WEB_PORT)" == "$WEB_PORT" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" APP_NAME)" == "Invoice Ninja" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" APP_ENV)" == "production" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" APP_DEBUG)" == "false" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" REQUIRE_HTTPS)" == "$REQUIRE_HTTPS" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" TRUSTED_PROXIES)" == "*" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" PDF_GENERATOR)" == "snappdf" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" DB_DATABASE)" == "invoiceninja" ]] || return 1 [[ "$(get_env_value_from_file "$ENV_FILE" DB_USERNAME)" == "ninjauser" ]] || return 1 app_url="$(get_env_value_from_file "$ENV_FILE" APP_URL)" [[ -n "$app_url" && "$app_url" != "" ]] || return 1 case "$DEPLOYMENT_MODE" in production) [[ "$app_url" == https://* ]] || return 1 ;; test) [[ "$app_url" == http://* ]] || return 1 [[ "$app_url" == *":${WEB_PORT}"* ]] || return 1 ;; esac [[ -n "$(get_env_value_from_file "$ENV_FILE" IN_USER_EMAIL)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" IN_PASSWORD)" ]] || return 1 app_key="$(get_env_value_from_file "$ENV_FILE" APP_KEY)" db_password="$(get_env_value_from_file "$ENV_FILE" DB_PASSWORD)" db_root_password="$(get_env_value_from_file "$ENV_FILE" DB_ROOT_PASSWORD)" redis_password="$(get_env_value_from_file "$ENV_FILE" REDIS_PASSWORD)" [[ "$app_key" =~ ^base64:[A-Za-z0-9+/=]+$ ]] || return 1 [[ "$db_password" =~ ^[0-9a-f]{64}$ ]] || return 1 [[ "$db_root_password" =~ ^[0-9a-f]{64}$ ]] || return 1 [[ "$redis_password" =~ ^[0-9a-f]{64}$ ]] || return 1 mail_mailer="$(get_env_value_from_file "$ENV_FILE" MAIL_MAILER)" case "$mail_mailer" in log) ;; smtp) [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_HOST)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_USERNAME)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_PASSWORD)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_ENCRYPTION)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_FROM_ADDRESS)" ]] || return 1 [[ -n "$(get_env_value_from_file "$ENV_FILE" MAIL_FROM_NAME)" ]] || return 1 mail_port="$(get_env_value_from_file "$ENV_FILE" MAIL_PORT)" [[ "$mail_port" =~ ^[0-9]+$ ]] || return 1 (( mail_port >= 1 && mail_port <= 65535 )) || return 1 ;; *) return 1 ;; esac return 0 } runtime_stack_is_healthy() { local expected_services local actual_services local expected_images local actual_images local service local container_ids local container_count local container_id local container_state local health_state local container_project local port_container_ids local port_container_id local port_project [[ -f "$TARGET_DIR/docker-compose.yml" && ! -L "$TARGET_DIR/docker-compose.yml" ]] || return 1 [[ -f "$TARGET_DIR/in-vhost.conf" && ! -L "$TARGET_DIR/in-vhost.conf" ]] || return 1 [[ -d "$TARGET_DIR/db" && ! -L "$TARGET_DIR/db" ]] || return 1 [[ -d "$TARGET_DIR/storage" && ! -L "$TARGET_DIR/storage" ]] || return 1 docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ config >/dev/null 2>&1 || return 1 expected_services="$( printf '%s\n' app db nginx redis | LC_ALL=C sort )" actual_services="$( docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ config --services 2>/dev/null | LC_ALL=C sort )" || return 1 [[ "$actual_services" == "$expected_services" ]] || return 1 expected_images="$( printf '%s\n' \ 'invoiceninja/invoiceninja-debian:5.13' \ 'mariadb:11.4-noble' \ 'nginx:1.30-alpine' \ 'redis:8.10-alpine' | LC_ALL=C sort )" actual_images="$( docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ config --images 2>/dev/null | LC_ALL=C sort -u )" || return 1 [[ "$actual_images" == "$expected_images" ]] || return 1 for service in app db nginx redis; do container_ids="$( docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ ps -a -q "$service" 2>/dev/null )" || return 1 container_count="$( printf '%s\n' "$container_ids" | awk 'NF { count++ } END { print count + 0 }' )" [[ "$container_count" -eq 1 ]] || return 1 container_id="$container_ids" container_state="$( docker inspect \ --format '{{.State.Status}}' \ "$container_id" 2>/dev/null )" || return 1 health_state="$( docker inspect \ --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \ "$container_id" 2>/dev/null )" || return 1 container_project="$( docker inspect \ --format '{{if .Config.Labels}}{{index .Config.Labels "com.docker.compose.project"}}{{end}}' \ "$container_id" 2>/dev/null )" || return 1 [[ "$container_state" == "running" ]] || return 1 [[ "$health_state" == "healthy" ]] || return 1 [[ "$container_project" == "$PROJECT_NAME" ]] || return 1 done port_container_ids="$( docker ps \ --filter "publish=${WEB_PORT}" \ --format '{{.ID}}' )" [[ -n "$port_container_ids" ]] || return 1 for port_container_id in $port_container_ids; do port_project="$( docker inspect \ --format '{{if .Config.Labels}}{{index .Config.Labels "com.docker.compose.project"}}{{end}}' \ "$port_container_id" 2>/dev/null )" || return 1 [[ "$port_project" == "$PROJECT_NAME" ]] || return 1 done return 0 } runtime_core_is_valid() { runtime_env_matches_profile || return 1 runtime_stack_is_healthy || return 1 return 0 } target_has_only_final_runtime_files() { local existing_path local existing_name [[ -d "$TARGET_DIR" && ! -L "$TARGET_DIR" ]] || return 1 while IFS= read -r existing_path; do existing_name="${existing_path##*/}" case "$existing_name" in .env|docker-compose.yml|in-vhost.conf|db|storage) ;; *) return 1 ;; esac done < <( find "$TARGET_DIR" \ -mindepth 1 \ -maxdepth 1 \ -print 2>/dev/null ) return 0 } finalized_runtime_is_valid() { [[ ! -e "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || return 1 [[ ! -e "$STATE_FILE" && ! -L "$STATE_FILE" ]] || return 1 [[ ! -e "$INSTALLED_FILE" && ! -L "$INSTALLED_FILE" ]] || return 1 [[ ! -e "$TARGET_DIR/.env.example" && ! -L "$TARGET_DIR/.env.example" ]] || return 1 [[ ! -e "$TARGET_DIR/install-invoiceninja.sh" && ! -L "$TARGET_DIR/install-invoiceninja.sh" ]] || return 1 [[ ! -e "$TARGET_DIR/prepare_invoiceninja_environment.sh" && ! -L "$TARGET_DIR/prepare_invoiceninja_environment.sh" ]] || return 1 target_has_only_final_runtime_files || return 1 runtime_core_is_valid || return 1 return 0 } finalize_runtime() { local cleanup_file log "Erfolgreiche Installation wird finalisiert." log "Temporäre Kunden- und Installationsdateien werden entfernt." runtime_core_is_valid \ || die "Die Runtime ist vor der Finalisierung nicht vollständig gültig." # ------------------------------------------------------------------------- # Zuerst nur temporäre Deployment-Dateien entfernen. # docker-compose.yml und in-vhost.conf bleiben Bestandteil der Runtime. # ------------------------------------------------------------------------- for cleanup_file in \ "$TARGET_DIR/.env.example" \ "$TARGET_DIR/install-invoiceninja.sh" \ "$TARGET_DIR/prepare_invoiceninja_environment.sh" do rm -f -- "$cleanup_file" \ || die "Temporäre Installationsdatei konnte nicht entfernt werden: ${cleanup_file##*/}" done # Die SMB-sichtbare Kundenkonfiguration enthält Zugangsdaten und wird # bewusst vor den internen Bootstrap-Markern entfernt. rm -f -- "$CONFIG_FILE" \ || die "Die temporäre Kundenkonfiguration konnte nicht entfernt werden." for cleanup_file in \ "$TARGET_DIR/.env.example" \ "$TARGET_DIR/install-invoiceninja.sh" \ "$TARGET_DIR/prepare_invoiceninja_environment.sh" \ "$CONFIG_FILE" do if [[ -e "$cleanup_file" || -L "$cleanup_file" ]]; then die "Temporäre Datei ist nach der Finalisierung weiterhin vorhanden: ${cleanup_file##*/}" fi done runtime_core_is_valid \ || die "Die Runtime ist nach dem Entfernen der Installationsdateien nicht mehr gültig." # ------------------------------------------------------------------------- # Bootstrap-Marker zuletzt entfernen. # # Falls die Finalisierung davor unterbrochen wird, bleiben die Marker als # Wiederaufnahme-Schutz erhalten. Falls sie während dieses letzten Schritts # nur teilweise entfernt werden, kann ein noch vorhandener gültiger Marker # beim nächsten Lauf die Finalisierung fortsetzen. # ------------------------------------------------------------------------- rm -f -- "$INSTALLED_FILE" \ || die "Der Installationsabschluss konnte nicht entfernt werden." rm -f -- "$STATE_FILE" \ || die "Der Installationsstatus konnte nicht entfernt werden." if ! finalized_runtime_is_valid; then die "Die finalisierte Invoice-Ninja-Runtime hat die Abschlussprüfung nicht bestanden." fi log "Temporäre Installationsdateien wurden erfolgreich entfernt." log "Bootstrap-Status wurde erfolgreich entfernt." log "Im Ziel verbleiben ausschließlich die benötigten Runtime-Dateien und Daten." } # ----------------------------------------------------------------------------- # 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 "$INSTALLED_BUILD_FILE" ]]; then rm -f -- "$INSTALLED_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 # ============================================================================= # FRÜHER ABSCHLUSS / FINALISIERUNGS-WIEDERAUFNAHME # # Dieser Block läuft bewusst VOR dem normalen Sicherheits-Preflight. # Eine bereits finalisierte Installation besitzt keine Kundenkonfiguration # und keine Bootstrap-Marker mehr. Sie muss deshalb anhand ihrer geschützten # Runtime-Dateien und des tatsächlich laufenden Compose-Stacks erkannt werden. # ============================================================================= if finalized_runtime_is_valid; then printf '\n' log "Eine bereits finalisierte Invoice-Ninja-Installation wurde erkannt." log "Der vorhandene Compose-Stack ist vollständig running und healthy." log "Es wird keine Neuinstallation und kein Upgrade ausgeführt." printf '\n' printf ' Installationsmodus : %s\n' "$DEPLOYMENT_MODE" printf ' Zielverzeichnis : %s\n' "$TARGET_DIR" printf ' Compose-Projekt : %s\n' "$PROJECT_NAME" printf ' Web-Port : %s\n' "$WEB_PORT" printf ' APP_URL : %s\n' "$(get_env_value_from_file "$ENV_FILE" APP_URL)" printf '\n' exit 0 fi EARLY_STATE_VALID="false" EARLY_INSTALLED_VALID="false" if state_file_matches_expected; then EARLY_STATE_VALID="true" fi if installed_file_matches_expected; then EARLY_INSTALLED_VALID="true" fi # Ein gültiger Abschlussmarker erlaubt die Wiederaufnahme der Finalisierung. # Falls dieser Marker während der letzten Löschphase bereits entfernt wurde, # reicht ein gültiger State-Marker zusammen mit einer bereits entfernten # Kundenkonfiguration als eindeutiges Wiederaufnahme-Signal. if runtime_core_is_valid; then if [[ "$EARLY_INSTALLED_VALID" == "true" ]] || \ [[ "$EARLY_STATE_VALID" == "true" && ! -e "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then printf '\n' log "Eine erfolgreich installierte Runtime mit offenem Finalisierungsschritt wurde erkannt." log "Die Finalisierung wird sicher fortgesetzt." finalize_runtime printf '\n' log "Invoice-Ninja-Deployment ist bereits vollständig installiert und finalisiert." exit 0 fi 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 if [[ -L "$INSTALLED_FILE" ]]; then add_safety_error \ "Der Installationsabschluss ist ein symbolischer Link: $INSTALLED_FILE" elif [[ -e "$INSTALLED_FILE" && ! -f "$INSTALLED_FILE" ]]; then add_safety_error \ "Der Installationsabschluss ist keine reguläre Datei: $INSTALLED_FILE" 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 INSTALLATION_COMPLETE="false" if [[ -e "$INSTALLED_FILE" || -L "$INSTALLED_FILE" ]]; then if [[ "$STATE_VALID" != "true" ]]; then add_safety_error \ "Ein Installationsabschluss ist vorhanden, aber der Installationsstatus ist ungültig." elif installed_file_matches_expected; then INSTALLATION_COMPLETE="true" else add_safety_error \ "Der vorhandene Installationsabschluss 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"|"$INSTALLED_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 # # Eigene Container sind nur zulässig, wenn ein gültiger Installationsstatus # vorhanden ist. Dadurch kann ein unterbrochener Phase-6-Lauf sicher erneut # ausgeführt werden. # ----------------------------------------------------------------------------- EXISTING_PROJECT_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Names}}' )" if [[ -n "$EXISTING_PROJECT_CONTAINERS" && "$STATE_VALID" != "true" ]]; then add_safety_error \ "Das Docker-Compose-Projekt '$PROJECT_NAME' besitzt Container ohne gültigen Installationsstatus." fi if [[ "$INSTALLATION_COMPLETE" == "true" && -z "$EXISTING_PROJECT_CONTAINERS" ]]; then add_safety_error \ "Die Installation ist als abgeschlossen markiert, aber es existieren keine Compose-Container." fi # ----------------------------------------------------------------------------- # Docker-Port prüfen # # Bei einer begonnenen eigenen Installation darf der Port vom eigenen # Compose-Projekt belegt sein. Fremde Container bleiben immer ein # Sicherheitskonflikt. # ----------------------------------------------------------------------------- PORT_CONTAINER_IDS="$( docker ps -a \ --filter "publish=${WEB_PORT}" \ --format '{{.ID}}' )" PORT_CONTAINERS="" FOREIGN_PORT_CONTAINERS="" for port_container_id in $PORT_CONTAINER_IDS; do port_container_name="$( docker inspect \ --format '{{.Name}}' \ "$port_container_id" 2>/dev/null | sed 's#^/##' )" port_container_project="$( docker inspect \ --format '{{if .Config.Labels}}{{index .Config.Labels "com.docker.compose.project"}}{{end}}' \ "$port_container_id" 2>/dev/null )" if [[ -n "$PORT_CONTAINERS" ]]; then PORT_CONTAINERS+=$'\n' fi PORT_CONTAINERS+="$port_container_name" if [[ "$port_container_project" != "$PROJECT_NAME" ]]; then if [[ -n "$FOREIGN_PORT_CONTAINERS" ]]; then FOREIGN_PORT_CONTAINERS+=$'\n' fi FOREIGN_PORT_CONTAINERS+="$port_container_name" fi done if [[ -n "$FOREIGN_PORT_CONTAINERS" ]]; then add_safety_error \ "Der vorgesehene Docker-Port ${WEB_PORT} wird von einem fremden Container verwendet." elif [[ -n "$PORT_CONTAINERS" && "$STATE_VALID" != "true" ]]; then add_safety_error \ "Der vorgesehene Docker-Port ${WEB_PORT} ist ohne gültigen Installationsstatus belegt." 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="" INSTALLED_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." # ----------------------------------------------------------------------------- # Vorhandene Compose-Container nach der Vorbereitung einordnen # ----------------------------------------------------------------------------- PRE_INSTALL_PROJECT_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Names}}' )" if [[ -n "$PRE_INSTALL_PROJECT_CONTAINERS" && "$STATE_VALID" != "true" ]]; then error "Vor der Installation wurden Container ohne gültigen Installationsstatus erkannt." 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 vor der Installation ungültig." # ============================================================================= # INSTALLATION # ============================================================================= INSTALL_SCRIPT="$TARGET_DIR/install-invoiceninja.sh" [[ -f "$INSTALL_SCRIPT" && ! -L "$INSTALL_SCRIPT" ]] \ || die "Das Installationsskript ist ungültig: $INSTALL_SCRIPT" if [[ "$INSTALLATION_COMPLETE" == "true" ]]; then printf '\n' log "Die Installation ist bereits als erfolgreich abgeschlossen markiert." log "install-invoiceninja.sh wird nicht erneut ausgeführt." log "Vorhandener Compose-Stack wird ausschließlich kontrolliert." else printf '\n' log "Invoice-Ninja-Installation wird gestartet." log "Docker-Images werden geladen und der Compose-Stack wird gestartet." log "Bestehende technische Secrets bleiben unverändert." if bash "$INSTALL_SCRIPT"; then : else INSTALL_EXIT_CODE=$? error "Die Invoice-Ninja-Installation ist fehlgeschlagen." error "Exit-Code des Installationsskripts: $INSTALL_EXIT_CODE" error "Der Installationsstatus bleibt für eine sichere Fortsetzung erhalten." error "Es werden keine Container oder Daten automatisch gelöscht." exit "$INSTALL_EXIT_CODE" fi fi # ============================================================================= # POST-INSTALLATIONSVALIDIERUNG # ============================================================================= log "Installierten Compose-Stack wird abschließend geprüft." COMPOSE_SERVICES="$( docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ config --services )" [[ -n "$COMPOSE_SERVICES" ]] \ || die "Es konnten keine Docker-Compose-Services ermittelt werden." STACK_VALID="true" for service in $COMPOSE_SERVICES; do container_id="$( docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ ps -a -q "$service" )" if [[ -z "$container_id" ]]; then error "Service '$service' besitzt keinen Container." STACK_VALID="false" continue fi container_state="$( docker inspect \ --format '{{.State.Status}}' \ "$container_id" )" health_state="$( docker inspect \ --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \ "$container_id" )" if [[ "$container_state" != "running" ]]; then error "Service '$service' ist nicht running (Status: $container_state)." STACK_VALID="false" continue fi case "$health_state" in healthy|none) ;; *) error "Service '$service' besitzt keinen erfolgreichen Health-Status (Status: $health_state)." STACK_VALID="false" ;; esac done if [[ "$STACK_VALID" != "true" ]]; then printf '\n' >&2 docker compose \ -p "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ -f "$TARGET_DIR/docker-compose.yml" \ ps >&2 || true error "Der Compose-Stack hat die Abschlussprüfung nicht bestanden." exit 1 fi # ----------------------------------------------------------------------------- # Port nach der Installation kontrollieren # ----------------------------------------------------------------------------- POST_PORT_CONTAINER_IDS="$( docker ps \ --filter "publish=${WEB_PORT}" \ --format '{{.ID}}' )" POST_PORT_MATCH="false" for post_port_container_id in $POST_PORT_CONTAINER_IDS; do post_port_project="$( docker inspect \ --format '{{if .Config.Labels}}{{index .Config.Labels "com.docker.compose.project"}}{{end}}' \ "$post_port_container_id" 2>/dev/null )" if [[ "$post_port_project" != "$PROJECT_NAME" ]]; then error "Nach der Installation verwendet ein fremder Container den Port ${WEB_PORT}." exit "$EXIT_SAFETY" fi POST_PORT_MATCH="true" done [[ "$POST_PORT_MATCH" == "true" ]] \ || die "Nach der Installation ist der erwartete Web-Port ${WEB_PORT} nicht durch das Compose-Projekt veröffentlicht." # ----------------------------------------------------------------------------- # Erfolgreichen Installationsabschluss markieren # ----------------------------------------------------------------------------- if [[ "$INSTALLATION_COMPLETE" != "true" ]]; then log "Erfolgreicher Installationsabschluss wird markiert." INSTALLED_BUILD_FILE="$TEMP_DIR/installed-state" ( umask 077 print_expected_installed_state > "$INSTALLED_BUILD_FILE" ) chmod 0600 "$INSTALLED_BUILD_FILE" chown root:root "$INSTALLED_BUILD_FILE" if [[ -e "$INSTALLED_FILE" || -L "$INSTALLED_FILE" ]]; then die "Der Installationsabschluss ist unerwartet bereits vorhanden." fi mv -- "$INSTALLED_BUILD_FILE" "$INSTALLED_FILE" INSTALLED_BUILD_FILE="" chmod 0600 "$INSTALLED_FILE" chown root:root "$INSTALLED_FILE" if ! installed_file_matches_expected; then die "Der Installationsabschluss konnte nicht sicher angelegt werden." fi INSTALLATION_COMPLETE="true" log "Installationsabschluss erfolgreich markiert." fi # ============================================================================= # FINALISIERUNG # ============================================================================= printf '\n' finalize_runtime # ----------------------------------------------------------------------------- # Abschluss Phase 7 # ----------------------------------------------------------------------------- printf '\n' log "Invoice-Ninja-Deployment 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 geprüft." log "Die technische .env und die generierten Secrets wurden geprüft." log "db/ und storage/ sind vorbereitet." log "Der Compose-Stack ist gestartet und hat die Abschlussprüfung bestanden." log "Die temporäre Kundenkonfiguration wurde entfernt." log "Die temporären Installationsskripte und .env.example wurden entfernt." log "Die Bootstrap-Statusdateien wurden nach erfolgreicher Finalisierung entfernt." log "Es wurden keine bestehenden Docker-Ressourcen automatisch gelöscht."