ensure_coturn_user() auto-installs shared coturn (services/coturn.sh) any time $DOCKER_DIR/coturn doesn't exist — correct behavior for "first service that needs TURN", wrong behavior for "an operator deliberately decided every consumer should run its own dedicated coturn instead and removed the shared one on purpose". The function had no way to tell those two states apart, so deleting ~/docker/coturn didn't actually retire it — the next service to call this function (a Mattermost reinstall, a fresh Asterisk install) would silently bring it right back. touch ~/docker/.coturn-retired now short-circuits the function straight to the existing "no TURN available, caller degrades gracefully" return path, before it ever looks at install_coturn. Every existing consumer (Asterisk, Mattermost) already handles that path correctly today — it's exactly what happens if shared coturn simply fails to install — so this needed no changes on the consumer side, only closing the gap in the shared function. Verified with a mock: with the flag present, install_coturn is never invoked and the function returns empty COTURN_HOST/rc=1 as expected.
1137 lines
53 KiB
Bash
1137 lines
53 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'"
|
|
}
|
|
|
|
# ── 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 "<consumer-name>"
|
|
#
|
|
# 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/<name>.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=""
|
|
|
|
# An operator can permanently retire shared coturn (e.g. deciding every
|
|
# consumer should run its own dedicated instance instead) by creating
|
|
# this flag file. Without checking it here first, retiring shared coturn
|
|
# by just deleting $DOCKER_DIR/coturn wouldn't actually stick — the very
|
|
# next service that calls this function (a Mattermost reinstall, a fresh
|
|
# Asterisk install, ...) would silently reinstall it right back, since
|
|
# the only signal this function otherwise has is "does the directory
|
|
# exist yet", which looks identical to "never installed" and "removed on
|
|
# purpose". touch/rm $DOCKER_DIR/.coturn-retired to toggle this.
|
|
if [ -f "$DOCKER_DIR/.coturn-retired" ]; then
|
|
log_info "Shared coturn is retired on this box ($DOCKER_DIR/.coturn-retired exists) —"
|
|
log_info "$_consumer will run its own dedicated coturn instead."
|
|
return 1
|
|
fi
|
|
|
|
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"
|
|
|
|
# The cache file surviving doesn't mean the username still exists in
|
|
# coturn's own live database — confirmed live: a coturn
|
|
# container/volume recreated without preserving ./db wipes the
|
|
# database while this file (a separate directory) survives
|
|
# untouched, silently orphaning every consumer's credentials until
|
|
# something re-registers them. Without this check, re-running the
|
|
# consumer's installer (fresh or update) never re-registers anything
|
|
# since it only ever hits the else branch below on a MISSING cache
|
|
# file — a stale-but-present one looked identical to a healthy one.
|
|
# A real "user[realm]" line never contains a space; turnadmin -l's
|
|
# own startup log lines do (confirmed live, at least one coturn
|
|
# build writes them to stdout, not stderr), so filtering on that
|
|
# keeps this robust across builds without needing to match a
|
|
# specific log format.
|
|
local _db_users
|
|
_db_users="$(docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>/dev/null | grep -v ' ' | sed -E 's/\[.*//' | awk 'NF')"
|
|
if ! grep -qx "$_u" <<< "$_db_users"; then
|
|
log_warning "coturn user '$_u' ($_consumer) has cached credentials but isn't in coturn's live database — re-registering with the same password."
|
|
if docker exec coturn turnadmin -a -u "$_u" -p "$_p" -r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then
|
|
log_success "Re-registered coturn user '$_u' for $_consumer"
|
|
else
|
|
log_warning "Could not re-register coturn user '$_u' for $_consumer — is the coturn container running?"
|
|
fi
|
|
fi
|
|
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"
|
|
}
|