#!/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=("caddy") ACTIVE_SERVICES=() SKIPPED_SERVICES=() NTFY_URL="https://ntfy.orfel.de/PaperClip" # Send a push notification via ntfy (non-blocking, fails silently) notify() { local title="$1" local message="$2" local priority="${3:-default}" local tags="${4:-}" curl -s -o /dev/null -w "" \ -H "Title: ${title}" \ -H "Priority: ${priority}" \ -H "Tags: ${tags}" \ -d "${message}" \ "${NTFY_URL}" 2>/dev/null || true } ############################################################################### # 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." notify "PaperClip Deploy FEHLGESCHLAGEN" "Deployment von PaperClip ist fehlgeschlagen. Prüfe die Server-Logs." "urgent" "x,rotating_light" 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..." # System Updates log "Running system updates..." apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq # 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 weekly automatic reboot (Monday 1:23) and @reboot deployment..." local CRON_REBOOT="23 1 * * 1 /sbin/reboot" local CRON_DEPLOY="@reboot sleep 60 && 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" # Remove old copies crontab -l 2>/dev/null | grep -v -E "(/opt/PaperClip/deploy_script.sh|/sbin/reboot)" | crontab - || true # Install new cron jobs (crontab -l 2>/dev/null; echo "$CRON_REBOOT"; echo "$CRON_DEPLOY") | 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..." local reexec_needed=false 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 reexec_needed=true else cd "${REPO_DIR}" local old_commit old_commit=$(git rev-parse HEAD 2>/dev/null || echo "old") git fetch origin git reset --hard origin/main || git reset --hard origin/master || true local new_commit new_commit=$(git rev-parse HEAD 2>/dev/null || echo "new") if [[ "${old_commit}" != "${new_commit}" ]]; then log "Update detected! (${old_commit:0:7} -> ${new_commit:0:7})" reexec_needed=true fi fi chmod 700 "${REPO_DIR}/deploy_script.sh" 2>/dev/null || true if [[ "${reexec_needed}" == "true" ]]; then if [[ -z "${PAPERCLIP_REEXEC:-}" ]]; then log "Re-executing updated script..." export PAPERCLIP_REEXEC=1 exec bash "$0" "$@" else warn "Re-exec already attempted. Continuing to avoid infinite loop." fi fi } ############################################################################### # 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 "
Das System wird gerade aktualisiert.
Die Seite ist in wenigen Minuten wieder erreichbar.