From ed7270dcf2a78202443c1eb3c79c31b12e4a9045 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:31:42 +0000 Subject: [PATCH 1/6] Add unattended DR bring-up script for the backup service restore_kopia.sh is interactive and one-service-at-a-time, which doesn't scale to standing up a cold spare box quickly during a real outage. dr_bringup.sh restores every service's latest snapshot (or one named service) and runs docker compose up -d with no prompts, so a full-stack recovery is one command instead of N interactive restores. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- README.md | 2 +- extras/dr_bringup_kopia.sh | 227 +++++++++++++++++++++++++++++++++++++ services/backup.sh | 25 +++- 3 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 extras/dr_bringup_kopia.sh diff --git a/README.md b/README.md index c947c4a..e466420 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | | `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `sync-cc` | -| `backup` | `backup` — complete recovery: entire `~/docker//` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) | +| `backup` | `backup` — complete recovery: entire `~/docker//` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) | Run `./setup.sh --list` to see descriptions. diff --git a/extras/dr_bringup_kopia.sh b/extras/dr_bringup_kopia.sh new file mode 100644 index 0000000..924fb24 --- /dev/null +++ b/extras/dr_bringup_kopia.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# extras/dr_bringup_kopia.sh — non-interactive disaster-recovery bring-up. +# Installed to ~/docker/backup/dr_bringup.sh by the backup service installer. +# +# Restores the LATEST snapshot of every backed-up service (or one chosen +# service) straight into place and brings it up with `docker compose up -d`. +# Meant to run unattended on a cold spare box during a real outage — unlike +# restore_kopia.sh (one service at a time, interactive prompts per step), +# this walks every discovered service with no prompts so it can complete a +# full-stack recovery in one command. +# +# sudo ./dr_bringup.sh restore + start every service +# sudo ./dr_bringup.sh --service NAME restore + start one service +# sudo ./dr_bringup.sh --list list restorable sources and exit +# sudo ./dr_bringup.sh --dry-run show what would happen, touch nothing +# sudo ./dr_bringup.sh --no-start restore only, skip docker compose up -d +# +# Reads backup.conf from the same directory. On the spare box this file +# won't exist yet on its own — copy it over from the primary box first (it +# holds the repository paths/passwords needed to connect): +# scp primary:~/docker/backup/backup.conf ~/docker/backup/backup.conf +# If the destination repo is a local path shared with the primary (e.g. the +# spare box IS the box the primary's REMOTE_TYPE=sftp mirror targets), +# nothing else is needed — the repo data is already there. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF="${BACKUP_CONF:-$HERE/backup.conf}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +die() { err "$*"; exit 1; } + +# ── Preflight ───────────────────────────────────────────────────────────────── +[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0" +[ -f "$CONF" ] || die "backup.conf not found: $CONF — copy it from the primary box first." +command -v jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq" + +# shellcheck source=/dev/null +source "$CONF" + +# gaming-backup's single-dest conf format normalises the same way restore_kopia.sh does. +if [ -z "${DEST_NAMES:-}" ]; then + DEST_NAMES="default" + DEST_default_CONFIG="${KOPIA_CONFIG:-}" + DEST_default_PASSWORD="${KOPIA_PASSWORD:-}" +fi + +command -v "$KOPIA" >/dev/null 2>&1 || die "Kopia not found: $KOPIA" +command -v docker >/dev/null 2>&1 || die "Docker not found — run this repo's post-install (base + require_docker) on this box first." + +ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" +ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")" +DOCKER_BASE="$ACTUAL_HOME/docker" + +# ── Args ────────────────────────────────────────────────────────────────────── +DO_LIST=false DRY=false START=true ONLY_SVC="" +while [ $# -gt 0 ]; do + case "$1" in + --list) DO_LIST=true; shift ;; + --dry-run) DRY=true; shift ;; + --no-start) START=false; shift ;; + --service) ONLY_SVC="${2:-}"; shift 2 ;; + *) die "Unknown argument: $1 (see --help by reading the script header)" ;; + esac +done + +k_for() { + local dest="$1"; shift + local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD" + local cfg="${!cfg_var:-}" pw="${!pw_var:-}" + [ -n "$cfg" ] || return 1 + env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@" +} + +# ── Discover the latest restorable snapshot per service, across every +# destination — no dependency on backup.conf's SVC_ map, which only +# says where a *new* backup should go, not where past snapshots actually +# landed (relevant if a service was ever reassigned between destinations). +read -ra _DEST_ARR <<< "$DEST_NAMES" +declare -a SRC_PATH_LIST=() SRC_DEST_LIST=() SRC_SNAP_LIST=() + +for dest in "${_DEST_ARR[@]}"; do + if ! k_for "$dest" repository status >/dev/null 2>&1; then + warn "Cannot connect to destination '$dest' — skipping." + continue + fi + SNAP_JSON="$(k_for "$dest" snapshot list --all --json 2>/dev/null)" + [ -z "$SNAP_JSON" ] && continue + [ "$SNAP_JSON" = "null" ] && continue + + mapfile -t _paths < <(echo "$SNAP_JSON" | jq -r --arg base "$DOCKER_BASE/" \ + '[.[] | select(.source.path | startswith($base))] | group_by(.source.path)[] | .[0].source.path') + + for p in "${_paths[@]}"; do + svc="$(basename "$p")" + [ -n "$ONLY_SVC" ] && [ "$svc" != "$ONLY_SVC" ] && continue + + # First destination to claim a service name wins (DEST_NAMES always + # lists "default" first — see backup.sh) so a stale duplicate in a + # second repo can't shadow the current one. + _dupe=false + for _seen in "${SRC_PATH_LIST[@]:-}"; do + [ "$(basename "$_seen")" = "$svc" ] && _dupe=true && break + done + [ "$_dupe" = true ] && continue + + latest_id="$(echo "$SNAP_JSON" | jq -r --arg p "$p" \ + '[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0].id')" + [ -z "$latest_id" ] && continue + [ "$latest_id" = "null" ] && continue + + SRC_PATH_LIST+=("$p") + SRC_DEST_LIST+=("$dest") + SRC_SNAP_LIST+=("$latest_id") + done +done + +echo "" +echo "╔═══════════════════════════════════════════════════════╗" +echo "║ Kopia Disaster-Recovery Bring-Up ║" +echo "╚═══════════════════════════════════════════════════════╝" + +if [ "$DO_LIST" = true ]; then + echo "" + [ "${#SRC_PATH_LIST[@]}" -eq 0 ] && { warn "No restorable sources found."; exit 0; } + printf " %-16s %-10s %s\n" "SERVICE" "DEST" "PATH" + for i in "${!SRC_PATH_LIST[@]}"; do + printf " %-16s %-10s %s\n" "$(basename "${SRC_PATH_LIST[$i]}")" "${SRC_DEST_LIST[$i]}" "${SRC_PATH_LIST[$i]}" + done + exit 0 +fi + +if [ "${#SRC_PATH_LIST[@]}" -eq 0 ]; then + if [ -n "$ONLY_SVC" ]; then + die "No snapshots found for service '$ONLY_SVC'." + else + die "No snapshots found. Run a backup on the primary box first, then copy backup.conf here." + fi +fi + +# ── Restore + start ─────────────────────────────────────────────────────────── +TOTAL_START=$(date +%s) +declare -a UP_SVCS=() FAILED_SVCS=() + +for i in "${!SRC_PATH_LIST[@]}"; do + path="${SRC_PATH_LIST[$i]}" + dest="${SRC_DEST_LIST[$i]}" + snap="${SRC_SNAP_LIST[$i]}" + svc="$(basename "$path")" + compose="${path%/}/docker-compose.yml" + + echo "" + info "── $svc (dest: $dest, snapshot: ${snap:0:12}...) ──" + + if [ "$DRY" = true ]; then + echo " [DRY-RUN] Would restore → $path" + [ "$START" = true ] && echo " [DRY-RUN] Would run: docker compose -f $compose up -d" + continue + fi + + SVC_START=$(date +%s) + + if [ -e "$path" ]; then + aside="${path%/}.pre-dr-$(date +%Y%m%d-%H%M%S)" + mv "$path" "$aside" + info " Existing data moved aside → $(basename "$aside")" + fi + mkdir -p "$path" + + if ! k_for "$dest" restore "$snap" "$path"; then + err " Restore failed for $svc" + FAILED_SVCS+=("$svc: restore failed") + continue + fi + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$path" 2>/dev/null || true + ok " Restored" + + if [ "$START" = true ]; then + if [ ! -f "$compose" ]; then + warn " No docker-compose.yml at $path — restored but not started" + FAILED_SVCS+=("$svc: no compose file") + continue + fi + if docker compose -f "$compose" up -d 2>/dev/null; then + ok " Started" + else + err " docker compose up -d failed for $svc" + FAILED_SVCS+=("$svc: compose up failed") + continue + fi + fi + + SVC_ELAPSED=$(( $(date +%s) - SVC_START )) + UP_SVCS+=("$svc (${SVC_ELAPSED}s)") +done + +TOTAL_ELAPSED=$(( $(date +%s) - TOTAL_START )) + +echo "" +echo "═══════════════════════════════════════════════════════" +if [ "$DRY" = true ]; then + echo " DRY-RUN COMPLETE — nothing was touched" +else + echo " DISASTER-RECOVERY BRING-UP COMPLETE" +fi +echo "═══════════════════════════════════════════════════════" +echo "" +[ "$DRY" = false ] && echo " Total time: $((TOTAL_ELAPSED/60))m $((TOTAL_ELAPSED%60))s" && echo "" + +if [ "${#UP_SVCS[@]}" -gt 0 ]; then + echo " Up:" + for s in "${UP_SVCS[@]}"; do echo " ✓ $s"; done + echo "" +fi + +if [ "${#FAILED_SVCS[@]}" -gt 0 ]; then + echo " Failed:" + for s in "${FAILED_SVCS[@]}"; do echo " ✗ $s"; done + echo "" + exit 1 +fi + +exit 0 diff --git a/services/backup.sh b/services/backup.sh index 23f5a84..5535506 100644 --- a/services/backup.sh +++ b/services/backup.sh @@ -214,6 +214,7 @@ install_backup() { local CONF_FILE="$DIR/backup.conf" local WORKER="$DIR/backup_kopia.sh" local RESTORE="$DIR/restore_kopia.sh" + local DR_BRINGUP="$DIR/dr_bringup.sh" local SVC_NAME="post-install-backup" echo "" @@ -554,6 +555,21 @@ install_backup() { log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE" fi + # ── 10b. Install disaster-recovery bring-up script ──────────────────────── + # Non-interactive counterpart to restore_kopia.sh: restores every service's + # latest snapshot and runs `docker compose up -d` with no prompts, meant to + # run on a cold spare box during a real outage rather than the primary. + local DR_BRINGUP_SRC="${HERE:-}/extras/dr_bringup_kopia.sh" + if [ -f "$DR_BRINGUP_SRC" ]; then + cp "$DR_BRINGUP_SRC" "$DR_BRINGUP" + chmod +x "$DR_BRINGUP" + chown root:root "$DR_BRINGUP" 2>/dev/null || true + log_success "dr_bringup.sh installed" + else + log_warning "extras/dr_bringup_kopia.sh not found — DR bring-up script not installed" + log_warning "Copy it manually: cp extras/dr_bringup_kopia.sh $DR_BRINGUP" + fi + # ── 11. Install test scripts ───────────────────────────────────────────── local TEST_SCRIPT="$DIR/test_backup_kopia.sh" local TEST_SRC="${HERE:-}/extras/test_backup_kopia.sh" @@ -693,10 +709,17 @@ SVCEOF echo " sudo $WORKER back up now" echo " sudo $WORKER snapshots list all snapshots" echo "" - echo " Restore:" + echo " Restore (interactive, one service at a time):" echo " sudo $RESTORE" echo " sudo $RESTORE --list" echo "" + echo " Disaster recovery (unattended, every service — for a cold spare box):" + echo " sudo $DR_BRINGUP restore + start everything" + echo " sudo $DR_BRINGUP --list list what's restorable" + echo " sudo $DR_BRINGUP --dry-run preview, touch nothing" + echo " Copy backup.conf to the spare box first — it holds the repo path(s)" + echo " and password(s) this needs to connect." + echo "" echo " Backup test (stop/restore/compare/restore-back):" echo " sudo $TEST_SCRIPT test most recent backup (all services)" echo " sudo $TEST_SCRIPT --list list testable services" From b7f69090a6182893223373458fe5fe5b80ef11ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:40:53 +0000 Subject: [PATCH 2/6] Sync backup.conf/README to a spare box; make DR bring-up fault-tolerant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dr_bringup.sh: bound every kopia call and docker compose up with a timeout so one stuck service can't stall the rest of the batch, and only exit non-zero if literally nothing came up — a partial recovery is a partial success, not a failed run. - backup_kopia.sh: optional DR_SYNC_HOST/DR_SYNC_PATH in backup.conf scp's backup.conf + README.md to a spare box over SSH after every successful backup, so dr_bringup.sh is ready there with no manual copy step. - backup.sh: prompts for the spare's SSH destination, verifies connectivity at install time instead of failing silently at 2am, and writes ~/docker/backup/README.md (this service never had one) so the synced copy documents every command listed above. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- extras/backup_kopia.sh | 23 ++++++ extras/dr_bringup_kopia.sh | 29 +++++-- services/backup.sh | 153 ++++++++++++++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/extras/backup_kopia.sh b/extras/backup_kopia.sh index 5a9dac0..39fe9ea 100644 --- a/extras/backup_kopia.sh +++ b/extras/backup_kopia.sh @@ -170,6 +170,29 @@ if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then done fi +# ── Keep a spare box's copy of backup.conf + README current ───────────────── +# Runs after the data itself is backed up (and mirrored, if configured) so a +# sync never ships config pointing at a repo state that isn't actually there +# yet. dr_bringup.sh on the spare only needs these two small files — the +# repo data itself already lives wherever REMOTE_TYPE mirrored it (or is +# local, if the spare IS that target). +if [ -n "${DR_SYNC_HOST:-}" ]; then + _dr_path="${DR_SYNC_PATH:-~/docker/backup}" + log "Syncing backup.conf + README to spare ($DR_SYNC_HOST:$_dr_path)..." + _dr_files=("$CONF") + [ -f "$HERE/README.md" ] && _dr_files+=("$HERE/README.md") + if ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "mkdir -p '$_dr_path'" 2>"$_ERR" \ + && scp -o BatchMode=yes -o ConnectTimeout=10 "${_dr_files[@]}" "$DR_SYNC_HOST:$_dr_path/" 2>>"$_ERR" \ + && ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "chmod 600 '$_dr_path/backup.conf'" 2>>"$_ERR"; then + log "OK spare sync ($DR_SYNC_HOST)" + else + _reason="$(categorize_error "$(cat "$_ERR")")" + log "WARNING: spare sync failed — $_reason" + FAILED_SVCS+=("spare-sync: $_reason") + rc=1 + fi +fi + DURATION=$(( $(date +%s) - START_TS )) DURATION_STR="$((DURATION/60))m $((DURATION%60))s" diff --git a/extras/dr_bringup_kopia.sh b/extras/dr_bringup_kopia.sh index 924fb24..48ca048 100644 --- a/extras/dr_bringup_kopia.sh +++ b/extras/dr_bringup_kopia.sh @@ -68,12 +68,16 @@ while [ $# -gt 0 ]; do esac done +# Bounded so a stalled repo connection (offsite mirror host unreachable +# mid-restore, etc.) can't hang forever and block every service behind it in +# the batch — 300s is generous for this stack's data sizes; raise it if a +# service's dataset genuinely needs longer. k_for() { local dest="$1"; shift local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD" local cfg="${!cfg_var:-}" pw="${!pw_var:-}" [ -n "$cfg" ] || return 1 - env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@" + timeout 300 env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@" } # ── Discover the latest restorable snapshot per service, across every @@ -172,8 +176,8 @@ for i in "${!SRC_PATH_LIST[@]}"; do mkdir -p "$path" if ! k_for "$dest" restore "$snap" "$path"; then - err " Restore failed for $svc" - FAILED_SVCS+=("$svc: restore failed") + err " Restore failed (or timed out) for $svc" + FAILED_SVCS+=("$svc: restore failed or timed out") continue fi chown -R "$ACTUAL_USER:$ACTUAL_USER" "$path" 2>/dev/null || true @@ -185,11 +189,15 @@ for i in "${!SRC_PATH_LIST[@]}"; do FAILED_SVCS+=("$svc: no compose file") continue fi - if docker compose -f "$compose" up -d 2>/dev/null; then + # Bounded so a stuck image pull or a compose file waiting on something + # (e.g. an interactive prompt) can't stall the rest of the batch — one + # bad service should never cost the others their spot in the 10-minute + # budget this script exists for. + if timeout 120 docker compose -f "$compose" up -d 2>/dev/null; then ok " Started" else - err " docker compose up -d failed for $svc" - FAILED_SVCS+=("$svc: compose up failed") + err " docker compose up -d failed (or timed out) for $svc" + FAILED_SVCS+=("$svc: compose up failed or timed out") continue fi fi @@ -218,9 +226,16 @@ if [ "${#UP_SVCS[@]}" -gt 0 ]; then fi if [ "${#FAILED_SVCS[@]}" -gt 0 ]; then - echo " Failed:" + echo " Failed (skipped — did not stop the rest of the batch):" for s in "${FAILED_SVCS[@]}"; do echo " ✗ $s"; done echo "" +fi + +# One bad service is a partial success, not a failed run — the whole point of +# this script is getting as much of the stack back up as possible. Only a +# fully empty result (nothing came up at all) counts as a failed exit code. +if [ "$DRY" = false ] && [ "${#UP_SVCS[@]}" -eq 0 ]; then + err "Nothing came up." exit 1 fi diff --git a/services/backup.sh b/services/backup.sh index 5535506..c32b388 100644 --- a/services/backup.sh +++ b/services/backup.sh @@ -452,6 +452,40 @@ install_backup() { if [ -n "$NTFY_URL" ]; then prompt_text " ntfy access token (blank if public/no auth):" "" NTFY_TOKEN fi + + # ── Disaster-recovery spare box (optional) ──────────────────────────────── + echo "" + echo "═══════════════════════════════════════════════════════" + echo " DISASTER-RECOVERY SPARE (optional)" + echo "═══════════════════════════════════════════════════════" + echo "" + echo " If you keep a spare box ready to take over on failure (running" + echo " dr_bringup.sh), this can push backup.conf + README.md to it after" + echo " every successful backup, so it's always ready without a manual copy." + echo "" + echo " Requires passwordless SSH (key-based) from THIS box to the spare," + echo " as the account below. Since the backup timer runs as root, that" + echo " usually means a key in /root/.ssh authorized on the spare — set that" + echo " up first if you haven't (ssh-keygen, then ssh-copy-id to the spare)." + echo "" + local DR_SYNC_HOST="" DR_SYNC_PATH="" + prompt_text " Spare box SSH destination, user@host (blank to skip):" "" DR_SYNC_HOST + if [ -n "$DR_SYNC_HOST" ]; then + prompt_text " Path for backup.conf/README on the spare:" "~/docker/backup" DR_SYNC_PATH + DR_SYNC_PATH="${DR_SYNC_PATH:-~/docker/backup}" + + # Catch a missing/unauthorized key now, not at 2am during the first + # scheduled backup. Non-fatal either way — the setting is saved + # regardless, since the key may simply not be set up yet. + if ssh -o BatchMode=yes -o ConnectTimeout=5 "$DR_SYNC_HOST" true 2>/dev/null; then + log_success " SSH to $DR_SYNC_HOST works — spare sync will run after each backup." + else + log_warning " Couldn't SSH to $DR_SYNC_HOST without a password right now." + log_warning " Spare sync is saved but will fail until this works (as root, since" + log_warning " the backup timer runs as root): ssh-keygen; ssh-copy-id $DR_SYNC_HOST" + fi + fi + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" @@ -531,6 +565,14 @@ install_backup() { echo "# Leave blank to disable. NTFY_TOKEN is optional (for private topics)." printf "NTFY_URL='%s'\n" "${NTFY_URL:-}" printf "NTFY_TOKEN='%s'\n" "${NTFY_TOKEN:-}" + echo "" + echo "# ── Disaster-recovery spare sync ───────────────────────────────────────────" + echo "# If set, backup_kopia.sh scp's this backup.conf + README.md to" + echo "# DR_SYNC_HOST:DR_SYNC_PATH after every successful backup, so a spare box" + echo "# running dr_bringup.sh is always ready with no manual copy step. Requires" + echo "# passwordless SSH from this box to the spare (see README.md)." + printf "DR_SYNC_HOST='%s'\n" "${DR_SYNC_HOST:-}" + printf "DR_SYNC_PATH='%s'\n" "${DR_SYNC_PATH:-~/docker/backup}" } > "$CONF_FILE" chown root:root "$CONF_FILE" 2>/dev/null || true chmod 600 "$CONF_FILE" @@ -676,6 +718,106 @@ SVCEOF AUTORUN="cat /etc/cron.d/${SVC_NAME}" fi + # ── Write README ───────────────────────────────────────────────────────── + # Written before the optional first run below so, if DR sync is enabled, + # the very first backup already ships an up-to-date README to the spare. + local DEST_LIST_MD="" + for dn in "${DEST_NAMES_ARR[@]}"; do + DEST_LIST_MD+="- **${dn}**: ${DEST_REPOS[$dn]}"$'\n' + done + + local DR_SYNC_MD OFFSITE_MD + if [ -n "${DR_SYNC_HOST:-}" ]; then + DR_SYNC_MD="Configured: after every successful backup, this box copies backup.conf + this README to \`${DR_SYNC_HOST}:${DR_SYNC_PATH:-~/docker/backup}\` over SSH." + else + DR_SYNC_MD="Not configured. Re-run this installer to set it up, or copy backup.conf to the spare manually whenever it changes." + fi + if [ "${REMOTE_TYPE:-none}" != "none" ]; then + OFFSITE_MD="Configured: every backup also runs \`kopia repository sync-to ${REMOTE_TYPE}\` to mirror the repo off this box." + else + OFFSITE_MD="Not configured. Set REMOTE_TYPE/REMOTE_ARGS in backup.conf (see the comment above them) to mirror the repo off this box." + fi + + write_readme "$DIR" << MD +# Backup — Kopia + +Full recovery for every Docker service under \`$DOCKER_DIR\`: each service's +entire directory (compose file, \`.env\`, config, data, databases) is +snapshotted with Kopia — deduplicated, compressed (zstd), and encrypted. +Databases are captured consistently (container stopped briefly, snapshotted, +restarted); Minecraft instead gets a live save-all flush, no downtime. + +## Destinations + +$DEST_LIST_MD +## Schedule + +$SCHED_LABEL — keeps the latest $KEEP_LATEST snapshots (plus 7 daily / 4 +weekly / 3 monthly). + +## Commands + +\`\`\`bash +sudo $WORKER # back up now +sudo $WORKER snapshots # list all snapshots +sudo $WORKER policy # show retention policy +\`\`\` + +### Restore — interactive, one service at a time + +\`\`\`bash +sudo $RESTORE +sudo $RESTORE --list +\`\`\` + +### Disaster recovery — unattended, every service, for a cold spare box + +\`\`\`bash +sudo $DR_BRINGUP # restore + start everything +sudo $DR_BRINGUP --list # list what's restorable +sudo $DR_BRINGUP --dry-run # preview, touch nothing +sudo $DR_BRINGUP --service NAME # just one service +\`\`\` + +A single service failing to restore or start does not stop the rest of the +batch — it's logged and skipped so the run maximizes what actually comes +back up. The exit code is only non-zero if nothing came up at all. + +**On the spare box**, \`dr_bringup.sh\` needs \`backup.conf\` from this +directory to connect to the repo — see the DR spare sync section below. + +## Disaster-recovery spare sync + +$DR_SYNC_MD + +Requires passwordless SSH (key-based) from this box to the spare — since the +backup timer runs as root, generate/authorize a key for root: +\`ssh-keygen\`, then \`ssh-copy-id\` to the spare. + +## Offsite mirror + +$OFFSITE_MD + +## Backup test — stop / restore / compare / restore-back + +\`\`\`bash +sudo $TEST_SCRIPT # test most recent backup, all services +sudo $TEST_SCRIPT --list # list testable services +sudo $TEST_SCRIPT --service NAME +\`\`\` + +## Files + +- \`backup.conf\` — destinations, passwords, retention, DR-sync/offsite settings (chmod 600) +- \`backup_kopia.sh\` — the worker the systemd timer runs +- \`restore_kopia.sh\` — interactive restore +- \`dr_bringup.sh\` — unattended full-stack restore + start +- \`test_backup_kopia.sh\` / \`test_backup.sh\` — automated restore tests + +**Save the passwords in \`backup.conf\` somewhere safe** — without them the +encrypted repos cannot be restored. +MD + # ── 12. Optional first run ──────────────────────────────────────────────── echo "" local _now="" @@ -717,8 +859,15 @@ SVCEOF echo " sudo $DR_BRINGUP restore + start everything" echo " sudo $DR_BRINGUP --list list what's restorable" echo " sudo $DR_BRINGUP --dry-run preview, touch nothing" - echo " Copy backup.conf to the spare box first — it holds the repo path(s)" - echo " and password(s) this needs to connect." + if [ -n "${DR_SYNC_HOST:-}" ]; then + echo " backup.conf + README.md sync to $DR_SYNC_HOST after every backup — the" + echo " spare stays ready with no manual copy step." + else + echo " Copy backup.conf to the spare box first — it holds the repo path(s)" + echo " and password(s) this needs to connect." + fi + echo "" + echo " Full docs: $DIR/README.md" echo "" echo " Backup test (stop/restore/compare/restore-back):" echo " sudo $TEST_SCRIPT test most recent backup (all services)" From 2fc49fa43eebdbfa078ee01b613554115dfeb422 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:48:49 +0000 Subject: [PATCH 3/6] Document Teams setup in Mattermost's generated README Team Edition includes multiple Teams natively (no Enterprise license needed), but nothing in the generated README said so or explained how to create one. Added a Teams section covering creation, adding members, and multi-team membership, plus a one-line pointer in the install summary. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- services/mattermost.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/services/mattermost.sh b/services/mattermost.sh index 3a2f098..007cc74 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -379,7 +379,31 @@ Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay. ## Access - URL: $SITE_URL (or http://localhost:8065) -- First run: create admin account at the URL above +- First run: create admin account at the URL above — this account becomes + System Admin automatically. + +## Teams +Team Edition (the free edition this installer uses) includes multiple Teams +natively — separate spaces (their own channels, their own members) on the +same server, same database, same login. No Enterprise license needed; this +is the resource-efficient alternative to running a second Mattermost +instance for a second group. + +Create one: +- Click the **+** at the bottom of the team sidebar (the narrow column on + the far left) → **Create a new team**, or +- System Console → User Management → Teams → **Create Team** + +Add people to a team: +- Team name (top-left) → **Invite People** → share the invite link, or send + email invites (requires SMTP — System Console → Environment → SMTP), or +- System Console → User Management → Teams → the team → **Add Members** + (adds existing server accounts directly, no invite flow) + +Users can belong to more than one team and switch between them via the team +sidebar icons. By default any user can create a team — restrict that at +System Console → User Management → Permissions if you only want admins +creating them. ## Voice/Video Calls (Calls plugin) Port 8443/udp must be open on your router/firewall. @@ -413,6 +437,8 @@ MD echo "" echo " Access at: $SITE_URL" echo " First run: open the URL above and create your admin account." + echo " Teams: team sidebar '+' → Create a new team (see README.md — no" + echo " Enterprise license needed, Team Edition includes this)." echo " Calls plugin: System Console → Plugins → Calls to configure coturn." echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp" echo " Auth secret: see COTURN_SECRET in $DIR/.env" From 883f0d255758ad8ea59b94beedb6dab52fef36d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:57:51 +0000 Subject: [PATCH 4/6] Document restricting DMs to teammates in Mattermost's README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers a real gap: Teams alone don't limit who can Direct Message whom — that's a separate System Console setting (TeamSettings.RestrictDirectMessage), free in Team Edition. Documented it in the Teams section along with the caveat that it only filters the DM picker UI, not a hard boundary (existing DMs unaffected, multi-team users can still DM across all their teams). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- services/mattermost.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/mattermost.sh b/services/mattermost.sh index 007cc74..9d7682d 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -405,6 +405,16 @@ sidebar icons. By default any user can create a team — restrict that at System Console → User Management → Permissions if you only want admins creating them. +By default, Direct Messages ignore team boundaries — anyone on the server +can DM anyone else regardless of shared team membership. To limit the DM +picker to teammates only: **System Console → Site Configuration → Users and +Teams → "Enable users to open Direct Message channels with" → Any member of +the team** (free in Team Edition, no license needed). This is a UI filter, +not a hard boundary — it doesn't hide DM channels that already exist, and a +user in multiple teams can still DM anyone across all of them, not just the +team currently open. If you need real isolation between groups rather than +a tidier picker, that means separate Mattermost instances, not this setting. + ## Voice/Video Calls (Calls plugin) Port 8443/udp must be open on your router/firewall. coturn relay runs on port 3479 (HMAC secret in .env). From cf3d4bf6de2aa46b146dc3df8fdd816999439bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:32:08 +0000 Subject: [PATCH 5/6] Extract coturn into a shared service; add Mattermost multi-instance support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asterisk and Mattermost each used to embed their own dedicated coturn container (network_mode: host), and their default relay port ranges overlapped by ~100 UDP ports — running both on one box meant a coin-flip over which service's active call lost its media relay. - services/coturn.sh: new shared TURN/STUN relay, one instance for every consumer instead of one each. Runs --lt-cred-mech with a SQLite user database (not --use-auth-secret — coturn doesn't support both auth mechanisms on one instance at once, confirmed via coturn's own upstream docs/issues) so each consumer gets its own dedicated username/password without stepping on any other's. - lib/common.sh: ensure_coturn_user() — chain-installs coturn.sh on first need (same declare -F guard pattern as the existing asterisk -> security-dashboard chaining) and registers/reuses a per-consumer credential, mirroring configure_caddy_for_service's out-param convention. - services/asterisk.sh: _asterisk_write_compose gains a USE_EMBEDDED_COTURN flag. New installs use the shared service; existing installs keep their dedicated coturn exactly as-is on every "update" (detected from the existing compose file before regenerating it, so a rebuild can never silently drop the container its own .env TURN_PASSWORD still points at) and only switch on an explicit "fresh" reinstall, with a warning first. - services/mattermost.sh: same embedded/shared coturn handling, plus genuine multi-instance support (separate dir/containers/DB/ports per instance, auto-scanned free ports for extras) for real isolation between groups, as opposed to Team Edition's built-in Teams feature. Calls plugin TURN config switched from the HMAC "TURN Static Auth Secret" field to the verified "ICE Servers Configurations" JSON field, which accepts the same fixed username/credential shared coturn issues. Also fixes a latent bug found while adding proper update-mode detection: DB_PASS/MM_SECRET were regenerated on every single rerun with no existing-install check at all, silently breaking Postgres auth on any reinstall. - CLAUDE.md: documents the ensure_coturn_user pattern (including the auth-mechanism constraint and the embedded-coturn migration-safety rule) for any future service that needs TURN. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- CLAUDE.md | 61 +++++++ README.md | 2 +- lib/common.sh | 79 +++++++++ services/asterisk.sh | 191 +++++++++++++------- services/coturn.sh | 385 +++++++++++++++++++++++++++++++++++++++++ services/mattermost.sh | 282 +++++++++++++++++++++++------- 6 files changed, 877 insertions(+), 123 deletions(-) create mode 100644 services/coturn.sh diff --git a/CLAUDE.md b/CLAUDE.md index 44804a7..5d0a3e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -544,3 +544,64 @@ network) needs `host.docker.internal:PORT` in the Caddyfile, not so that hostname resolves; `configure_caddy_for_service`'s bare-port upstream case already does this for you — don't hand-roll `localhost:PORT` in a Caddy site block. + +## Shared coturn (TURN/STUN) relay + +Any service that needs a TURN server for WebRTC/SIP NAT traversal shares +**one** coturn instance (`services/coturn.sh`) instead of running its own. +This exists because it didn't always: `asterisk` and `mattermost` used to +each embed a dedicated coturn container (`network_mode: host`, each with its +own relay port range) — confirmed live, their default ranges overlapped by +~100 UDP ports, so running both on one box meant a coin-flip over which +service's active call lost its media relay. One shared instance with one +port range removes the collision instead of just moving it around. + +**Use `ensure_coturn_user` (`lib/common.sh`), not your own coturn container:** + +```bash +ensure_coturn_user "my-service" +if [ -n "$COTURN_HOST" ]; then + # Out-params (not `local` — read them after the call returns, same + # convention as configure_caddy_for_service's CADDY_SERVICE_*): + # COTURN_HOST COTURN_PORT COTURN_USERNAME COTURN_PASSWORD +else + # coturn unavailable (not installed and services/coturn.sh isn't loaded + # to chain-install it — e.g. this file run fully standalone) — degrade + # gracefully. Don't block the rest of your install on this. +fi +``` + +`ensure_coturn_user` chain-installs `services/coturn.sh` the first time +*any* service needs one (guarded with `declare -F install_coturn`, same +pattern as the asterisk → security-dashboard chaining below), then +registers a dedicated long-term-credential username/password for your +consumer name. The credential is cached in +`~/docker/coturn/users/.env`, so calling this again on a rerun +reuses the same credential instead of minting a new one and silently +orphaning whatever client already has the old one configured. + +**Why long-term credentials (`--lt-cred-mech`), not the REST-API/HMAC mode +(`--use-auth-secret`) some WebRTC apps default to:** coturn does not support +running both auth mechanisms on one instance at once — enabling +`--use-auth-secret` silently overrides `--lt-cred-mech` server-wide, which +would break every static-credential consumer. `--lt-cred-mech` supports any +number of named users out of the box, which is the actual shape a +shared-multi-consumer coturn needs. If the service you're adding only +exposes an HMAC-secret TURN setting in its own UI (no plain +username/password option), check its docs for an alternative field first — +Mattermost's Calls plugin looked HMAC-only at a glance but also accepts a +fixed username/credential pair via its "ICE Servers Configurations" JSON +field (see `services/mattermost.sh` for the exact format). Don't fall back +to a second coturn instance just because the first field you found expects +a shared secret. + +**Migrating an existing service from its own embedded coturn:** don't do it +silently. An `update` rerun must keep whatever coturn shape a service +already has — detect the existing embedded container (e.g. `grep -q '^ +coturn:' docker-compose.yml` before regenerating it) and preserve it +exactly, the same non-destructive rule as every other `update` path in this +file. Only switch to the shared coturn on an explicit `fresh` reinstall, and +warn before doing it — the TURN username/password changes, and any +already-configured client (a SIP phone, a browser session) keeps the old +credentials until it's reconfigured. See `services/asterisk.sh`'s +`USE_EMBEDDED_COTURN` handling for the reference pattern. diff --git a/README.md b/README.md index e466420..94b8e17 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | diff --git a/lib/common.sh b/lib/common.sh index 349af3f..cf940de 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -681,3 +681,82 @@ CADDY_BLOCK fi echo "" } + +# ── Shared coturn (TURN/STUN) wiring ────────────────────────────────────────── +# Usage: ensure_coturn_user "" +# +# Installs the shared coturn service (services/coturn.sh) if this is the +# first service on the box that needs TURN, then registers (or reuses) a +# dedicated long-term-credential user for the caller — one coturn instance, +# one relay port range, shared by every consumer instead of each service +# running its own and fighting over host ports (see services/coturn.sh's +# header for why that used to be a real, confirmed-live problem). +# +# Out-params (not `local` — read them after the call returns), same +# convention as configure_caddy_for_service's CADDY_SERVICE_* above: +# COTURN_HOST host/IP TURN clients should connect to +# COTURN_PORT coturn's listening port +# COTURN_USERNAME this consumer's long-term-credential username +# COTURN_PASSWORD this consumer's long-term-credential password +# COTURN_HOST is left empty if coturn couldn't be installed or reached — +# callers should treat that as "no TURN available" and degrade gracefully, +# same as checking CADDY_SERVICE_CONFIGURED after configure_caddy_for_service. +# +# Credentials are cached per-consumer in coturn's own users/.env so a +# service re-running its own installer reuses the same one instead of +# minting a new credential and orphaning the old one (which would silently +# break already-configured clients still holding it). +ensure_coturn_user() { + local _consumer="$1" + COTURN_HOST="" COTURN_PORT="" COTURN_USERNAME="" COTURN_PASSWORD="" + + if [ ! -d "$DOCKER_DIR/coturn" ]; then + if declare -F install_coturn >/dev/null 2>&1; then + log_info "No shared coturn (TURN/STUN) server yet — setting one up for $_consumer..." + install_coturn || { log_warning "coturn setup failed — $_consumer will run without TURN."; return 1; } + else + log_warning "services/coturn.sh not loaded — $_consumer will run without TURN." + log_warning "Run: sudo ./setup.sh coturn" + return 1 + fi + fi + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would register coturn user '$_consumer'" + return 0 + fi + + local _env="$DOCKER_DIR/coturn/.env" + [ -f "$_env" ] || { log_warning "coturn installed but $_env missing — cannot register '$_consumer'."; return 1; } + local _realm _host _port + _realm="$(grep '^COTURN_REALM=' "$_env" | cut -d= -f2-)" + _host="$(grep '^COTURN_HOST=' "$_env" | cut -d= -f2-)" + _port="$(grep '^COTURN_PORT=' "$_env" | cut -d= -f2-)"; _port="${_port:-3478}" + + local _userdir="$DOCKER_DIR/coturn/users" + local _userfile="$_userdir/${_consumer}.env" + mkdir -p "$_userdir" + + if [ -f "$_userfile" ]; then + local _u _p + _u="$(grep '^COTURN_USER=' "$_userfile" | cut -d= -f2-)" + _p="$(grep '^COTURN_PASS=' "$_userfile" | cut -d= -f2-)" + COTURN_USERNAME="$_u" COTURN_PASSWORD="$_p" + else + COTURN_USERNAME="$_consumer" + COTURN_PASSWORD="$(generate_password 24)" + if docker exec coturn turnadmin -a -u "$COTURN_USERNAME" -p "$COTURN_PASSWORD" \ + -r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then + { echo "COTURN_USER=$COTURN_USERNAME"; echo "COTURN_PASS=$COTURN_PASSWORD"; } > "$_userfile" + chmod 600 "$_userfile" + log_success "Registered coturn user '$COTURN_USERNAME' for $_consumer" + else + log_warning "Could not register a coturn user for $_consumer — is the coturn container running?" + COTURN_USERNAME="" COTURN_PASSWORD="" + return 1 + fi + fi + + COTURN_HOST="$_host" + COTURN_PORT="$_port" +} diff --git a/services/asterisk.sh b/services/asterisk.sh index 019ea2b..ac5cf06 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -232,7 +232,7 @@ CBLOCK fi # ───────────────────────────────────────────────────────────────────────────── -register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (intercom/VoIP; auto-tunes for a DigitalOcean droplet)" 5061 +register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); TURN via the shared coturn service" 5061 # ── Install layout: directory + container names ──────────────────────────── # Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT. @@ -795,9 +795,52 @@ _asterisk_offer_dashboard_and_trunk() { # literally (it interpolates them from .env, this script must not). Project # and container names are therefore substituted afterwards, same placeholder # trick the Caddy volume line already uses below. +# +# USE_EMBEDDED_COTURN controls whether this install runs its own dedicated +# coturn container (legacy shape) or relies on the shared coturn service +# (services/coturn.sh) instead. This is NOT a free choice at every call site +# — an install that already has its own embedded coturn must keep getting +# one on every "update" regeneration of this file, or the next `docker +# compose up` silently drops the container its own .env TURN_PASSWORD still +# points at, breaking every already-configured phone with no warning. See +# the two call sites below for how each decides. _asterisk_write_compose() { - local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" - cat > docker-compose.yml << 'EOF' + local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" USE_EMBEDDED_COTURN="${4:-true}" + + local _COTURN_DEPENDS=" depends_on: + coturn: + condition: service_started +" + local _COTURN_SERVICE=" + coturn: + image: coturn/coturn:latest + container_name: COTURN_CONTAINER_PLACEHOLDER + network_mode: host + user: root + entrypoint: [\"/coturn-entrypoint.sh\"] + volumes: + - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro + env_file: .env + command: + - -n + - --listening-port=\${TURN_PORT:-3478} + - --listening-ip=0.0.0.0 + - --fingerprint + - --lt-cred-mech + - --user=\${TURN_USERNAME:-easyasterisk}:\${TURN_PASSWORD} + - --realm=\${DOMAIN_NAME:-localhost} + - --min-port=49152 + - --max-port=49252 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped +" + [[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE="" + + cat > docker-compose.yml << EOF name: PROJECT_NAME_PLACEHOLDER services: @@ -805,10 +848,7 @@ services: build: . container_name: ASTERISK_CONTAINER_PLACEHOLDER network_mode: host - depends_on: - coturn: - condition: service_started - volumes: +${_COTURN_DEPENDS} volumes: - ./config/asterisk:/etc/asterisk - ./config/easy-asterisk:/etc/easy-asterisk - ./logs:/var/log/asterisk @@ -824,33 +864,7 @@ CADDY_VOLUME_PLACEHOLDER interval: 30s timeout: 5s retries: 3 - - coturn: - image: coturn/coturn:latest - container_name: COTURN_CONTAINER_PLACEHOLDER - network_mode: host - user: root - entrypoint: ["/coturn-entrypoint.sh"] - volumes: - - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro - env_file: .env - command: - - -n - - --listening-port=${TURN_PORT:-3478} - - --listening-ip=0.0.0.0 - - --fingerprint - - --lt-cred-mech - - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} - - --realm=${DOMAIN_NAME:-localhost} - - --min-port=49152 - - --max-port=49252 - - --no-tls - - --no-dtls - - --no-cli - - --no-multicast-peers - - --log-file=stdout - restart: unless-stopped - +${_COTURN_SERVICE} EOF sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \ @@ -859,8 +873,10 @@ EOF # Share Caddy's cert store (read-only) so the entrypoint can auto-sync a # real Let's Encrypt cert for DOMAIN_NAME instead of falling back to - # self-signed. No-op if Caddy isn't installed on this box. - if [[ -d "$DOCKER_DIR/caddy/data" ]]; then + # self-signed. No-op if Caddy isn't installed on this box. Only relevant + # to the embedded coturn — the shared coturn service doesn't do TLS/TURNS + # at all (see services/coturn.sh's README for that tradeoff). + if [[ "$USE_EMBEDDED_COTURN" == true && -d "$DOCKER_DIR/caddy/data" ]]; then sed -i "s#CADDY_VOLUME_PLACEHOLDER# - ${DOCKER_DIR}/caddy/data:/caddy-data:ro#" docker-compose.yml else sed -i "/CADDY_VOLUME_PLACEHOLDER/d" docker-compose.yml @@ -1145,7 +1161,9 @@ _asterisk_configure_do_cloud_firewall() { # the two deployment shapes can't document themselves differently by accident. _asterisk_write_readme() { local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6" + local USE_EMBEDDED_COTURN="${7:-true}" TURN_USERNAME_VAL="${8:-easyasterisk}" TURN_SERVER_DISPLAY="${9:-}" local _host="${DOMAIN_NAME:-${PUBLIC_IP:-}}" + [ -z "$TURN_SERVER_DISPLAY" ] && TURN_SERVER_DISPLAY="${_host}:3478" { cat << MD @@ -1186,10 +1204,14 @@ connecting a phone. The Security Dashboard's Extensions tab |-----------------|--------------------------------------| | SIP server | \`${_host}\` | | SIP port | 5061 (TLS) / 5060 (UDP) | -| TURN server | \`${_host}:3478\` | -| TURN username | easyasterisk | +| TURN server | \`${TURN_SERVER_DISPLAY}\` | +| TURN username | ${TURN_USERNAME_VAL} | | TURN password | see \`.env\` → \`TURN_PASSWORD\` | +$( [[ "$USE_EMBEDDED_COTURN" == true ]] \ + && echo "This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml)." \ + || echo "TURN is served by the box's shared coturn service, not a container in this compose file — see \`~/docker/coturn/README.md\`. Every service on the box that needs TURN (Mattermost Calls, etc.) shares this same relay, each with its own dedicated username." ) + Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and [Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP + STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN, @@ -1403,7 +1425,11 @@ install_asterisk() { echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin" echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl" echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)" - echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 3478, 10000-20000, 49152-49252" + echo "[DRY-RUN] Would register a TURN user with the shared coturn service (chain-installing it" + echo "[DRY-RUN] if this is the first service on the box that needs one), falling back to" + echo "[DRY-RUN] Asterisk's own dedicated coturn if the shared service is unavailable" + echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 10000-20000," + echo "[DRY-RUN] plus 3478 + 49152-49252 only if falling back to a dedicated coturn" echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route" echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context," @@ -1434,13 +1460,24 @@ install_asterisk() { prompt_reinstall_mode REINSTALL_MODE case "$REINSTALL_MODE" in update) + # Detect BEFORE regenerating docker-compose.yml below: an + # install that already runs its own dedicated coturn (every + # install predating the shared coturn service) must keep + # getting one on every update, or this rebuild silently + # drops the container its own .env TURN_PASSWORD still + # points at — every already-configured phone loses TURN with + # no warning. Only an install with no embedded coturn block + # (new installs made after shared coturn existed) skips it. + local _HAD_EMBEDDED_COTURN=false + grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true + mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ "$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports" ensure_docker_dir_ownership "$EA_DIR" cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files - _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$_HAD_EMBEDDED_COTURN" _asterisk_write_logrotate "$EA_DIR" _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -1559,21 +1596,53 @@ install_asterisk() { [[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y" fi - # ── Secrets ─────────────────────────────────────────────────────────────── - local TURN_PASSWORD - TURN_PASSWORD="$(generate_password 24)" + # ── Secrets / TURN ─────────────────────────────────────────────────────── + # Prefer the shared coturn service (services/coturn.sh) — one TURN server + # for every service on the box instead of Asterisk running its own and + # fighting other consumers (Mattermost, etc.) over relay ports. Falls + # back to Asterisk's own dedicated coturn if the shared service isn't + # available (e.g. this file run standalone with no sibling services/*.sh + # sourced) or registration fails for any reason — Asterisk should never + # end up with no TURN at all just because the shared path had a problem. + local USE_EMBEDDED_COTURN=true + local TURN_USERNAME TURN_PASSWORD TURN_PORT_VAL TURN_SERVER_VAL - # A public box always has a usable TURN address (the FQDN if set, else its - # public IP). A LAN box with no FQDN has none — coturn is only reachable - # over the local network, so clients use the server's LAN address directly. - local TURN_SERVER_VAL="" - if [[ "$IS_DO" == true ]]; then - TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478" - elif [[ -n "$DOMAIN_NAME" ]]; then - TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + # Only reachable here via an explicit "fresh" choice above — "update" + # is handled separately and always preserves whatever coturn shape + # already exists, never silently switches it. + if [[ -f "$EA_DIR/docker-compose.yml" ]] && grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then + echo "" + log_warning "This box's existing Asterisk install has its own dedicated coturn." + log_warning "Continuing may switch it to the new shared coturn service — any" + log_warning "phone/softphone configured with the OLD TURN username/password will" + log_warning "need updating once this completes." fi - _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" + ensure_coturn_user "asterisk" + if [[ -n "${COTURN_HOST:-}" ]]; then + USE_EMBEDDED_COTURN=false + TURN_USERNAME="$COTURN_USERNAME" + TURN_PASSWORD="$COTURN_PASSWORD" + TURN_PORT_VAL="$COTURN_PORT" + TURN_SERVER_VAL="${COTURN_HOST}:${COTURN_PORT}" + log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'." + else + TURN_USERNAME="easyasterisk" + TURN_PASSWORD="$(generate_password 24)" + TURN_PORT_VAL="3478" + # A public box always has a usable TURN address (the FQDN if set, else its + # public IP). A LAN box with no FQDN has none — coturn is only reachable + # over the local network, so clients use the server's LAN address directly. + TURN_SERVER_VAL="" + if [[ "$IS_DO" == true ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478" + elif [[ -n "$DOMAIN_NAME" ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + fi + log_info "Shared coturn unavailable — Asterisk will run its own dedicated coturn." + fi + + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN" # ── Pick a free port for the web admin ───────────────────────────────────── # Hardcoding a single number gets fragile fast once several services share @@ -1612,9 +1681,10 @@ install_asterisk() { DOMAIN_NAME=${DOMAIN_NAME} # ── TURN/STUN ───────────────────────────────────────────────── -TURN_USERNAME=easyasterisk +# $( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml)." || echo "Using the shared coturn service — see ~/docker/coturn/README.md." ) +TURN_USERNAME=${TURN_USERNAME} TURN_PASSWORD=${TURN_PASSWORD} -TURN_PORT=3478 +TURN_PORT=${TURN_PORT_VAL} # Empty when there's no publicly resolvable address (LAN-only, no FQDN). TURN_SERVER=${TURN_SERVER_VAL} @@ -1679,10 +1749,14 @@ ENV fi ufw allow 8088/tcp ufw allow 8089/tcp - ufw allow 3478/udp - ufw allow 3478/tcp ufw allow 10000:20000/udp - ufw allow 49152:49252/udp + if [[ "$USE_EMBEDDED_COTURN" == true ]]; then + ufw allow 3478/udp + ufw allow 3478/tcp + ufw allow 49152:49252/udp + fi + # Shared coturn opens its own ports once, at its own install time + # (services/coturn.sh) — nothing to open here when using it. ensure_ufw_enabled log_success "UFW rules added." fi @@ -1713,7 +1787,8 @@ ENV _asterisk_run_presence_step "$EA_DIR" "$CONTAINER" # ── README ──────────────────────────────────────────────────────────────── - _asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" + _asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" \ + "$USE_EMBEDDED_COTURN" "$TURN_USERNAME" "$TURN_SERVER_VAL" # ── Start ───────────────────────────────────────────────────────────────── echo "" diff --git a/services/coturn.sh b/services/coturn.sh new file mode 100644 index 0000000..7b7ec33 --- /dev/null +++ b/services/coturn.sh @@ -0,0 +1,385 @@ +#!/bin/bash +# services/coturn.sh — Shared TURN/STUN relay (coturn) for WebRTC-capable services. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash coturn.sh +# (Docker must already be installed when run standalone) +# +# One coturn instance, shared by every service that needs TURN (Asterisk, +# Mattermost, and anything added later) instead of each service running its +# own — which used to mean N containers all on network_mode: host fighting +# over relay port ranges (confirmed live: Asterisk's default range and +# Mattermost's default range overlapped by ~100 ports before this existed). +# +# Runs in long-term-credential mode (--lt-cred-mech) with a SQLite user +# database instead of a single static user — every consumer registers its +# own dedicated username/password via ensure_coturn_user() (lib/common.sh), +# so credentials are per-service and one consumer being compromised or +# reconfigured doesn't affect any other's TURN access. +# +# Deliberately NOT --use-auth-secret (the HMAC/REST-API mode Mattermost's +# Calls plugin also supports): coturn does not support both auth mechanisms +# on one running instance at once — turning on --use-auth-secret silently +# overrides --lt-cred-mech server-wide, which would break every +# static-credential consumer (Asterisk's PJSIP TURN client wants a fixed +# long-lived username/password, not a periodically-regenerated HMAC one). +# lt-cred-mech supports any number of named users out of the box, which is +# exactly the shared-multi-consumer shape this needs — no tradeoff either +# way. Mattermost's Calls plugin is configured with a static username/ +# credential pair too (its "ICE Servers Configurations" field), not its +# "TURN Static Auth Secret" field, so both consumers use the same mechanism. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +# Detected when the script is executed directly rather than sourced by setup.sh. +# Sets up helpers and globals, then defers execution until after the function +# definition at the bottom of this file. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi + echo " Existing install detected. Choose:" + echo " r) Reinstall in place — refresh vendor files, keep existing settings" + echo " f) Full install — re-run every prompt from scratch" + echo " c) Cancel — leave everything as-is [default]" + read -r -p " Choice [r/f/c, Enter=cancel]: " _r + case "${_r,,}" in + r) eval "$_var='update'" ;; + f) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + ensure_ufw_enabled() { + command -v ufw &>/dev/null || return 0 + [[ "${DRY_RUN:-false}" == "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}')" + ufw allow "${_ssh_port:-22}/tcp" comment 'SSH' >/dev/null 2>&1 + ufw --force enable >/dev/null 2>&1 + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service coturn homelab "Shared TURN/STUN relay (coturn) for Asterisk, Mattermost, and other WebRTC-capable services" 3478 + +install_coturn() { + require_docker || return 1 + + local DIR="$DOCKER_DIR/coturn" + local ENV_FILE="$DIR/.env" + + echo "" + echo "╔═══════════════════════════════════════════════════════╗" + echo "║ Shared coturn (TURN/STUN relay) ║" + echo "╚═══════════════════════════════════════════════════════╝" + echo "" + echo " One TURN server, shared by every service that needs one (Asterisk," + echo " Mattermost Calls, anything added later) — each gets its own" + echo " dedicated username/password, registered automatically the first" + echo " time that service is installed. You normally don't run this" + echo " directly; another service's installer chains into it." + echo "" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env" + echo "[DRY-RUN] Would run coturn in --lt-cred-mech mode with a SQLite user database" + echo "[DRY-RUN] Would open UFW: 3478/udp+tcp, and the relay port range udp" + return 0 + fi + + # ── Update vs. fresh reinstall ───────────────────────────────────────────── + # "update" only refreshes the image/compose shape — realm, host, port + # range, and every registered consumer's credentials are left exactly as + # they are. Rotating any of those here would silently break TURN for + # every service already relying on this instance (Asterisk phones, + # Mattermost Calls) without those services knowing to reconfigure. + local MODE="fresh" + if [[ -f "$DIR/docker-compose.yml" && -f "$ENV_FILE" ]]; then + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing the coturn image/compose only — realm, host, port range, and" + log_info "every registered consumer's credentials are left exactly as they are." + ;; + cancel) + log_info "Leaving the existing coturn install as-is." + return 0 + ;; + fresh) + echo "" + log_warning "A full reinstall regenerates nothing destructive by itself, but if you" + log_warning "change the host/port/realm below, every already-registered consumer" + log_warning "(Asterisk, Mattermost, ...) keeps pointing at the OLD values in its own" + log_warning ".env until you re-run that service's installer too." + ;; + esac + fi + + mkdir -p "$DIR/db" "$DIR/users" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local COTURN_REALM="" COTURN_HOST="" COTURN_PORT="3478" + local COTURN_MIN_PORT="49152" COTURN_MAX_PORT="49452" + + if [ "$MODE" = "update" ]; then + # shellcheck source=/dev/null + source "$ENV_FILE" + else + local _default_realm="${SITE_DOMAIN:-localhost}" + prompt_text " Realm (usually your domain, or 'localhost' for LAN-only):" "$_default_realm" COTURN_REALM + + local _detected_ip + _detected_ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')" + prompt_text " Public hostname/IP TURN clients should connect to:" "$_detected_ip" COTURN_HOST + + prompt_text " Listening port:" "3478" COTURN_PORT + prompt_text " Relay port range — min:" "49152" COTURN_MIN_PORT + prompt_text " Relay port range — max (each concurrent relayed call needs ~1 port; 300 ports is generous for a homelab):" "49452" COTURN_MAX_PORT + fi + + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + cat > docker-compose.yml << 'EOF' +name: coturn + +services: + coturn: + image: coturn/coturn:latest + container_name: coturn + network_mode: host + user: root + env_file: .env + volumes: + - ./db:/var/lib/coturn + command: + - -n + - --listening-port=${COTURN_PORT:-3478} + - --listening-ip=0.0.0.0 + - --fingerprint + - --lt-cred-mech + - --userdb=/var/lib/coturn/turndb + - --realm=${COTURN_REALM:-localhost} + - --min-port=${COTURN_MIN_PORT:-49152} + - --max-port=${COTURN_MAX_PORT:-49452} + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped +EOF + + cat > "$ENV_FILE" << ENVEOF +TZ=$TZ_VAL + +# ── Identity — read by lib/common.sh's ensure_coturn_user() ──────────────── +# Changing these after consumers already registered breaks TURN for them +# until each one is reconfigured — see the warning above before editing. +COTURN_REALM=$COTURN_REALM +COTURN_HOST=$COTURN_HOST +COTURN_PORT=$COTURN_PORT +COTURN_MIN_PORT=$COTURN_MIN_PORT +COTURN_MAX_PORT=$COTURN_MAX_PORT +ENVEOF + chmod 600 "$ENV_FILE" + chown "$ACTUAL_USER:$ACTUAL_USER" docker-compose.yml "$ENV_FILE" + + log_success "coturn configured at $DIR" + + # ── Firewall ────────────────────────────────────────────────────────────── + if command -v ufw &>/dev/null; then + ufw allow "${COTURN_PORT}/udp" comment 'coturn TURN/STUN' >/dev/null 2>&1 + ufw allow "${COTURN_PORT}/tcp" comment 'coturn TURN/STUN' >/dev/null 2>&1 + ufw allow "${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp" comment 'coturn relay' >/dev/null 2>&1 + log_success "UFW: opened ${COTURN_PORT}/udp+tcp and ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp" + ensure_ufw_enabled + fi + + # ── Admin helper: list/add/remove consumers without touching compose ─────── + cat > coturn_user.sh << 'USEREOF' +#!/bin/bash +# ~/docker/coturn/coturn_user.sh — manage TURN users in the shared coturn's +# SQLite user database. Most services register themselves automatically via +# ensure_coturn_user() (lib/common.sh) at install time — this is for manual +# inspection/cleanup. +# +# sudo ./coturn_user.sh list +# sudo ./coturn_user.sh add +# sudo ./coturn_user.sh remove +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "$HERE/.env" + +case "${1:-}" in + list) + docker exec coturn turnadmin -l -b /var/lib/coturn/turndb + ;; + add) + [ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "Usage: $0 add "; exit 1; } + docker exec coturn turnadmin -a -u "$2" -p "$3" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \ + && echo "Added: $2" \ + || echo "Failed to add $2 — is the coturn container running?" + ;; + remove) + [ -n "${2:-}" ] || { echo "Usage: $0 remove "; exit 1; } + docker exec coturn turnadmin -d -u "$2" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \ + && { echo "Removed: $2"; rm -f "$HERE/users/$2.env"; } \ + || echo "Failed to remove $2" + ;; + *) + echo "Usage: $0 {list|add |remove }" + exit 1 + ;; +esac +USEREOF + chmod +x coturn_user.sh + chown "$ACTUAL_USER:$ACTUAL_USER" coturn_user.sh + + write_readme "$DIR" << MD +# coturn — shared TURN/STUN relay + +One coturn instance shared by every service on this box that needs TURN +(Asterisk, Mattermost Calls, anything added later) — instead of each running +its own and fighting over host ports for the relay range. + +Runs in long-term-credential mode with a SQLite user database. Each +consumer gets its own dedicated username/password, registered automatically +by that service's installer via \`ensure_coturn_user()\` — you don't +normally need to touch this directly. + +## Identity +- Realm: \`$COTURN_REALM\` +- Host clients connect to: \`$COTURN_HOST\` +- Listening port: \`$COTURN_PORT\` +- Relay port range: \`$COTURN_MIN_PORT-$COTURN_MAX_PORT\` (udp) + +**Changing any of the above breaks TURN for every already-registered +consumer until that service's installer is re-run** — they cache the host/ +port/credentials in their own \`.env\` at registration time, not read live. + +## Manage users +\`\`\`bash +sudo ./coturn_user.sh list +sudo ./coturn_user.sh add +sudo ./coturn_user.sh remove +\`\`\` +Per-consumer credentials are also cached in \`users/.env\` (chmod 600) +so a service re-running its own installer reuses the same credential +instead of silently minting a new one and orphaning the old. + +## Manage the container +\`\`\`bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d +\`\`\` + +## Adding a new service that needs TURN +In that service's \`install_()\`, after \`require_docker\`: +\`\`\`bash +ensure_coturn_user "my-service" +if [ -n "\$COTURN_HOST" ]; then + # COTURN_HOST / COTURN_PORT / COTURN_USERNAME / COTURN_PASSWORD are set + # (not local — read them after the call returns, same convention as + # configure_caddy_for_service's CADDY_SERVICE_* out-params) +else + # coturn unavailable — degrade gracefully (no TURN, or prompt to run + # `sudo ./setup.sh coturn` first) +fi +\`\`\` +MD + + local START="" + prompt_yn "Start coturn now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "coturn started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo "" + echo " Realm: $COTURN_REALM Host: $COTURN_HOST Port: $COTURN_PORT" + echo " Relay range: $COTURN_MIN_PORT-$COTURN_MAX_PORT/udp" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_coturn diff --git a/services/mattermost.sh b/services/mattermost.sh index 9d7682d..390d496 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -208,40 +208,129 @@ CBLOCK fi # ───────────────────────────────────────────────────────────────────────────── -register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065 +register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; TURN via the shared coturn service); supports multiple isolated instances" 8065 install_mattermost() { require_docker || return 1 - log_info "Installing Mattermost + coturn..." + # ── Instance selection ────────────────────────────────────────────────── + # First instance keeps the plain "mattermost" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces the suffixed naming. local DIR="$DOCKER_DIR/mattermost" + local INSTANCE_SUFFIX="" PROJECT="mattermost" + local MM_CONTAINER="mattermost" DB_CONTAINER="mattermost-db" + local WEB_PORT="8065" CALLS_UDP_PORT="8443" + local COTURN_CONSUMER="mattermost" + + if [ -d "$DIR" ]; then + echo "" + echo " Mattermost is already installed at $DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Mattermost instance alongside it (its own" + echo " server, database, and TURN credential — full isolation, not Teams)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'team-b'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/mattermost-$_suffix" ]; then + log_warning "mattermost-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + DIR="$DOCKER_DIR/mattermost-$_suffix" + PROJECT="mattermost-$_suffix" + MM_CONTAINER="mattermost-$_suffix" + DB_CONTAINER="mattermost-$_suffix-db" + COTURN_CONSUMER="mattermost-$_suffix" + + # Free-port scan — same pattern services/asterisk.sh uses for its + # web admin port. WEB_PORT is also set as Mattermost's own + # internal ListenAddress below (not just the host publish side), + # so configure_caddy_for_service's single upstream "name:port" + # string works unmodified in both local and remote-Caddy mode — + # it assumes host-published-port == container-internal-port, + # true for every other service in this repo and made true here + # too rather than special-casing the shared helper for one caller. + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do + CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1)) + done + log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)" + fi + fi + + log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..." if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with docker-compose.yml" echo "[DRY-RUN] Would write .env with DB and Mattermost secrets" echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories" - echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp" + echo "[DRY-RUN] Would register a TURN user with the shared coturn service for '$COTURN_CONSUMER'" + echo "[DRY-RUN] (falling back to a dedicated coturn if the shared service is unavailable)" + echo "[DRY-RUN] Would open UFW ports ${WEB_PORT}/tcp, ${CALLS_UDP_PORT}/udp" return 0 fi + # ── Existing install (this exact instance)? Offer update-in-place ─────── + local MODE="fresh" + local _HAD_EMBEDDED_COTURN=false + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + prompt_reinstall_mode MODE + grep -q '^ coturn:' "$DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true + case "$MODE" in + cancel) + log_info "Leaving the existing install as-is." + return 0 + ;; + fresh) + if [ "$_HAD_EMBEDDED_COTURN" = true ]; then + echo "" + log_warning "This install has its own dedicated coturn. Continuing may switch it to" + log_warning "the shared coturn service — the Calls plugin's TURN config in System" + log_warning "Console will need updating to the new credentials afterward (see below)." + fi + ;; + esac + fi + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - local DB_PASS - local MM_SECRET - DB_PASS=$(generate_password 32) - MM_SECRET=$(generate_password 48) + # Reuse existing secrets on update — Postgres's volume keeps the password + # from its first init, so overwriting .env with a fresh one locks + # Mattermost out of its own database. Confirmed this was previously + # unconditional (regenerated every single rerun, silently breaking the DB + # connection) — fixed here as part of adding proper update detection. + local DB_PASS="" MM_SECRET="" + if [ "$MODE" = "update" ]; then + DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)" + [ "$_HAD_EMBEDDED_COTURN" = true ] && MM_SECRET="$(grep '^COTURN_SECRET=' .env 2>/dev/null | cut -d= -f2-)" + fi + [ -n "$DB_PASS" ] || DB_PASS=$(generate_password 32) local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" local UID_VAL GID_VAL UID_VAL=$(id -u "$ACTUAL_USER") GID_VAL=$(id -g "$ACTUAL_USER") - # Compute SITE_URL - local SITE_URL="http://localhost:8065" + # Compute SITE_URL — extra instances default to a distinct subdomain so + # they don't collide with the first instance's. + local _default_subdomain="mattermost${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" + local SITE_URL="http://localhost:${WEB_PORT}" if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then - SITE_URL="https://mattermost.${SITE_DOMAIN}" + SITE_URL="https://${_default_subdomain}.${SITE_DOMAIN}" fi local CONFIGURED_SITEURL="" prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL @@ -270,45 +359,34 @@ networks: " fi - cat > docker-compose.yml << EOF -name: mattermost + # ── TURN: shared coturn preferred, dedicated coturn as fallback ───────── + # See services/coturn.sh's header for why one shared TURN server beats + # every service (Asterisk, each Mattermost instance, ...) running its + # own and fighting over host relay ports. + local USE_EMBEDDED_COTURN=true + local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL="" -services: - db: - image: postgres:15-alpine - container_name: mattermost-db - hostname: mattermost-db - restart: unless-stopped - env_file: .env - volumes: - - ./db:/var/lib/postgresql/data -${_CADDY_NET_BLOCK} healthcheck: - test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 + if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then + USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update + else + ensure_coturn_user "$COTURN_CONSUMER" + if [ -n "${COTURN_HOST:-}" ]; then + USE_EMBEDDED_COTURN=false + TURN_HOST_VAL="$COTURN_HOST"; TURN_PORT_VAL="$COTURN_PORT" + TURN_USERNAME_VAL="$COTURN_USERNAME"; TURN_PASSWORD_VAL="$COTURN_PASSWORD" + log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'." + else + log_info "Shared coturn unavailable — this instance will run its own dedicated coturn." + fi + fi + [ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48) - mattermost: - image: mattermost/mattermost-team-edition:latest - container_name: mattermost - hostname: mattermost - restart: unless-stopped - env_file: .env - depends_on: - db: - condition: service_healthy - volumes: - - ./data:/mattermost/data - - ./logs:/mattermost/logs - - ./config:/mattermost/config - - ./plugins:/mattermost/plugins - ports: - - "8065:8065" - - "8443:8443/udp" -${_CADDY_NET_BLOCK} + local _COTURN_SERVICE="" + if [ "$USE_EMBEDDED_COTURN" = true ]; then + _COTURN_SERVICE=" coturn: image: coturn/coturn:latest - container_name: mattermost-coturn + container_name: ${MM_CONTAINER}-coturn network_mode: host user: root command: @@ -327,7 +405,45 @@ ${_CADDY_NET_BLOCK} - --no-multicast-peers - --log-file=stdout restart: unless-stopped -${_CADDY_NET_SECTION} +" + fi + + cat > docker-compose.yml << EOF +name: ${PROJECT} + +services: + db: + image: postgres:15-alpine + container_name: ${DB_CONTAINER} + hostname: ${DB_CONTAINER} + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/postgresql/data +${_CADDY_NET_BLOCK} healthcheck: + test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: mattermost/mattermost-team-edition:latest + container_name: ${MM_CONTAINER} + hostname: ${MM_CONTAINER} + restart: unless-stopped + env_file: .env + depends_on: + db: + condition: service_healthy + volumes: + - ./data:/mattermost/data + - ./logs:/mattermost/logs + - ./config:/mattermost/config + - ./plugins:/mattermost/plugins + ports: + - "${WEB_PORT}:${WEB_PORT}" + - "${CALLS_UDP_PORT}:8443/udp" +${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION} EOF cat > .env << EOF @@ -341,15 +457,24 @@ POSTGRES_PASSWORD=$DB_PASS # Mattermost MM_SQLSETTINGS_DRIVERNAME=postgres -MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10 +MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@${DB_CONTAINER}:5432/mattermost?sslmode=disable&connect_timeout=10 MM_SERVICESETTINGS_SITEURL=$SITE_URL +MM_SERVICESETTINGS_LISTENADDRESS=:${WEB_PORT} MM_SERVICESETTINGS_ENABLELOCALMODE=true MM_FILESETTINGS_DRIVERNAME=local MM_PLUGINSETTINGS_ENABLE=true -# coturn HMAC secret for Mattermost Calls plugin +# ── TURN/STUN (Calls plugin) ───────────────────────────────── +$( [ "$USE_EMBEDDED_COTURN" = true ] \ + && echo "# This instance runs its own dedicated coturn (the coturn: service in docker-compose.yml)." \ + || echo "# Using the shared coturn service — see ~/docker/coturn/README.md." ) +# coturn HMAC secret — only used if this instance runs its own dedicated coturn. COTURN_SECRET=$MM_SECRET MM_REALM=${SITE_DOMAIN:-localhost} +TURN_HOST=$TURN_HOST_VAL +TURN_PORT=$TURN_PORT_VAL +TURN_USERNAME=$TURN_USERNAME_VAL +TURN_PASSWORD=$TURN_PASSWORD_VAL # PUID/PGID for file ownership PUID=$UID_VAL @@ -360,25 +485,59 @@ EOF mkdir -p data logs config plugins db chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" - # Open required firewall ports + # ── Firewall ───────────────────────────────────────────────────────────── if command -v ufw &>/dev/null; then - ufw allow 8443/udp comment "Mattermost Calls RTC" - ufw allow 3479/udp; ufw allow 3479/tcp - ufw allow 49153:49352/udp comment "Mattermost coturn relay" + ufw allow "${WEB_PORT}/tcp" comment "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" + ufw allow "${CALLS_UDP_PORT}/udp" comment "Mattermost Calls RTC${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" + if [ "$USE_EMBEDDED_COTURN" = true ]; then + ufw allow 3479/udp; ufw allow 3479/tcp + ufw allow 49153:49352/udp comment "Mattermost coturn relay" + fi + # Shared coturn opens its own ports once, at its own install time. fi echo "" log_success "Mattermost configured at $DIR" - configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost" + configure_caddy_for_service "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${MM_CONTAINER}:${WEB_PORT}" "$_default_subdomain" + + # Exact ICEServersConfigs JSON to paste into System Console — verified + # against the Calls plugin's actual config schema (plugin.json): this + # field takes a fixed username/credential pair, which is what a + # --lt-cred-mech coturn (shared or dedicated) expects, as opposed to the + # "TURN Static Auth Secret" field (HMAC/REST-API mode, which coturn + # cannot run at the same time as --lt-cred-mech on one instance). + local _ICE_JSON _turn_config_md + if [ "$USE_EMBEDDED_COTURN" = true ]; then + _ICE_JSON="[{\"urls\":[\"turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\"],\"username\":\"static\",\"credential\":\"see COTURN_SECRET below — this dedicated coturn uses use-auth-secret/HMAC, not a fixed credential\"}]" + _turn_config_md="This instance runs its own dedicated coturn (HMAC/REST-API auth): +- TURN Server URI: \`turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\` +- System Console → Plugins → Calls → **TURN Static Auth Secret**: value of \`COTURN_SECRET\` in \`.env\`" + else + _ICE_JSON="[{\"urls\":[\"turn:${TURN_HOST_VAL}:${TURN_PORT_VAL}?transport=udp\"],\"username\":\"${TURN_USERNAME_VAL}\",\"credential\":\"${TURN_PASSWORD_VAL}\"}]" + _turn_config_md="This instance uses the shared coturn service (fixed username/credential, not HMAC): +- System Console → Plugins → Calls → **ICE Servers Configurations** — paste: +\`\`\`json +$_ICE_JSON +\`\`\` +- Leave **TURN Static Auth Secret** empty — that field is for the OTHER auth + mode coturn supports and doesn't apply here." + fi + [ "${CALLS_UDP_PORT}" != "8443" ] && _turn_config_md="$_turn_config_md +- System Console → Plugins → Calls → **ICE Host Port Override**: \`${CALLS_UDP_PORT}\` (this instance publishes Calls RTC on a non-default port)" write_readme "$DIR" << MD -# Mattermost +# Mattermost${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} -Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay. +Team messaging with voice/video calls. PostgreSQL backend. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " + +This is a separate, fully isolated instance (own server, own database, own +TURN credential) — not a Team within another instance. See that instance's +own README for its own access details." ) ## Access -- URL: $SITE_URL (or http://localhost:8065) +- URL: $SITE_URL (or http://localhost:${WEB_PORT}) - First run: create admin account at the URL above — this account becomes System Admin automatically. @@ -416,12 +575,9 @@ team currently open. If you need real isolation between groups rather than a tidier picker, that means separate Mattermost instances, not this setting. ## Voice/Video Calls (Calls plugin) -Port 8443/udp must be open on your router/firewall. -coturn relay runs on port 3479 (HMAC secret in .env). +Port ${CALLS_UDP_PORT}/udp must be open on your router/firewall. -Configure in Mattermost: System Console → Plugins → Calls: -- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp -- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET) +${_turn_config_md} ## Manage \`\`\`bash @@ -449,9 +605,7 @@ MD echo " First run: open the URL above and create your admin account." echo " Teams: team sidebar '+' → Create a new team (see README.md — no" echo " Enterprise license needed, Team Edition includes this)." - echo " Calls plugin: System Console → Plugins → Calls to configure coturn." - echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp" - echo " Auth secret: see COTURN_SECRET in $DIR/.env" + echo " Calls plugin TURN config: see README.md (System Console → Plugins → Calls)." echo "" } From f731efa2faed5290b65df829c8d7e963fc9a4cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:08:55 +0000 Subject: [PATCH 6/6] Fix DB/admin password regeneration on rerun in 5 services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug class just fixed in mattermost.sh: immich, joplin, koha, mail-archiver, and nextcloud all generated a fresh random DB/admin password on every single run with no check for an existing one. Each backs its database with a persistent volume, so Postgres/MariaDB keeps the password from its first init while the freshly overwritten .env (or config-main.env for koha) no longer matches it — any rerun would have locked the app out of its own database. koha, mail-archiver, and nextcloud also regenerated an app-level admin login password the same way. Found by cross-referencing every service with a DB password against which ones actually guard reuse on rerun (only traccar.sh did, already correctly) rather than waiting to be told about each one individually. Fix mirrors traccar.sh's existing pattern: read the password back out of the existing .env/config file if present, only generate fresh when there's genuinely nothing there yet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN --- services/immich.sh | 7 ++++++- services/joplin.sh | 8 ++++++-- services/koha.sh | 23 ++++++++++++++++++++--- services/mail-archiver.sh | 13 +++++++++++-- services/nextcloud.sh | 16 ++++++++++++---- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/services/immich.sh b/services/immich.sh index 46cbd06..2f2e1b6 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -286,8 +286,13 @@ install_immich() { cd "$IMMICH_DIR" || return 1 # ── Generate DB password ──────────────────────────────────────────────── + # Reused across reruns if already set — the Postgres volume keeps the + # password from its first init, so a fresh random one on every rerun + # would lock Immich out of its own database. local DB_PASS TZ_VAL - DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) + DB_PASS="" + [ -f ".env" ] && DB_PASS="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" + [ -n "$DB_PASS" ] || DB_PASS="$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)" TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" # ── Write docker-compose.yml ──────────────────────────────────────────── diff --git a/services/joplin.sh b/services/joplin.sh index bae8650..b161743 100644 --- a/services/joplin.sh +++ b/services/joplin.sh @@ -197,8 +197,12 @@ install_joplin() { ensure_docker_dir_ownership "$JOPLIN_DIR" cd "$JOPLIN_DIR" || return 1 - local DB_PASS - DB_PASS="$(generate_password 32)" + # Reused across reruns if already set — the Postgres volume keeps the + # password from its first init, so a fresh random one on every rerun + # would lock Joplin out of its own database. + local DB_PASS="" + [ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" + [ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)" local BASE_URL="https://joplin.${SITE_DOMAIN}" # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): diff --git a/services/koha.sh b/services/koha.sh index 6b0b692..c68258e 100644 --- a/services/koha.sh +++ b/services/koha.sh @@ -336,10 +336,27 @@ install_koha() { fi # ── Passwords ───────────────────────────────────────────────────────────── + # Reused across reruns if already set — the MariaDB volume keeps + # DB_PASS/DB_ROOT_PASS from first init, and an already-created Koha admin + # account keeps its own KOHA_ADMIN_PASS; regenerating any of these on a + # rerun would lock the reinstall out of both the database and + # post-setup.sh's REST API login. local DB_PASS DB_ROOT_PASS RABBIT_PASS - DB_PASS="$(generate_password 24)" - DB_ROOT_PASS="$(generate_password 24)" - RABBIT_PASS="$(generate_password 24)" + local _existing_koha_conf="$KOHA_DIR/config-main.env" + if [ -f "$_existing_koha_conf" ]; then + DB_PASS="$(grep '^DB_PASS=' "$_existing_koha_conf" | cut -d= -f2-)" + DB_ROOT_PASS="$(grep '^DB_ROOT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)" + RABBIT_PASS="$(grep '^RABBIT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)" + local _existing_admin_pass + _existing_admin_pass="$(grep '^KOHA_ADMIN_PASS=' "$_existing_koha_conf" | cut -d= -f2-)" + if [ -n "$_existing_admin_pass" ]; then + KOHA_ADMIN_PASS="$_existing_admin_pass" + log_info "Existing install detected — reusing its DB/admin passwords instead of what was just entered above, so this rebuild doesn't lock out the already-initialized database and admin account." + fi + fi + [ -n "${DB_PASS:-}" ] || DB_PASS="$(generate_password 24)" + [ -n "${DB_ROOT_PASS:-}" ] || DB_ROOT_PASS="$(generate_password 24)" + [ -n "${RABBIT_PASS:-}" ] || RABBIT_PASS="$(generate_password 24)" # ── Create directories ──────────────────────────────────────────────────── mkdir -p "$KOHA_DIR/data" diff --git a/services/mail-archiver.sh b/services/mail-archiver.sh index 995c2f9..1101ec5 100644 --- a/services/mail-archiver.sh +++ b/services/mail-archiver.sh @@ -207,9 +207,18 @@ install_mail-archiver() { ensure_docker_dir_ownership "$MA_DIR" cd "$MA_DIR" || return 1 + # Reused across reruns if already set — the Postgres volume keeps + # DB_PASS from its first init, and an already-created admin account + # keeps ADMIN_PASS; regenerating either on a rerun would lock the + # reinstall out of the database and the app's own admin login. local DB_PASS ADMIN_PASS TZ_VAL - DB_PASS=$(generate_password 32) - ADMIN_PASS=$(generate_password 24) + DB_PASS="" ADMIN_PASS="" + if [ -f ".env" ]; then + DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" + ADMIN_PASS="$(grep '^Authentication__Password=' .env | cut -d= -f2-)" + fi + [ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)" + [ -n "$ADMIN_PASS" ] || ADMIN_PASS="$(generate_password 24)" TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): diff --git a/services/nextcloud.sh b/services/nextcloud.sh index fefbdcd..addd268 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -190,10 +190,18 @@ install_nextcloud() { ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - local DB_PASS - DB_PASS=$(generate_password 32) - local NC_ADMIN_PASS - NC_ADMIN_PASS=$(generate_password 16) + # Reused across reruns if already set — the MariaDB volume keeps DB_PASS + # from its first init (regenerating it would lock Nextcloud out of its + # own database), and NEXTCLOUD_ADMIN_USER/PASSWORD are only consulted by + # the container on its very first boot to create the admin account — + # printing a fresh NC_ADMIN_PASS on every rerun would silently show a + # password that was never actually applied to the existing account. + local DB_PASS="" + [ -f ".env" ] && DB_PASS="$(grep '^MYSQL_PASSWORD=' .env | cut -d= -f2-)" + [ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)" + local NC_ADMIN_PASS="" + [ -f ".env" ] && NC_ADMIN_PASS="$(grep '^NEXTCLOUD_ADMIN_PASSWORD=' .env | cut -d= -f2-)" + [ -n "$NC_ADMIN_PASS" ] || NC_ADMIN_PASS="$(generate_password 16)" local TZ_VAL="${SITE_TZ:-UTC}" # ── Dockerfile ──────────────────────────────────────────────────────────