Retire the shared coturn service — every WebRTC/SIP service now runs its own

Shared coturn (services/coturn.sh, ensure_coturn_user in lib/common.sh) is no
longer an installable or usable option anywhere in this repo. It's moved to
attic/coturn.sh (with tools/coturn-test-check.sh alongside it), which is
outside setup.sh's services/*.sh glob, so it never registers, never appears
in the menu, and `sudo ./setup.sh coturn` now fails with "unknown service".

Asterisk and Mattermost each already had an opt-out to run their own
dedicated coturn instead of the shared one; that opt-out is now the only
behavior — the shared-coturn preference, the opt-out prompt, and every
ensure_coturn_user() call site are gone. find_free_coturn_range()
(lib/common.sh) is what makes unconditional dedicated coturn safe: it scans
every coturn-owning service's own .env on the box for already-claimed relay
ranges and picks one that can't collide, so Asterisk + any number of
Mattermost instances can each run their own coturn on one box without the
relay-port collisions this repo's coturn history warns about.

Existing installs still pointed at a shared coturn container are left
running as-is on `update` (no silent migration attempt against a service
that no longer exists to heal against) — a full/fresh reinstall is the
migration path, which generates a new dedicated coturn with fresh
credentials and says so.

Also updates CLAUDE.md's coturn guidance for future service authors,
attic/README.md with the retirement rationale, and stale
services/coturn.sh path references in services/asterisk.sh,
tools/pstn-test-check.sh, README.md, and docs/vps-sizing-recommendations.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crt4ymNEHEbWqscB1qvZgC
This commit is contained in:
Claude
2026-08-13 02:12:32 +00:00
parent d835e0d734
commit ca239a3886
10 changed files with 242 additions and 367 deletions
+70 -130
View File
@@ -280,7 +280,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); TURN via the shared coturn service" 5061
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); own dedicated coturn for TURN" 5061
# ── Install layout: directory + container names ────────────────────────────
# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT.
@@ -1024,14 +1024,14 @@ _asterisk_offer_dashboard_and_trunk() {
# 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.
# Every install now runs its own dedicated coturn — there's no shared coturn
# service left in this repo to opt into (see attic/coturn.sh for why it was
# retired). USE_EMBEDDED_COTURN still exists as a parameter purely for
# backward compatibility with pre-retirement installs that were pointed at
# the old shared coturn service instead: an "update" on one of those must
# keep NOT writing a coturn: block (there's no .env TURN_PASSWORD for it to
# use), so it stays exactly as it was rather than silently gaining or losing
# a container. See the two call sites below for how each decides.
_asterisk_write_compose() {
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" USE_EMBEDDED_COTURN="${4:-true}"
local COTURN_MIN_PORT_VAL="${5:-49152}" COTURN_MAX_PORT_VAL="${6:-49252}"
@@ -1106,9 +1106,9 @@ EOF
#
# This is entirely about Asterisk's OWN SIP transport-tls cert (port
# 5061) -- it has nothing to do with coturn's separate, unrelated TURNS
# (TLS-wrapped TURN) capability, which the shared coturn service indeed
# doesn't support (see services/coturn.sh's README). A previous version
# of this check gated the mount on USE_EMBEDDED_COTURN == true, conflating
# (TLS-wrapped TURN) capability, which the (since-retired) shared coturn
# service indeed didn't support (see attic/coturn.sh's README). A
# previous version of this check gated the mount on USE_EMBEDDED_COTURN == true, conflating
# the two. Confirmed live: on a shared-coturn install with a real Caddy
# cert already sitting on disk for DOMAIN_NAME, Asterisk silently kept
# generating (and re-generating) a self-signed cert forever, because
@@ -1380,8 +1380,8 @@ _asterisk_configure_do_cloud_firewall() {
# silently dropped everything else before it ever reached the box. UFW being
# wide open proves nothing about a layer in front of it that UFW can't see.
_asterisk_remind_non_do_firewall() {
local WEB_ADMIN_PORT_VAL="$1" WEB_ADMIN_PUBLIC_ACCESS_NEEDED="$2" USE_EMBEDDED_COTURN_VAL="${3:-true}"
local COTURN_MIN_PORT_VAL="${4:-49152}" COTURN_MAX_PORT_VAL="${5:-49252}"
local WEB_ADMIN_PORT_VAL="$1" WEB_ADMIN_PUBLIC_ACCESS_NEEDED="$2"
local COTURN_MIN_PORT_VAL="${3:-49152}" COTURN_MAX_PORT_VAL="${4:-49252}"
echo ""
log_warning "This box is reachable via FQDN but wasn't set up as a DigitalOcean droplet,"
log_warning "so no automatic network-edge firewall was configured (that step only exists"
@@ -1397,15 +1397,8 @@ _asterisk_remind_non_do_firewall() {
[[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]] && echo " TCP ${WEB_ADMIN_PORT_VAL} (web admin)"
echo " TCP 8088, 8089 (Asterisk HTTP/HTTPS)"
echo " UDP 10000-20000 (RTP media)"
if [[ "$USE_EMBEDDED_COTURN_VAL" == true ]]; then
echo " UDP/TCP 3478 (TURN/STUN)"
echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)"
else
echo " UDP/TCP 3478 too, if the shared coturn instance (services/coturn.sh) lives"
echo " on this same box — its exact TURN relay range is in its own README"
echo " (~/docker/coturn/README.md), not repeated here since it's independently"
echo " configurable and this install doesn't own it."
fi
echo " UDP/TCP 3478 (TURN/STUN)"
echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)"
}
# ── Shared: README ─────────────────────────────────────────────────────────
@@ -1461,9 +1454,7 @@ connecting a phone. The Security Dashboard's Extensions tab
| 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." )
This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml).
Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and
[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP +
@@ -1682,11 +1673,10 @@ 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 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 run its own dedicated coturn container for TURN, with a relay port"
echo "[DRY-RUN] range picked to avoid colliding with any other coturn already on the box"
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] plus 3478 + the dedicated coturn's relay port range"
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,"
@@ -1754,19 +1744,16 @@ install_asterisk() {
log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
fi
# Self-heal a stale/orphaned shared-coturn registration on
# every update, not just a full reinstall — the check inside
# ensure_coturn_user() is what actually re-registers a
# missing user, this just needs to reach it. Gated on NOT
# having an embedded coturn: an install with its own
# dedicated coturn deliberately never touches the shared one
# on update (see the warning above and CLAUDE.md's coturn
# migration guidance) — calling this unconditionally would
# silently chain-install services/coturn.sh for a box that
# was never using it, the exact "don't migrate silently on
# update" mistake that guidance warns against.
# A pre-existing install with no embedded coturn block predates
# this repo's dedicated-coturn-only model — it's still pointed
# at a shared coturn container this repo no longer installs or
# manages (attic/coturn.sh). Leave it running as-is; update
# never touches .env or firewall rules anyway. Point at a
# fresh reinstall as the migration path instead of silently
# trying to heal a registration against a service that no
# longer exists here.
if [[ "$_HAD_EMBEDDED_COTURN" != true ]]; then
ensure_coturn_user "asterisk"
log_info "This install still points at a shared coturn service, which this repo no longer installs or manages. It will keep working as long as that coturn container keeps running. Run a full reinstall (not update) to migrate to a dedicated coturn."
fi
_asterisk_run_presence_step "$EA_DIR" "$CONTAINER"
@@ -1794,8 +1781,9 @@ install_asterisk() {
echo ""
log_warning "Full reinstall stops the existing containers and re-runs every"
log_warning "prompt below from scratch (domain, networking, firewall, Caddy/"
log_warning "Authelia). The TURN credential registered with the shared coturn"
log_warning "service is reused as-is — no need to touch coturn for this."
log_warning "Authelia), including generating a fresh dedicated coturn container"
log_warning "with new TURN credentials — any already-configured phone's TURN"
log_warning "settings will need to be updated afterward (re-scan its QR code)."
local _WIPE_PBX_DATA=""
prompt_yn " Also delete stored PBX data (extensions, voicemail, recordings, spool)? (y/n):" "n" _WIPE_PBX_DATA
@@ -1899,83 +1887,41 @@ install_asterisk() {
fi
# ── 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.
# Asterisk always runs its own dedicated coturn — there is no shared
# coturn service in this repo anymore (see attic/coturn.sh for why it
# was retired). find_free_coturn_range (below) is what makes running a
# dedicated coturn per service safe: it checks every coturn-owning
# service's .env on the box and picks a relay range that can't collide
# with any of them.
local USE_EMBEDDED_COTURN=true
local TURN_USERNAME TURN_PASSWORD TURN_PORT_VAL TURN_SERVER_VAL
local FORCE_EMBEDDED_COTURN=""
# 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."
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
# Opt-out of the shared coturn preference below, for the rare case where
# you specifically want Asterisk isolated on its own TURN relay again
# (e.g. reproducing an older install's exact shape to rule out anything
# coturn-sharing-specific during troubleshooting). Only offered when a
# shared instance actually exists — no meaningful choice otherwise.
if [[ -d "$DOCKER_DIR/coturn" ]]; then
local _USE_SHARED_COTURN=""
prompt_yn "Use the shared coturn service for TURN? (n = run Asterisk's own dedicated coturn instead) (y/n):" "y" _USE_SHARED_COTURN
[[ "$_USE_SHARED_COTURN" =~ ^[Nn]$ ]] && FORCE_EMBEDDED_COTURN=true
fi
[[ "$FORCE_EMBEDDED_COTURN" != true ]] && ensure_coturn_user "asterisk"
if [[ "$FORCE_EMBEDDED_COTURN" != true && -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
if [[ "$FORCE_EMBEDDED_COTURN" == true ]]; then
log_info "Running Asterisk's own dedicated coturn, as requested."
else
log_info "Shared coturn unavailable — Asterisk will run its own dedicated coturn."
fi
fi
# A dedicated embedded coturn running ALONGSIDE any other coturn on the
# same box (the shared instance, Asterisk's own on a prior install,
# any Mattermost instance's own) is exactly the pre-merge collision bug
# this repo's coturn history warns about if two of them claim overlapping
# relay ports — confirmed live, two independent coturns' default ranges
# used to overlap by ~100 UDP ports. find_free_coturn_range (lib/common.sh)
# checks every coturn-owning service's .env on the box, not just the
# shared instance's, and picks a range starting safely past whatever's
# already claimed. No other coturn on the box at all leaves it at the
# historical 49152-49252 default — nothing to collide with yet.
# A dedicated coturn here running alongside Asterisk's own on a prior
# install, or any Mattermost instance's own, is exactly the pre-merge
# collision bug this repo's coturn history warns about if two of them
# claim overlapping relay ports — confirmed live, two independent
# coturns' default ranges used to overlap by ~100 UDP ports.
# find_free_coturn_range (lib/common.sh) checks every coturn-owning
# service's .env on the box and picks a range starting safely past
# whatever's already claimed. No other coturn on the box at all leaves
# it at the historical 49152-49252 default — nothing to collide with yet.
local EMBEDDED_COTURN_MIN_PORT=49152 EMBEDDED_COTURN_MAX_PORT=49252
if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
find_free_coturn_range EMBEDDED_COTURN_MIN_PORT EMBEDDED_COTURN_MAX_PORT 100 49152
[[ "$EMBEDDED_COTURN_MIN_PORT" != 49152 ]] && \
log_info "Dedicated coturn relay range shifted to ${EMBEDDED_COTURN_MIN_PORT}-${EMBEDDED_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
fi
find_free_coturn_range EMBEDDED_COTURN_MIN_PORT EMBEDDED_COTURN_MAX_PORT 100 49152
[[ "$EMBEDDED_COTURN_MIN_PORT" != 49152 ]] && \
log_info "Dedicated coturn relay range shifted to ${EMBEDDED_COTURN_MIN_PORT}-${EMBEDDED_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
@@ -2017,18 +1963,16 @@ install_asterisk() {
DOMAIN_NAME=${DOMAIN_NAME}
# ── TURN/STUN ─────────────────────────────────────────────────
# $( [[ "$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." )
# This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml).
TURN_USERNAME=${TURN_USERNAME}
TURN_PASSWORD=${TURN_PASSWORD}
TURN_PORT=${TURN_PORT_VAL}
# Empty when there's no publicly resolvable address (LAN-only, no FQDN).
TURN_SERVER=${TURN_SERVER_VAL}
# This install's OWN coturn relay range -- only set when USE_EMBEDDED_COTURN
# is true above. Left blank when using the shared coturn service, so other
# services' find_free_coturn_range (lib/common.sh) scan correctly skips this
# file instead of treating a range this install doesn't actually own as claimed.
TURN_MIN_PORT=$( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "$EMBEDDED_COTURN_MIN_PORT" )
TURN_MAX_PORT=$( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "$EMBEDDED_COTURN_MAX_PORT" )
# This install's own coturn relay range — other services' find_free_coturn_range
# (lib/common.sh) scans this file to avoid claiming an overlapping range.
TURN_MIN_PORT=${EMBEDDED_COTURN_MIN_PORT}
TURN_MAX_PORT=${EMBEDDED_COTURN_MAX_PORT}
# ── RTP port range ────────────────────────────────────────────
RTP_START=10000
@@ -2092,13 +2036,9 @@ ENV
ufw allow 8088/tcp
ufw allow 8089/tcp
ufw allow 10000:20000/udp
if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow "${EMBEDDED_COTURN_MIN_PORT}:${EMBEDDED_COTURN_MAX_PORT}/udp"
fi
# Shared coturn opens its own ports once, at its own install time
# (services/coturn.sh) — nothing to open here when using it.
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow "${EMBEDDED_COTURN_MIN_PORT}:${EMBEDDED_COTURN_MAX_PORT}/udp"
ensure_ufw_enabled
log_success "UFW rules added."
fi
@@ -2108,7 +2048,7 @@ ENV
_asterisk_configure_do_cloud_firewall "$DROPLET_ID" "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
elif [[ -n "$DOMAIN_NAME" ]]; then
_asterisk_remind_non_do_firewall "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" "$USE_EMBEDDED_COTURN" \
_asterisk_remind_non_do_firewall "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
fi
-406
View File
@@ -1,406 +0,0 @@
#!/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
+28 -49
View File
@@ -238,7 +238,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; TURN via the shared coturn service); supports multiple isolated instances" 8065
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; own dedicated coturn for TURN); supports multiple isolated instances" 8065
install_mattermost() {
require_docker || return 1
@@ -251,7 +251,6 @@ install_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 ""
@@ -280,7 +279,6 @@ install_mattermost() {
PROJECT="mattermost-$_suffix"
MM_CONTAINER="mattermost-$_suffix"
DB_CONTAINER="mattermost-$_suffix-db"
COTURN_CONSUMER="mattermost-$_suffix"
log_info "New instance: $DIR"
fi
fi
@@ -305,8 +303,8 @@ install_mattermost() {
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 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 run this instance's own dedicated coturn container for TURN, with a relay"
echo "[DRY-RUN] port range picked to avoid colliding with any other coturn already on the box"
echo "[DRY-RUN] Would open UFW ports ${WEB_PORT}/tcp, ${CALLS_UDP_PORT}/udp"
return 0
fi
@@ -323,16 +321,11 @@ install_mattermost() {
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
echo ""
log_warning "Full reinstall stops the existing containers and re-runs every prompt"
log_warning "below from scratch. The TURN credential registered with the shared"
log_warning "coturn service is reused as-is — no need to touch coturn for this."
log_warning "below from scratch, including generating a fresh dedicated coturn"
log_warning "container with new TURN credentials — the Calls plugin's TURN config in"
log_warning "System Console will need updating afterward (see below)."
local _WIPE_MM_DATA=""
prompt_yn " Also delete stored data (Postgres database, uploaded files, config, plugins)? (y/n):" "n" _WIPE_MM_DATA
@@ -408,47 +401,31 @@ networks:
"
fi
# ── 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.
# ── TURN: always this instance's own dedicated coturn ───────────────────
# There is no shared coturn service in this repo anymore (see
# attic/coturn.sh for why it was retired) — every instance runs its own.
# find_free_coturn_range (below) is what makes that safe: it checks
# every coturn-owning service's .env on the box and picks a relay range
# that can't collide with any of them.
local USE_EMBEDDED_COTURN=true
local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL=""
local FORCE_EMBEDDED_COTURN=""
# Opt-out of the shared coturn preference, same as services/asterisk.sh —
# only offered on a genuinely fresh install (never re-asked on update,
# matching every other coturn-shape decision in this file) and only when
# a shared instance actually exists to opt out of.
if [ "$MODE" = "fresh" ] && [ -d "$DOCKER_DIR/coturn" ]; then
local _USE_SHARED_COTURN=""
prompt_yn "Use the shared coturn service for TURN? (n = run this instance's own dedicated coturn instead) (y/n):" "y" _USE_SHARED_COTURN
[[ "$_USE_SHARED_COTURN" =~ ^[Nn]$ ]] && FORCE_EMBEDDED_COTURN=true
fi
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then
USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update
elif [ "$FORCE_EMBEDDED_COTURN" = true ]; then
USE_EMBEDDED_COTURN=true
log_info "Running this instance's own dedicated coturn, as requested."
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
# A pre-existing instance with no embedded coturn block predates this
# repo's dedicated-coturn-only model — it's still pointed at a shared
# coturn container this repo no longer installs or manages. Leave it
# running as-is (update never touches .env anyway) rather than trying
# to heal a registration against a service that no longer exists here.
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" != true ]; then
USE_EMBEDDED_COTURN=false
log_info "This instance still points at a shared coturn service, which this repo no longer installs or manages. It will keep working as long as that coturn container keeps running. Run a full reinstall (not update) to migrate to a dedicated coturn."
fi
[ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48)
# A dedicated coturn here running alongside the shared instance, Asterisk's
# own, or a sibling Mattermost instance's own is the same pre-merge relay-
# port collision this repo's coturn history warns about (confirmed live:
# two independent coturns' default ranges used to overlap by ~100 UDP
# ports). find_free_coturn_range (lib/common.sh) checks every coturn-
# A dedicated coturn here running alongside Asterisk's own, a sibling
# Mattermost instance's own, or a legacy shared instance still running is
# the same pre-merge relay-port collision this repo's coturn history
# warns about (confirmed live: two independent coturns' default ranges
# used to overlap by ~100 UDP ports). find_free_coturn_range (lib/common.sh) checks every coturn-
# owning service's .env on the box and picks a range starting safely past
# whatever's already claimed; the historical 49153-49352 default only
# survives when nothing else on the box claims a range at all.
@@ -593,7 +570,9 @@ EOF
ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow "${MM_COTURN_MIN_PORT}:${MM_COTURN_MAX_PORT}/udp" comment "Mattermost coturn relay"
fi
# Shared coturn opens its own ports once, at its own install time.
# A legacy instance still on a shared coturn (USE_EMBEDDED_COTURN=false
# above) has nothing to open here — that coturn's ports were opened
# once, at its own install time, whenever that was.
fi
echo ""