From 6ff71851e33eb19b22e3b996cb7dbdb2c9032faa Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Thu, 1 Oct 2026 17:18:09 +0200 Subject: [PATCH] Add deploy_script.sh and configure caddy volume --- deploy_script.sh | 378 ++++++++++++++++++++++++++++++ services/caddy/docker-compose.yml | 2 +- 2 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 deploy_script.sh diff --git a/deploy_script.sh b/deploy_script.sh new file mode 100644 index 0000000..e7ed756 --- /dev/null +++ b/deploy_script.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +############################################################################### +# PaperClip — Master Deploy Script +# Fully idempotent. Safe to re-run at any time. +# Usage: sudo bash /opt/PaperClip/deploy_script.sh +############################################################################### +set -euo pipefail + +REPO_DIR="/opt/PaperClip" +GIT_REPO_URL="https://github.com/orfelorfel23/PaperClip.git" +DATA_BASE="/opt/PaperClip-Data" +DOCKER_NETWORK="paperclip-net" +SERVICES_DIR="${REPO_DIR}/services" +AGE_PRIVATE_KEY="${REPO_DIR}/keys/age-key.txt" +CADDY_FRAGMENTS_DIR="${DATA_BASE}/caddy/fragments" +POSTGRES_CONTAINER="postgres" + +# Infrastructure services that are started in a specific order +INFRA_SERVICES=("postgres" "caddy") + +ACTIVE_SERVICES=() +SKIPPED_SERVICES=() + +############################################################################### +# Helpers +############################################################################### +log() { echo -e "\e[32m[DEPLOY]\e[0m $*"; } +warn() { echo -e "\e[33m[WARN]\e[0m $*"; } +err() { echo -e "\e[31m[ERROR]\e[0m $*" >&2; } +die() { err "$@"; exit 1; } + +on_deploy_failure() { + err "Deployment GEFEHLT. Prüfe die Server-Logs." + disable_maintenance_mode 2>/dev/null || true +} + +is_active_service() { + local svc_dir="$1" + [[ -f "${svc_dir}/docker-compose.yml" && -f "${svc_dir}/service.enabled" ]] +} + +############################################################################### +# 1. Install missing packages +############################################################################### +install_packages() { + log "Checking required packages..." + + # Docker + if ! command -v docker &>/dev/null; then + log "Installing Docker..." + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg lsb-release + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ + gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker + else + log "Docker already installed." + fi + + # age + if ! command -v age &>/dev/null; then + log "Installing age..." + apt-get update -qq + apt-get install -y -qq age + else + log "age already installed." + fi + + # fail2ban + if ! command -v fail2ban-client &>/dev/null; then + log "Installing fail2ban..." + apt-get update -qq + apt-get install -y -qq fail2ban + else + log "fail2ban already installed." + fi +} + +############################################################################### +# 2. Configure System (Docker, fail2ban, swap, timezone, cron) +############################################################################### +configure_system() { + log "Configuring Docker daemon log limits..." + mkdir -p /etc/docker + if ! grep -q "max-file" /etc/docker/daemon.json 2>/dev/null; then + cat > /etc/docker/daemon.json <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + } +} +EOF + systemctl restart docker || true + fi + + log "Configuring fail2ban for SSH..." + cat > /etc/fail2ban/jail.local <<'EOF' +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 5 +bantime = 3600 +findtime = 600 +EOF + systemctl enable --now fail2ban + systemctl restart fail2ban + + local SWAP_FILE="/swapfile" + local SWAP_SIZE="4G" + if ! swapon --show | grep -q "${SWAP_FILE}"; then + log "Setting up ${SWAP_SIZE} swap file..." + fallocate -l "${SWAP_SIZE}" "${SWAP_FILE}" + chmod 600 "${SWAP_FILE}" + mkswap "${SWAP_FILE}" + swapon "${SWAP_FILE}" + if ! grep -q "${SWAP_FILE}" /etc/fstab; then + echo "${SWAP_FILE} none swap sw 0 0" >> /etc/fstab + fi + fi + + local TARGET_TZ="Europe/Berlin" + if ! timedatectl status | grep -q "${TARGET_TZ}"; then + timedatectl set-timezone "${TARGET_TZ}" + fi + + log "Configuring daily automatic deployment (cron) for 03:00..." + local CRON_JOB="0 3 * * * cd /opt/PaperClip && git fetch origin && git reset --hard origin/main && /bin/bash /opt/PaperClip/deploy_script.sh >> /var/log/paperclip-deploy.log 2>&1" + crontab -l 2>/dev/null | grep -v "/opt/PaperClip/deploy_script.sh" | crontab - || true + (crontab -l 2>/dev/null; echo "$CRON_JOB") | crontab - +} + +############################################################################### +# 3. Handle AGE private key +############################################################################### +handle_age_key() { + log "Checking AGE private key..." + mkdir -p "$(dirname "${AGE_PRIVATE_KEY}")" + + if [[ ! -f "${AGE_PRIVATE_KEY}" ]]; then + warn "AGE private key not found." + echo -n "Please paste your AGE private key now (input hidden): " + read -rs age_key_input + echo "" + if [[ -z "${age_key_input}" ]]; then + die "No key provided. Aborting." + fi + echo "${age_key_input}" > "${AGE_PRIVATE_KEY}" + chmod 600 "${AGE_PRIVATE_KEY}" + fi + + if ! head -1 "${AGE_PRIVATE_KEY}" | grep -q "^AGE-SECRET-KEY-"; then + die "Invalid AGE private key format. Must start with AGE-SECRET-KEY-" + fi + chmod 600 "${AGE_PRIVATE_KEY}" +} + +############################################################################### +# 4. Git clone / pull +############################################################################### +update_repo() { + log "Updating repository..." + if [[ ! -d "${REPO_DIR}/.git" ]]; then + log "No .git directory found — cloning repository..." + local tmp_dir + tmp_dir="$(mktemp -d)" + git clone "${GIT_REPO_URL}" "${tmp_dir}/repo" + rsync -a --ignore-existing "${tmp_dir}/repo/" "${REPO_DIR}/" + cp -a "${tmp_dir}/repo/.git" "${REPO_DIR}/.git" + rm -rf "${tmp_dir}" + cd "${REPO_DIR}" + git checkout -- . 2>/dev/null || true + else + cd "${REPO_DIR}" + git fetch origin + git reset --hard origin/main || git reset --hard origin/master || true + fi + chmod 700 "${REPO_DIR}/deploy_script.sh" 2>/dev/null || true +} + +############################################################################### +# 5. Docker network & Discover services +############################################################################### +ensure_network() { + if ! docker network inspect "${DOCKER_NETWORK}" &>/dev/null; then + log "Creating Docker network: ${DOCKER_NETWORK}" + docker network create "${DOCKER_NETWORK}" + fi +} + +discover_services() { + ACTIVE_SERVICES=() + for svc_dir in "${SERVICES_DIR}"/*/; do + svc_name="$(basename "${svc_dir}")" + if is_active_service "${svc_dir}"; then + ACTIVE_SERVICES+=("${svc_name}") + fi + done + log "Active services: ${ACTIVE_SERVICES[*]}" +} + +cleanup_deactivated() { + log "Checking for deactivated services..." + local running_containers + running_containers=$(docker ps --format '{{.Names}}' 2>/dev/null || true) + for svc_dir in "${SERVICES_DIR}"/*/; do + svc_name="$(basename "${svc_dir}")" + if ! is_active_service "${svc_dir}" && [[ -f "${svc_dir}/docker-compose.yml" ]]; then + if echo "${running_containers}" | grep -q "${svc_name}"; then + log "Stopping deactivated service: ${svc_name}" + cd "${svc_dir}" + docker compose down --remove-orphans 2>/dev/null || true + fi + fi + done +} + +############################################################################### +# 6. Maintenance Mode +############################################################################### +enable_maintenance_mode() { + log "Enabling maintenance mode on all active subdomains..." + local all_domains + all_domains=$(grep -hEo "([a-zA-Z0-9.-]+\.orfel\.de|orfel\.de)" "${SERVICES_DIR}"/*/*.caddy 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//') + if [[ -z "${all_domains}" ]]; then return; fi + + mkdir -p "${CADDY_FRAGMENTS_DIR}" + find "${CADDY_FRAGMENTS_DIR}" -name '*.caddy' -type f -delete 2>/dev/null || true + + cat << 'EOF' > "${CADDY_FRAGMENTS_DIR}/maintenance.caddy" +DOMAINS_PLACEHOLDER { + header Content-Type text/html + respond "Wartungsarbeiten

