Frigate has its own built-in login separate from Authelia's session, so just adding `import authelia` in front of it (the pattern used for no-built-in-auth services) would leave two independent logins stacked, defeating the point of Authelia's "remember me" on mobile. Frigate has a `proxy` auth mode built for exactly this — trust Remote-User/Remote-Groups from an upstream forward_auth proxy and disable its own login entirely. - Extend configure_caddy_for_service() with an optional 5th arg for sub-directives inside the reverse_proxy block itself (header_up), needed to pin an X-Proxy-Secret header so Frigate's proxy-auth trust can't be spoofed by a request reaching its published port directly, bypassing Caddy/Authelia. Backward compatible — every other caller is unaffected. - services/frigate.sh: prompt to protect with Authelia when installed; wires import authelia + the X-Proxy-Secret header_up into Caddy, and only writes config.yml's auth.enabled: False + proxy block once Caddy actually confirms it's fronting the domain (never disables the native login with nothing else gating access). Reuses the secret across reinstalls instead of rotating it. Calls _authelia_scope_access() so access can be restricted to specific users instead of every Authelia account. Fixed a latent bug in the standalone-mode Caddy stub where the auth block was placed after reverse_proxy instead of before it (dead code — the same "Authelia never prompts" bug class CLAUDE.md documents for the real helper). - CLAUDE.md: document the new configure_caddy_for_service parameter and Frigate's hybrid built-in-auth/forward_auth pattern. Verified end-to-end against a local test harness (fake Authelia/Caddy dirs): config.yml, .env, and the generated Caddyfile block all agree on the shared secret and header names, auth is skipped cleanly when Caddy isn't configured, and the secret is reused on a second run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
1068 lines
49 KiB
Bash
1068 lines
49 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)"
|
|
}
|
|
|
|
# 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 <port>` 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 <caddy_net-subnet> 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/<name> directory. Scoped to the common case (a Docker service
|
|
# living at $DOCKER_DIR/<name> 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 <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
|
|
}
|
|
|
|
# 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 " u) Update — refresh vendor files/config, keep existing settings"
|
|
echo " f) Full reinstall — re-run every prompt from scratch"
|
|
echo " c) Cancel — leave everything as-is [default]"
|
|
read -p " Choice [u/f/c, Enter=cancel]: " response
|
|
case "${response,,}" in
|
|
u) 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
|
|
#
|
|
# If services/<name>.md exists next to the calling services/<name>.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_<name>() 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'"
|
|
}
|
|
|
|
# find_free_coturn_range MIN_VARNAME MAX_VARNAME RANGE_SIZE [START_PORT]
|
|
# A coturn relay port range can't be collision-checked with port_in_use /
|
|
# find_free_port the way a single fixed port can: coturn only opens ports
|
|
# inside min-port..max-port on demand, per active TURN allocation, so an
|
|
# idle range shows up as nothing listening either way — a live socket scan
|
|
# can't tell two coturn CONFIGS apart. The only reliable check is reading
|
|
# what range every other coturn-owning service on the box actually claims,
|
|
# from its own .env (COTURN_MAX_PORT for the shared instance in
|
|
# ~/docker/coturn/.env, TURN_MAX_PORT for every dedicated per-service coturn
|
|
# — Asterisk's own, each Mattermost instance's, etc., each in that service's
|
|
# own .env). Every service directory keeps its .env at the same top-level
|
|
# path, so one glob covers all of them without needing to know which
|
|
# services exist ahead of time.
|
|
#
|
|
# Writes a RANGE_SIZE-wide block starting safely past the highest claimed
|
|
# max-port back into MIN_VARNAME/MAX_VARNAME. No other coturn on the box at
|
|
# all (fresh install, nothing else uses TURN) leaves it at START_PORT — no
|
|
# collision is possible yet, so there's nothing to shift away from.
|
|
find_free_coturn_range() {
|
|
local _min_varname="$1" _max_varname="$2" _range_size="${3:-200}" _start="${4:-49152}"
|
|
local _highest_max=$((_start - 1)) _f _found
|
|
for _f in "$DOCKER_DIR"/*/.env; do
|
|
[ -f "$_f" ] || continue
|
|
_found="$(grep -E '^(COTURN|TURN)_MAX_PORT=' "$_f" 2>/dev/null | tail -1 | cut -d= -f2-)"
|
|
[[ "$_found" =~ ^[0-9]+$ ]] || continue
|
|
[ "$_found" -gt "$_highest_max" ] && _highest_max=$_found
|
|
done
|
|
local _min=$_start
|
|
if [ "$_highest_max" -ge "$_start" ]; then
|
|
_min=$((_highest_max + 50))
|
|
fi
|
|
eval "$_min_varname='$_min'"
|
|
eval "$_max_varname='$((_min + _range_size))'"
|
|
}
|
|
|
|
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
|
|
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] ["reverse_proxy-extra"]
|
|
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
|
|
# or plain port number for localhost fallback (e.g. "8085").
|
|
# The optional 5th arg is inserted as sub-directives *inside* the
|
|
# reverse_proxy block itself (e.g. " header_up X-Proxy-Secret abc123")
|
|
# — for the rare case a backend needs a header only reverse_proxy's own
|
|
# header_up can set, as opposed to EXTRA_CONFIG's auth-gate directives that
|
|
# run before reverse_proxy entirely. See services/frigate.sh's Authelia
|
|
# integration for the reference caller (pins X-Proxy-Secret so Frigate's
|
|
# proxy-auth trust can't be spoofed by a request that reaches it directly,
|
|
# bypassing Caddy/Authelia).
|
|
configure_caddy_for_service() {
|
|
local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" REVERSE_PROXY_EXTRA="${5:-}"
|
|
|
|
# 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=""
|
|
CADDY_SERVICE_DOMAIN=""
|
|
|
|
# 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
|
|
# Set as soon as we know a domain was actually accepted — every path below
|
|
# this point that returns 0 without configuring Caddy is a genuine failure
|
|
# (write/reload error), not "no domain chosen", so leaving this set is
|
|
# correct: the caller can tell CADDY_SERVICE_CONFIGURED apart from whether
|
|
# a domain was entered at all. Callers that pre-compute their own default
|
|
# URL/domain before calling this (e.g. services/mealie.sh's BASE_URL) need
|
|
# this to reconcile against whatever the user actually typed here, which
|
|
# can differ from that pre-computed default.
|
|
CADDY_SERVICE_DOMAIN="$SERVICE_DOMAIN"
|
|
|
|
# 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
|
|
|
|
# Bare "reverse_proxy upstream" unless a caller needs sub-directives
|
|
# (header_up, etc.) inside it — see the REVERSE_PROXY_EXTRA comment above.
|
|
local _REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM}"
|
|
if [ -n "$REVERSE_PROXY_EXTRA" ]; then
|
|
_REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM} {
|
|
${REVERSE_PROXY_EXTRA}
|
|
}"
|
|
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_LINE}
|
|
|
|
# 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 ""
|
|
}
|
|
|