Merge main into ionos-script-integration-x32ofw, resolve coturn conflicts

Both branches independently solved the same Mattermost/Asterisk coturn
relay-port collision problem. Kept main's find_free_coturn_range-based
approach (documented in CLAUDE.md as the canonical pattern, and shared
across every coturn-owning service) over this branch's earlier
Mattermost-only port-slot scheme, and cleaned up the now-unused
_MM_COTURN_PORT/_MM_COTURN_MIN/_MM_COTURN_MAX/EMBEDDED_COTURN_SLOT
references that had auto-merged without conflict markers.
This commit is contained in:
Claude
2026-08-14 01:26:28 +00:00
12 changed files with 565 additions and 410 deletions
+176 -93
View File
@@ -123,6 +123,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)."
}
# Standalone-mode copy of lib/common.sh's find_free_coturn_range() —
# kept in sync by hand, same as every other helper stubbed in this block.
find_free_coturn_range() {
local _min_varname="$1" _max_varname="$2" _range_size="${3:-200}" _start="${4:-49152}"
local _highest_max=$((_start - 1)) _f _found
for _f in "$DOCKER_DIR"/*/.env; do
[ -f "$_f" ] || continue
_found="$(grep -E '^(COTURN|TURN)_MAX_PORT=' "$_f" 2>/dev/null | tail -1 | cut -d= -f2-)"
[[ "$_found" =~ ^[0-9]+$ ]] || continue
[ "$_found" -gt "$_highest_max" ] && _highest_max=$_found
done
local _min=$_start
[ "$_highest_max" -ge "$_start" ] && _min=$((_highest_max + 50))
eval "$_min_varname='$_min'"
eval "$_max_varname='$((_min + _range_size))'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
@@ -263,7 +280,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); TURN via the shared coturn service" 5061
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); own dedicated coturn for TURN" 5061
# ── Install layout: directory + container names ────────────────────────────
# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT.
@@ -385,6 +402,30 @@ _asterisk_refresh_vendor_files() {
else
log_warning "entrypoint.sh logger.conf template changed upstream — security events won't be logged to a file. Update the sed patch in this installer."
fi
# Regenerate the self-signed TLS cert when it doesn't match the current
# DOMAIN_NAME. Vendor's own check only asks "does the file exist" and
# "does it have a SAN extension" -- never "does the SAN match the domain
# actually configured now" -- so a domain entered once (even a
# placeholder, or one later changed) sticks in the cert FOREVER: it
# survives every subsequent update *and* full reinstall, because
# /etc/asterisk/certs is a bind-mounted host directory neither install
# mode ever wipes (the same reason pjsip.conf/devices survive reinstalls
# too). Confirmed live: a box's TLS transport kept presenting a cert for
# a stale, originally-entered domain long after DOMAIN_NAME had changed
# and a full reinstall had been run in between -- most SIP/TLS clients
# refuse a cert like that outright with no clear error, and this was the
# actual cause of a "port's open but registration still fails" case that
# every other check (firewall, coturn, DNS) had already come back clean.
if grep -q '^if \$regen_cert; then$' ./docker/entrypoint.sh; then
sed -i '/^if \$regen_cert; then$/i\
if [[ "$regen_cert" != true && -n "${DOMAIN_NAME:-}" ]] && ! openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:${DOMAIN_NAME}"; then\
log_info "Existing TLS cert does not match current DOMAIN_NAME (${DOMAIN_NAME}) -- regenerating"\
regen_cert=true\
fi' ./docker/entrypoint.sh
else
log_warning "entrypoint.sh cert-regen check changed upstream — a stale-domain cert won't auto-regenerate. Update the sed patch in this installer."
fi
}
# ── Shared: log rotation for logs/full (unbounded otherwise) ──────────────
@@ -983,16 +1024,17 @@ _asterisk_offer_dashboard_and_trunk() {
# and container names are therefore substituted afterwards, same placeholder
# trick the Caddy volume line already uses below.
#
# USE_EMBEDDED_COTURN controls whether this install runs its own dedicated
# coturn container (legacy shape) or relies on the shared coturn service
# (services/coturn.sh) instead. This is NOT a free choice at every call site
# — an install that already has its own embedded coturn must keep getting
# one on every "update" regeneration of this file, or the next `docker
# compose up` silently drops the container its own .env TURN_PASSWORD still
# points at, breaking every already-configured phone with no warning. See
# the two call sites below for how each decides.
# Every install now runs its own dedicated coturn — there's no shared coturn
# service left in this repo to opt into (see attic/coturn.sh for why it was
# retired). USE_EMBEDDED_COTURN still exists as a parameter purely for
# backward compatibility with pre-retirement installs that were pointed at
# the old shared coturn service instead: an "update" on one of those must
# keep NOT writing a coturn: block (there's no .env TURN_PASSWORD for it to
# use), so it stays exactly as it was rather than silently gaining or losing
# a container. See the two call sites below for how each decides.
_asterisk_write_compose() {
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" USE_EMBEDDED_COTURN="${4:-true}"
local COTURN_MIN_PORT_VAL="${5:-49152}" COTURN_MAX_PORT_VAL="${6:-49252}"
local _COTURN_DEPENDS=" depends_on:
coturn:
@@ -1016,8 +1058,8 @@ _asterisk_write_compose() {
- --lt-cred-mech
- --user=\${TURN_USERNAME:-easyasterisk}:\${TURN_PASSWORD}
- --realm=\${DOMAIN_NAME:-localhost}
- --min-port=49152
- --max-port=49252
- --min-port=${COTURN_MIN_PORT_VAL}
- --max-port=${COTURN_MAX_PORT_VAL}
- --no-tls
- --no-dtls
- --no-cli
@@ -1060,10 +1102,24 @@ 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. 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
# self-signed. No-op if Caddy isn't installed on this box.
#
# This is entirely about Asterisk's OWN SIP transport-tls cert (port
# 5061) -- it has nothing to do with coturn's separate, unrelated TURNS
# (TLS-wrapped TURN) capability, which the (since-retired) shared coturn
# service indeed didn't support (see attic/coturn.sh's README). A
# previous version of this check gated the mount on USE_EMBEDDED_COTURN == true, conflating
# the two. Confirmed live: on a shared-coturn install with a real Caddy
# cert already sitting on disk for DOMAIN_NAME, Asterisk silently kept
# generating (and re-generating) a self-signed cert forever, because
# /caddy-data was never mounted into the container at all -- sync_caddy_
# cert() couldn't see a cert store that, from its own vantage point,
# simply didn't exist. Most SIP/TLS clients refuse a self-signed cert
# outright with no clear error, which was the actual cause of a
# "port's open, cert domain matches, registration still silently fails"
# case that every other layer (firewall, coturn reachability, DNS, cert
# CN/SAN) had already checked out clean on.
if [[ -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
@@ -1258,6 +1314,7 @@ CADDY_BLOCK
# ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ────
_asterisk_configure_do_cloud_firewall() {
local DROPLET_ID="$1" WEB_ADMIN_PORT_VAL="$2" WEB_ADMIN_PUBLIC="$3"
local COTURN_MIN_PORT_VAL="${4:-49152}" COTURN_MAX_PORT_VAL="${5:-49252}"
local DO_FW_RULES=(
"protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0"
@@ -1273,7 +1330,7 @@ _asterisk_configure_do_cloud_firewall() {
"protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0"
"protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0"
"protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0"
"protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0"
"protocol:udp,ports:${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL},address:0.0.0.0/0,address:::/0"
)
echo ""
@@ -1310,12 +1367,47 @@ _asterisk_configure_do_cloud_firewall() {
fi
}
# ── Non-DO public VPS: no automated network-edge firewall step exists for
# arbitrary providers the way _asterisk_configure_do_cloud_firewall automates
# DigitalOcean via doctl -- there's no universal API to drive. But a box set
# up with a public FQDN is, in practice, almost always sitting behind some
# provider-managed firewall anyway, and skipping this reminder left it
# entirely unmentioned. Confirmed live on an IONOS VPS: UFW showed every SIP/
# TURN/RTP port as ALLOW, Asterisk's own PJSIP logger showed zero incoming
# packets of any kind, and nothing in this installer's own output pointed at
# the actual cause -- IONOS's separate network-level firewall (Cloud Panel ->
# Networking -> Firewall Policies) only allowed 22/80/443/8443/8447 and
# silently dropped everything else before it ever reached the box. UFW being
# wide open proves nothing about a layer in front of it that UFW can't see.
_asterisk_remind_non_do_firewall() {
local WEB_ADMIN_PORT_VAL="$1" WEB_ADMIN_PUBLIC_ACCESS_NEEDED="$2"
local COTURN_MIN_PORT_VAL="${3:-49152}" COTURN_MAX_PORT_VAL="${4:-49252}"
echo ""
log_warning "This box is reachable via FQDN but wasn't set up as a DigitalOcean droplet,"
log_warning "so no automatic network-edge firewall was configured (that step only exists"
log_warning "for DO, via doctl). Most VPS/cloud providers run their OWN network-level"
log_warning "firewall in front of the box, separate from UFW and invisible to it — UFW can"
log_warning "show every port as ALLOW while traffic still gets silently dropped before it"
log_warning "ever reaches this box. Check your provider's console for it (e.g. IONOS: Cloud"
log_warning "Panel -> Networking -> Firewall Policies) and allow inbound, matching what UFW"
log_warning "just opened on this box:"
echo " TCP 22 (SSH)"
echo " UDP/TCP 5060 (SIP)"
echo " TCP 5061 (SIP TLS)"
[[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]] && echo " TCP ${WEB_ADMIN_PORT_VAL} (web admin)"
echo " TCP 8088, 8089 (Asterisk HTTP/HTTPS)"
echo " UDP 10000-20000 (RTP media)"
echo " UDP/TCP 3478 (TURN/STUN)"
echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)"
}
# ── Shared: README ─────────────────────────────────────────────────────────
# One document with a droplet-only section appended in public-cloud mode, so
# 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 COTURN_MIN_PORT_VAL="${10:-49152}" COTURN_MAX_PORT_VAL="${11:-49252}"
local _host="${DOMAIN_NAME:-${PUBLIC_IP:-<host-ip>}}"
[ -z "$TURN_SERVER_DISPLAY" ] && TURN_SERVER_DISPLAY="${_host}:3478"
@@ -1362,9 +1454,7 @@ connecting a phone. The Security Dashboard's Extensions tab
| TURN username | ${TURN_USERNAME_VAL} |
| TURN password | see \`.env\` → \`TURN_PASSWORD\` |
$( [[ "$USE_EMBEDDED_COTURN" == true ]] \
&& echo "This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml)." \
|| echo "TURN is served by the box's shared coturn service, not a container in this compose file — see \`~/docker/coturn/README.md\`. Every service on the box that needs TURN (Mattermost Calls, etc.) shares this same relay, each with its own dedicated username." )
This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml).
Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and
[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP +
@@ -1447,11 +1537,9 @@ docker exec -it ${CONTAINER} easy-asterisk
| 5061 | TCP | SIP over TLS |
| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`) |
| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) |
| 3478 | UDP/TCP | TURN/STUN (coturn) |
| 1000020000 | UDP | RTP media streams |
$( [[ "$USE_EMBEDDED_COTURN" == true ]] \
&& echo "| 3478 | UDP/TCP | TURN/STUN (this install's own dedicated coturn) |
| 4915249252 | UDP | TURN relay media ports (dedicated coturn) |" \
|| echo "| See \`~/docker/coturn/.env\` | UDP/TCP | TURN/STUN — shared coturn service, not opened by this install |" )
| ${COTURN_MIN_PORT_VAL}${COTURN_MAX_PORT_VAL} | UDP | TURN relay media ports (only if this install runs its own dedicated coturn — see below) |
## Data directories (all inside ${EA_DIR}/, included in backup)
@@ -1585,11 +1673,10 @@ install_asterisk() {
echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin"
echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl"
echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)"
echo "[DRY-RUN] Would register a TURN user with the shared coturn service (chain-installing it"
echo "[DRY-RUN] if this is the first service on the box that needs one), falling back to"
echo "[DRY-RUN] Asterisk's own dedicated coturn if the shared service is unavailable"
echo "[DRY-RUN] Would run its own dedicated coturn container for TURN, with a relay port"
echo "[DRY-RUN] range picked to avoid colliding with any other coturn already on the box"
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 10000-20000,"
echo "[DRY-RUN] plus 3478 + 49152-49252 only if falling back to a dedicated coturn"
echo "[DRY-RUN] plus 3478 + the dedicated coturn's relay port range"
echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists"
echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route"
echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context,"
@@ -1657,19 +1744,16 @@ install_asterisk() {
log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
fi
# Self-heal a stale/orphaned shared-coturn registration on
# every update, not just a full reinstall — the check inside
# ensure_coturn_user() is what actually re-registers a
# missing user, this just needs to reach it. Gated on NOT
# having an embedded coturn: an install with its own
# dedicated coturn deliberately never touches the shared one
# on update (see the warning above and CLAUDE.md's coturn
# migration guidance) — calling this unconditionally would
# silently chain-install services/coturn.sh for a box that
# was never using it, the exact "don't migrate silently on
# update" mistake that guidance warns against.
# A pre-existing install with no embedded coturn block predates
# this repo's dedicated-coturn-only model — it's still pointed
# at a shared coturn container this repo no longer installs or
# manages (attic/coturn.sh). Leave it running as-is; update
# never touches .env or firewall rules anyway. Point at a
# fresh reinstall as the migration path instead of silently
# trying to heal a registration against a service that no
# longer exists here.
if [[ "$_HAD_EMBEDDED_COTURN" != true ]]; then
ensure_coturn_user "asterisk"
log_info "This install still points at a shared coturn service, which this repo no longer installs or manages. It will keep working as long as that coturn container keeps running. Run a full reinstall (not update) to migrate to a dedicated coturn."
fi
_asterisk_run_presence_step "$EA_DIR" "$CONTAINER"
@@ -1697,8 +1781,9 @@ install_asterisk() {
echo ""
log_warning "Full reinstall stops the existing containers and re-runs every"
log_warning "prompt below from scratch (domain, networking, firewall, Caddy/"
log_warning "Authelia). The TURN credential registered with the shared coturn"
log_warning "service is reused as-is — no need to touch coturn for this."
log_warning "Authelia), including generating a fresh dedicated coturn container"
log_warning "with new TURN credentials — any already-configured phone's TURN"
log_warning "settings will need to be updated afterward (re-scan its QR code)."
local _WIPE_PBX_DATA=""
prompt_yn " Also delete stored PBX data (extensions, voicemail, recordings, spool)? (y/n):" "n" _WIPE_PBX_DATA
@@ -1802,52 +1887,44 @@ install_asterisk() {
fi
# ── Secrets / TURN ───────────────────────────────────────────────────────
# Prefer the shared coturn service (services/coturn.sh) — one TURN server
# for every service on the box instead of Asterisk running its own and
# fighting other consumers (Mattermost, etc.) over relay ports. Falls
# back to Asterisk's own dedicated coturn if the shared service isn't
# available (e.g. this file run standalone with no sibling services/*.sh
# sourced) or registration fails for any reason — Asterisk should never
# end up with no TURN at all just because the shared path had a problem.
# Asterisk always runs its own dedicated coturn — there is no shared
# coturn service in this repo anymore (see attic/coturn.sh for why it
# was retired). find_free_coturn_range (below) is what makes running a
# dedicated coturn per service safe: it checks every coturn-owning
# service's .env on the box and picks a relay range that can't collide
# with any of them.
local USE_EMBEDDED_COTURN=true
local TURN_USERNAME TURN_PASSWORD TURN_PORT_VAL TURN_SERVER_VAL
# Only reachable here via an explicit "fresh" choice above — "update"
# is handled separately and always preserves whatever coturn shape
# already exists, never silently switches it.
if [[ -f "$EA_DIR/docker-compose.yml" ]] && grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
echo ""
log_warning "This box's existing Asterisk install has its own dedicated coturn."
log_warning "Continuing may switch it to the new shared coturn service — any"
log_warning "phone/softphone configured with the OLD TURN username/password will"
log_warning "need updating once this completes."
TURN_USERNAME="easyasterisk"
TURN_PASSWORD="$(generate_password 24)"
TURN_PORT_VAL="3478"
# A public box always has a usable TURN address (the FQDN if set, else its
# public IP). A LAN box with no FQDN has none — coturn is only reachable
# over the local network, so clients use the server's LAN address directly.
TURN_SERVER_VAL=""
if [[ "$IS_DO" == true ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478"
elif [[ -n "$DOMAIN_NAME" ]]; then
TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
fi
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
# A dedicated coturn here running alongside Asterisk's own on a prior
# install, or any Mattermost instance's own, is exactly the pre-merge
# collision bug this repo's coturn history warns about if two of them
# claim overlapping relay ports — confirmed live, two independent
# coturns' default ranges used to overlap by ~100 UDP ports.
# find_free_coturn_range (lib/common.sh) checks every coturn-owning
# service's .env on the box and picks a range starting safely past
# whatever's already claimed. No other coturn on the box at all leaves
# it at the historical 49152-49252 default — nothing to collide with yet.
local EMBEDDED_COTURN_MIN_PORT=49152 EMBEDDED_COTURN_MAX_PORT=49252
find_free_coturn_range EMBEDDED_COTURN_MIN_PORT EMBEDDED_COTURN_MAX_PORT 100 49152
[[ "$EMBEDDED_COTURN_MIN_PORT" != 49152 ]] && \
log_info "Dedicated coturn relay range shifted to ${EMBEDDED_COTURN_MIN_PORT}-${EMBEDDED_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN"
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
# ── Pick a free port for the web admin ─────────────────────────────────────
# Hardcoding a single number gets fragile fast once several services share
@@ -1886,12 +1963,16 @@ install_asterisk() {
DOMAIN_NAME=${DOMAIN_NAME}
# ── TURN/STUN ─────────────────────────────────────────────────
# $( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml)." || echo "Using the shared coturn service — see ~/docker/coturn/README.md." )
# This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml).
TURN_USERNAME=${TURN_USERNAME}
TURN_PASSWORD=${TURN_PASSWORD}
TURN_PORT=${TURN_PORT_VAL}
# Empty when there's no publicly resolvable address (LAN-only, no FQDN).
TURN_SERVER=${TURN_SERVER_VAL}
# This install's own coturn relay range — other services' find_free_coturn_range
# (lib/common.sh) scans this file to avoid claiming an overlapping range.
TURN_MIN_PORT=${EMBEDDED_COTURN_MIN_PORT}
TURN_MAX_PORT=${EMBEDDED_COTURN_MAX_PORT}
# ── RTP port range ────────────────────────────────────────────
RTP_START=10000
@@ -1955,20 +2036,21 @@ ENV
ufw allow 8088/tcp
ufw allow 8089/tcp
ufw allow 10000:20000/udp
if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow 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.
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow "${EMBEDDED_COTURN_MIN_PORT}:${EMBEDDED_COTURN_MAX_PORT}/udp"
ensure_ufw_enabled
log_success "UFW rules added."
fi
# ── DigitalOcean Cloud Firewall (network edge) ────────────────────────────
[[ "$IS_DO" == true ]] && \
_asterisk_configure_do_cloud_firewall "$DROPLET_ID" "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED"
# ── Network-edge firewall (in front of the box, not UFW) ──────────────────
if [[ "$IS_DO" == true ]]; then
_asterisk_configure_do_cloud_firewall "$DROPLET_ID" "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
elif [[ -n "$DOMAIN_NAME" ]]; then
_asterisk_remind_non_do_firewall "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
fi
# ── CrowdSec note ──────────────────────────────────────────────────────────
# Not installed here — select it separately from the whiptail menu, or
@@ -1993,7 +2075,8 @@ ENV
# ── README ────────────────────────────────────────────────────────────────
_asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" \
"$USE_EMBEDDED_COTURN" "$TURN_USERNAME" "$TURN_SERVER_VAL"
"$USE_EMBEDDED_COTURN" "$TURN_USERNAME" "$TURN_SERVER_VAL" \
"$EMBEDDED_COTURN_MIN_PORT" "$EMBEDDED_COTURN_MAX_PORT"
# ── Start ─────────────────────────────────────────────────────────────────
echo ""
-406
View File
@@ -1,406 +0,0 @@
#!/bin/bash
# services/coturn.sh — Shared TURN/STUN relay (coturn) for WebRTC-capable services.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash coturn.sh
# (Docker must already be installed when run standalone)
#
# One coturn instance, shared by every service that needs TURN (Asterisk,
# Mattermost, and anything added later) instead of each service running its
# own — which used to mean N containers all on network_mode: host fighting
# over relay port ranges (confirmed live: Asterisk's default range and
# Mattermost's default range overlapped by ~100 ports before this existed).
#
# Runs in long-term-credential mode (--lt-cred-mech) with a SQLite user
# database instead of a single static user — every consumer registers its
# own dedicated username/password via ensure_coturn_user() (lib/common.sh),
# so credentials are per-service and one consumer being compromised or
# reconfigured doesn't affect any other's TURN access.
#
# Deliberately NOT --use-auth-secret (the HMAC/REST-API mode Mattermost's
# Calls plugin also supports): coturn does not support both auth mechanisms
# on one running instance at once — turning on --use-auth-secret silently
# overrides --lt-cred-mech server-wide, which would break every
# static-credential consumer (Asterisk's PJSIP TURN client wants a fixed
# long-lived username/password, not a periodically-regenerated HMAC one).
# lt-cred-mech supports any number of named users out of the box, which is
# exactly the shared-multi-consumer shape this needs — no tradeoff either
# way. Mattermost's Calls plugin is configured with a static username/
# credential pair too (its "ICE Servers Configurations" field), not its
# "TURN Static Auth Secret" field, so both consumers use the same mechanism.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Existing install detected. Choose:"
echo " u) Update — refresh vendor files, keep existing settings"
echo " f) Full reinstall — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
case "${_r,,}" in
u) eval "$_var='update'" ;;
f) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
ensure_ufw_enabled() {
command -v ufw &>/dev/null || return 0
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
ufw status 2>/dev/null | grep -q "Status: active" && return 0
local _ssh_port
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | tail -1 | awk '{print $2}')"
ufw allow "${_ssh_port:-22}/tcp" comment 'SSH' >/dev/null 2>&1
ufw --force enable >/dev/null 2>&1
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service coturn homelab "Shared TURN/STUN relay (coturn) for Asterisk, Mattermost, and other WebRTC-capable services" 3478
install_coturn() {
require_docker || return 1
local DIR="$DOCKER_DIR/coturn"
local ENV_FILE="$DIR/.env"
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Shared coturn (TURN/STUN relay) ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
echo " One TURN server, shared by every service that needs one (Asterisk,"
echo " Mattermost Calls, anything added later) — each gets its own"
echo " dedicated username/password, registered automatically the first"
echo " time that service is installed. You normally don't run this"
echo " directly; another service's installer chains into it."
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env"
echo "[DRY-RUN] Would run coturn in --lt-cred-mech mode with a SQLite user database"
echo "[DRY-RUN] Would open UFW: 3478/udp+tcp, and the relay port range udp"
return 0
fi
# ── Update vs. fresh reinstall ─────────────────────────────────────────────
# "update" only refreshes the image/compose shape — realm, host, port
# range, and every registered consumer's credentials are left exactly as
# they are. Rotating any of those here would silently break TURN for
# every service already relying on this instance (Asterisk phones,
# Mattermost Calls) without those services knowing to reconfigure.
local MODE="fresh"
if [[ -f "$DIR/docker-compose.yml" && -f "$ENV_FILE" ]]; then
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the coturn image/compose only — realm, host, port range, and"
log_info "every registered consumer's credentials are left exactly as they are."
;;
cancel)
log_info "Leaving the existing coturn install as-is."
return 0
;;
fresh)
echo ""
log_warning "A full reinstall regenerates nothing destructive by itself, but if you"
log_warning "change the host/port/realm below, every already-registered consumer"
log_warning "(Asterisk, Mattermost, ...) keeps pointing at the OLD values in its own"
log_warning ".env until you re-run that service's installer too."
local _consumers=""
[ -d "$DIR/users" ] && _consumers="$(find "$DIR/users" -maxdepth 1 -name '*.env' -printf '%f\n' 2>/dev/null | sed 's/\.env$//' | tr '\n' ' ')"
if [ -n "$_consumers" ]; then
echo ""
log_info "Registered consumers: $_consumers"
local _WIPE_USERS=""
prompt_yn " Also delete all TURN user credentials and the user database (forces every consumer above to re-register)? (y/n):" "n" _WIPE_USERS
if [[ "$_WIPE_USERS" =~ ^[Yy]$ ]]; then
rm -rf "$DIR/users" "$DIR/db"
mkdir -p "$DIR/db" "$DIR/users"
# The running container (if any) still holds the old,
# now-deleted turndb file open — new turnadmin writes
# to the fresh file at that path go unseen until the
# server process restarts and reopens it.
docker restart coturn >/dev/null 2>&1
log_warning "Deleted TURN credentials and the user database."
log_warning "Re-run each consumer's installer in Update mode afterward —"
log_warning "ensure_coturn_user() auto-recovers a fresh credential for it."
fi
fi
;;
esac
fi
mkdir -p "$DIR/db" "$DIR/users"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local COTURN_REALM="" COTURN_HOST="" COTURN_PORT="3478"
local COTURN_MIN_PORT="49152" COTURN_MAX_PORT="49452"
if [ "$MODE" = "update" ]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
else
local _default_realm="${SITE_DOMAIN:-localhost}"
prompt_text " Realm (usually your domain, or 'localhost' for LAN-only):" "$_default_realm" COTURN_REALM
local _detected_ip
_detected_ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
prompt_text " Public hostname/IP TURN clients should connect to:" "$_detected_ip" COTURN_HOST
prompt_text " Listening port:" "3478" COTURN_PORT
prompt_text " Relay port range — min:" "49152" COTURN_MIN_PORT
prompt_text " Relay port range — max (each concurrent relayed call needs ~1 port; 300 ports is generous for a homelab):" "49452" COTURN_MAX_PORT
fi
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
cat > docker-compose.yml << 'EOF'
name: coturn
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
network_mode: host
user: root
env_file: .env
volumes:
- ./db:/var/lib/coturn
command:
- -n
- --listening-port=${COTURN_PORT:-3478}
- --listening-ip=0.0.0.0
- --fingerprint
- --lt-cred-mech
- --userdb=/var/lib/coturn/turndb
- --realm=${COTURN_REALM:-localhost}
- --min-port=${COTURN_MIN_PORT:-49152}
- --max-port=${COTURN_MAX_PORT:-49452}
- --no-tls
- --no-dtls
- --no-cli
- --no-multicast-peers
- --log-file=stdout
restart: unless-stopped
EOF
cat > "$ENV_FILE" << ENVEOF
TZ=$TZ_VAL
# ── Identity — read by lib/common.sh's ensure_coturn_user() ────────────────
# Changing these after consumers already registered breaks TURN for them
# until each one is reconfigured — see the warning above before editing.
COTURN_REALM=$COTURN_REALM
COTURN_HOST=$COTURN_HOST
COTURN_PORT=$COTURN_PORT
COTURN_MIN_PORT=$COTURN_MIN_PORT
COTURN_MAX_PORT=$COTURN_MAX_PORT
ENVEOF
chmod 600 "$ENV_FILE"
chown "$ACTUAL_USER:$ACTUAL_USER" docker-compose.yml "$ENV_FILE"
log_success "coturn configured at $DIR"
# ── Firewall ──────────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow "${COTURN_PORT}/udp" comment 'coturn TURN/STUN' >/dev/null 2>&1
ufw allow "${COTURN_PORT}/tcp" comment 'coturn TURN/STUN' >/dev/null 2>&1
ufw allow "${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp" comment 'coturn relay' >/dev/null 2>&1
log_success "UFW: opened ${COTURN_PORT}/udp+tcp and ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp"
ensure_ufw_enabled
fi
# ── Admin helper: list/add/remove consumers without touching compose ───────
cat > coturn_user.sh << 'USEREOF'
#!/bin/bash
# ~/docker/coturn/coturn_user.sh — manage TURN users in the shared coturn's
# SQLite user database. Most services register themselves automatically via
# ensure_coturn_user() (lib/common.sh) at install time — this is for manual
# inspection/cleanup.
#
# sudo ./coturn_user.sh list
# sudo ./coturn_user.sh add <name> <password>
# sudo ./coturn_user.sh remove <name>
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "$HERE/.env"
case "${1:-}" in
list)
docker exec coturn turnadmin -l -b /var/lib/coturn/turndb
;;
add)
[ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "Usage: $0 add <name> <password>"; exit 1; }
docker exec coturn turnadmin -a -u "$2" -p "$3" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
&& echo "Added: $2" \
|| echo "Failed to add $2 — is the coturn container running?"
;;
remove)
[ -n "${2:-}" ] || { echo "Usage: $0 remove <name>"; exit 1; }
docker exec coturn turnadmin -d -u "$2" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
&& { echo "Removed: $2"; rm -f "$HERE/users/$2.env"; } \
|| echo "Failed to remove $2"
;;
*)
echo "Usage: $0 {list|add <name> <password>|remove <name>}"
exit 1
;;
esac
USEREOF
chmod +x coturn_user.sh
chown "$ACTUAL_USER:$ACTUAL_USER" coturn_user.sh
write_readme "$DIR" << MD
# coturn — shared TURN/STUN relay
One coturn instance shared by every service on this box that needs TURN
(Asterisk, Mattermost Calls, anything added later) — instead of each running
its own and fighting over host ports for the relay range.
Runs in long-term-credential mode with a SQLite user database. Each
consumer gets its own dedicated username/password, registered automatically
by that service's installer via \`ensure_coturn_user()\` — you don't
normally need to touch this directly.
## Identity
- Realm: \`$COTURN_REALM\`
- Host clients connect to: \`$COTURN_HOST\`
- Listening port: \`$COTURN_PORT\`
- Relay port range: \`$COTURN_MIN_PORT-$COTURN_MAX_PORT\` (udp)
**Changing any of the above breaks TURN for every already-registered
consumer until that service's installer is re-run** — they cache the host/
port/credentials in their own \`.env\` at registration time, not read live.
## Manage users
\`\`\`bash
sudo ./coturn_user.sh list
sudo ./coturn_user.sh add <name> <password>
sudo ./coturn_user.sh remove <name>
\`\`\`
Per-consumer credentials are also cached in \`users/<name>.env\` (chmod 600)
so a service re-running its own installer reuses the same credential
instead of silently minting a new one and orphaning the old.
## Manage the container
\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
\`\`\`
## Adding a new service that needs TURN
In that service's \`install_<name>()\`, after \`require_docker\`:
\`\`\`bash
ensure_coturn_user "my-service"
if [ -n "\$COTURN_HOST" ]; then
# COTURN_HOST / COTURN_PORT / COTURN_USERNAME / COTURN_PASSWORD are set
# (not local — read them after the call returns, same convention as
# configure_caddy_for_service's CADDY_SERVICE_* out-params)
else
# coturn unavailable — degrade gracefully (no TURN, or prompt to run
# \`sudo ./setup.sh coturn\` first)
fi
\`\`\`
MD
local START=""
prompt_yn "Start coturn now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "coturn started" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo ""
echo " Realm: $COTURN_REALM Host: $COTURN_HOST Port: $COTURN_PORT"
echo " Relay range: $COTURN_MIN_PORT-$COTURN_MAX_PORT/udp"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_coturn
+73 -98
View File
@@ -59,6 +59,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
eval "$_varname='$_port'"
}
find_free_coturn_range() {
local _min_varname="$1" _max_varname="$2" _range_size="${3:-200}" _start="${4:-49152}"
local _highest_max=$((_start - 1)) _f _found
for _f in "$DOCKER_DIR"/*/.env; do
[ -f "$_f" ] || continue
_found="$(grep -E '^(COTURN|TURN)_MAX_PORT=' "$_f" 2>/dev/null | tail -1 | cut -d= -f2-)"
[[ "$_found" =~ ^[0-9]+$ ]] || continue
[ "$_found" -gt "$_highest_max" ] && _highest_max=$_found
done
local _min=$_start
[ "$_highest_max" -ge "$_start" ] && _min=$((_highest_max + 50))
eval "$_min_varname='$_min'"
eval "$_max_varname='$((_min + _range_size))'"
}
# 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
@@ -223,7 +238,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; TURN via the shared coturn service); supports multiple isolated instances" 8065
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; own dedicated coturn for TURN); supports multiple isolated instances" 8065
install_mattermost() {
require_docker || return 1
@@ -236,7 +251,6 @@ install_mattermost() {
local INSTANCE_SUFFIX="" PROJECT="mattermost"
local MM_CONTAINER="mattermost" DB_CONTAINER="mattermost-db"
local WEB_PORT="8065" CALLS_UDP_PORT="8443"
local COTURN_CONSUMER="mattermost"
if [ -d "$DIR" ]; then
echo ""
@@ -265,7 +279,6 @@ install_mattermost() {
PROJECT="mattermost-$_suffix"
MM_CONTAINER="mattermost-$_suffix"
DB_CONTAINER="mattermost-$_suffix-db"
COTURN_CONSUMER="mattermost-$_suffix"
log_info "New instance: $DIR"
fi
fi
@@ -283,8 +296,8 @@ install_mattermost() {
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
echo "[DRY-RUN] Would register a TURN user with the shared coturn service for '$COTURN_CONSUMER'"
echo "[DRY-RUN] (falling back to a dedicated coturn if the shared service is unavailable)"
echo "[DRY-RUN] Would run this instance's own dedicated coturn container for TURN, with a relay"
echo "[DRY-RUN] port range picked to avoid colliding with any other coturn already on the box"
echo "[DRY-RUN] Would open UFW ports ${WEB_PORT}/tcp, ${CALLS_UDP_PORT}/udp"
return 0
fi
@@ -301,16 +314,11 @@ install_mattermost() {
return 0
;;
fresh)
if [ "$_HAD_EMBEDDED_COTURN" = true ]; then
echo ""
log_warning "This install has its own dedicated coturn. Continuing may switch it to"
log_warning "the shared coturn service — the Calls plugin's TURN config in System"
log_warning "Console will need updating to the new credentials afterward (see below)."
fi
echo ""
log_warning "Full reinstall stops the existing containers and re-runs every prompt"
log_warning "below from scratch. The TURN credential registered with the shared"
log_warning "coturn service is reused as-is — no need to touch coturn for this."
log_warning "below from scratch, including generating a fresh dedicated coturn"
log_warning "container with new TURN credentials — the Calls plugin's TURN config in"
log_warning "System Console will need updating afterward (see below)."
local _WIPE_MM_DATA=""
prompt_yn " Also delete stored data (Postgres database, uploaded files, config, plugins)? (y/n):" "n" _WIPE_MM_DATA
@@ -421,84 +429,49 @@ networks:
"
fi
# ── TURN: shared coturn preferred, dedicated coturn as fallback ─────────
# See services/coturn.sh's header for why one shared TURN server beats
# every service (Asterisk, each Mattermost instance, ...) running its
# own and fighting over host relay ports.
# ── TURN: always this instance's own dedicated coturn ───────────────────
# There is no shared coturn service in this repo anymore (see
# attic/coturn.sh for why it was retired) — every instance runs its own.
# find_free_coturn_range (below) is what makes that safe: it checks
# every coturn-owning service's .env on the box and picks a relay range
# that can't collide with any of them.
local USE_EMBEDDED_COTURN=true
local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL=""
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
# A pre-existing instance with no embedded coturn block predates this
# repo's dedicated-coturn-only model — it's still pointed at a shared
# coturn container this repo no longer installs or manages. Leave it
# running as-is (update never touches .env anyway) rather than trying
# to heal a registration against a service that no longer exists here.
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" != true ]; then
USE_EMBEDDED_COTURN=false
log_info "This instance still points at a shared coturn service, which this repo no longer installs or manages. It will keep working as long as that coturn container keeps running. Run a full reinstall (not update) to migrate to a dedicated coturn."
fi
[ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48)
# Each embedded-coturn instance (this Mattermost falls back to its own
# dedicated coturn when the shared one isn't available) needs its own
# listening port and relay range, or two instances both on embedded
# coturn collide on identical fixed numbers — confirmed live for the
# Asterisk-vs-Mattermost case this same offset scheme now also fixes
# (see the git history on this block). A relay range can't be found by
# scanning port-by-port like find_free_port does for a single port
# (CLAUDE.md's port-collision-avoidance section is explicit about this
# for large ranges) — so instead each instance gets an integer "slot"
# or the next one already used elsewhere, and a wide-enough width
# (200, matching this range's existing size) keeps slots from
# overlapping each other. base 3479/49253 is right after Asterisk's own
# embedded-coturn numbers (3478/49152-49252) so slot 0 doesn't collide
# with Asterisk either.
#
# The slot is assigned once (the smallest integer not already claimed
# by another mattermost*/.env on this box) and cached in THIS
# instance's own .env as EMBEDDED_COTURN_SLOT, so re-running this same
# instance's installer (update or full reinstall) always reads the
# same slot back instead of potentially reassigning it — reassignment
# would silently move an already-configured instance's TURN port out
# from under it.
local EMBEDDED_COTURN_SLOT=""
[ -f "$DIR/.env" ] && EMBEDDED_COTURN_SLOT="$(grep '^EMBEDDED_COTURN_SLOT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
if [ -z "$EMBEDDED_COTURN_SLOT" ]; then
# Assigning a NEW slot — also live-verify the candidate control port
# and relay-range boundaries aren't already bound by something this
# box's own .env files don't know about (a manually-run process, an
# unrelated service). An already-cached slot (the branch above) is
# trusted as-is and never re-verified — that's what "stable across
# re-runs" means; a live process squatting on an already-assigned
# slot's port is a conflict to report, not silently route around by
# moving an already-configured instance. Can't scan the full
# 200-port relay range port-by-port (CLAUDE.md's
# port-collision-avoidance section covers why large ranges use an
# offset instead of scanning) — checking the control port plus the
# relay range's own two boundary ports is the practical middle
# ground between "no live check at all" and a full range scan.
local _used_slots _cand _p _min _max
_used_slots="$(grep -h '^EMBEDDED_COTURN_SLOT=' "$DOCKER_DIR"/mattermost*/.env 2>/dev/null | cut -d= -f2-)"
_cand=0
while true; do
_p=$((3479 + _cand)); _min=$((49253 + _cand * 200)); _max=$((_min + 199))
if echo "$_used_slots" | grep -qx "$_cand" \
|| port_in_use "$_p" || port_in_use "$_p" udp \
|| port_in_use "$_min" udp || port_in_use "$_max" udp; then
_cand=$((_cand + 1))
continue
fi
break
done
EMBEDDED_COTURN_SLOT="$_cand"
# A dedicated coturn here running alongside Asterisk's own, a sibling
# Mattermost instance's own, or a legacy shared instance still running is
# the same pre-merge relay-port collision this repo's coturn history
# warns about (confirmed live: two independent coturns' default ranges
# used to overlap by ~100 UDP ports). find_free_coturn_range (lib/common.sh) checks every coturn-
# owning service's .env on the box and picks a range starting safely past
# whatever's already claimed; the historical 49153-49352 default only
# survives when nothing else on the box claims a range at all.
local MM_COTURN_MIN_PORT=49153 MM_COTURN_MAX_PORT=49352
if [ "$USE_EMBEDDED_COTURN" = true ] && [ "$MODE" != "update" ]; then
find_free_coturn_range MM_COTURN_MIN_PORT MM_COTURN_MAX_PORT 200 49153
[[ "$MM_COTURN_MIN_PORT" != 49153 ]] && \
log_info "Dedicated coturn relay range shifted to ${MM_COTURN_MIN_PORT}-${MM_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
elif [ "$MODE" = "update" ] && [ -f "$DIR/.env" ]; then
# Preserve whatever range this instance was already using — an update
# must never silently move it (a live coturn container restarting on
# a different port range would break in-flight/repeat Calls sessions).
local _existing_min _existing_max
_existing_min="$(grep -E '^TURN_MIN_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
_existing_max="$(grep -E '^TURN_MAX_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
[[ "$_existing_min" =~ ^[0-9]+$ ]] && MM_COTURN_MIN_PORT="$_existing_min"
[[ "$_existing_max" =~ ^[0-9]+$ ]] && MM_COTURN_MAX_PORT="$_existing_max"
fi
local _MM_COTURN_PORT=$((3479 + EMBEDDED_COTURN_SLOT))
local _MM_COTURN_MIN=$((49253 + EMBEDDED_COTURN_SLOT * 200))
local _MM_COTURN_MAX=$((_MM_COTURN_MIN + 199))
local _COTURN_SERVICE=""
if [ "$USE_EMBEDDED_COTURN" = true ]; then
@@ -510,14 +483,14 @@ networks:
user: root
command:
- -n
- --listening-port=${_MM_COTURN_PORT}
- --listening-port=3479
- --listening-ip=0.0.0.0
- --fingerprint
- --use-auth-secret
- --static-auth-secret=\${COTURN_SECRET}
- --realm=\${MM_REALM:-localhost}
- --min-port=${_MM_COTURN_MIN}
- --max-port=${_MM_COTURN_MAX}
- --min-port=${MM_COTURN_MIN_PORT}
- --max-port=${MM_COTURN_MAX_PORT}
- --no-tls
- --no-dtls
- --no-cli
@@ -601,12 +574,12 @@ TURN_HOST=$TURN_HOST_VAL
TURN_PORT=$TURN_PORT_VAL
TURN_USERNAME=$TURN_USERNAME_VAL
TURN_PASSWORD=$TURN_PASSWORD_VAL
# This instance's embedded-coturn port slot (see the comment above the
# EMBEDDED_COTURN_SLOT assignment in mattermost.sh) — read back on every
# re-run so it never gets reassigned out from under an already-running
# instance. Reserved even when USE_EMBEDDED_COTURN is currently false, in
# case this instance ever falls back to its own coturn later.
EMBEDDED_COTURN_SLOT=$EMBEDDED_COTURN_SLOT
# This instance's OWN coturn relay range -- only set when it runs a dedicated
# coturn above. Left blank when using the shared coturn service, so other
# services' find_free_coturn_range (lib/common.sh) scan correctly skips this
# file instead of treating a range this instance doesn't actually own as claimed.
TURN_MIN_PORT=$( [ "$USE_EMBEDDED_COTURN" = true ] && echo "$MM_COTURN_MIN_PORT" )
TURN_MAX_PORT=$( [ "$USE_EMBEDDED_COTURN" = true ] && echo "$MM_COTURN_MAX_PORT" )
EOF
chmod 600 .env
@@ -629,10 +602,12 @@ EOF
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 "${_MM_COTURN_PORT}/udp"; ufw allow "${_MM_COTURN_PORT}/tcp"
ufw allow "${_MM_COTURN_MIN}:${_MM_COTURN_MAX}/udp" comment "Mattermost coturn relay${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow "${MM_COTURN_MIN_PORT}:${MM_COTURN_MAX_PORT}/udp" comment "Mattermost coturn relay"
fi
# Shared coturn opens its own ports once, at its own install time.
# A legacy instance still on a shared coturn (USE_EMBEDDED_COTURN=false
# above) has nothing to open here — that coturn's ports were opened
# once, at its own install time, whenever that was.
fi
echo ""
@@ -648,9 +623,9 @@ EOF
# 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}:${_MM_COTURN_PORT}?transport=udp\"],\"username\":\"static\",\"credential\":\"see COTURN_SECRET below — this dedicated coturn uses use-auth-secret/HMAC, not a fixed credential\"}]"
_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}:${_MM_COTURN_PORT}?transport=udp\`
- 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}\"}]"
+128 -31
View File
@@ -3036,11 +3036,31 @@ def ea_device_sipnetic_string(extension):
"""Sipnetic's own documented "account string" QR-scan format
(https://www.sipnetic.com/qr-codes): semicolon-separated key=value pairs,
n=display name, u=username, d=domain/IP (no port), p=password,
dt=default transport (0=UDP, 1=TCP, 2=TLS). Unlike
dt=default transport (0=UDP, 1=TCP, 2=TLS), st=STUN/TURN server. Unlike
ea_device_provisioning()'s deliberately generic plain-text file, this one
IS a verified, documented format for one specific app, built from the
exact same ea_device_details() data.
st is intentionally set to an explicit turn:user:pass@host URI whenever
coturn is configured, rather than left unset -- leaving it out doesn't
mean "no TURN", it means Sipnetic falls back to its own default/built-in
STUN server instead of the coturn instance Asterisk itself is actually
using, which is silently wrong rather than absent. This is the same
TURN_SERVER/TURN_USERNAME/TURN_PASSWORD ea_device_details() already
reads from the Asterisk .env (see ea_connection_defaults()) -- whichever
coturn Asterisk is actually configured against.
The host is deliberately stripped of its port before going into st.
Sipnetic's own doc for this field is explicit that the value is a
"hostname or IP address without port", and its own worked example is
`st=turn:user:password@turn.mydomain.com;` -- no port anywhere, even
in the URI form. Confirmed live: appending :3478 (matching the doc's
generic URI-with-credentials description, which doesn't actually show a
port example) gets silently truncated by the app -- the FQDN came
through, the port after it did not. coturn's listening port in this
repo is always the STUN/TURN-conventional 3478, which is what a
portless address implies anyway, so dropping it costs nothing.
A literal ';' in any field must be doubled per that same doc page --
generated passwords are alnum-only (_ea_generate_password) so this only
matters for a hand-typed device name, but escaping costs nothing."""
@@ -3053,10 +3073,19 @@ def ea_device_sipnetic_string(extension):
host = d["server"] or ""
dt = "2" if d["transport"] == "TLS" else "0"
return "n=%s;u=%s;d=%s;p=%s;dt=%s;" % (
esc_field(d["name"] or d["extension"]), esc_field(d["extension"]),
esc_field(host), esc_field(d["password"]), dt,
)
fields = [
"n=%s" % esc_field(d["name"] or d["extension"]),
"u=%s" % esc_field(d["extension"]),
"d=%s" % esc_field(host),
"p=%s" % esc_field(d["password"]),
"dt=%s" % dt,
]
if d.get("turn_server") and d.get("turn_username") and d.get("turn_password"):
turn_host = d["turn_server"].rsplit(":", 1)[0]
fields.append("st=%s" % esc_field(
"turn:%s:%s@%s" % (d["turn_username"], d["turn_password"], turn_host)
))
return ";".join(fields) + ";"
def _ea_edit_device_block(extension, mutate):
@@ -3612,7 +3641,14 @@ INDEX_HTML = """<!doctype html>
}
nav button:hover { color: var(--text); }
nav button.active { color: var(--text); border-bottom-color: var(--accent); }
main { padding: var(--sp-6); max-width: 1180px; margin: 0 auto; }
/* 1180 was too narrow for the Extensions table specifically (Ext, Name,
Mobile, Status, Transport, PSTN, Whitelist, Messaging, Voicemail, plus
the row-action column) -- ten columns including a dropdown and a free-text
whitelist field forced .table-wrap's horizontal scrollbar even on a normal
desktop viewport. 1600 gives every tab's tables room without it; narrow
viewports still fall back to that same scrollbar (.table-wrap already
handles it), this only raises the ceiling for wide ones. */
main { padding: var(--sp-6); max-width: 1600px; margin: 0 auto; }
/* ── Cards ────────────────────────────────────────────────────────────── */
.card {
@@ -3804,6 +3840,48 @@ INDEX_HTML = """<!doctype html>
.toast.ok { border-left-color: var(--ok); }
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* ── QR modal ─────────────────────────────────────────────────────────── */
/* Above #toasts (z-index 60) since a toast firing while the modal is open
(e.g. a save from another tab action) should still be visible on top. */
#qr-modal-overlay {
position: fixed; inset: 0; z-index: 70;
background: rgba(0,0,0,0.6);
display: none; align-items: center; justify-content: center;
padding: var(--sp-4);
}
#qr-modal-overlay.show { display: flex; }
/* Sized to the QR frame's own 192px, not this -- the caption text below it
(TURN credentials warning included) wrapped down to a couple of
characters per line at that width. 320px gives it room to breathe while
staying well short of the surrounding card. */
.qr-modal {
position: relative; width: 320px; max-width: calc(100vw - 2 * var(--sp-4));
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: var(--sp-4);
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
}
.qr-modal-close {
position: absolute; top: var(--sp-2); right: var(--sp-2);
background: none; border: none; color: var(--text-faint); cursor: pointer;
font-size: 1.3rem; line-height: 1; padding: 0.2rem 0.4rem; border-radius: var(--radius-sm);
}
.qr-modal-close:hover { color: var(--danger); background: rgba(255,107,107,0.1); }
/* qrcode.js draws modules edge-to-edge with no margin of its own -- the
rendered image's own content ran right to its edge (verified against
the raw generated PNG: the code region covered all but ~1px of it).
Real camera scanners need an actual light quiet zone around the code
per the QR spec, not the modal's dark theme background touching the
modules directly -- this was reported as unreadable by both Sipnetic
and Linphone before this fix. 192px/20px = 2in outer frame, ~4-module
white border on each side, sized generously since exact module count
varies with the encoded string length. */
#qr-modal-canvas {
width: 192px; height: 192px; box-sizing: border-box;
background: #fff; padding: 20px; border-radius: var(--radius-sm);
}
#qr-modal-canvas img, #qr-modal-canvas canvas { width: 100%; height: 100%; display: block; }
/* The one-time device password must not auto-dismiss like a toast does. */
.callout {
display: none; align-items: flex-start; gap: var(--sp-3);
@@ -4033,7 +4111,7 @@ INDEX_HTML = """<!doctype html>
<p class="muted"><b>Ring</b> dials every member at once — first to answer gets the call, everyone else stops ringing. <b>Page</b> tells Asterisk to signal auto-answer to every member via SIP headers, for devices that honor it, turning the same simultaneous dial into a one-way intercom-style broadcast instead.</p>
<p class="muted">You don't need <b>Page</b> just to mix an auto-answering device with normally-ringing phones in the same group, though — auto-answer is really a property of the device's own SIP client configuration, not something Asterisk enforces per member. Confirmed against baresip's own source: it decides purely from its account's local <code>answermode</code> setting and never looks at any auto-answer signal on the incoming call, so a device configured to auto-answer (e.g. a dedicated intercom/kiosk running <code>baresip</code> in Answer Mode: Auto) picks up <i>everything</i> routed to it instantly and unconditionally, while ordinary phones in the same plain <b>Ring</b> group just keep ringing until a person answers — no extra setting needed here for that mix.</p>
<p class="muted">For a dedicated always-on auto-answer device (a wall-mounted intercom, a paging station), Easy Asterisk — the vendor project this installer builds on — has a built-in <code>baresip</code>-based kiosk client for exactly that. It installs on a separate small Linux machine (an old PC, a Raspberry Pi), not this Asterisk server itself: <a href="/download/kiosk-client-installer.sh" download>download the installer script</a>, then see <code>docs/kiosk-paging-setup.md</code> in this repo for the full walkthrough.</p>
<p class="muted">For a phone or tablet running Sipnetic instead, each extension's own detail panel below (Extensions tab → click a row → "Sipnetic QR code") can generate a scan-to-configure code — no dedicated kiosk hardware needed for that one.</p>
<p class="muted">For a phone or tablet running Sipnetic instead, each extension's own detail panel below (Extensions tab → click a row → "Click here for a QR code") can generate a scan-to-configure code — no dedicated kiosk hardware needed for that one.</p>
</details>
<div class="row" style="margin-bottom:var(--sp-2)">
<input type="text" id="ea-room-ext" placeholder="Extension, e.g. 500" style="width:9rem">
@@ -4106,6 +4184,14 @@ INDEX_HTML = """<!doctype html>
</div>
</main>
<div id="toasts"></div>
<div id="qr-modal-overlay" onclick="if (event.target === this) closeSipneticQr()">
<div class="qr-modal">
<button class="qr-modal-close" onclick="closeSipneticQr()" aria-label="Close">&times;</button>
<div id="qr-modal-canvas"></div>
<p class="muted" style="margin:var(--sp-2) 0 0; font-size:0.8rem; width:100%">Scan with Sipnetic (Add Account → Scan QR Code) to auto-fill this extension's SIP and TURN settings.</p>
<p class="muted" style="margin:0; font-size:0.75rem; width:100%">Contains the extension's password and TURN credentials in plain text — treat the image like the password itself.</p>
</div>
</div>
<script>
// Embedded verbatim (license header preserved below) for the Sipnetic
// QR-provisioning feature -- self-contained, no CDN dependency, same
@@ -5525,10 +5611,9 @@ async function showEaDeviceDetails(ext) {
</button>
<button class="action" onclick="resetEaPassword('${esc(d.extension)}')">Reset password</button>
<a class="action" style="text-decoration:none" href="/api/ea-device-provisioning?ext=${encodeURIComponent(d.extension)}" download>Download settings</a>
<button class="action" onclick="toggleSipneticQr('${esc(d.extension)}')">Sipnetic QR code</button>
<button class="action" onclick="showSipneticQr('${esc(d.extension)}')">Click here for a QR code</button>
<button class="action" onclick="closeEaDeviceDetails()">Close</button>
</div>
<div id="sipnetic-qr-box" style="display:none"></div>
${d.env_error ? `<p class="muted" style="margin:var(--sp-2) 0 0; color:var(--warn)">${esc(d.env_error)}</p>` : ""}
<p class="muted" style="margin:var(--sp-2) 0 0">A phone that never registers is most often the transport above: an extension
written LAN-only while the phone dials in over TLS from outside. If the Security Log shows nothing at all for it, the traffic
@@ -5543,33 +5628,45 @@ async function showEaDeviceDetails(ext) {
// qr-codes) -- scan-to-configure, built server-side from the same data the
// details panel already shows. Rendered client-side with the embedded
// qrcode.js at the top of this script block so nothing here needs a CDN or
// a new Python dependency.
async function toggleSipneticQr(ext) {
const box = document.getElementById("sipnetic-qr-box");
if (!box) return;
if (box.style.display !== "none" && box.dataset.ext === ext) {
box.style.display = "none";
box.innerHTML = "";
return;
}
// a new Python dependency. Shown in a small popup (#qr-modal-overlay, ~2in
// square) rather than inline -- it contains the extension's password in
// plain text, so it should be glanced at and dismissed, not left sitting
// open in the page.
async function showSipneticQr(ext) {
const overlay = document.getElementById("qr-modal-overlay");
const canvas = document.getElementById("qr-modal-canvas");
if (!overlay || !canvas) return;
const res = await fetch("/api/ea-device-qr?ext=" + encodeURIComponent(ext));
if (!res.ok) { toast("No QR data for extension " + ext, "err"); return; }
const data = await res.json();
box.dataset.ext = ext;
box.innerHTML = `
<div class="row" style="align-items:flex-start; gap:var(--sp-3); margin-top:var(--sp-3)">
<div id="sipnetic-qr-canvas"></div>
<div style="flex:1">
<p class="muted" style="margin-top:0">Scan with Sipnetic (Add Account → Scan QR Code) to auto-fill this extension's SIP settings.</p>
<p class="muted">Contains this extension's password in plain text — treat the image like the password itself.</p>
<code style="word-break:break-all; display:block; margin-top:var(--sp-1)">${esc(data.account_string)}</code>
</div>
</div>`;
box.style.display = "";
new QRCode(document.getElementById("sipnetic-qr-canvas"), {
text: data.account_string, width: 180, height: 180,
canvas.innerHTML = "";
new QRCode(canvas, {
// Rendered at 3x the on-screen size (456, not 152) then scaled back down
// by the #qr-modal-canvas img/canvas{width:100%} CSS rule -- the physical
// footprint doesn't change, but the extra resolution gives the browser's
// downscaling real anti-aliasing to work with instead of the blocky
// 1px-per-module edges qrcode.js draws natively. Verified with a headless
// render + OpenCV decode: since the st= TURN field was added, the account
// string is long enough to need a denser QR (more modules in the same
// frame) that a real camera reads far more reliably at this resolution
// than at 1:1. 152 = the 192px frame's content box after its 20px white
// quiet-zone padding on each side (192 - 2*20) -- keep the *displayed*
// size (via CSS, not this) in sync with #qr-modal-canvas if that changes.
text: data.account_string, width: 456, height: 456,
correctLevel: QRCode.CorrectLevel.M,
});
overlay.classList.add("show");
document.addEventListener("keydown", qrModalEscHandler);
}
function closeSipneticQr() {
const overlay = document.getElementById("qr-modal-overlay");
if (overlay) overlay.classList.remove("show");
document.removeEventListener("keydown", qrModalEscHandler);
}
function qrModalEscHandler(e) {
if (e.key === "Escape") closeSipneticQr();
}
async function setEaTransport(ext, connType) {