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:
@@ -34,3 +34,33 @@ rather than `fresh` at the reinstall prompt, and having a snapshot.
|
||||
Two copies of the same logic is the exact problem the merge existed to fix,
|
||||
and this one will drift the moment `services/asterisk.sh` gets a fix that
|
||||
isn't backported here — which it deliberately won't be.
|
||||
|
||||
## `coturn.sh` / `coturn-test-check.sh`
|
||||
|
||||
The shared-coturn service (`services/coturn.sh`, moved here unchanged from
|
||||
`services/`) and its standalone health-check tool (`tools/coturn-test-check.sh`,
|
||||
moved from `tools/`). This model — every WebRTC/SIP-capable service
|
||||
(`asterisk`, `mattermost`) sharing one coturn instance via `ensure_coturn_user`
|
||||
— is no longer offered anywhere in this repo. Every service now runs its own
|
||||
dedicated coturn instead, with `lib/common.sh`'s `find_free_coturn_range()`
|
||||
avoiding the relay-port collisions a shared instance used to prevent by
|
||||
scanning every coturn-owning service's own `.env` on the box. See
|
||||
`CLAUDE.md`'s "coturn (TURN/STUN) relay" section for the current pattern.
|
||||
|
||||
Sharing one instance only ever saved ~40MB RAM per additional consumer
|
||||
beyond the first — real, but small — against being a single point of
|
||||
failure every consumer depended on. Parked here, not deleted, since the
|
||||
code is still correct and someone could resurrect it if a future need for
|
||||
it shows up. Both files still run standalone if invoked directly:
|
||||
|
||||
```bash
|
||||
sudo bash attic/coturn.sh
|
||||
sudo bash attic/coturn-test-check.sh
|
||||
```
|
||||
|
||||
Nothing in this repo calls `ensure_coturn_user()` anymore (the function
|
||||
itself was removed from `lib/common.sh`), so resurrecting this only makes
|
||||
sense if you're deliberately reintroducing the shared-coturn pattern
|
||||
yourself — a new consumer service would need its own call to whatever
|
||||
takes `ensure_coturn_user`'s place, since that helper no longer exists to
|
||||
call.
|
||||
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
# attic/coturn-test-check.sh — RETIRED along with attic/coturn.sh (formerly
|
||||
# services/coturn.sh). This tool only makes sense against a shared coturn
|
||||
# instance, which this repo no longer offers — see attic/coturn.sh's header
|
||||
# for what replaced it (each service gets its own dedicated coturn now).
|
||||
# Kept for reference alongside it, not actively maintained.
|
||||
#
|
||||
# tools/coturn-test-check.sh — Health-check for the shared coturn (TURN/STUN)
|
||||
# instance services/coturn.sh sets up, and every consumer registered against
|
||||
# it (Asterisk, one or more Mattermost instances, anything else added via
|
||||
# ensure_coturn_user() in lib/common.sh).
|
||||
#
|
||||
# Checks: container up, identity/.env readable, every registered consumer
|
||||
# actually exists in coturn's own user database (not just a cached
|
||||
# users/<name>.env file — the two can drift, e.g. a container recreated from
|
||||
# an older image/db), UFW has the TURN port + relay range open, and — the
|
||||
# part nothing else in this repo does — a REAL TURN allocation test per
|
||||
# consumer via turnutils_uclient (bundled in the coturn/coturn image), which
|
||||
# is the only way to prove credentials + port range + firewall all actually
|
||||
# work together end to end, not just that each piece looks right in isolation.
|
||||
#
|
||||
# Does NOT attempt a concurrent load test (e.g. opening dozens of allocations
|
||||
# at once) — that would consume real relay ports on a server other services
|
||||
# may be actively using. See "Capacity" in the output for how the port range
|
||||
# bounds concurrent capacity, reasoned from the numbers instead of guessed at.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash tools/coturn-test-check.sh
|
||||
#
|
||||
# Safe to run any time — the one allocation test per consumer opens and
|
||||
# immediately releases a single relay port, the same as a single real call
|
||||
# briefly would.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PASS=0
|
||||
WARN=0
|
||||
FAIL=0
|
||||
|
||||
ok() { printf ' [OK] %s\n' "$1"; PASS=$((PASS + 1)); }
|
||||
warn() { printf ' [WARN] %s\n' "$1"; WARN=$((WARN + 1)); }
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); }
|
||||
section() { printf '\n== %s ==\n' "$1"; }
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Run with sudo — needs docker exec." >&2
|
||||
exec sudo bash "$0" "$@"
|
||||
fi
|
||||
|
||||
ACTUAL_USER="${SUDO_USER:-${USER:-root}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/root")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
COTURN_DIR="$DOCKER_DIR/coturn"
|
||||
|
||||
# ── Container + identity ──────────────────────────────────────────────────────
|
||||
section "Detecting install"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx coturn; then
|
||||
fail "No running 'coturn' container found — is services/coturn.sh installed and started?"
|
||||
echo ""
|
||||
echo " $PASS passed, $WARN warnings, $FAIL failed. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
ok "Container running: coturn"
|
||||
|
||||
if [ ! -f "$COTURN_DIR/.env" ]; then
|
||||
fail "$COTURN_DIR/.env not found — can't read realm/host/port range."
|
||||
exit 1
|
||||
fi
|
||||
set +u
|
||||
# shellcheck disable=SC1090
|
||||
source "$COTURN_DIR/.env"
|
||||
set -u
|
||||
COTURN_REALM="${COTURN_REALM:-}"
|
||||
COTURN_HOST="${COTURN_HOST:-}"
|
||||
COTURN_PORT="${COTURN_PORT:-3478}"
|
||||
COTURN_MIN_PORT="${COTURN_MIN_PORT:-49152}"
|
||||
COTURN_MAX_PORT="${COTURN_MAX_PORT:-49452}"
|
||||
ok "Realm: ${COTURN_REALM:-<unset>} Host: ${COTURN_HOST:-<unset>} Port: $COTURN_PORT"
|
||||
ok "Relay port range: ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}"
|
||||
|
||||
# ── Registered consumers ──────────────────────────────────────────────────────
|
||||
section "Registered consumers"
|
||||
|
||||
# turnadmin -l writes its own startup log lines ("INFO SQLite connection
|
||||
# was closed.", "INFO log file opened: ...") to STDOUT on at least some
|
||||
# coturn builds, not stderr — confirmed live, `2>/dev/null` alone let them
|
||||
# through and got misparsed as usernames. A real "user[realm]" line never
|
||||
# contains a space; every log line here does, so filtering those out is a
|
||||
# safe, simple way to keep only genuine entries regardless of which coturn
|
||||
# build's log-noise happens to leak onto stdout.
|
||||
DB_USERS="$(docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>/dev/null | grep -v ' ' | sed -E 's/\[.*//' | awk 'NF' | sort -u)"
|
||||
if [ -z "$DB_USERS" ]; then
|
||||
warn "No users found in coturn's own database — nothing has actually registered yet, or turnadmin -l's output format changed. Raw:"
|
||||
docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>&1 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
CACHED_CONSUMERS=()
|
||||
if [ -d "$COTURN_DIR/users" ]; then
|
||||
while IFS= read -r f; do
|
||||
CACHED_CONSUMERS+=("$(basename "$f" .env)")
|
||||
done < <(find "$COTURN_DIR/users" -maxdepth 1 -name '*.env' -type f 2>/dev/null | sort)
|
||||
fi
|
||||
|
||||
if [ "${#CACHED_CONSUMERS[@]}" -eq 0 ]; then
|
||||
warn "No cached consumer credentials in $COTURN_DIR/users — nothing has registered via ensure_coturn_user() yet."
|
||||
else
|
||||
for c in "${CACHED_CONSUMERS[@]}"; do
|
||||
if grep -qx "$c" <<< "$DB_USERS"; then
|
||||
ok "Consumer '$c' — cached credentials present AND found in coturn's live database"
|
||||
else
|
||||
fail "Consumer '$c' has a cached users/${c}.env but is NOT in coturn's database — its calls will fail 401 Unauthorized. Likely cause: the coturn container/volume was recreated without preserving ./db. Fix: sudo docker exec coturn turnadmin -a -u $c -p <password from users/${c}.env> -r $COTURN_REALM -b /var/lib/coturn/turndb"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Flag anything in the live DB with no cached file too — orphaned/manually
|
||||
# added users aren't wrong, just worth knowing about.
|
||||
while IFS= read -r u; do
|
||||
[ -z "$u" ] && continue
|
||||
found=false
|
||||
for c in "${CACHED_CONSUMERS[@]:-}"; do [ "$c" = "$u" ] && found=true && break; done
|
||||
[ "$found" = false ] && warn "Database has user '$u' with no matching users/${u}.env — added manually, or a leftover from a removed service."
|
||||
done <<< "$DB_USERS"
|
||||
|
||||
# ── Firewall ───────────────────────────────────────────────────────────────────
|
||||
section "Firewall (UFW)"
|
||||
if command -v ufw &>/dev/null; then
|
||||
UFW_STATUS="$(ufw status 2>/dev/null)"
|
||||
if grep -qE "^${COTURN_PORT}(/udp|/tcp)?\b.*ALLOW" <<< "$UFW_STATUS"; then
|
||||
ok "TURN listening port ${COTURN_PORT} allowed"
|
||||
else
|
||||
fail "TURN listening port ${COTURN_PORT} not found in 'ufw status' — clients may not reach it"
|
||||
fi
|
||||
if grep -qE "^${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp\b.*ALLOW" <<< "$UFW_STATUS"; then
|
||||
ok "Relay port range ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp allowed"
|
||||
else
|
||||
fail "Relay port range ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp not found in 'ufw status' — allocated relay ports would be unreachable, breaking media even after a successful TURN allocation"
|
||||
fi
|
||||
else
|
||||
warn "ufw not installed — can't confirm the relay range is actually open (may be fine if this box has no firewall, or one outside UFW)"
|
||||
fi
|
||||
|
||||
# ── Capacity ───────────────────────────────────────────────────────────────────
|
||||
section "Capacity"
|
||||
RANGE_SIZE=$((COTURN_MAX_PORT - COTURN_MIN_PORT + 1))
|
||||
CONSUMER_COUNT="${#CACHED_CONSUMERS[@]}"
|
||||
echo " Relay range holds ${RANGE_SIZE} ports. Each concurrent relayed call/leg typically"
|
||||
echo " uses one allocation (roughly one port) for its lifetime — released when the call"
|
||||
echo " ends, not held permanently. With ${CONSUMER_COUNT} registered consumer(s), the range"
|
||||
echo " would need all of them to have ~$((RANGE_SIZE / (CONSUMER_COUNT > 0 ? CONSUMER_COUNT : 1))) simultaneous relayed calls each, at the same"
|
||||
echo " moment, before it runs out — for Asterisk + a handful of Mattermost instances at"
|
||||
echo " personal/small-team scale, that ceiling is not realistically reachable in normal"
|
||||
echo " use. If you ever DO expect that much simultaneous WebRTC/SIP relay traffic, raise"
|
||||
echo " COTURN_MIN_PORT/COTURN_MAX_PORT in $COTURN_DIR/.env, update the matching UFW rule,"
|
||||
echo " and restart coturn — no consumer reconfiguration needed, they don't cache the range."
|
||||
echo ""
|
||||
echo " Note: not every call needs a TURN relay at all — TURN is the FALLBACK when two"
|
||||
echo " peers can't reach each other directly (STUN/ICE finds a direct path first when"
|
||||
echo " possible). Real relay usage is usually well below \"every concurrent call.\""
|
||||
|
||||
# ── Real allocation test per consumer ─────────────────────────────────────────
|
||||
section "Live allocation test (one real TURN allocation per registered consumer)"
|
||||
if ! docker exec coturn which turnutils_uclient &>/dev/null; then
|
||||
warn "turnutils_uclient not found in the coturn image — skipping live allocation tests."
|
||||
else
|
||||
TEST_HOST="${COTURN_HOST:-127.0.0.1}"
|
||||
for c in "${CACHED_CONSUMERS[@]:-}"; do
|
||||
[ -z "$c" ] && continue
|
||||
_u="$(grep '^COTURN_USER=' "$COTURN_DIR/users/${c}.env" 2>/dev/null | cut -d= -f2-)"
|
||||
_p="$(grep '^COTURN_PASS=' "$COTURN_DIR/users/${c}.env" 2>/dev/null | cut -d= -f2-)"
|
||||
if [ -z "$_u" ] || [ -z "$_p" ]; then
|
||||
warn "$c: couldn't read cached credentials, skipping live test"
|
||||
continue
|
||||
fi
|
||||
# Plain UDP only — no -t/-T (TCP/TLS) flags; coturn runs with
|
||||
# --no-tls --no-dtls (services/coturn.sh), so requesting an
|
||||
# encrypted/TCP transport here fails the allocation against a
|
||||
# server that never offered one. See tools/pstn-test-check.sh's
|
||||
# matching comment — confirmed live this was the actual cause of a
|
||||
# "Cannot complete Allocation" failure, not a real coturn problem.
|
||||
#
|
||||
# -y ("client-to-client"), not -e <peer>: turnutils_uclient refuses
|
||||
# to run at all without one of the two ("Either -e peer_address or
|
||||
# -y must be specified", confirmed live), but -e needs an actual
|
||||
# reachable, non-loopback peer — services/coturn.sh never sets
|
||||
# --allow-loopback-peers, so -e 127.0.0.1 gets rejected with
|
||||
# "channel bind: error 403 (Forbidden IP)" (confirmed live against
|
||||
# a real local coturn instance built specifically to test this).
|
||||
# -y negotiates both ends of a real relay through the server
|
||||
# itself, no separate peer needed, and works over loopback —
|
||||
# confirmed correctly reporting success (exit 0, real packet-loss
|
||||
# stats) with valid credentials and failure ("Cannot complete
|
||||
# Allocation", exit 255) with a wrong password.
|
||||
OUT="$(docker exec coturn timeout 20 turnutils_uclient -u "$_u" -w "$_p" -y "$TEST_HOST" -p "$COTURN_PORT" 2>&1)"
|
||||
RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
ok "$c: TURN allocation succeeded (credentials + relay range + reachability all confirmed working)"
|
||||
elif [ "$RC" -eq 124 ]; then
|
||||
# timeout(1)'s own exit code — no error was printed yet when the
|
||||
# clock ran out, so this isn't a reported failure like "Cannot
|
||||
# complete Allocation" would be. Worth a look, not a hard FAIL.
|
||||
warn "$c: TURN test didn't finish within 20s (no error printed — likely still negotiating). Raw output so far:"
|
||||
echo "$OUT" | tail -n 15 | sed 's/^/ /'
|
||||
else
|
||||
fail "$c: TURN allocation failed (exit $RC) — raw output:"
|
||||
echo "$OUT" | tail -n 15 | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
section "Summary"
|
||||
echo " $PASS passed, $WARN warnings, $FAIL failed."
|
||||
echo ""
|
||||
echo " A passing allocation test here proves TURN works end to end for that consumer."
|
||||
echo " It does NOT by itself prove Asterisk or Mattermost are actually configured to USE"
|
||||
echo " it — check each service's own .env for TURN_HOST/TURN_USERNAME (asterisk.sh) or"
|
||||
echo " the Calls plugin's ICE Servers Configurations (mattermost.sh) matches what's"
|
||||
echo " printed above, then place a real call from outside the LAN (the case TURN"
|
||||
echo " actually exists for — two peers on the same LAN usually connect directly and never"
|
||||
echo " touch the relay at all, so a same-LAN test call proves nothing about TURN)."
|
||||
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
#!/bin/bash
|
||||
# attic/coturn.sh — RETIRED. Formerly services/coturn.sh.
|
||||
#
|
||||
# The shared-coturn model this file implements is no longer offered by this
|
||||
# repo at all: services/asterisk.sh and services/mattermost.sh each now run
|
||||
# their own dedicated coturn unconditionally, with lib/common.sh's
|
||||
# find_free_coturn_range() making that safe (it scans every coturn-owning
|
||||
# service's own .env on the box for already-claimed relay ranges and picks
|
||||
# a block that can't collide with any of them, dedicated or shared). Sharing
|
||||
# one instance only ever saved ~40MB RAM per additional consumer beyond the
|
||||
# first — real, but small — and it was a single point of failure every
|
||||
# consumer depended on. Parked here, not deleted, since the code is still
|
||||
# correct and someone could resurrect it if a future need for it shows up;
|
||||
# living in attic/ (outside services/*.sh's glob) means it never
|
||||
# self-registers, never appears in the menu, and `sudo ./setup.sh coturn`
|
||||
# now correctly fails with "unknown service" instead of silently offering
|
||||
# a coturn shape nothing else in this repo will register a user against.
|
||||
#
|
||||
# ── Everything below this point is the file exactly as it ran before
|
||||
# retirement, kept for reference/rollback, not actively maintained. ────────
|
||||
#
|
||||
# Can still be run standalone on any machine, same as before:
|
||||
# sudo bash attic/coturn.sh
|
||||
# (Docker must already be installed when run standalone) — but nothing in
|
||||
# this repo will call ensure_coturn_user() to register with it anymore, so
|
||||
# doing this only makes sense if you're deliberately reintroducing the
|
||||
# shared-coturn pattern yourself.
|
||||
#
|
||||
# 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
|
||||
Reference in New Issue
Block a user