mattermost, asterisk: dynamic, collision-safe dedicated coturn ranges

Mattermost's own embedded-coturn fallback hardcoded the same relay range
(49153-49352) for every instance, with no per-instance offset -- running
two Mattermost instances without the shared coturn service (or one
alongside Asterisk's own dedicated coturn, now possible via the prior
commit) would silently reproduce the exact pre-merge collision bug this
repo's coturn history warns about, just among Mattermost instances
instead of Asterisk/Mattermost.

Adds find_free_coturn_range() (lib/common.sh, standalone-mode-stubbed in
both services matching the existing port_in_use/find_free_port
convention): a coturn relay range can't be collision-checked with live
socket scanning the way a single port can -- coturn only opens ports
inside its configured range on demand, so an idle range looks the same
as an unclaimed one to ss/netstat. The only reliable check is reading
what every other coturn-owning service's .env on the box actually
claims (COTURN_MAX_PORT for the shared instance, TURN_MAX_PORT for each
dedicated one) and picking a range starting safely past the highest.

Also adds Mattermost's own opt-out prompt for the shared coturn
preference, matching the one just added to Asterisk (fresh-install-only,
never re-asked on update, same as every other coturn-shape decision in
that file). An update now explicitly preserves its existing dedicated
range from .env rather than silently recomputing a new one.

