Files
ubuntu-post-install/services/coturn.sh
T
Claude aa65b5ef5b Make "Full reinstall" a real teardown for asterisk, mattermost, and coturn
Extends the security-dashboard prototype to the shared-coturn trio, since
these three are exactly the case that pattern was built for — a fresh
reinstall of any of them today just overwrote files in place without
stopping old containers first, and coturn's own fresh path never made an
informed choice about the consumer credentials/database it happens to
leave alone (safe today, but by omission rather than design).

- asterisk.sh / mattermost.sh: "Full reinstall" now stops the existing
  containers (`docker compose down`) before falling through to the normal
  install flow, and asks a single explicit question — delete stored data
  (PBX config/spool/voicemail for Asterisk; Postgres db/uploads/config/
  plugins for Mattermost) — defaulting to preserve. Their shared-coturn
  TURN credential is deliberately left alone either way (reused from
  cache via ensure_coturn_user(), same as update) — it's not this
  service's own data, and coturn already handles that continuity.
  Mattermost's existing "_db_has_data" check already reads the
  filesystem to decide whether to reuse or regenerate DB_PASS, so the
  wipe/preserve choice composes with that for free — no separate flag
  needed. Asterisk's warns to re-run pstn-trunk afterward if data is
  wiped, since that's what actually goes stale (its dialplan patch),
  not the fabricated "AMI secret" framing an earlier draft of this
  warning used before I checked the actual code.

- coturn.sh: "Full reinstall" now lists which consumers are currently
  registered (from users/*.env) and asks explicitly whether to also
  wipe TURN credentials and the user database, instead of silently
  preserving them as an unexamined side effect of never deleting the
  directory. Defaults to preserve. If the operator does choose to wipe,
  the running container is restarted afterward — it holds the old,
  now-deleted turndb file open, so new turnadmin writes to the fresh
  file would otherwise go unseen until a restart anyway. Every affected
  consumer already self-heals a missing credential on its own next
  Update run via ensure_coturn_user()'s existing cache-miss path — no
  changes needed there, just confirmed it covers this case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-12 13:07:38 +00:00

407 lines
18 KiB
Bash

#!/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 " u) Update — refresh vendor files, keep existing settings"
echo " f) Full reinstall — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
case "${_r,,}" in
u) 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."
local _consumers=""
[ -d "$DIR/users" ] && _consumers="$(find "$DIR/users" -maxdepth 1 -name '*.env' -printf '%f\n' 2>/dev/null | sed 's/\.env$//' | tr '\n' ' ')"
if [ -n "$_consumers" ]; then
echo ""
log_info "Registered consumers: $_consumers"
local _WIPE_USERS=""
prompt_yn " Also delete all TURN user credentials and the user database (forces every consumer above to re-register)? (y/n):" "n" _WIPE_USERS
if [[ "$_WIPE_USERS" =~ ^[Yy]$ ]]; then
rm -rf "$DIR/users" "$DIR/db"
mkdir -p "$DIR/db" "$DIR/users"
# The running container (if any) still holds the old,
# now-deleted turndb file open — new turnadmin writes
# to the fresh file at that path go unseen until the
# server process restarts and reopens it.
docker restart coturn >/dev/null 2>&1
log_warning "Deleted TURN credentials and the user database."
log_warning "Re-run each consumer's installer in Update mode afterward —"
log_warning "ensure_coturn_user() auto-recovers a fresh credential for it."
fi
fi
;;
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