#!/bin/bash # lib/common.sh — shared helpers for the modular post-install system. # # This is the single source of truth for the helper functions every service # module relies on (logging, prompts, ownership, Caddy wiring, the service # registry). Both the full menu (setup.sh) and single-service runs source it, # so there is exactly ONE implementation of each helper. # # Modules under services/*.sh source this file (guarded), register themselves # with register_service, and define an install_ function. # Guard against double-sourcing [ -n "${_COMMON_SH_LOADED:-}" ] && return 0 _COMMON_SH_LOADED=1 # ── Global modes (overridable by the dispatcher / environment) ─────────────── DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" # ── Identity / paths ───────────────────────────────────────────────────────── # The actual (non-root) user, even when run under sudo. ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6)" [ -z "$ACTUAL_HOME" ] && ACTUAL_HOME="$HOME" # Per-service docker folders live here: ~/docker//docker-compose.yml DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" # ── Colored logging ────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } log_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # ── Service registry ───────────────────────────────────────────────────────── # Modules call: register_service [port] declare -gA SERVICE_GROUP=() declare -gA SERVICE_DESC=() declare -gA SERVICE_PORT=() declare -ga SERVICE_ORDER=() register_service() { local name="$1" group="$2" desc="$3" port="${4:-}" SERVICE_GROUP["$name"]="$group" SERVICE_DESC["$name"]="$desc" SERVICE_PORT["$name"]="$port" SERVICE_ORDER+=("$name") } # ── Site-wide defaults ──────────────────────────────────────────────────────── # Stored in $DOCKER_DIR/.config (key=value, one per line). # Service modules read these as prompt defaults so the user only types # timezone, domain, and Caddy network once. Run: sudo ./setup.sh configure SITE_TZ="" SITE_DOMAIN="" SITE_CADDY_NET="caddy_net" SITE_PUID="" SITE_PGID="" CADDY_MODE="" # local | remote | none (set by site configure wizard) CADDY_REMOTE_HOST="" # legacy — kept for backward compat with old .config files load_site_config() { local cfg="$DOCKER_DIR/.config" [ -f "$cfg" ] || return 0 local key val while IFS='=' read -r key val; do [[ "$key" =~ ^[[:space:]]*# ]] && continue [[ -z "${key// }" ]] && continue # Strip leading/trailing whitespace from both sides so hand-edited files work key="${key#"${key%%[^[:space:]]*}"}"; key="${key%"${key##*[^[:space:]]}"}" val="${val#"${val%%[^[:space:]]*}"}"; val="${val%"${val##*[^[:space:]]}"}" case "$key" in SITE_TZ) SITE_TZ="$val" ;; SITE_DOMAIN) SITE_DOMAIN="$val" ;; SITE_CADDY_NET) SITE_CADDY_NET="$val" ;; SITE_PUID) SITE_PUID="$val" ;; SITE_PGID) SITE_PGID="$val" ;; CADDY_MODE) CADDY_MODE="$val" ;; CADDY_REMOTE_HOST) CADDY_REMOTE_HOST="$val" ;; BASE_DOMAIN) [ -z "$SITE_DOMAIN" ] && SITE_DOMAIN="$val" ;; esac done < "$cfg" # Backward compat: old installs used CADDY_REMOTE_HOST to signal remote mode [ -z "$CADDY_MODE" ] && [ -n "$CADDY_REMOTE_HOST" ] && CADDY_MODE="remote" export SITE_TZ SITE_DOMAIN SITE_CADDY_NET SITE_PUID SITE_PGID CADDY_MODE CADDY_REMOTE_HOST } save_site_config() { local cfg="$DOCKER_DIR/.config" mkdir -p "$(dirname "$cfg")" { echo "# ubuntu-post-install site defaults" echo "# Re-run wizard: sudo ./setup.sh configure" [ -n "$SITE_TZ" ] && echo "SITE_TZ=$SITE_TZ" [ -n "$SITE_DOMAIN" ] && echo "SITE_DOMAIN=$SITE_DOMAIN" [ -n "$SITE_CADDY_NET" ] && echo "SITE_CADDY_NET=$SITE_CADDY_NET" [ -n "$SITE_PUID" ] && echo "SITE_PUID=$SITE_PUID" [ -n "$SITE_PGID" ] && echo "SITE_PGID=$SITE_PGID" [ -n "$CADDY_MODE" ] && echo "CADDY_MODE=$CADDY_MODE" # Backward-compat alias for services that still read BASE_DOMAIN directly [ -n "$SITE_DOMAIN" ] && echo "BASE_DOMAIN=$SITE_DOMAIN" } > "$cfg" chmod 600 "$cfg" } # Load immediately so all service modules inherit the values when sourced load_site_config # ── OS detection ───────────────────────────────────────────────────────────── OS_DISTRO="unknown" OS_VERSION="unknown" OS_CODENAME="unknown" detect_os() { [ -f /etc/os-release ] || return 0 local key val while IFS='=' read -r key val; do val="${val//\"/}" case "$key" in ID) OS_DISTRO="$val" ;; VERSION_ID) OS_VERSION="$val" ;; VERSION_CODENAME|UBUNTU_CODENAME) [ "$OS_CODENAME" = "unknown" ] && OS_CODENAME="$val" ;; esac done < /etc/os-release export OS_DISTRO OS_VERSION OS_CODENAME } # Return 0 (true) if the detected Ubuntu version is >= the argument (e.g., "24.04"). ubuntu_version_ge() { [ "$OS_DISTRO" = "ubuntu" ] || return 1 local a="${OS_VERSION//./}" b="${1//./}" [ "${a:-0}" -ge "${b:-0}" ] 2>/dev/null } # pip install --user as actual user. # --break-system-packages overrides PEP 668 ("externally managed environment"), # required on Ubuntu 24.04+ — the flag name sounds alarming but with --user the # install goes to ~/.local/ which apt never touches; nothing system-level is at risk. # The flag was added in pip 22.3; probe once so older pip (Ubuntu 22.04) still works. _PIP_HAS_BSP="" _pip_probe() { [ -n "$_PIP_HAS_BSP" ] && return pip3 install --help 2>/dev/null | grep -q -- '--break-system-packages' \ && _PIP_HAS_BSP=1 || _PIP_HAS_BSP=0 } pip_user_install() { _pip_probe local flags="--user --quiet" [ "$_PIP_HAS_BSP" = "1" ] && flags="$flags --break-system-packages" sudo -u "$ACTUAL_USER" pip3 install $flags "$@" } detect_os # ── Pre-flight ─────────────────────────────────────────────────────────────── require_root() { if [ "${EUID:-$(id -u)}" -ne 0 ]; then log_error "Please run as root (use sudo)." exit 1 fi } require_docker() { if command -v docker &>/dev/null; then ensure_caddy_network return 0 fi log_info "Docker is not installed — installing now..." if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would install Docker CE and Docker Compose plugin from Docker's apt repo" return 0 fi # Docker's official apt-repo steps (docs.docker.com/engine/install/ubuntu), # run directly rather than via the get.docker.com convenience script. # That script wraps every step in "sudo -E sh -c ..."; on minimal/cloud # images that never installed the sudo package (common when operating as # root with no separate sudo user), those internal sudo calls fail while # the outer script still exits 0 — a silent no-op install. We're already # root here, so there's no need for sudo at all. export DEBIAN_FRONTEND=noninteractive local _ok=true apt-get update -qq || _ok=false apt-get install -y -qq ca-certificates curl >/dev/null || _ok=false install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" -o /etc/apt/keyrings/docker.asc || _ok=false chmod a+r /etc/apt/keyrings/docker.asc local _arch _codename _arch="$(dpkg --print-architecture)" _codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" echo "deb [arch=${_arch} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${_codename} stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq || _ok=false apt-get install -y -qq \ docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ || _ok=false systemctl enable --now docker.service || _ok=false unset DEBIAN_FRONTEND hash -r 2>/dev/null || true # flush command hash so the new binary is found if [ "$_ok" != true ]; then log_error "Docker installation failed — see apt output above for the real error." return 1 fi if ! command -v docker &>/dev/null && ! [ -x /usr/bin/docker ]; then log_error "Docker binary not found after install — something went wrong." return 1 fi if [ -n "$ACTUAL_USER" ] && [ "$ACTUAL_USER" != "root" ]; then usermod -aG docker "$ACTUAL_USER" \ && log_info "Added $ACTUAL_USER to the docker group (re-login or run 'newgrp docker' to activate)" fi local _docker_bin _docker_bin="$(command -v docker 2>/dev/null || echo /usr/bin/docker)" log_success "Docker installed ($("$_docker_bin" --version 2>/dev/null))" ensure_caddy_network } # Create the shared caddy_net bridge network if it doesn't exist yet. # Most services declare it "external: true" in their docker-compose.yml (see # CLAUDE.md → Caddy network wiring) — meaning THEY require it to already # exist, and only Caddy's own compose file (services/caddy.sh) actually # creates it. Installing any caddy_net-dependent service before Caddy would # otherwise fail outright with "network caddy_net declared as external, but # could not be found." Called from require_docker so every service gets this # for free regardless of install order. No-op in DRY_RUN; safe/idempotent # otherwise — docker network create is a no-op if the network already exists. ensure_caddy_network() { [ "$DRY_RUN" = true ] && return 0 local _net="${SITE_CADDY_NET:-caddy_net}" docker network inspect "$_net" &>/dev/null && return 0 docker network create "$_net" &>/dev/null \ && log_info "Created Docker network ${_net} (needed by Caddy-fronted services)" } # Installs yq v4 (Go binary release, not the Python click-based yq some # distros package under the same name) if not already present. Shared by # any service that needs to patch YAML config robustly instead of # hand-rolling sed/awk text surgery — originally lived only in # services/onlyoffice.sh (FileBrowser config patching); promoted here once # services/gatus.sh needed the same thing (endpoint sync from Caddyfile), # so both use one implementation instead of two copies drifting apart. ensure_yq() { # Not just `command -v yq` — confirmed live, a box can already have # `yq` on PATH that's actually kislyuk/yq (the Python jq-wrapper apt # packages under the same name on Debian/Ubuntu), which silently # errors on mikefarah's `e '.path' file` syntax every caller here # uses ("argument files: can't open '.path'"). Check the version # string actually identifies as mikefarah's before trusting it. yq --version 2>/dev/null | grep -q mikefarah && return 0 log_info "Installing yq..." local _arch; _arch=$(uname -m) local _binary="yq_linux_amd64" [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _binary="yq_linux_arm64" # /usr/local/bin precedes /usr/bin on Ubuntu's default PATH, so this # correctly shadows a wrong /usr/bin/yq without needing to touch or # remove it (something else on the box may depend on the real one). curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/${_binary}" \ -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \ && log_success "yq installed" || log_warning "yq install failed" } # Enables UFW if it isn't already active. Call this AFTER the caller has # already added its own `ufw allow` rules for whatever it needs — this only # flips UFW from inactive to active, it doesn't add rules for the calling # service itself. # # Always allows SSH first, using the sshd_config port if it's non-default — # getting this wrong and then enabling UFW would lock out the very SSH # session most people are running this script from. If UFW is already # active, this is a no-op (assumed already handled correctly). ensure_ufw_enabled() { command -v ufw &>/dev/null || return 0 [ "$DRY_RUN" = true ] && return 0 ufw status 2>/dev/null | grep -q "Status: active" && return 0 local _ssh_port _ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null \ | tail -1 | awk '{print $2}')" _ssh_port="${_ssh_port:-22}" ufw allow "${_ssh_port}/tcp" comment 'SSH' >/dev/null 2>&1 ufw --force enable >/dev/null 2>&1 log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)." } # Adds a swapfile on any box with modest RAM and no swap already active — # no cloud-provider detection, just the actual condition that matters. Used # to be DigitalOcean-droplet-gated logic living only in services/asterisk.sh; # generalized here so every install gets the same safety net regardless of # which services get chosen or which provider the box is on — a small VPS # running several Docker services at once needs this just as much as a # single-purpose Asterisk droplet did. Idempotent and safe to call from # multiple places in the same run (services/base.sh calls it for every # install; services/asterisk.sh also calls it directly so the standalone # `sudo bash asterisk.sh` path — no base.sh involved — still gets it): a # box that already has swap, or already got it from an earlier call in the # same session, just returns immediately. ensure_swapfile() { local TOTAL_RAM_MB TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 4096 ]] || return 0 swapon --show | grep -q . && return 0 [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would add a swapfile (${TOTAL_RAM_MB}MB RAM, no swap detected)"; return 0; } local FREE_DISK_MB SWAP_MB=2048 FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." log_warning "Consider a bigger box, or free up disk before installing." return 0 fi local ADD_SWAP="" prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (recommended) (y/n):" "y" ADD_SWAP [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none chmod 600 /swapfile mkswap /swapfile >/dev/null swapon /swapfile grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf sysctl -w vm.swappiness=10 >/dev/null 2>&1 log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." } # Scopes a UFW allow rule to just the caddy_net bridge subnet instead of # every interface. Needed for any port that only needs to be reachable from # a *locally* Caddy-fronted service (via host.docker.internal) — a plain # `ufw delete allow ` closes it everywhere, but Caddy's own request to # host.docker.internal is still ordinary INPUT-chain traffic as far as UFW # is concerned, arriving over the caddy_net bridge, not the internet. UFW # rules apply to all interfaces unless scoped like this, so closing the # port outright also silently breaks Caddy. ufw_allow_from_caddy_net() { local _port="$1" _proto="${2:-tcp}" command -v ufw &>/dev/null || return 0 [ "$DRY_RUN" = true ] && return 0 local _subnet _subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \ --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)" if [ -n "$_subnet" ]; then ufw allow from "$_subnet" to any port "$_port" proto "$_proto" comment 'Caddy internal only' >/dev/null 2>&1 log_info "Port ${_port}/${_proto} reachable only from Caddy's internal network (${_subnet}), not the public internet." else log_warning "Could not determine ${SITE_CADDY_NET:-caddy_net}'s subnet — port ${_port}/${_proto} stays closed." log_warning "If Caddy can't reach it: ufw allow from to any port ${_port} proto ${_proto}" fi } # ── Remove a service ────────────────────────────────────────────────────────── # Removes a specific site block from a Caddyfile, keyed on the block whose # body reverse_proxy's to the given container name. Bounded by tracking # actual brace depth (handles nested log{}/header{}/forward_auth{} blocks # correctly), not a "delete to next blank line" scan — see this repo's own # history for why an unbounded range delete on a live Caddy/Samba config is # exactly the kind of thing that silently destroys unrelated content. _remove_caddy_site_block() { local caddy_file="$1" container="$2" awk -v container="$container" ' BEGIN { depth = 0; buf = ""; skip = 0; pending_comment = "" } { line = $0 opens = gsub(/\{/, "{", line) closes = gsub(/\}/, "}", line) if (depth == 0 && opens == 0) { if ($0 ~ /^#/) { if (pending_comment != "") print pending_comment pending_comment = $0 next } else { if (pending_comment != "") { print pending_comment; pending_comment = "" } print $0 next } } if (depth == 0 && opens > 0) { buf = $0 "\n" depth += opens - closes if (index($0, "reverse_proxy " container ":") > 0) skip = 1 next } if (depth > 0) { buf = buf $0 "\n" if (index($0, "reverse_proxy " container ":") > 0) skip = 1 depth += opens - closes if (depth <= 0) { depth = 0 if (!skip) { if (pending_comment != "") print pending_comment printf "%s", buf } pending_comment = "" buf = "" skip = 0 next } next } } END { if (pending_comment != "") print pending_comment } ' "$caddy_file" } # Generic per-service removal: stops/removes its containers, its Caddy site # block (if any), any UFW rule tagged with its name, and optionally its # ~/docker/ directory. Scoped to the common case (a Docker service # living at $DOCKER_DIR/ with a standard configure_caddy_for_service # site block) — a service with a hand-built Caddy block or non-standard # layout may need manual cleanup for the parts this can't find. remove_service() { local name="$1" local dir="$DOCKER_DIR/$name" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would stop/remove $name's containers, Caddy site block, and any tagged UFW rule" return 0 fi if [ ! -d "$dir" ]; then log_error "No $dir found — nothing to remove. (Non-Docker services, e.g. base/ssh-key-import, aren't handled by this — remove those manually.)" return 1 fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Remove $name" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo " This will, as applicable:" [ -f "$dir/docker-compose.yml" ] && echo " - Stop and remove its Docker container(s)" echo " - Remove its Caddy site block, if any (Caddyfile backed up first)" echo " - Remove any UFW rule tagged with '$name'" echo "" local CONFIRM="" prompt_yn " Continue? (y/n):" "n" CONFIRM if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then log_info "Cancelled." return 0 fi # ── Docker teardown ──────────────────────────────────────────────────── local container="" if [ -f "$dir/docker-compose.yml" ]; then container="$(grep -m1 '^\s*container_name:' "$dir/docker-compose.yml" 2>/dev/null | awk '{print $2}')" local WIPE_DATA="" prompt_yn " Also delete its data volumes (database, uploaded files, etc. — irreversible)? (y/n):" "n" WIPE_DATA if [[ "$WIPE_DATA" =~ ^[Yy]$ ]]; then ( cd "$dir" && docker compose down -v ) \ && log_success "Containers and volumes removed" \ || log_warning "docker compose down -v failed — check manually" else ( cd "$dir" && docker compose down ) \ && log_success "Containers stopped and removed (data left on disk)" \ || log_warning "docker compose down failed — check manually" fi fi # ── Caddy site block ──────────────────────────────────────────────────── local caddy_file="$DOCKER_DIR/caddy/Caddyfile" if [ -n "$container" ] && [ -f "$caddy_file" ] && grep -q "reverse_proxy ${container}:" "$caddy_file"; then local bk="$caddy_file.backup.$(date +%Y%m%d-%H%M%S)" cp "$caddy_file" "$bk" _remove_caddy_site_block "$caddy_file" "$container" > "$caddy_file.tmp" \ && mv "$caddy_file.tmp" "$caddy_file" log_success "Removed $name's Caddy site block (backup: $(basename "$bk"))" if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx caddy; then docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ || docker restart caddy &>/dev/null \ || log_warning "Reload/restart Caddy manually to apply this." fi fi # ── UFW rules ─────────────────────────────────────────────────────────── if command -v ufw &>/dev/null; then local rule_nums # [[:space:]]* after the opening bracket — ufw pads single-digit # rule numbers with a leading space to align with double-digit # ones ("[ 3]" vs "[10]"); without it, every single-digit rule # silently fails to match and never gets deleted. rule_nums="$(ufw status numbered 2>/dev/null | grep -i "# .*\b${name}\b" | grep -oE '^\[[[:space:]]*[0-9]+\]' | tr -d '[] ' | sort -rn)" if [ -n "$rule_nums" ]; then local n for n in $rule_nums; do ufw --force delete "$n" >/dev/null 2>&1 done log_success "Removed UFW rule(s) tagged for $name" fi fi # ── Directory itself ──────────────────────────────────────────────────── local DELETE_DIR="" prompt_yn " Also delete $dir itself (its README, configs, and any data left on disk)? (y/n):" "n" DELETE_DIR if [[ "$DELETE_DIR" =~ ^[Yy]$ ]]; then rm -rf "$dir" log_success "Removed $dir" else log_info "Left $dir in place." fi } # ── SSH client config (~/.ssh/config) Host aliases ──────────────────────────── # Lets "ssh " connect directly to user@host without typing it out each # time — handy for VPN/NetBird peers with unmemorable IPs. Operates on the # ACTUAL_USER's config (not root's), since that's whose terminal runs ssh. ssh_config_path() { echo "$ACTUAL_HOME/.ssh/config"; } ssh_host_alias_exists() { local alias="$1" cfg; cfg="$(ssh_config_path)" [ -f "$cfg" ] && grep -qiE "^Host[[:space:]]+${alias}([[:space:]]|\$)" "$cfg" } add_ssh_host_alias() { local alias="$1" hostname="$2" user="$3" port="${4:-22}" local cfg; cfg="$(ssh_config_path)" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would add SSH alias '$alias' -> $user@$hostname:$port to $cfg" return 0 fi mkdir -p "$(dirname "$cfg")" touch "$cfg" chmod 700 "$(dirname "$cfg")" chmod 600 "$cfg" if ssh_host_alias_exists "$alias"; then log_warning "Host alias '$alias' already exists in $cfg — skipping (remove it first to replace)." return 1 fi { echo "" echo "Host $alias" echo " HostName $hostname" echo " User $user" [ "$port" != "22" ] && echo " Port $port" } >> "$cfg" chown -R "$ACTUAL_USER:$ACTUAL_USER" "$(dirname "$cfg")" 2>/dev/null || true log_success "Added SSH alias: ssh $alias -> $user@$hostname:$port" } list_ssh_host_aliases() { local cfg; cfg="$(ssh_config_path)" if [ ! -f "$cfg" ] || ! grep -qiE "^Host[[:space:]]+" "$cfg"; then echo " (none — $cfg has no Host entries yet)" return 0 fi grep -inE "^Host[[:space:]]+" "$cfg" | sed -E 's/^([0-9]+):Host[[:space:]]+/ \1) /' } remove_ssh_host_alias() { local alias="$1" cfg; cfg="$(ssh_config_path)" if [ ! -f "$cfg" ]; then log_warning "No SSH config file found at $cfg" return 1 fi if ! ssh_host_alias_exists "$alias"; then log_warning "Host alias '$alias' not found in $cfg" return 1 fi if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would remove SSH alias '$alias' from $cfg" return 0 fi local tmp; tmp="$(mktemp)" awk -v alias="$alias" ' BEGIN { skip=0 } tolower($1)=="host" && tolower($2)==tolower(alias) { skip=1; next } skip==1 && /^Host[[:space:]]/ { skip=0 } skip==1 && /^[[:space:]]*$/ { skip=0; next } skip==1 { next } { print } ' "$cfg" > "$tmp" mv "$tmp" "$cfg" chmod 600 "$cfg" chown "$ACTUAL_USER:$ACTUAL_USER" "$cfg" 2>/dev/null || true log_success "Removed SSH alias: $alias" } # ── Command execution honoring dry-run ─────────────────────────────────────── run_cmd() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would execute: $*" return 0 else "$@" fi } # Ensure Docker directories are owned by the actual user (not root) ensure_docker_dir_ownership() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" return 0 fi for dir in "$@"; do [ -d "$dir" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true done } # Waits briefly after a container starts, then reports whether it's # actually running or stuck restarting/crash-looping — printing recent # logs on failure instead of leaving a silent "started" message that # doesn't reflect whether it's actually working. Confirmed live: # several services' own "Started"-looking `docker compose up -d` # success message meant nothing — the container was already # crash-looping by the time that message printed, with no indication # anything was wrong until someone separately ran `docker ps -a` much # later and had to go dig through logs by hand. # # Usage: check_container_health CONTAINER_NAME [WAIT_SECONDS] # Returns 0 if the container is up and hasn't restarted, 1 otherwise. check_container_health() { local container="$1" wait_seconds="${2:-8}" [ "$DRY_RUN" = true ] && return 0 sleep "$wait_seconds" local status status="$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)" if [ -z "$status" ]; then log_warning "Container '$container' doesn't exist — something failed before it could even be created." return 1 fi local restart_count restart_count="$(docker inspect -f '{{.RestartCount}}' "$container" 2>/dev/null || echo 0)" if [ "$status" = "running" ] && [ "$restart_count" -eq 0 ]; then return 0 fi if [ "$status" = "running" ]; then log_warning "Container '$container' is running now but already restarted $restart_count time(s) — check the logs below." else log_warning "Container '$container' is not running (status: $status) — recent logs:" fi echo "" docker logs "$container" --tail 20 2>&1 | sed 's/^/ /' echo "" return 1 } # Generate a secure alphanumeric password (no special characters) generate_password() { local length="${1:-32}" openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" } # Validate password (alphanumeric only, minimum length). Returns 0/1. validate_password() { local password="$1" min_length="${2:-12}" if [ ${#password} -lt "$min_length" ]; then echo " ⚠ Password must be at least $min_length characters long"; return 1 fi if echo "$password" | grep -q '[^a-zA-Z0-9]'; then echo " ⚠ Password must contain only letters and numbers (no special characters)"; return 1 fi return 0 } # Prompt yes/no, honoring unattended. prompt_yn "Question?" "default" VARNAME prompt_yn() { local question="$1" default="$2" varname="$3" response hint="" if [ "$UNATTENDED" = true ]; then eval "$varname='$default'"; echo "$question [auto: $default]"; return fi # Confirmed live: this used to have no fallback to $default at all here — # pressing Enter on a stated "(y/n): y" default silently set the variable # to an EMPTY string, not "y", so every downstream `[[ "$VAR" =~ ^[Yy]$ ]]` # check treated "just press Enter to accept the default" as a no. Every # prompt_yn call in every service was affected. [ -n "$default" ] && hint=" [$default]" read -p "${question}${hint} " response eval "$varname='${response:-$default}'" } # Prompt text, honoring unattended. prompt_text "Question?" "default" VARNAME prompt_text() { local question="$1" default="$2" varname="$3" response hint="" if [ "$UNATTENDED" = true ]; then eval "$varname='$default'"; echo "$question [auto: $default]"; return fi [ -n "$default" ] && hint=" [$default]" read -p "${question}${hint} " response eval "$varname='${response:-$default}'" } # Prompt for how to handle a service that's already installed, honoring # unattended. prompt_reinstall_mode VARNAME # Sets VARNAME to one of: update | fresh | cancel # Enter (no input) and any unrecognized input both resolve to "cancel" — this # guards a destructive full reinstall behind a deliberate keypress instead of # a stray Enter. Unattended mode always resolves to "cancel" too: never # silently touch an existing install when nobody's watching the prompt. prompt_reinstall_mode() { local varname="$1" response if [ "$UNATTENDED" = true ]; then eval "$varname='cancel'" echo "Existing install detected — leaving it as-is [auto: cancel, unattended mode]" return fi echo " Existing install detected. Choose:" echo " r) Reinstall in place — refresh vendor files/config, keep existing settings" echo " f) Full install — re-run every prompt from scratch" echo " c) Cancel — leave everything as-is [default]" read -p " Choice [r/f/c, Enter=cancel]: " response case "${response,,}" in r) eval "$varname='update'" ;; f) eval "$varname='fresh'" ;; *) eval "$varname='cancel'" ;; esac } # ── Per-service README generation ──────────────────────────────────────────── # Write /README.md from stdin (markdown). Every module is encouraged to # call this so each ~/docker// folder is self-documenting. # Usage: # write_readme "$DIR" <.md exists next to the calling services/.sh, its # contents are appended automatically. That file is optional and untouched by # setup.sh's services/*.sh glob (doesn't register, doesn't run) — it's just a # place for install-time-invariant walkthroughs (multi-step UI instructions, # third-party linking, etc.) that would otherwise bloat the heredoc above with # content that doesn't depend on any variable chosen during install. write_readme() { local dir="$1" if [ "$DRY_RUN" = true ]; then cat >/dev/null # consume the heredoc so the caller isn't blocked echo "[DRY-RUN] Would write $dir/README.md" return 0 fi mkdir -p "$dir" cat > "$dir/README.md" local caller_script="${BASH_SOURCE[1]:-}" if [ -n "$caller_script" ]; then local companion_doc="${caller_script%.sh}.md" if [ -f "$companion_doc" ]; then printf '\n' >> "$dir/README.md" cat "$companion_doc" >> "$dir/README.md" fi fi chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true } # ── Host port collision avoidance (shared by every service that publishes a # fixed host port) ──────────────────────────────────────────────────────────── # With 70+ services in this repo, several ship the same default port (e.g. # emby and jellyfin both default to 8096; changedetection and frigate both # default to 5000). Nothing enforced those defaults were actually free on the # host, so whichever service started its container second would fail to bind # ("port is already allocated") instead of just landing on the next free port. # Confirmed live: installing jellyfin after emby (or vice versa) writes a # docker-compose.yml claiming a port the other service's container already # holds, and only fails at `docker compose up` time — not at install time. # # port_in_use PORT [PROTO] — PROTO defaults to tcp; pass "udp" for UDP-only # ports. Returns 0 (true, in use) or 1 (free). port_in_use() { local _port="$1" _proto="${2:-tcp}" local _flag="-tlnH" [ "$_proto" = "udp" ] && _flag="-ulnH" ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . } # find_free_port VARNAME START [PROTO] # Scans upward from START for a free host port and writes the result back # into VARNAME. Single-port convenience wrapper around port_in_use — every # service's install_() should run its default/candidate port through # this (or a hand-rolled port_in_use loop for multiple ports that must move # together, e.g. a web port + an agent port) before writing docker-compose.yml, # not only when adding an explicit additional instance of itself. On a normal # single-install host this is a silent no-op (the default port is free, so # VARNAME comes back unchanged); it only changes behavior when something else # already holds the port, which is exactly the case that used to fail at # startup instead of at install time. find_free_port() { local _varname="$1" _port="$2" _proto="${3:-tcp}" while port_in_use "$_port" "$_proto"; do _port=$((_port + 1)) done eval "$_varname='$_port'" } # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), # or plain port number for localhost fallback (e.g. "8085"). configure_caddy_for_service() { local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" # Out-params (not `local` — callers read these after the call returns) so # a caller can tell whether Caddy actually ended up fronting the service # and, if so, whether that's a local container (reachable only over the # host's internal network) or a remote machine (needs to reach this host # over the network — usually its public IP). Services that also open a # host firewall for the same port use this to skip that when Caddy is # already the only intended way in, instead of leaving both routes open. CADDY_SERVICE_CONFIGURED=false CADDY_SERVICE_MODE="" # Derive the proxy upstream and a port number for display messages. # Plain number → host.docker.internal:PORT (host-network or legacy # services — Caddy itself runs in its own container on # caddy_net, a bridge network, so "localhost" here would # resolve to Caddy's own container, not the host. Requires # the extra_hosts entry set in services/caddy.sh's compose # file — see the comment there.) # name:port → used as-is (preferred: service on shared caddy_net) local _UPSTREAM _DISPLAY_PORT case "$SERVICE_UPSTREAM" in *:*) _UPSTREAM="$SERVICE_UPSTREAM"; _DISPLAY_PORT="${SERVICE_UPSTREAM##*:}" ;; *) _UPSTREAM="host.docker.internal:$SERVICE_UPSTREAM"; _DISPLAY_PORT="$SERVICE_UPSTREAM" ;; esac # ── Determine Caddy mode ────────────────────────────────────────────────── # Explicit CADDY_MODE (set by site wizard) takes priority. # Fall back to: local if ~/docker/caddy exists, remote if legacy CADDY_REMOTE_HOST set. local _CADDY_MODE="${CADDY_MODE:-none}" [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" [ "$_CADDY_MODE" = "none" ] && return 0 echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " CADDY REVERSE PROXY CONFIGURATION" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" if [ "$_CADDY_MODE" = "remote" ]; then echo " Caddy is on a remote machine — a snippet file will be saved to" echo " ~/docker/caddy-snippets/ for you to copy to your Caddy machine." else echo " Caddy is installed on this machine." fi echo "" local CONFIGURE_CADDY="" prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then echo " Skipping Caddy configuration." echo " Access $SERVICE_NAME at: http://localhost:$_DISPLAY_PORT" return 0 fi # Domain prompt — pre-fill from SITE_DOMAIN when available echo "" local _default_domain="" if [ -n "$SITE_DOMAIN" ]; then _default_domain="${DEFAULT_SUBDOMAIN}.${SITE_DOMAIN}" echo " Default: $_default_domain" else echo " No base domain set — run: sudo ./setup.sh configure" echo " Examples: ${DEFAULT_SUBDOMAIN}.example.com, ${DEFAULT_SUBDOMAIN}.yourdomain.com" fi echo "" local SERVICE_DOMAIN="" prompt_text "Domain [${_default_domain:-required}]:" "$_default_domain" SERVICE_DOMAIN if [ -z "$SERVICE_DOMAIN" ]; then echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 fi # Build the site block — upstream differs by mode local _BLOCK_UPSTREAM="$_UPSTREAM" if [ "$_CADDY_MODE" = "remote" ]; then # Remote Caddy can't resolve Docker container names — use this machine's IP + published port. # Prefer legacy CADDY_REMOTE_HOST if set (old installs that stored it explicitly), # otherwise auto-detect the primary non-loopback IP. local _THIS_IP="${CADDY_REMOTE_HOST:-}" if [ -z "$_THIS_IP" ]; then _THIS_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" fi [ -z "$_THIS_IP" ] && _THIS_IP="$(hostname -f 2>/dev/null || echo "127.0.0.1")" _BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}" fi local _SITE_BLOCK _SITE_BLOCK="$(cat << CADDY_BLOCK # $SERVICE_NAME ${SERVICE_DOMAIN} { # Auth (if any) must come before reverse_proxy — forward_auth is the # same directive family as reverse_proxy internally, and Caddy doesn't # reorder repeats of the same directive within a block; it runs them in # the order they're written. With reverse_proxy first, it would handle # and terminate every request immediately, so an auth check written # after it would be dead code that never runs — full bypass regardless # of what the auth server's own rules say. ${EXTRA_CONFIG} reverse_proxy ${_BLOCK_UPSTREAM} # Security headers header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } # Logging for CrowdSec (Caddy JSON access logs) log { output file /var/log/caddy/${SERVICE_DOMAIN}.log format json } } CADDY_BLOCK )" # ── Local Caddy: write to Caddyfile and reload ──────────────────────────── if [ "$_CADDY_MODE" = "local" ]; then local CADDY_DIR="$DOCKER_DIR/caddy" local CADDYFILE="$CADDY_DIR/Caddyfile" local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" if [ -f "$CADDYFILE" ]; then echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")" cp "$CADDYFILE" "$BACKUP_FILE" else echo " Creating new Caddyfile"; touch "$CADDYFILE" fi if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" local OVERWRITE="" prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then echo " Keeping existing configuration." CADDY_SERVICE_CONFIGURED=true CADDY_SERVICE_MODE="local" return 0 fi sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" fi CADDY_SERVICE_CONFIGURED=true CADDY_SERVICE_MODE="local" echo " Adding $SERVICE_NAME configuration to Caddyfile..." printf '%s\n' "$_SITE_BLOCK" >> "$CADDYFILE" echo " ✓ Configuration added to Caddyfile" echo " Reloading Caddy configuration..." docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true # The template Caddyfile ships with "admin off" (security hardening — # no local API attack surface), so `caddy reload` never works here; # it depends on that same admin endpoint. Try it anyway in case a # box has admin enabled, but fall back to a full container restart # (brief availability gap for everything Caddy fronts, but reliable # regardless of the admin setting) rather than leaving the change # sitting unapplied on disk. if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then echo " ✓ $SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN" elif docker restart caddy &>/dev/null; then echo " ✓ Caddy restarted to apply changes (reload API is disabled by default)" echo " ✓ $SERVICE_NAME should be accessible at: https://$SERVICE_DOMAIN" else echo " ⚠ Failed to reload or restart Caddy. Check: docker logs caddy" echo " You can restore from backup: $BACKUP_FILE" fi # ── Remote Caddy: write snippet file ───────────────────────────────────── else CADDY_SERVICE_CONFIGURED=true CADDY_SERVICE_MODE="remote" local SNIPPET_DIR="$DOCKER_DIR/caddy-snippets" local SNIPPET_FILE="$SNIPPET_DIR/${DEFAULT_SUBDOMAIN}.caddy" mkdir -p "$SNIPPET_DIR" printf '%s\n' "$_SITE_BLOCK" > "$SNIPPET_FILE" chown "$ACTUAL_USER:$ACTUAL_USER" "$SNIPPET_FILE" 2>/dev/null || true echo " ✓ Snippet saved: $SNIPPET_FILE" echo "" echo " Copy to your Caddy machine and append to its Caddyfile:" echo " scp $SNIPPET_FILE caddy-host:~/caddy-snippets/" echo " # then on the Caddy machine:" echo " cat ~/caddy-snippets/${DEFAULT_SUBDOMAIN}.caddy >> /path/to/Caddyfile" echo " docker restart caddy # reload API is disabled by default; a restart is what applies it" echo "" echo " Or rsync all snippets at once:" echo " rsync -av $SNIPPET_DIR/ caddy-host:~/caddy-snippets/" fi echo "" } # ── Shared coturn (TURN/STUN) wiring ────────────────────────────────────────── # Usage: ensure_coturn_user "" # # Installs the shared coturn service (services/coturn.sh) if this is the # first service on the box that needs TURN, then registers (or reuses) a # dedicated long-term-credential user for the caller — one coturn instance, # one relay port range, shared by every consumer instead of each service # running its own and fighting over host ports (see services/coturn.sh's # header for why that used to be a real, confirmed-live problem). # # Out-params (not `local` — read them after the call returns), same # convention as configure_caddy_for_service's CADDY_SERVICE_* above: # COTURN_HOST host/IP TURN clients should connect to # COTURN_PORT coturn's listening port # COTURN_USERNAME this consumer's long-term-credential username # COTURN_PASSWORD this consumer's long-term-credential password # COTURN_HOST is left empty if coturn couldn't be installed or reached — # callers should treat that as "no TURN available" and degrade gracefully, # same as checking CADDY_SERVICE_CONFIGURED after configure_caddy_for_service. # # Credentials are cached per-consumer in coturn's own users/.env so a # service re-running its own installer reuses the same one instead of # minting a new credential and orphaning the old one (which would silently # break already-configured clients still holding it). ensure_coturn_user() { local _consumer="$1" COTURN_HOST="" COTURN_PORT="" COTURN_USERNAME="" COTURN_PASSWORD="" if [ ! -d "$DOCKER_DIR/coturn" ]; then if declare -F install_coturn >/dev/null 2>&1; then log_info "No shared coturn (TURN/STUN) server yet — setting one up for $_consumer..." # install_coturn cd's into $DOCKER_DIR/coturn and never cd's back — # the caller (e.g. asterisk.sh, already cd'd into its own install # directory) would otherwise return here with the wrong cwd and go # on to write ITS docker-compose.yml/.env into coturn's directory # instead of its own. Confirmed live: this clobbered coturn's # compose file and left the consumer's own directory without one, # so its later `docker compose up --build` failed with "Dockerfile: # no such file or directory" (no Dockerfile in coturn's directory). local _caller_pwd _caller_pwd="$(pwd)" install_coturn local _coturn_rc=$? cd "$_caller_pwd" || true [ "$_coturn_rc" -ne 0 ] && { log_warning "coturn setup failed — $_consumer will run without TURN."; return 1; } else log_warning "services/coturn.sh not loaded — $_consumer will run without TURN." log_warning "Run: sudo ./setup.sh coturn" return 1 fi fi if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would register coturn user '$_consumer'" return 0 fi local _env="$DOCKER_DIR/coturn/.env" [ -f "$_env" ] || { log_warning "coturn installed but $_env missing — cannot register '$_consumer'."; return 1; } local _realm _host _port _realm="$(grep '^COTURN_REALM=' "$_env" | cut -d= -f2-)" _host="$(grep '^COTURN_HOST=' "$_env" | cut -d= -f2-)" _port="$(grep '^COTURN_PORT=' "$_env" | cut -d= -f2-)"; _port="${_port:-3478}" local _userdir="$DOCKER_DIR/coturn/users" local _userfile="$_userdir/${_consumer}.env" mkdir -p "$_userdir" if [ -f "$_userfile" ]; then local _u _p _u="$(grep '^COTURN_USER=' "$_userfile" | cut -d= -f2-)" _p="$(grep '^COTURN_PASS=' "$_userfile" | cut -d= -f2-)" COTURN_USERNAME="$_u" COTURN_PASSWORD="$_p" else COTURN_USERNAME="$_consumer" COTURN_PASSWORD="$(generate_password 24)" if docker exec coturn turnadmin -a -u "$COTURN_USERNAME" -p "$COTURN_PASSWORD" \ -r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then { echo "COTURN_USER=$COTURN_USERNAME"; echo "COTURN_PASS=$COTURN_PASSWORD"; } > "$_userfile" chmod 600 "$_userfile" log_success "Registered coturn user '$COTURN_USERNAME' for $_consumer" else log_warning "Could not register a coturn user for $_consumer — is the coturn container running?" COTURN_USERNAME="" COTURN_PASSWORD="" return 1 fi fi COTURN_HOST="$_host" COTURN_PORT="$_port" }