Verified end-to-end: a shared instance + Asterisk's dedicated coturn +
two independent Mattermost instances, each discovering and avoiding
every range already claimed by the others, land on entirely
non-overlapping port blocks.
This commit is contained in:
Claude
2026-08-13 00:55:31 +00:00
parent 85fc172377
commit d835e0d734
3 changed files with 134 additions and 22 deletions
+35
View File
@@ -805,6 +805,41 @@ find_free_port() {
eval "$_varname='$_port'" eval "$_varname='$_port'"
} }
# find_free_coturn_range MIN_VARNAME MAX_VARNAME RANGE_SIZE [START_PORT]
# A coturn relay port range can't be collision-checked with port_in_use /
# find_free_port the way a single fixed port can: coturn only opens ports
# inside min-port..max-port on demand, per active TURN allocation, so an
# idle range shows up as nothing listening either way — a live socket scan
# can't tell two coturn CONFIGS apart. The only reliable check is reading
# what range every other coturn-owning service on the box actually claims,
# from its own .env (COTURN_MAX_PORT for the shared instance in
# ~/docker/coturn/.env, TURN_MAX_PORT for every dedicated per-service coturn
# — Asterisk's own, each Mattermost instance's, etc., each in that service's
# own .env). Every service directory keeps its .env at the same top-level
# path, so one glob covers all of them without needing to know which
# services exist ahead of time.
#
# Writes a RANGE_SIZE-wide block starting safely past the highest claimed
# max-port back into MIN_VARNAME/MAX_VARNAME. No other coturn on the box at
# all (fresh install, nothing else uses TURN) leaves it at START_PORT — no
# collision is possible yet, so there's nothing to shift away from.
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
if [ "$_highest_max" -ge "$_start" ]; then
_min=$((_highest_max + 50))
fi
eval "$_min_varname='$_min'"
eval "$_max_varname='$((_min + _range_size))'"
}
# ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] # Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
+37 -19
View File
@@ -123,6 +123,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." 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() { configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
@@ -1943,26 +1960,21 @@ install_asterisk() {
fi fi
fi fi
# A dedicated embedded coturn running ALONGSIDE the shared instance on the # A dedicated embedded coturn running ALONGSIDE any other coturn on the
# same box (this install's own choice above, or Mattermost/anything else # same box (the shared instance, Asterisk's own on a prior install,
# still using the shared one) is exactly the pre-merge collision bug this # any Mattermost instance's own) is exactly the pre-merge collision bug
# repo's coturn history warns about if both claim the same relay ports — # this repo's coturn history warns about if two of them claim overlapping
# confirmed live, their default ranges used to overlap by ~100 UDP ports. # relay ports — confirmed live, two independent coturns' default ranges
# Read the shared instance's actual configured range (not just its # used to overlap by ~100 UDP ports. find_free_coturn_range (lib/common.sh)
# install-time default, since coturn.sh lets that be customized) and pick # checks every coturn-owning service's .env on the box, not just the
# a range that starts safely past its end, so the two can never collide # shared instance's, and picks a range starting safely past whatever's
# regardless of what the shared instance was configured with. No shared # already claimed. No other coturn on the box at all leaves it at the
# instance on this box at all means no collision is possible, so the # historical 49152-49252 default — nothing to collide with yet.
# historical default is left alone in that case.
local EMBEDDED_COTURN_MIN_PORT=49152 EMBEDDED_COTURN_MAX_PORT=49252 local EMBEDDED_COTURN_MIN_PORT=49152 EMBEDDED_COTURN_MAX_PORT=49252
if [[ "$USE_EMBEDDED_COTURN" == true && -f "$DOCKER_DIR/coturn/.env" ]]; then if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
local _shared_coturn_max_port="" find_free_coturn_range EMBEDDED_COTURN_MIN_PORT EMBEDDED_COTURN_MAX_PORT 100 49152
_shared_coturn_max_port="$(grep -E '^COTURN_MAX_PORT=' "$DOCKER_DIR/coturn/.env" 2>/dev/null | cut -d= -f2-)" [[ "$EMBEDDED_COTURN_MIN_PORT" != 49152 ]] && \
if [[ "$_shared_coturn_max_port" =~ ^[0-9]+$ ]]; then 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."
EMBEDDED_COTURN_MIN_PORT=$((_shared_coturn_max_port + 50))
EMBEDDED_COTURN_MAX_PORT=$((EMBEDDED_COTURN_MIN_PORT + 100))
log_info "Dedicated coturn relay range shifted to ${EMBEDDED_COTURN_MIN_PORT}-${EMBEDDED_COTURN_MAX_PORT} to stay clear of the shared instance's ${_shared_coturn_max_port}-port ceiling."
fi
fi fi
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN" \ _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN" \
@@ -2011,6 +2023,12 @@ TURN_PASSWORD=${TURN_PASSWORD}
TURN_PORT=${TURN_PORT_VAL} TURN_PORT=${TURN_PORT_VAL}
# Empty when there's no publicly resolvable address (LAN-only, no FQDN). # Empty when there's no publicly resolvable address (LAN-only, no FQDN).
TURN_SERVER=${TURN_SERVER_VAL} TURN_SERVER=${TURN_SERVER_VAL}
# This install's OWN coturn relay range -- only set when USE_EMBEDDED_COTURN
# is true above. Left blank when using the shared coturn service, so other
# services' find_free_coturn_range (lib/common.sh) scan correctly skips this
# file instead of treating a range this install doesn't actually own as claimed.
TURN_MIN_PORT=$( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "$EMBEDDED_COTURN_MIN_PORT" )
TURN_MAX_PORT=$( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "$EMBEDDED_COTURN_MAX_PORT" )
# ── RTP port range ──────────────────────────────────────────── # ── RTP port range ────────────────────────────────────────────
RTP_START=10000 RTP_START=10000
+62 -3
View File
@@ -59,6 +59,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
eval "$_varname='$_port'" 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 # Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() { prompt_text() {
local _q="$1" _def="$2" _var="$3" _r local _q="$1" _def="$2" _var="$3" _r
@@ -399,9 +414,23 @@ networks:
# own and fighting over host relay ports. # own and fighting over host relay ports.
local USE_EMBEDDED_COTURN=true local USE_EMBEDDED_COTURN=true
local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL="" local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL=""
local FORCE_EMBEDDED_COTURN=""
# Opt-out of the shared coturn preference, same as services/asterisk.sh —
# only offered on a genuinely fresh install (never re-asked on update,
# matching every other coturn-shape decision in this file) and only when
# a shared instance actually exists to opt out of.
if [ "$MODE" = "fresh" ] && [ -d "$DOCKER_DIR/coturn" ]; then
local _USE_SHARED_COTURN=""
prompt_yn "Use the shared coturn service for TURN? (n = run this instance's own dedicated coturn instead) (y/n):" "y" _USE_SHARED_COTURN
[[ "$_USE_SHARED_COTURN" =~ ^[Nn]$ ]] && FORCE_EMBEDDED_COTURN=true
fi
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then
USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update
elif [ "$FORCE_EMBEDDED_COTURN" = true ]; then
USE_EMBEDDED_COTURN=true
log_info "Running this instance's own dedicated coturn, as requested."
else else
ensure_coturn_user "$COTURN_CONSUMER" ensure_coturn_user "$COTURN_CONSUMER"
if [ -n "${COTURN_HOST:-}" ]; then if [ -n "${COTURN_HOST:-}" ]; then
@@ -415,6 +444,30 @@ networks:
fi fi
[ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48) [ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48)
# A dedicated coturn here running alongside the shared instance, Asterisk's
# own, or a sibling Mattermost instance's own is the same pre-merge relay-
# port collision this repo's coturn history warns about (confirmed live:
# two independent coturns' default ranges used to overlap by ~100 UDP
# ports). find_free_coturn_range (lib/common.sh) checks every coturn-
# 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 _COTURN_SERVICE="" local _COTURN_SERVICE=""
if [ "$USE_EMBEDDED_COTURN" = true ]; then if [ "$USE_EMBEDDED_COTURN" = true ]; then
_COTURN_SERVICE=" _COTURN_SERVICE="
@@ -431,8 +484,8 @@ networks:
- --use-auth-secret - --use-auth-secret
- --static-auth-secret=\${COTURN_SECRET} - --static-auth-secret=\${COTURN_SECRET}
- --realm=\${MM_REALM:-localhost} - --realm=\${MM_REALM:-localhost}
- --min-port=49153 - --min-port=${MM_COTURN_MIN_PORT}
- --max-port=49352 - --max-port=${MM_COTURN_MAX_PORT}
- --no-tls - --no-tls
- --no-dtls - --no-dtls
- --no-cli - --no-cli
@@ -509,6 +562,12 @@ TURN_HOST=$TURN_HOST_VAL
TURN_PORT=$TURN_PORT_VAL TURN_PORT=$TURN_PORT_VAL
TURN_USERNAME=$TURN_USERNAME_VAL TURN_USERNAME=$TURN_USERNAME_VAL
TURN_PASSWORD=$TURN_PASSWORD_VAL TURN_PASSWORD=$TURN_PASSWORD_VAL
# 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 EOF
chmod 600 .env chmod 600 .env
@@ -532,7 +591,7 @@ EOF
ufw allow "${CALLS_UDP_PORT}/udp" comment "Mattermost Calls RTC${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" ufw allow "${CALLS_UDP_PORT}/udp" comment "Mattermost Calls RTC${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
if [ "$USE_EMBEDDED_COTURN" = true ]; then if [ "$USE_EMBEDDED_COTURN" = true ]; then
ufw allow 3479/udp; ufw allow 3479/tcp ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow 49153:49352/udp comment "Mattermost coturn relay" ufw allow "${MM_COTURN_MIN_PORT}:${MM_COTURN_MAX_PORT}/udp" comment "Mattermost coturn relay"
fi fi
# Shared coturn opens its own ports once, at its own install time. # Shared coturn opens its own ports once, at its own install time.
fi fi