Extract coturn into a shared service; add Mattermost multi-instance support

Asterisk and Mattermost each used to embed their own dedicated coturn
container (network_mode: host), and their default relay port ranges
overlapped by ~100 UDP ports — running both on one box meant a
coin-flip over which service's active call lost its media relay.

- services/coturn.sh: new shared TURN/STUN relay, one instance for
  every consumer instead of one each. Runs --lt-cred-mech with a
  SQLite user database (not --use-auth-secret — coturn doesn't
  support both auth mechanisms on one instance at once, confirmed via
  coturn's own upstream docs/issues) so each consumer gets its own
  dedicated username/password without stepping on any other's.

- lib/common.sh: ensure_coturn_user() — chain-installs coturn.sh on
  first need (same declare -F guard pattern as the existing
  asterisk -> security-dashboard chaining) and registers/reuses a
  per-consumer credential, mirroring configure_caddy_for_service's
  out-param convention.

- services/asterisk.sh: _asterisk_write_compose gains a
  USE_EMBEDDED_COTURN flag. New installs use the shared service;
  existing installs keep their dedicated coturn exactly as-is on
  every "update" (detected from the existing compose file before
  regenerating it, so a rebuild can never silently drop the container
  its own .env TURN_PASSWORD still points at) and only switch on an
  explicit "fresh" reinstall, with a warning first.

- services/mattermost.sh: same embedded/shared coturn handling, plus
  genuine multi-instance support (separate dir/containers/DB/ports per
  instance, auto-scanned free ports for extras) for real isolation
  between groups, as opposed to Team Edition's built-in Teams feature.
  Calls plugin TURN config switched from the HMAC "TURN Static Auth
  Secret" field to the verified "ICE Servers Configurations" JSON
  field, which accepts the same fixed username/credential shared
  coturn issues. Also fixes a latent bug found while adding proper
  update-mode detection: DB_PASS/MM_SECRET were regenerated on every
  single rerun with no existing-install check at all, silently
  breaking Postgres auth on any reinstall.

- CLAUDE.md: documents the ensure_coturn_user pattern (including the
  auth-mechanism constraint and the embedded-coturn migration-safety
  rule) for any future service that needs TURN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
This commit is contained in:
Claude
2026-08-04 16:32:08 +00:00
parent 883f0d2557
commit cf3d4bf6de
6 changed files with 877 additions and 123 deletions
+218 -64
View File
@@ -208,40 +208,129 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; TURN via the shared coturn service); supports multiple isolated instances" 8065
install_mattermost() {
require_docker || return 1
log_info "Installing Mattermost + coturn..."
# ── Instance selection ──────────────────────────────────────────────────
# First instance keeps the plain "mattermost" name/paths/ports exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces the suffixed naming.
local DIR="$DOCKER_DIR/mattermost"
local INSTANCE_SUFFIX="" PROJECT="mattermost"
local MM_CONTAINER="mattermost" DB_CONTAINER="mattermost-db"
local WEB_PORT="8065" CALLS_UDP_PORT="8443"
local COTURN_CONSUMER="mattermost"
if [ -d "$DIR" ]; then
echo ""
echo " Mattermost is already installed at $DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Mattermost instance alongside it (its own"
echo " server, database, and TURN credential — full isolation, not Teams)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'team-b'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/mattermost-$_suffix" ]; then
log_warning "mattermost-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
DIR="$DOCKER_DIR/mattermost-$_suffix"
PROJECT="mattermost-$_suffix"
MM_CONTAINER="mattermost-$_suffix"
DB_CONTAINER="mattermost-$_suffix-db"
COTURN_CONSUMER="mattermost-$_suffix"
# Free-port scan — same pattern services/asterisk.sh uses for its
# web admin port. WEB_PORT is also set as Mattermost's own
# internal ListenAddress below (not just the host publish side),
# so configure_caddy_for_service's single upstream "name:port"
# string works unmodified in both local and remote-Caddy mode —
# it assumes host-published-port == container-internal-port,
# true for every other service in this repo and made true here
# too rather than special-casing the shared helper for one caller.
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
WEB_PORT=$((WEB_PORT + 1))
done
while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do
CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1))
done
log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)"
fi
fi
log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..."
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp"
echo "[DRY-RUN] Would register a TURN user with the shared coturn service for '$COTURN_CONSUMER'"
echo "[DRY-RUN] (falling back to a dedicated coturn if the shared service is unavailable)"
echo "[DRY-RUN] Would open UFW ports ${WEB_PORT}/tcp, ${CALLS_UDP_PORT}/udp"
return 0
fi
# ── Existing install (this exact instance)? Offer update-in-place ───────
local MODE="fresh"
local _HAD_EMBEDDED_COTURN=false
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
prompt_reinstall_mode MODE
grep -q '^ coturn:' "$DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true
case "$MODE" in
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh)
if [ "$_HAD_EMBEDDED_COTURN" = true ]; then
echo ""
log_warning "This install has its own dedicated coturn. Continuing may switch it to"
log_warning "the shared coturn service — the Calls plugin's TURN config in System"
log_warning "Console will need updating to the new credentials afterward (see below)."
fi
;;
esac
fi
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local DB_PASS
local MM_SECRET
DB_PASS=$(generate_password 32)
MM_SECRET=$(generate_password 48)
# Reuse existing secrets on update — Postgres's volume keeps the password
# from its first init, so overwriting .env with a fresh one locks
# Mattermost out of its own database. Confirmed this was previously
# unconditional (regenerated every single rerun, silently breaking the DB
# connection) — fixed here as part of adding proper update detection.
local DB_PASS="" MM_SECRET=""
if [ "$MODE" = "update" ]; then
DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)"
[ "$_HAD_EMBEDDED_COTURN" = true ] && MM_SECRET="$(grep '^COTURN_SECRET=' .env 2>/dev/null | cut -d= -f2-)"
fi
[ -n "$DB_PASS" ] || DB_PASS=$(generate_password 32)
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
local UID_VAL GID_VAL
UID_VAL=$(id -u "$ACTUAL_USER")
GID_VAL=$(id -g "$ACTUAL_USER")
# Compute SITE_URL
local SITE_URL="http://localhost:8065"
# Compute SITE_URL — extra instances default to a distinct subdomain so
# they don't collide with the first instance's.
local _default_subdomain="mattermost${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
local SITE_URL="http://localhost:${WEB_PORT}"
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
SITE_URL="https://mattermost.${SITE_DOMAIN}"
SITE_URL="https://${_default_subdomain}.${SITE_DOMAIN}"
fi
local CONFIGURED_SITEURL=""
prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL
@@ -270,45 +359,34 @@ networks:
"
fi
cat > docker-compose.yml << EOF
name: mattermost
# ── TURN: shared coturn preferred, dedicated coturn as fallback ─────────
# See services/coturn.sh's header for why one shared TURN server beats
# every service (Asterisk, each Mattermost instance, ...) running its
# own and fighting over host relay ports.
local USE_EMBEDDED_COTURN=true
local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL=""
services:
db:
image: postgres:15-alpine
container_name: mattermost-db
hostname: mattermost-db
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/postgresql/data
${_CADDY_NET_BLOCK} healthcheck:
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then
USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update
else
ensure_coturn_user "$COTURN_CONSUMER"
if [ -n "${COTURN_HOST:-}" ]; then
USE_EMBEDDED_COTURN=false
TURN_HOST_VAL="$COTURN_HOST"; TURN_PORT_VAL="$COTURN_PORT"
TURN_USERNAME_VAL="$COTURN_USERNAME"; TURN_PASSWORD_VAL="$COTURN_PASSWORD"
log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'."
else
log_info "Shared coturn unavailable — this instance will run its own dedicated coturn."
fi
fi
[ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48)
mattermost:
image: mattermost/mattermost-team-edition:latest
container_name: mattermost
hostname: mattermost
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- ./data:/mattermost/data
- ./logs:/mattermost/logs
- ./config:/mattermost/config
- ./plugins:/mattermost/plugins
ports:
- "8065:8065"
- "8443:8443/udp"
${_CADDY_NET_BLOCK}
local _COTURN_SERVICE=""
if [ "$USE_EMBEDDED_COTURN" = true ]; then
_COTURN_SERVICE="
coturn:
image: coturn/coturn:latest
container_name: mattermost-coturn
container_name: ${MM_CONTAINER}-coturn
network_mode: host
user: root
command:
@@ -327,7 +405,45 @@ ${_CADDY_NET_BLOCK}
- --no-multicast-peers
- --log-file=stdout
restart: unless-stopped
${_CADDY_NET_SECTION}
"
fi
cat > docker-compose.yml << EOF
name: ${PROJECT}
services:
db:
image: postgres:15-alpine
container_name: ${DB_CONTAINER}
hostname: ${DB_CONTAINER}
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/postgresql/data
${_CADDY_NET_BLOCK} healthcheck:
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
mattermost:
image: mattermost/mattermost-team-edition:latest
container_name: ${MM_CONTAINER}
hostname: ${MM_CONTAINER}
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- ./data:/mattermost/data
- ./logs:/mattermost/logs
- ./config:/mattermost/config
- ./plugins:/mattermost/plugins
ports:
- "${WEB_PORT}:${WEB_PORT}"
- "${CALLS_UDP_PORT}:8443/udp"
${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION}
EOF
cat > .env << EOF
@@ -341,15 +457,24 @@ POSTGRES_PASSWORD=$DB_PASS
# Mattermost
MM_SQLSETTINGS_DRIVERNAME=postgres
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@${DB_CONTAINER}:5432/mattermost?sslmode=disable&connect_timeout=10
MM_SERVICESETTINGS_SITEURL=$SITE_URL
MM_SERVICESETTINGS_LISTENADDRESS=:${WEB_PORT}
MM_SERVICESETTINGS_ENABLELOCALMODE=true
MM_FILESETTINGS_DRIVERNAME=local
MM_PLUGINSETTINGS_ENABLE=true
# coturn HMAC secret for Mattermost Calls plugin
# ── TURN/STUN (Calls plugin) ─────────────────────────────────
$( [ "$USE_EMBEDDED_COTURN" = true ] \
&& echo "# This instance runs its own dedicated coturn (the coturn: service in docker-compose.yml)." \
|| echo "# Using the shared coturn service — see ~/docker/coturn/README.md." )
# coturn HMAC secret — only used if this instance runs its own dedicated coturn.
COTURN_SECRET=$MM_SECRET
MM_REALM=${SITE_DOMAIN:-localhost}
TURN_HOST=$TURN_HOST_VAL
TURN_PORT=$TURN_PORT_VAL
TURN_USERNAME=$TURN_USERNAME_VAL
TURN_PASSWORD=$TURN_PASSWORD_VAL
# PUID/PGID for file ownership
PUID=$UID_VAL
@@ -360,25 +485,59 @@ EOF
mkdir -p data logs config plugins db
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
# Open required firewall ports
# ── Firewall ─────────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow 8443/udp comment "Mattermost Calls RTC"
ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow 49153:49352/udp comment "Mattermost coturn relay"
ufw allow "${WEB_PORT}/tcp" comment "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
ufw allow "${CALLS_UDP_PORT}/udp" comment "Mattermost Calls RTC${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
if [ "$USE_EMBEDDED_COTURN" = true ]; then
ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow 49153:49352/udp comment "Mattermost coturn relay"
fi
# Shared coturn opens its own ports once, at its own install time.
fi
echo ""
log_success "Mattermost configured at $DIR"
configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost"
configure_caddy_for_service "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${MM_CONTAINER}:${WEB_PORT}" "$_default_subdomain"
# Exact ICEServersConfigs JSON to paste into System Console — verified
# against the Calls plugin's actual config schema (plugin.json): this
# field takes a fixed username/credential pair, which is what a
# --lt-cred-mech coturn (shared or dedicated) expects, as opposed to the
# "TURN Static Auth Secret" field (HMAC/REST-API mode, which coturn
# cannot run at the same time as --lt-cred-mech on one instance).
local _ICE_JSON _turn_config_md
if [ "$USE_EMBEDDED_COTURN" = true ]; then
_ICE_JSON="[{\"urls\":[\"turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\"],\"username\":\"static\",\"credential\":\"see COTURN_SECRET below — this dedicated coturn uses use-auth-secret/HMAC, not a fixed credential\"}]"
_turn_config_md="This instance runs its own dedicated coturn (HMAC/REST-API auth):
- TURN Server URI: \`turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\`
- System Console → Plugins → Calls → **TURN Static Auth Secret**: value of \`COTURN_SECRET\` in \`.env\`"
else
_ICE_JSON="[{\"urls\":[\"turn:${TURN_HOST_VAL}:${TURN_PORT_VAL}?transport=udp\"],\"username\":\"${TURN_USERNAME_VAL}\",\"credential\":\"${TURN_PASSWORD_VAL}\"}]"
_turn_config_md="This instance uses the shared coturn service (fixed username/credential, not HMAC):
- System Console → Plugins → Calls → **ICE Servers Configurations** — paste:
\`\`\`json
$_ICE_JSON
\`\`\`
- Leave **TURN Static Auth Secret** empty — that field is for the OTHER auth
mode coturn supports and doesn't apply here."
fi
[ "${CALLS_UDP_PORT}" != "8443" ] && _turn_config_md="$_turn_config_md
- System Console → Plugins → Calls → **ICE Host Port Override**: \`${CALLS_UDP_PORT}\` (this instance publishes Calls RTC on a non-default port)"
write_readme "$DIR" << MD
# Mattermost
# Mattermost${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay.
Team messaging with voice/video calls. PostgreSQL backend.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own database, own
TURN credential) — not a Team within another instance. See that instance's
own README for its own access details." )
## Access
- URL: $SITE_URL (or http://localhost:8065)
- URL: $SITE_URL (or http://localhost:${WEB_PORT})
- First run: create admin account at the URL above — this account becomes
System Admin automatically.
@@ -416,12 +575,9 @@ team currently open. If you need real isolation between groups rather than
a tidier picker, that means separate Mattermost instances, not this setting.
## Voice/Video Calls (Calls plugin)
Port 8443/udp must be open on your router/firewall.
coturn relay runs on port 3479 (HMAC secret in .env).
Port ${CALLS_UDP_PORT}/udp must be open on your router/firewall.
Configure in Mattermost: System Console → Plugins → Calls:
- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp
- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET)
${_turn_config_md}
## Manage
\`\`\`bash
@@ -449,9 +605,7 @@ MD
echo " First run: open the URL above and create your admin account."
echo " Teams: team sidebar '+' → Create a new team (see README.md — no"
echo " Enterprise license needed, Team Edition includes this)."
echo " Calls plugin: System Console → Plugins → Calls to configure coturn."
echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp"
echo " Auth secret: see COTURN_SECRET in $DIR/.env"
echo " Calls plugin TURN config: see README.md (System Console → Plugins → Calls)."
echo ""
}