# common.sh — Shared functions sourced by all deploy and backup scripts # This file should NOT be executed directly. # # Usage: source "${SCRIPT_DIR}/common.sh" # ──────────────────────────── VARIABLES ─────────────────────────────────────── ACME_EMAIL="${ACME_EMAIL:-admin@an2.io}" TRAEFIK_DIR="/opt/traefik" TRAEFIK_DYNAMIC_DIR="/opt/traefik/dynamic" DOCKER_NETWORK="traefik-public" # ──────────────────────────── COLOURS / LOGGING ─────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } fatal() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } # ──────────────────────────── ARGUMENT PARSING ──────────────────────────────── parse_args() { ARG_DOMAIN="" ARG_REMOVE="0" ARG_PURGE="0" ARG_YES="0" ARG_COLD="0" ARG_DIR="" ARG_EXTRA=() while [[ $# -gt 0 ]]; do case "$1" in --domain) [[ $# -ge 2 ]] || fatal "--domain requires a value" ARG_DOMAIN="$2"; shift 2 ;; --dir) [[ $# -ge 2 ]] || fatal "--dir requires a value" ARG_DIR="$2"; shift 2 ;; --remove) ARG_REMOVE="1"; shift ;; --purge) ARG_PURGE="1"; shift ;; --yes) ARG_YES="1"; shift ;; --cold) ARG_COLD="1"; shift ;; *) ARG_EXTRA+=("$1"); shift ;; esac done } # ──────────────────────────── REQUIRE ROOT ──────────────────────────────────── require_root() { [[ $EUID -eq 0 ]] || fatal "This script must be run as root (or via sudo)." } # ──────────────────────────── OS DETECTION ──────────────────────────────────── detect_os() { if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null source /etc/os-release OS_ID="${ID,,}" OS_VERSION="${VERSION_ID:-}" OS_NAME="${PRETTY_NAME}" else fatal "Cannot detect OS — /etc/os-release not found." fi case "$OS_ID" in fedora|rhel|centos|rocky|alma|amzn) DISTRO_FAMILY="fedora" ;; debian|ubuntu|pop|linuxmint|raspbian|kali) DISTRO_FAMILY="debian" ;; *) fatal "Unsupported distro: $OS_ID ($OS_NAME). Supported: Fedora/RHEL family, Debian/Ubuntu family." ;; esac ok "Detected OS: $OS_NAME (family: $DISTRO_FAMILY)" } # ──────────────────────────── INSTALL PREREQUISITES ─────────────────────────── install_prerequisites() { case "$DISTRO_FAMILY" in debian) info "Installing prerequisites (Debian/Ubuntu)..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq \ apt-transport-https ca-certificates curl gnupg lsb-release \ openssl jq net-tools > /dev/null 2>&1 if ! command -v docker &>/dev/null; then info "Installing Docker Engine..." install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null chmod a+r /etc/apt/keyrings/docker.gpg local codename codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$(lsb_release -cs)}")" echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/${OS_ID} ${codename} stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1 fi ok "Prerequisites installed (Debian/Ubuntu)." ;; fedora) info "Installing prerequisites (Fedora/RHEL)..." dnf install -y -q \ ca-certificates curl gnupg2 openssl jq net-tools > /dev/null 2>&1 if ! command -v docker &>/dev/null; then info "Installing Docker Engine..." dnf -y install dnf-plugins-core > /dev/null 2>&1 dnf config-manager --add-repo \ https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null || \ dnf config-manager --add-repo \ https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1 fi ok "Prerequisites installed (Fedora/RHEL)." ;; esac systemctl enable --now docker > /dev/null 2>&1 ok "Docker $(docker --version | awk '{print $3}') is running." if ! docker compose version &>/dev/null; then fatal "docker compose v2 plugin not found. Please install docker-compose-plugin." fi ok "Docker Compose $(docker compose version --short) available." } # ──────────────────────────── DOCKER NETWORK ────────────────────────────────── ensure_docker_network() { docker network inspect "$DOCKER_NETWORK" &>/dev/null || \ docker network create "$DOCKER_NETWORK" ok "Docker network '$DOCKER_NETWORK' ready." } # ──────────────────────────── TRAEFIK ───────────────────────────────────────── # Deploys shared Traefik at /opt/traefik/ if not already present. # Uses file provider (not Docker socket) — Docker Engine 29+ has API # negotiation bugs with Traefik's Go client, and the docs warn about # Docker socket security. ensure_traefik() { # Already running — nothing to do if [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then if docker ps --format '{{.Names}}' | grep -q '^traefik$'; then ok "Traefik is already running." return 0 fi info "Traefik compose exists but not running. Starting..." (cd "$TRAEFIK_DIR" && docker compose up -d) ok "Traefik started." return 0 fi # Full deploy info "Deploying Traefik (file provider, Let's Encrypt)..." # Check port 443 if ss -tlnp 2>/dev/null | grep -q ':443 '; then local proc proc="$(ss -tlnp 2>/dev/null | grep ':443 ')" if ! echo "$proc" | grep -q 'traefik'; then echo "$proc" | sed 's/^/ /' fatal "Port 443 is in use by another process. Free it before deploying Traefik." fi fi # Warn if port 80 is busy (LE HTTP challenge needs it) if ss -tlnp 2>/dev/null | grep -q ':80 '; then warn "Port 80 is in use. Let's Encrypt HTTP challenge requires port 80." fi mkdir -p "$TRAEFIK_DIR" "$TRAEFIK_DYNAMIC_DIR" touch "${TRAEFIK_DIR}/acme.json" chmod 600 "${TRAEFIK_DIR}/acme.json" cat > "${TRAEFIK_DIR}/docker-compose.yml" </dev/null && ufw status 2>/dev/null | grep -q "active"; then ufw allow 80/tcp > /dev/null 2>&1 ufw allow 443/tcp > /dev/null 2>&1 for port in "${extra_ports[@]}"; do ufw allow "$port" > /dev/null 2>&1 done ok "UFW rules added (80, 443${extra_ports:+, ${extra_ports[*]}})." elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then firewall-cmd --permanent --add-service=http > /dev/null 2>&1 firewall-cmd --permanent --add-service=https > /dev/null 2>&1 for port in "${extra_ports[@]}"; do firewall-cmd --permanent --add-port="$port" > /dev/null 2>&1 done firewall-cmd --reload > /dev/null 2>&1 ok "Firewalld rules added (http, https${extra_ports:+, ${extra_ports[*]}})." else warn "No active firewall detected. Ensure ports 80, 443${extra_ports:+, ${extra_ports[*]}} are open." fi } # ──────────────────────────── TRAEFIK DYNAMIC CONFIG ────────────────────────── write_traefik_dynamic_config() { local config_name="$1" local domain="$2" local backend_url="$3" info "Writing Traefik dynamic config: ${config_name}.yml" cat > "${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" </dev/null | head -1)" || true if [[ -n "$latest" ]]; then backup_file="$latest" fi fi if [[ -z "$backup_file" ]]; then return 1 fi local size size="$(du -h "$backup_file" | cut -f1)" info "Found backup: $backup_file ($size)" if [[ "$ARG_YES" != "1" ]]; then read -rp "Restore from this backup? [y/N] " confirm if [[ "${confirm,,}" != "y" ]]; then info "Skipping backup restore." return 1 fi fi info "Restoring backup..." tar xf "$backup_file" -C / ok "Backup restored from $backup_file" return 0 } # ──────────────────────────── HEALTH CHECK ──────────────────────────────────── wait_for_healthy() { local container_name="$1" local health_url="$2" local timeout="${3:-120}" info "Waiting for ${container_name} to become healthy (up to ${timeout}s)..." local elapsed=0 while (( elapsed < timeout )); do # Try curl first, fall back to wget (Alpine images lack curl) if docker exec "$container_name" curl -sf "$health_url" > /dev/null 2>&1 || \ docker exec "$container_name" wget -q --spider "$health_url" 2>/dev/null; then ok "${container_name} is healthy." return 0 fi sleep 5 (( elapsed += 5 )) done warn "${container_name} did not become healthy within ${timeout}s. Check: docker logs ${container_name}" return 1 } # ──────────────────────────── PASSWORD GENERATION ───────────────────────────── generate_password() { openssl rand -base64 32 | tr -d '/+=' | head -c 32 } # ──────────────────────────── SYSTEMD UNITS ─────────────────────────────────── install_systemd_unit() { local unit_name="$1" local working_directory="$2" cat > "/etc/systemd/system/${unit_name}.service" < /dev/null 2>&1 ok "Systemd unit installed: ${unit_name}.service" } remove_systemd_unit() { local unit_name="$1" local unit_file="/etc/systemd/system/${unit_name}.service" if [[ -f "$unit_file" ]]; then systemctl stop "${unit_name}.service" > /dev/null 2>&1 || true systemctl disable "${unit_name}.service" > /dev/null 2>&1 || true rm -f "$unit_file" systemctl daemon-reload ok "Removed systemd unit: ${unit_name}.service" fi } # ──────────────────────────── REMOVAL ───────────────────────────────────────── do_remove() { local service_name="$1" local config_name="$2" local unit_name="$3" local base_dir="$4" local backup_script="${SCRIPT_DIR}/backup-${service_name}.sh" echo "" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" echo -e "${YELLOW} Removing ${service_name}${NC}" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" if [[ "$ARG_PURGE" == "1" ]]; then echo -e " Mode: ${RED}Purge (delete all data)${NC}" else echo -e " Mode: Safe (keep data)" fi echo -e " Data: ${base_dir}" [[ -n "$config_name" ]] && \ echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" echo -e " Systemd: ${unit_name}.service" echo -e "${YELLOW}══════════════════════════════════════════════${NC}" echo "" # Suggest backup before purge if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$base_dir" ]]; then if [[ -f "$backup_script" ]]; then warn "Back up first? ${backup_script}" fi echo "" fi # Stop containers if [[ -d "$base_dir" ]] && [[ -f "${base_dir}/docker-compose.yml" ]]; then (cd "$base_dir" && docker compose down) || true ok "Containers stopped." fi # Remove traefik dynamic config [[ -n "$config_name" ]] && remove_traefik_dynamic_config "$config_name" # Remove systemd unit remove_systemd_unit "$unit_name" # Purge data if requested if [[ "$ARG_PURGE" == "1" ]]; then echo "" warn "This will permanently delete: $base_dir" if [[ "$ARG_YES" != "1" ]]; then read -rp "Are you sure? [y/N] " confirm if [[ "${confirm,,}" != "y" ]]; then info "Purge cancelled." return 0 fi fi rm -rf "$base_dir" ok "Purged: $base_dir" fi # Summary echo "" echo -e "${GREEN}══════════════════════════════════════════════${NC}" echo -e "${GREEN} ${service_name} removed${NC}" echo -e "${GREEN}══════════════════════════════════════════════${NC}" if [[ "$ARG_PURGE" != "1" ]]; then echo -e " Data preserved at ${base_dir}" echo -e " To purge: $0 --remove --purge" fi echo -e " To redeploy: $0" echo -e "${GREEN}══════════════════════════════════════════════${NC}" echo "" return 0 }