Files
ubuntu-post-install/lib/common.sh
T
Claude 2330b47dd9 Add shared helpers: Caddy-fronting signal, safe UFW enable
configure_caddy_for_service() previously gave callers no way to know
whether Caddy actually ended up fronting the service, or whether that
was local (reachable only over host.docker.internal) vs remote
(needs network access to this host). Services that also open a host
firewall port for the same thing had no way to correctly skip that
when Caddy is the only intended way in. Now sets
CADDY_SERVICE_CONFIGURED/CADDY_SERVICE_MODE out-params after each
exit point.

Added ensure_ufw_enabled(): flips UFW from inactive to active (no
service in this repo has ever done this — ufw allow rules just sat
unenforced). Always allows SSH first, reading the real port from
sshd_config in case it's non-default, so this can't lock out the
session running the installer. No-ops if UFW is already active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
2026-07-20 18:20:23 +00:00

645 lines
28 KiB
Bash

#!/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_<name> 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/<service>/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 <name> <group> <description> [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)"
}
# 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)."
}
# ── SSH client config (~/.ssh/config) Host aliases ────────────────────────────
# Lets "ssh <alias>" 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
}
# 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
if [ "$UNATTENDED" = true ]; then
eval "$varname='$default'"; echo "$question [auto: $default]"; return
fi
read -p "$question " response
eval "$varname='$response'"
}
# Prompt text, honoring unattended. prompt_text "Question?" "default" VARNAME
prompt_text() {
local question="$1" default="$2" varname="$3" response
if [ "$UNATTENDED" = true ]; then
eval "$varname='$default'"; echo "$question [auto: $default]"; return
fi
read -p "$question " 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 <dir>/README.md from stdin (markdown). Every module is encouraged to
# call this so each ~/docker/<service>/ folder is self-documenting.
# Usage:
# write_readme "$DIR" <<MD
# # Title
# ...
# MD
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"
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true
}
# ── 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} {
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
}
${EXTRA_CONFIG}
}
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 ""
}