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
+61
View File
@@ -544,3 +544,64 @@ network) needs `host.docker.internal:PORT` in the Caddyfile, not
so that hostname resolves; `configure_caddy_for_service`'s bare-port upstream
case already does this for you — don't hand-roll `localhost:PORT` in a
Caddy site block.
## Shared coturn (TURN/STUN) relay
Any service that needs a TURN server for WebRTC/SIP NAT traversal shares
**one** coturn instance (`services/coturn.sh`) instead of running its own.
This exists because it didn't always: `asterisk` and `mattermost` used to
each embed a dedicated coturn container (`network_mode: host`, each with its
own relay port range) — confirmed live, their default ranges overlapped by
~100 UDP ports, so running both on one box meant a coin-flip over which
service's active call lost its media relay. One shared instance with one
port range removes the collision instead of just moving it around.
**Use `ensure_coturn_user` (`lib/common.sh`), not your own coturn container:**
```bash
ensure_coturn_user "my-service"
if [ -n "$COTURN_HOST" ]; then
# Out-params (not `local` — read them after the call returns, same
# convention as configure_caddy_for_service's CADDY_SERVICE_*):
# COTURN_HOST COTURN_PORT COTURN_USERNAME COTURN_PASSWORD
else
# coturn unavailable (not installed and services/coturn.sh isn't loaded
# to chain-install it — e.g. this file run fully standalone) — degrade
# gracefully. Don't block the rest of your install on this.
fi
```
`ensure_coturn_user` chain-installs `services/coturn.sh` the first time
*any* service needs one (guarded with `declare -F install_coturn`, same
pattern as the asterisk → security-dashboard chaining below), then
registers a dedicated long-term-credential username/password for your
consumer name. The credential is cached in
`~/docker/coturn/users/<consumer>.env`, so calling this again on a rerun
reuses the same credential instead of minting a new one and silently
orphaning whatever client already has the old one configured.
**Why long-term credentials (`--lt-cred-mech`), not the REST-API/HMAC mode
(`--use-auth-secret`) some WebRTC apps default to:** coturn does not support
running both auth mechanisms on one instance at once — enabling
`--use-auth-secret` silently overrides `--lt-cred-mech` server-wide, which
would break every static-credential consumer. `--lt-cred-mech` supports any
number of named users out of the box, which is the actual shape a
shared-multi-consumer coturn needs. If the service you're adding only
exposes an HMAC-secret TURN setting in its own UI (no plain
username/password option), check its docs for an alternative field first —
Mattermost's Calls plugin looked HMAC-only at a glance but also accepts a
fixed username/credential pair via its "ICE Servers Configurations" JSON
field (see `services/mattermost.sh` for the exact format). Don't fall back
to a second coturn instance just because the first field you found expects
a shared secret.
**Migrating an existing service from its own embedded coturn:** don't do it
silently. An `update` rerun must keep whatever coturn shape a service
already has — detect the existing embedded container (e.g. `grep -q '^
coturn:' docker-compose.yml` before regenerating it) and preserve it
exactly, the same non-destructive rule as every other `update` path in this
file. Only switch to the shared coturn on an explicit `fresh` reinstall, and
warn before doing it — the TURN username/password changes, and any
already-configured client (a SIP phone, a browser session) keeps the old
credentials until it's reconfigured. See `services/asterisk.sh`'s
`USE_EMBEDDED_COTURN` handling for the reference pattern.
+1 -1
View File
@@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
| Group | Services |
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
+79
View File
@@ -681,3 +681,82 @@ CADDY_BLOCK
fi
echo ""
}
# ── Shared coturn (TURN/STUN) wiring ──────────────────────────────────────────
# Usage: ensure_coturn_user "<consumer-name>"
#
# Installs the shared coturn service (services/coturn.sh) if this is the
# first service on the box that needs TURN, then registers (or reuses) a
# dedicated long-term-credential user for the caller — one coturn instance,
# one relay port range, shared by every consumer instead of each service
# running its own and fighting over host ports (see services/coturn.sh's
# header for why that used to be a real, confirmed-live problem).
#
# Out-params (not `local` — read them after the call returns), same
# convention as configure_caddy_for_service's CADDY_SERVICE_* above:
# COTURN_HOST host/IP TURN clients should connect to
# COTURN_PORT coturn's listening port
# COTURN_USERNAME this consumer's long-term-credential username
# COTURN_PASSWORD this consumer's long-term-credential password
# COTURN_HOST is left empty if coturn couldn't be installed or reached —
# callers should treat that as "no TURN available" and degrade gracefully,
# same as checking CADDY_SERVICE_CONFIGURED after configure_caddy_for_service.
#
# Credentials are cached per-consumer in coturn's own users/<name>.env so a
# service re-running its own installer reuses the same one instead of
# minting a new credential and orphaning the old one (which would silently
# break already-configured clients still holding it).
ensure_coturn_user() {
local _consumer="$1"
COTURN_HOST="" COTURN_PORT="" COTURN_USERNAME="" COTURN_PASSWORD=""
if [ ! -d "$DOCKER_DIR/coturn" ]; then
if declare -F install_coturn >/dev/null 2>&1; then
log_info "No shared coturn (TURN/STUN) server yet — setting one up for $_consumer..."
install_coturn || { log_warning "coturn setup failed — $_consumer will run without TURN."; return 1; }
else
log_warning "services/coturn.sh not loaded — $_consumer will run without TURN."
log_warning "Run: sudo ./setup.sh coturn"
return 1
fi
fi
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would register coturn user '$_consumer'"
return 0
fi
local _env="$DOCKER_DIR/coturn/.env"
[ -f "$_env" ] || { log_warning "coturn installed but $_env missing — cannot register '$_consumer'."; return 1; }
local _realm _host _port
_realm="$(grep '^COTURN_REALM=' "$_env" | cut -d= -f2-)"
_host="$(grep '^COTURN_HOST=' "$_env" | cut -d= -f2-)"
_port="$(grep '^COTURN_PORT=' "$_env" | cut -d= -f2-)"; _port="${_port:-3478}"
local _userdir="$DOCKER_DIR/coturn/users"
local _userfile="$_userdir/${_consumer}.env"
mkdir -p "$_userdir"
if [ -f "$_userfile" ]; then
local _u _p
_u="$(grep '^COTURN_USER=' "$_userfile" | cut -d= -f2-)"
_p="$(grep '^COTURN_PASS=' "$_userfile" | cut -d= -f2-)"
COTURN_USERNAME="$_u" COTURN_PASSWORD="$_p"
else
COTURN_USERNAME="$_consumer"
COTURN_PASSWORD="$(generate_password 24)"
if docker exec coturn turnadmin -a -u "$COTURN_USERNAME" -p "$COTURN_PASSWORD" \
-r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then
{ echo "COTURN_USER=$COTURN_USERNAME"; echo "COTURN_PASS=$COTURN_PASSWORD"; } > "$_userfile"
chmod 600 "$_userfile"
log_success "Registered coturn user '$COTURN_USERNAME' for $_consumer"
else
log_warning "Could not register a coturn user for $_consumer — is the coturn container running?"
COTURN_USERNAME="" COTURN_PASSWORD=""
return 1
fi
fi
COTURN_HOST="$_host"
COTURN_PORT="$_port"
}
+133 -58
View File
@@ -232,7 +232,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (intercom/VoIP; auto-tunes for a DigitalOcean droplet)" 5061
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); TURN via the shared coturn service" 5061
# ── Install layout: directory + container names ────────────────────────────
# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT.
@@ -795,9 +795,52 @@ _asterisk_offer_dashboard_and_trunk() {
# literally (it interpolates them from .env, this script must not). Project
# and container names are therefore substituted afterwards, same placeholder
# trick the Caddy volume line already uses below.
#
# USE_EMBEDDED_COTURN controls whether this install runs its own dedicated
# coturn container (legacy shape) or relies on the shared coturn service
# (services/coturn.sh) instead. This is NOT a free choice at every call site
# — an install that already has its own embedded coturn must keep getting
# one on every "update" regeneration of this file, or the next `docker
# compose up` silently drops the container its own .env TURN_PASSWORD still
# points at, breaking every already-configured phone with no warning. See
# the two call sites below for how each decides.
_asterisk_write_compose() {
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3"
cat > docker-compose.yml << 'EOF'
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" USE_EMBEDDED_COTURN="${4:-true}"
local _COTURN_DEPENDS=" depends_on:
coturn:
condition: service_started
"
local _COTURN_SERVICE="
coturn:
image: coturn/coturn:latest
container_name: COTURN_CONTAINER_PLACEHOLDER
network_mode: host
user: root
entrypoint: [\"/coturn-entrypoint.sh\"]
volumes:
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
env_file: .env
command:
- -n
- --listening-port=\${TURN_PORT:-3478}
- --listening-ip=0.0.0.0
- --fingerprint
- --lt-cred-mech
- --user=\${TURN_USERNAME:-easyasterisk}:\${TURN_PASSWORD}
- --realm=\${DOMAIN_NAME:-localhost}
- --min-port=49152
- --max-port=49252
- --no-tls
- --no-dtls
- --no-cli
- --no-multicast-peers
- --log-file=stdout
restart: unless-stopped
"
[[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE=""
cat > docker-compose.yml << EOF
name: PROJECT_NAME_PLACEHOLDER
services:
@@ -805,10 +848,7 @@ services:
build: .
container_name: ASTERISK_CONTAINER_PLACEHOLDER
network_mode: host
depends_on:
coturn:
condition: service_started
volumes:
${_COTURN_DEPENDS} volumes:
- ./config/asterisk:/etc/asterisk
- ./config/easy-asterisk:/etc/easy-asterisk
- ./logs:/var/log/asterisk
@@ -824,33 +864,7 @@ CADDY_VOLUME_PLACEHOLDER
interval: 30s
timeout: 5s
retries: 3
coturn:
image: coturn/coturn:latest
container_name: COTURN_CONTAINER_PLACEHOLDER
network_mode: host
user: root
entrypoint: ["/coturn-entrypoint.sh"]
volumes:
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
env_file: .env
command:
- -n
- --listening-port=${TURN_PORT:-3478}
- --listening-ip=0.0.0.0
- --fingerprint
- --lt-cred-mech
- --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD}
- --realm=${DOMAIN_NAME:-localhost}
- --min-port=49152
- --max-port=49252
- --no-tls
- --no-dtls
- --no-cli
- --no-multicast-peers
- --log-file=stdout
restart: unless-stopped
${_COTURN_SERVICE}
EOF
sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \
@@ -859,8 +873,10 @@ EOF
# Share Caddy's cert store (read-only) so the entrypoint can auto-sync a
# real Let's Encrypt cert for DOMAIN_NAME instead of falling back to
# self-signed. No-op if Caddy isn't installed on this box.
if [[ -d "$DOCKER_DIR/caddy/data" ]]; then
# self-signed. No-op if Caddy isn't installed on this box. Only relevant
# to the embedded coturn — the shared coturn service doesn't do TLS/TURNS
# at all (see services/coturn.sh's README for that tradeoff).
if [[ "$USE_EMBEDDED_COTURN" == true && -d "$DOCKER_DIR/caddy/data" ]]; then
sed -i "s#CADDY_VOLUME_PLACEHOLDER# - ${DOCKER_DIR}/caddy/data:/caddy-data:ro#" docker-compose.yml
else
sed -i "/CADDY_VOLUME_PLACEHOLDER/d" docker-compose.yml
@@ -1145,7 +1161,9 @@ _asterisk_configure_do_cloud_firewall() {
# the two deployment shapes can't document themselves differently by accident.
_asterisk_write_readme() {
local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6"
local USE_EMBEDDED_COTURN="${7:-true}" TURN_USERNAME_VAL="${8:-easyasterisk}" TURN_SERVER_DISPLAY="${9:-}"
local _host="${DOMAIN_NAME:-${PUBLIC_IP:-<host-ip>}}"
[ -z "$TURN_SERVER_DISPLAY" ] && TURN_SERVER_DISPLAY="${_host}:3478"
{
cat << MD
@@ -1186,10 +1204,14 @@ connecting a phone. The Security Dashboard's Extensions tab
|-----------------|--------------------------------------|
| SIP server | \`${_host}\` |
| SIP port | 5061 (TLS) / 5060 (UDP) |
| TURN server | \`${_host}:3478\` |
| TURN username | easyasterisk |
| TURN server | \`${TURN_SERVER_DISPLAY}\` |
| TURN username | ${TURN_USERNAME_VAL} |
| TURN password | see \`.env\` → \`TURN_PASSWORD\` |
$( [[ "$USE_EMBEDDED_COTURN" == true ]] \
&& echo "This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml)." \
|| echo "TURN is served by the box's shared coturn service, not a container in this compose file — see \`~/docker/coturn/README.md\`. Every service on the box that needs TURN (Mattermost Calls, etc.) shares this same relay, each with its own dedicated username." )
Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and
[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP +
STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN,
@@ -1403,7 +1425,11 @@ install_asterisk() {
echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin"
echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl"
echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)"
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 3478, 10000-20000, 49152-49252"
echo "[DRY-RUN] Would register a TURN user with the shared coturn service (chain-installing it"
echo "[DRY-RUN] if this is the first service on the box that needs one), falling back to"
echo "[DRY-RUN] Asterisk's own dedicated coturn if the shared service is unavailable"
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 10000-20000,"
echo "[DRY-RUN] plus 3478 + 49152-49252 only if falling back to a dedicated coturn"
echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists"
echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route"
echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context,"
@@ -1434,13 +1460,24 @@ install_asterisk() {
prompt_reinstall_mode REINSTALL_MODE
case "$REINSTALL_MODE" in
update)
# Detect BEFORE regenerating docker-compose.yml below: an
# install that already runs its own dedicated coturn (every
# install predating the shared coturn service) must keep
# getting one on every update, or this rebuild silently
# drops the container its own .env TURN_PASSWORD still
# points at — every already-configured phone loses TURN with
# no warning. Only an install with no embedded coturn block
# (new installs made after shared coturn existed) skips it.
local _HAD_EMBEDDED_COTURN=false
grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true
mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \
"$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports"
ensure_docker_dir_ownership "$EA_DIR"
cd "$EA_DIR" || return 1
_asterisk_refresh_vendor_files
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN"
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$_HAD_EMBEDDED_COTURN"
_asterisk_write_logrotate "$EA_DIR"
_asterisk_patch_messaging_vendor_files "$EA_DIR"
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
@@ -1559,21 +1596,53 @@ install_asterisk() {
[[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y"
fi
# ── Secrets ───────────────────────────────────────────────────────────────
local TURN_PASSWORD
TURN_PASSWORD="$(generate_password 24)"
# ── Secrets / TURN ───────────────────────────────────────────────────────
# Prefer the shared coturn service (services/coturn.sh) — one TURN server
# for every service on the box instead of Asterisk running its own and
# fighting other consumers (Mattermost, etc.) over relay ports. Falls
# back to Asterisk's own dedicated coturn if the shared service isn't
# available (e.g. this file run standalone with no sibling services/*.sh
# sourced) or registration fails for any reason — Asterisk should never
# end up with no TURN at all just because the shared path had a problem.
local USE_EMBEDDED_COTURN=true
local TURN_USERNAME TURN_PASSWORD TURN_PORT_VAL TURN_SERVER_VAL
# A public box always has a usable TURN address (the FQDN if set, else its
# public IP). A LAN box with no FQDN has none — coturn is only reachable
# over the local network, so clients use the server's LAN address directly.
local TURN_SERVER_VAL=""
if [[ "$IS_DO" == true ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478"
elif [[ -n "$DOMAIN_NAME" ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
# Only reachable here via an explicit "fresh" choice above — "update"
# is handled separately and always preserves whatever coturn shape
# already exists, never silently switches it.
if [[ -f "$EA_DIR/docker-compose.yml" ]] && grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
echo ""
log_warning "This box's existing Asterisk install has its own dedicated coturn."
log_warning "Continuing may switch it to the new shared coturn service — any"
log_warning "phone/softphone configured with the OLD TURN username/password will"
log_warning "need updating once this completes."
fi
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN"
ensure_coturn_user "asterisk"
if [[ -n "${COTURN_HOST:-}" ]]; then
USE_EMBEDDED_COTURN=false
TURN_USERNAME="$COTURN_USERNAME"
TURN_PASSWORD="$COTURN_PASSWORD"
TURN_PORT_VAL="$COTURN_PORT"
TURN_SERVER_VAL="${COTURN_HOST}:${COTURN_PORT}"
log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'."
else
TURN_USERNAME="easyasterisk"
TURN_PASSWORD="$(generate_password 24)"
TURN_PORT_VAL="3478"
# A public box always has a usable TURN address (the FQDN if set, else its
# public IP). A LAN box with no FQDN has none — coturn is only reachable
# over the local network, so clients use the server's LAN address directly.
TURN_SERVER_VAL=""
if [[ "$IS_DO" == true ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478"
elif [[ -n "$DOMAIN_NAME" ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
fi
log_info "Shared coturn unavailable — Asterisk will run its own dedicated coturn."
fi
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN"
# ── Pick a free port for the web admin ─────────────────────────────────────
# Hardcoding a single number gets fragile fast once several services share
@@ -1612,9 +1681,10 @@ install_asterisk() {
DOMAIN_NAME=${DOMAIN_NAME}
# ── TURN/STUN ─────────────────────────────────────────────────
TURN_USERNAME=easyasterisk
# $( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml)." || echo "Using the shared coturn service — see ~/docker/coturn/README.md." )
TURN_USERNAME=${TURN_USERNAME}
TURN_PASSWORD=${TURN_PASSWORD}
TURN_PORT=3478
TURN_PORT=${TURN_PORT_VAL}
# Empty when there's no publicly resolvable address (LAN-only, no FQDN).
TURN_SERVER=${TURN_SERVER_VAL}
@@ -1679,10 +1749,14 @@ ENV
fi
ufw allow 8088/tcp
ufw allow 8089/tcp
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow 10000:20000/udp
ufw allow 49152:49252/udp
if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow 49152:49252/udp
fi
# Shared coturn opens its own ports once, at its own install time
# (services/coturn.sh) — nothing to open here when using it.
ensure_ufw_enabled
log_success "UFW rules added."
fi
@@ -1713,7 +1787,8 @@ ENV
_asterisk_run_presence_step "$EA_DIR" "$CONTAINER"
# ── README ────────────────────────────────────────────────────────────────
_asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL"
_asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" \
"$USE_EMBEDDED_COTURN" "$TURN_USERNAME" "$TURN_SERVER_VAL"
# ── Start ─────────────────────────────────────────────────────────────────
echo ""
+385
View File
@@ -0,0 +1,385 @@
#!/bin/bash
# services/coturn.sh — Shared TURN/STUN relay (coturn) for WebRTC-capable services.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash coturn.sh
# (Docker must already be installed when run standalone)
#
# One coturn instance, shared by every service that needs TURN (Asterisk,
# Mattermost, and anything added later) instead of each service running its
# own — which used to mean N containers all on network_mode: host fighting
# over relay port ranges (confirmed live: Asterisk's default range and
# Mattermost's default range overlapped by ~100 ports before this existed).
#
# Runs in long-term-credential mode (--lt-cred-mech) with a SQLite user
# database instead of a single static user — every consumer registers its
# own dedicated username/password via ensure_coturn_user() (lib/common.sh),
# so credentials are per-service and one consumer being compromised or
# reconfigured doesn't affect any other's TURN access.
#
# Deliberately NOT --use-auth-secret (the HMAC/REST-API mode Mattermost's
# Calls plugin also supports): coturn does not support both auth mechanisms
# on one running instance at once — turning on --use-auth-secret silently
# overrides --lt-cred-mech server-wide, which would break every
# static-credential consumer (Asterisk's PJSIP TURN client wants a fixed
# long-lived username/password, not a periodically-regenerated HMAC one).
# lt-cred-mech supports any number of named users out of the box, which is
# exactly the shared-multi-consumer shape this needs — no tradeoff either
# way. Mattermost's Calls plugin is configured with a static username/
# credential pair too (its "ICE Servers Configurations" field), not its
# "TURN Static Auth Secret" field, so both consumers use the same mechanism.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Existing install detected. Choose:"
echo " r) Reinstall in place — refresh vendor files, keep existing settings"
echo " f) Full install — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [r/f/c, Enter=cancel]: " _r
case "${_r,,}" in
r) eval "$_var='update'" ;;
f) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
ensure_ufw_enabled() {
command -v ufw &>/dev/null || return 0
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
ufw status 2>/dev/null | grep -q "Status: active" && return 0
local _ssh_port
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | tail -1 | awk '{print $2}')"
ufw allow "${_ssh_port:-22}/tcp" comment 'SSH' >/dev/null 2>&1
ufw --force enable >/dev/null 2>&1
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service coturn homelab "Shared TURN/STUN relay (coturn) for Asterisk, Mattermost, and other WebRTC-capable services" 3478
install_coturn() {
require_docker || return 1
local DIR="$DOCKER_DIR/coturn"
local ENV_FILE="$DIR/.env"
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Shared coturn (TURN/STUN relay) ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
echo " One TURN server, shared by every service that needs one (Asterisk,"
echo " Mattermost Calls, anything added later) — each gets its own"
echo " dedicated username/password, registered automatically the first"
echo " time that service is installed. You normally don't run this"
echo " directly; another service's installer chains into it."
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env"
echo "[DRY-RUN] Would run coturn in --lt-cred-mech mode with a SQLite user database"
echo "[DRY-RUN] Would open UFW: 3478/udp+tcp, and the relay port range udp"
return 0
fi
# ── Update vs. fresh reinstall ─────────────────────────────────────────────
# "update" only refreshes the image/compose shape — realm, host, port
# range, and every registered consumer's credentials are left exactly as
# they are. Rotating any of those here would silently break TURN for
# every service already relying on this instance (Asterisk phones,
# Mattermost Calls) without those services knowing to reconfigure.
local MODE="fresh"
if [[ -f "$DIR/docker-compose.yml" && -f "$ENV_FILE" ]]; then
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the coturn image/compose only — realm, host, port range, and"
log_info "every registered consumer's credentials are left exactly as they are."
;;
cancel)
log_info "Leaving the existing coturn install as-is."
return 0
;;
fresh)
echo ""
log_warning "A full reinstall regenerates nothing destructive by itself, but if you"
log_warning "change the host/port/realm below, every already-registered consumer"
log_warning "(Asterisk, Mattermost, ...) keeps pointing at the OLD values in its own"
log_warning ".env until you re-run that service's installer too."
;;
esac
fi
mkdir -p "$DIR/db" "$DIR/users"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local COTURN_REALM="" COTURN_HOST="" COTURN_PORT="3478"
local COTURN_MIN_PORT="49152" COTURN_MAX_PORT="49452"
if [ "$MODE" = "update" ]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
else
local _default_realm="${SITE_DOMAIN:-localhost}"
prompt_text " Realm (usually your domain, or 'localhost' for LAN-only):" "$_default_realm" COTURN_REALM
local _detected_ip
_detected_ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
prompt_text " Public hostname/IP TURN clients should connect to:" "$_detected_ip" COTURN_HOST
prompt_text " Listening port:" "3478" COTURN_PORT
prompt_text " Relay port range — min:" "49152" COTURN_MIN_PORT
prompt_text " Relay port range — max (each concurrent relayed call needs ~1 port; 300 ports is generous for a homelab):" "49452" COTURN_MAX_PORT
fi
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
cat > docker-compose.yml << 'EOF'
name: coturn
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
network_mode: host
user: root
env_file: .env
volumes:
- ./db:/var/lib/coturn
command:
- -n
- --listening-port=${COTURN_PORT:-3478}
- --listening-ip=0.0.0.0
- --fingerprint
- --lt-cred-mech
- --userdb=/var/lib/coturn/turndb
- --realm=${COTURN_REALM:-localhost}
- --min-port=${COTURN_MIN_PORT:-49152}
- --max-port=${COTURN_MAX_PORT:-49452}
- --no-tls
- --no-dtls
- --no-cli
- --no-multicast-peers
- --log-file=stdout
restart: unless-stopped
EOF
cat > "$ENV_FILE" << ENVEOF
TZ=$TZ_VAL
# ── Identity — read by lib/common.sh's ensure_coturn_user() ────────────────
# Changing these after consumers already registered breaks TURN for them
# until each one is reconfigured — see the warning above before editing.
COTURN_REALM=$COTURN_REALM
COTURN_HOST=$COTURN_HOST
COTURN_PORT=$COTURN_PORT
COTURN_MIN_PORT=$COTURN_MIN_PORT
COTURN_MAX_PORT=$COTURN_MAX_PORT
ENVEOF
chmod 600 "$ENV_FILE"
chown "$ACTUAL_USER:$ACTUAL_USER" docker-compose.yml "$ENV_FILE"
log_success "coturn configured at $DIR"
# ── Firewall ──────────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow "${COTURN_PORT}/udp" comment 'coturn TURN/STUN' >/dev/null 2>&1
ufw allow "${COTURN_PORT}/tcp" comment 'coturn TURN/STUN' >/dev/null 2>&1
ufw allow "${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp" comment 'coturn relay' >/dev/null 2>&1
log_success "UFW: opened ${COTURN_PORT}/udp+tcp and ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp"
ensure_ufw_enabled
fi
# ── Admin helper: list/add/remove consumers without touching compose ───────
cat > coturn_user.sh << 'USEREOF'
#!/bin/bash
# ~/docker/coturn/coturn_user.sh — manage TURN users in the shared coturn's
# SQLite user database. Most services register themselves automatically via
# ensure_coturn_user() (lib/common.sh) at install time — this is for manual
# inspection/cleanup.
#
# sudo ./coturn_user.sh list
# sudo ./coturn_user.sh add <name> <password>
# sudo ./coturn_user.sh remove <name>
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "$HERE/.env"
case "${1:-}" in
list)
docker exec coturn turnadmin -l -b /var/lib/coturn/turndb
;;
add)
[ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "Usage: $0 add <name> <password>"; exit 1; }
docker exec coturn turnadmin -a -u "$2" -p "$3" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
&& echo "Added: $2" \
|| echo "Failed to add $2 — is the coturn container running?"
;;
remove)
[ -n "${2:-}" ] || { echo "Usage: $0 remove <name>"; exit 1; }
docker exec coturn turnadmin -d -u "$2" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
&& { echo "Removed: $2"; rm -f "$HERE/users/$2.env"; } \
|| echo "Failed to remove $2"
;;
*)
echo "Usage: $0 {list|add <name> <password>|remove <name>}"
exit 1
;;
esac
USEREOF
chmod +x coturn_user.sh
chown "$ACTUAL_USER:$ACTUAL_USER" coturn_user.sh
write_readme "$DIR" << MD
# coturn — shared TURN/STUN relay
One coturn instance shared by every service on this box that needs TURN
(Asterisk, Mattermost Calls, anything added later) — instead of each running
its own and fighting over host ports for the relay range.
Runs in long-term-credential mode with a SQLite user database. Each
consumer gets its own dedicated username/password, registered automatically
by that service's installer via \`ensure_coturn_user()\` — you don't
normally need to touch this directly.
## Identity
- Realm: \`$COTURN_REALM\`
- Host clients connect to: \`$COTURN_HOST\`
- Listening port: \`$COTURN_PORT\`
- Relay port range: \`$COTURN_MIN_PORT-$COTURN_MAX_PORT\` (udp)
**Changing any of the above breaks TURN for every already-registered
consumer until that service's installer is re-run** — they cache the host/
port/credentials in their own \`.env\` at registration time, not read live.
## Manage users
\`\`\`bash
sudo ./coturn_user.sh list
sudo ./coturn_user.sh add <name> <password>
sudo ./coturn_user.sh remove <name>
\`\`\`
Per-consumer credentials are also cached in \`users/<name>.env\` (chmod 600)
so a service re-running its own installer reuses the same credential
instead of silently minting a new one and orphaning the old.
## Manage the container
\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
\`\`\`
## Adding a new service that needs TURN
In that service's \`install_<name>()\`, after \`require_docker\`:
\`\`\`bash
ensure_coturn_user "my-service"
if [ -n "\$COTURN_HOST" ]; then
# COTURN_HOST / COTURN_PORT / COTURN_USERNAME / COTURN_PASSWORD are set
# (not local — read them after the call returns, same convention as
# configure_caddy_for_service's CADDY_SERVICE_* out-params)
else
# coturn unavailable — degrade gracefully (no TURN, or prompt to run
# `sudo ./setup.sh coturn` first)
fi
\`\`\`
MD
local START=""
prompt_yn "Start coturn now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "coturn started" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo ""
echo " Realm: $COTURN_REALM Host: $COTURN_HOST Port: $COTURN_PORT"
echo " Relay range: $COTURN_MIN_PORT-$COTURN_MAX_PORT/udp"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_coturn
+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 ""
}