Merge pull request #265 from outis1one/claude/droplet-capacity-assessment-s2voix

Claude/droplet capacity assessment s2voix
This commit is contained in:
Outis
2026-08-08 17:17:43 -04:00
committed by GitHub
14 changed files with 1408 additions and 138 deletions
+61
View File
@@ -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/<consumer>.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.
+2 -2
View File
@@ -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/<service>/` 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/<service>/` 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.
+23
View File
@@ -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"
+242
View File
@@ -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_<name> 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
+79
View File
@@ -698,3 +698,82 @@ CADDY_BLOCK
fi
echo ""
}
# ── Shared coturn (TURN/STUN) wiring ──────────────────────────────────────────
# Usage: ensure_coturn_user "<consumer-name>"
#
# Installs the shared coturn service (services/coturn.sh) if this is the
# first service on the box that needs TURN, then registers (or reuses) a
# dedicated long-term-credential user for the caller — one coturn instance,
# one relay port range, shared by every consumer instead of each service
# running its own and fighting over host ports (see services/coturn.sh's
# header for why that used to be a real, confirmed-live problem).
#
# Out-params (not `local` — read them after the call returns), same
# convention as configure_caddy_for_service's CADDY_SERVICE_* above:
# COTURN_HOST host/IP TURN clients should connect to
# COTURN_PORT coturn's listening port
# COTURN_USERNAME this consumer's long-term-credential username
# COTURN_PASSWORD this consumer's long-term-credential password
# COTURN_HOST is left empty if coturn couldn't be installed or reached —
# callers should treat that as "no TURN available" and degrade gracefully,
# same as checking CADDY_SERVICE_CONFIGURED after configure_caddy_for_service.
#
# Credentials are cached per-consumer in coturn's own users/<name>.env so a
# service re-running its own installer reuses the same one instead of
# minting a new credential and orphaning the old one (which would silently
# break already-configured clients still holding it).
ensure_coturn_user() {
local _consumer="$1"
COTURN_HOST="" COTURN_PORT="" COTURN_USERNAME="" COTURN_PASSWORD=""
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"
}
+133 -58
View File
@@ -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:-<host-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, <web admin port>, 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, <web admin port>, 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 ""
+173 -1
View File
@@ -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"
+385
View File
@@ -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 <name> <password>
# sudo ./coturn_user.sh remove <name>
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 <name> <password>"; 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 <name>"; 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 <name> <password>|remove <name>}"
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 <name> <password>
sudo ./coturn_user.sh remove <name>
\`\`\`
Per-consumer credentials are also cached in \`users/<name>.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_<name>()\`, 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
+6 -1
View File
@@ -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 ────────────────────────────────────────────
+6 -2
View File
@@ -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):
+20 -3
View File
@@ -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"
+11 -2
View File
@@ -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):
+255 -65
View File
@@ -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 ""
}
+12 -4
View File
@@ -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 ──────────────────────────────────────────────────────────