Wartungsarbeiten

Das System wird gerade aktualisiert.
Die Seite ist in wenigen Minuten wieder erreichbar.

" 503 +} +EOF + sed -i "s|DOMAINS_PLACEHOLDER|${all_domains}|g" "${CADDY_FRAGMENTS_DIR}/maintenance.caddy" + + if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true + elif [[ " ${ACTIVE_SERVICES[*]} " =~ " caddy " ]]; then + cd "${SERVICES_DIR}/caddy" + docker compose up -d --remove-orphans 2>/dev/null || true + fi + sleep 1 +} + +stop_all_services() { + log "Stopping active containers (excluding infra)..." + for svc_name in "${ACTIVE_SERVICES[@]}"; do + if [[ " ${INFRA_SERVICES[*]} " =~ " ${svc_name} " ]]; then continue; fi + cd "${SERVICES_DIR}/${svc_name}" + docker compose down --remove-orphans 2>/dev/null || true + done +} + +############################################################################### +# 7. Secrets & Data +############################################################################### +decrypt_envs() { + log "Decrypting .env.age files..." + for svc_name in "${ACTIVE_SERVICES[@]}"; do + local svc_dir="${SERVICES_DIR}/${svc_name}" + if [[ -f "${svc_dir}/.env.age" ]]; then + age --decrypt -i "${AGE_PRIVATE_KEY}" -o "${svc_dir}/.env" "${svc_dir}/.env.age" + sed -i 's/\r$//' "${svc_dir}/.env" + chmod 600 "${svc_dir}/.env" + fi + done +} + +create_volumes() { + log "Creating persistent volume directories..." + for svc_name in "${ACTIVE_SERVICES[@]}"; do + mkdir -p "${DATA_BASE}/${svc_name}" + done +} + +############################################################################### +# 8. Start Infra & Deploy +############################################################################### +pull_images() { + log "Pulling Docker images..." + for svc_name in "${ACTIVE_SERVICES[@]}"; do + cd "${SERVICES_DIR}/${svc_name}" + if [[ -f "Dockerfile" ]]; then + docker compose build --no-cache || SKIPPED_SERVICES+=("${svc_name}") + else + docker compose pull || true + fi + done +} + +start_infra() { + if [[ " ${ACTIVE_SERVICES[*]} " =~ " postgres " ]]; then + log "Starting PostgreSQL..." + cd "${SERVICES_DIR}/postgres" + docker compose up -d --remove-orphans + sleep 5 + fi +} + +assemble_caddy_fragments() { + log "Assembling Caddy fragments..." + mkdir -p "${CADDY_FRAGMENTS_DIR}" + rm -f "${CADDY_FRAGMENTS_DIR}"/*.caddy + for svc_name in "${ACTIVE_SERVICES[@]}"; do + if [[ -f "${SERVICES_DIR}/${svc_name}/${svc_name}.caddy" ]]; then + cp "${SERVICES_DIR}/${svc_name}/${svc_name}.caddy" "${CADDY_FRAGMENTS_DIR}/" + fi + done +} + +disable_maintenance_mode() { + log "Switching Caddy to production..." + assemble_caddy_fragments + if [[ " ${ACTIVE_SERVICES[*]} " =~ " caddy " ]]; then + cd "${SERVICES_DIR}/caddy" + docker compose up -d --remove-orphans + sleep 2 + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true + fi +} + +start_remaining() { + log "Starting remaining services..." + for svc_name in "${ACTIVE_SERVICES[@]}"; do + if [[ " ${INFRA_SERVICES[*]} " =~ " ${svc_name} " ]]; then continue; fi + cd "${SERVICES_DIR}/${svc_name}" + if ! docker compose up -d --remove-orphans; then + SKIPPED_SERVICES+=("${svc_name} (Start failed)") + fi + done +} + +############################################################################### +# MAIN +############################################################################### +main() { + log "==========================================" + log " PaperClip Deploy Script" + log "==========================================" + if [[ "$(id -u)" -ne 0 ]]; then die "Run as root (sudo)."; fi + + trap on_deploy_failure ERR + + install_packages + configure_system + handle_age_key + update_repo + ensure_network + discover_services + cleanup_deactivated + enable_maintenance_mode + stop_all_services + decrypt_envs + create_volumes + pull_images + start_infra + start_remaining + disable_maintenance_mode + docker image prune -af 2>/dev/null || true + + trap - ERR + log "Deployment complete! Active services: ${ACTIVE_SERVICES[*]}" +} + +main "$@" diff --git a/services/caddy/docker-compose.yml b/services/caddy/docker-compose.yml index 18a8a19..ed059b6 100644 --- a/services/caddy/docker-compose.yml +++ b/services/caddy/docker-compose.yml @@ -13,7 +13,7 @@ services: # 127.0.0.11 entry, breaking service discovery. volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./fragments:/etc/caddy/fragments:ro + - /opt/PaperClip-Data/caddy/fragments:/etc/caddy/fragments:ro - ./resolv.conf:/etc/resolv.conf:ro - caddy_data:/data - caddy_config:/config