diff --git a/CLAUDE.md b/CLAUDE.md index fea2c45..9a6309a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -556,3 +556,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 36ce734..9fbf20e 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,13 @@ 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` | | `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/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 new file mode 100644 index 0000000..48ca048 --- /dev/null +++ b/extras/dr_bringup_kopia.sh @@ -0,0 +1,242 @@ +#!/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 + +# 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 + timeout 300 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 (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 + 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 + # 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 (or timed out) for $svc" + FAILED_SVCS+=("$svc: compose up failed or timed out") + 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 (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 + +exit 0 diff --git a/lib/common.sh b/lib/common.sh index b8b8209..3eb0ef3 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -698,3 +698,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/backup.sh b/services/backup.sh index 23f5a84..c32b388 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 "" @@ -451,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" @@ -530,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" @@ -554,6 +597,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" @@ -660,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="" @@ -693,10 +851,24 @@ 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" + 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)" echo " sudo $TEST_SCRIPT --list list testable services" 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/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/mattermost.sh b/services/mattermost.sh index 3a2f098..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,34 +485,99 @@ 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) -- First run: create admin account at the URL above +- URL: $SITE_URL (or http://localhost:${WEB_PORT}) +- 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. + +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). +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 @@ -413,9 +603,9 @@ MD echo "" echo " Access at: $SITE_URL" echo " First run: open the URL above and create your admin account." - 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 " Teams: team sidebar '+' → Create a new team (see README.md — no" + echo " Enterprise license needed, Team Edition includes this)." + echo " Calls plugin TURN config: see README.md (System Console → Plugins → Calls)." echo "" } 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 ──────────────────────────────────────────────────────────