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
+62 -53
View File
@@ -674,7 +674,7 @@ interpolates `${WEB_PORT}` directly. A quoted `<< 'MD'` heredoc doesn't,
and converting it means escaping *every* backtick used for inline-code
formatting (`` \`...\` ``) — miss one and bash tries to execute it as a
command substitution the next time the heredoc is read, the same class of
bug the coturn.sh backtick incident was (see `services/coturn.sh`'s
bug the coturn.sh backtick incident was (see `attic/coturn.sh`'s
`write_readme` call). For a README with only one or two backticks,
escaping them is fine. For one with many (`services/iopaint.sh`'s model
reference table), it's safer to leave the heredoc quoted and patch the
@@ -778,63 +778,72 @@ 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
## coturn (TURN/STUN) relay — dedicated per service, not shared
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.
Any service that needs a TURN server for WebRTC/SIP NAT traversal runs its
**own dedicated** coturn container. There is no shared coturn service to
install or point at — `services/coturn.sh` was tried and retired; it's
parked at `attic/coturn.sh` (outside `services/*.sh`'s glob, so it never
registers or appears in the menu — see `attic/README.md`). Sharing one
instance saved a container per consumer (~40MB) but was a single point of
failure every consumer depended on, and needing a dedicated per-consumer
long-term-credential user added real setup complexity for a small RAM win.
Don't reintroduce it — give every new WebRTC/SIP-capable service its own
coturn, following the pattern below.
**Use `ensure_coturn_user` (`lib/common.sh`), not your own coturn container:**
**The collision this pattern has to avoid:** `asterisk` and `mattermost`
each embed a dedicated coturn container (`network_mode: host`, each with
its own relay port range) — confirmed live, two independent coturns'
default ranges used to overlap by ~100 UDP ports, so running both on one
box meant a coin-flip over which service's active call lost its media
relay. Static default ranges alone don't solve this; something has to pick
non-overlapping ranges per box.
**Use `find_free_coturn_range` (`lib/common.sh`) to size the range, not a
hardcoded default:**
```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
local MY_COTURN_MIN_PORT=49152 MY_COTURN_MAX_PORT=49252
find_free_coturn_range MY_COTURN_MIN_PORT MY_COTURN_MAX_PORT 100 49152
[[ "$MY_COTURN_MIN_PORT" != 49152 ]] && \
log_info "Dedicated coturn relay range shifted to ${MY_COTURN_MIN_PORT}-${MY_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
```
`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.
Unlike a single fixed host port (`find_free_port`'s job — coturn's relay
range isn't a statically bound listening socket you can detect with a live
`ss`/socket scan), `find_free_coturn_range` scans every `$DOCKER_DIR/*/.env`
for a `TURN_MAX_PORT=` line and starts the new range 50 ports past the
highest one found — so it works across every coturn-owning service on the
box (Asterisk, each Mattermost instance, yours), regardless of install
order. Persist the chosen range as `TURN_MIN_PORT=`/`TURN_MAX_PORT=` in your
own `.env` so later installs' scans see it, and on an `update` rerun read
those same keys back from the existing `.env` instead of re-scanning — a
live coturn container must never silently move to a different port range
(breaks in-flight/repeat sessions on whatever client already has the old
range's ports allowed through its own firewall/NAT). See
`services/asterisk.sh`'s and `services/mattermost.sh`'s `EMBEDDED_COTURN_MIN_PORT`/
`MM_COTURN_MIN_PORT` handling for the reference pattern, including the
`MODE != "update"` gate that scans only on a fresh install.
**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.
**Auth mode — long-term credentials (`--lt-cred-mech`) or HMAC
(`--use-auth-secret`), your choice per instance:** coturn doesn't support
running both on one instance at once, but since each service now owns its
instance outright, this is a free per-service choice — no shared-instance
constraint forcing one mode across every consumer. `services/asterisk.sh`
uses `--lt-cred-mech` (fixed username/password, simplest to bake into a SIP
device's config); `services/mattermost.sh` uses `--use-auth-secret` (HMAC),
matching the Calls plugin's own "TURN Static Auth Secret" field. Check the
consuming app's own TURN settings UI for which fields it actually exposes
before picking.
**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.
**Legacy installs still on the old shared coturn:** an `update` rerun on an
install that predates this repo's dedicated-coturn-only model (no `coturn:`
block in its `docker-compose.yml`) must not try to silently migrate or
"heal" it — there's no shared coturn service left in this repo to heal it
against. Leave it running exactly as-is (an `update` never touches `.env`
anyway) and point at a full/fresh reinstall as the migration path, which
generates a new dedicated coturn container with fresh credentials. See the
`_HAD_EMBEDDED_COTURN` handling in `services/asterisk.sh` and
`services/mattermost.sh` for the reference pattern — detect via `grep -q
'^ coturn:' docker-compose.yml` before regenerating it, same as any other
non-destructive `update` path in this file.
+1 -2
View File
@@ -185,7 +185,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`, `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`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `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`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
@@ -207,7 +207,6 @@ homelab
caddy
crowdsec
authelia
coturn
homeassistant
asterisk
pstn-trunk
+30
View File
@@ -34,3 +34,33 @@ rather than `fresh` at the reinstall prompt, and having a snapshot.
Two copies of the same logic is the exact problem the merge existed to fix,
and this one will drift the moment `services/asterisk.sh` gets a fix that
isn't backported here — which it deliberately won't be.
## `coturn.sh` / `coturn-test-check.sh`
The shared-coturn service (`services/coturn.sh`, moved here unchanged from
`services/`) and its standalone health-check tool (`tools/coturn-test-check.sh`,
moved from `tools/`). This model — every WebRTC/SIP-capable service
(`asterisk`, `mattermost`) sharing one coturn instance via `ensure_coturn_user`
— is no longer offered anywhere in this repo. Every service now runs its own
dedicated coturn instead, with `lib/common.sh`'s `find_free_coturn_range()`
avoiding the relay-port collisions a shared instance used to prevent by
scanning every coturn-owning service's own `.env` on the box. See
`CLAUDE.md`'s "coturn (TURN/STUN) relay" section for the current pattern.
Sharing one instance only ever saved ~40MB RAM per additional consumer
beyond the first — real, but small — against being a single point of
failure every consumer depended on. Parked here, not deleted, since the
code is still correct and someone could resurrect it if a future need for
it shows up. Both files still run standalone if invoked directly:
```bash
sudo bash attic/coturn.sh
sudo bash attic/coturn-test-check.sh
```
Nothing in this repo calls `ensure_coturn_user()` anymore (the function
itself was removed from `lib/common.sh`), so resurrecting this only makes
sense if you're deliberately reintroducing the shared-coturn pattern
yourself — a new consumer service would need its own call to whatever
takes `ensure_coturn_user`'s place, since that helper no longer exists to
call.
@@ -1,4 +1,10 @@
#!/usr/bin/env bash
# attic/coturn-test-check.sh — RETIRED along with attic/coturn.sh (formerly
# services/coturn.sh). This tool only makes sense against a shared coturn
# instance, which this repo no longer offers — see attic/coturn.sh's header
# for what replaced it (each service gets its own dedicated coturn now).
# Kept for reference alongside it, not actively maintained.
#
# tools/coturn-test-check.sh — Health-check for the shared coturn (TURN/STUN)
# instance services/coturn.sh sets up, and every consumer registered against
# it (Asterisk, one or more Mattermost instances, anything else added via
+25 -5
View File
@@ -1,10 +1,30 @@
#!/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).
# attic/coturn.sh — RETIRED. Formerly services/coturn.sh.
#
# Can also be run standalone on any machine:
# sudo bash coturn.sh
# (Docker must already be installed when run standalone)
# The shared-coturn model this file implements is no longer offered by this
# repo at all: services/asterisk.sh and services/mattermost.sh each now run
# their own dedicated coturn unconditionally, with lib/common.sh's
# find_free_coturn_range() making that safe (it scans every coturn-owning
# service's own .env on the box for already-claimed relay ranges and picks
# a block that can't collide with any of them, dedicated or shared). Sharing
# one instance only ever saved ~40MB RAM per additional consumer beyond the
# first — real, but small — and it was a single point of failure every
# consumer depended on. Parked here, not deleted, since the code is still
# correct and someone could resurrect it if a future need for it shows up;
# living in attic/ (outside services/*.sh's glob) means it never
# self-registers, never appears in the menu, and `sudo ./setup.sh coturn`
# now correctly fails with "unknown service" instead of silently offering
# a coturn shape nothing else in this repo will register a user against.
#
# ── Everything below this point is the file exactly as it ran before
# retirement, kept for reference/rollback, not actively maintained. ────────
#
# Can still be run standalone on any machine, same as before:
# sudo bash attic/coturn.sh
# (Docker must already be installed when run standalone) — but nothing in
# this repo will call ensure_coturn_user() to register with it anymore, so
# doing this only makes sense if you're deliberately reintroducing the
# shared-coturn pattern yourself.
#
# One coturn instance, shared by every service that needs TURN (Asterisk,
# Mattermost, and anything added later) instead of each service running its
+11 -5
View File
@@ -31,10 +31,16 @@ Rules of thumb:
`ensure_swapfile()` unconditionally, which offers a 2GB swapfile any time
RAM is ≤4096MB and none exists yet (`services/asterisk.sh` also calls it
directly for the standalone-run case, so it's covered either way).
- Sharing one `coturn` instance (`services/coturn.sh`) instead of letting
each WebRTC-capable service (Asterisk, Mattermost) embed its own saves a
container per consumer and — more importantly — avoids relay-port
collisions between them.
- **Historical note, no longer applicable:** this doc's Tier 2/3 plans below
were sized around one shared `coturn` instance instead of each WebRTC-
capable service (Asterisk, Mattermost) embedding its own — it saved a
container per consumer and avoided relay-port collisions between them.
That shared-coturn service has since been retired from this repo (see
`attic/coturn.sh`); every service now runs its own dedicated coturn, and
`lib/common.sh`'s `find_free_coturn_range()` avoids the same relay-port
collisions by scanning each coturn-owning service's `.env` instead. Budget
a coturn container per WebRTC-capable service/instance, not one shared
~40MB line, when re-planning a box from scratch.
## Tier 1 — ~1 vCPU / 1GB RAM / 25GB SSD
@@ -43,7 +49,7 @@ Example: DigitalOcean Basic, $6/mo.
This is tight enough that Docker's own daemon overhead is already a
meaningful fraction of the box. **Pick one purpose, not a stack:**
- **Option A — Asterisk only.** Asterisk + the shared coturn service fits
- **Option A — Asterisk only.** Asterisk + its own dedicated coturn fits
comfortably per this repo's own droplet-sizing notes (`services/asterisk.sh`
README section) — a swapfile is added automatically (RAM ≤4GB, see above),
and this plan is "fine for a couple of extensions and light personal use."
+35 -116
View File
@@ -805,6 +805,41 @@ find_free_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) ─────────────────
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
@@ -1003,119 +1038,3 @@ CADDY_BLOCK
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 cd's into $DOCKER_DIR/coturn and never cd's back —
# the caller (e.g. asterisk.sh, already cd'd into its own install
# directory) would otherwise return here with the wrong cwd and go
# on to write ITS docker-compose.yml/.env into coturn's directory
# instead of its own. Confirmed live: this clobbered coturn's
# compose file and left the consumer's own directory without one,
# so its later `docker compose up --build` failed with "Dockerfile:
# no such file or directory" (no Dockerfile in coturn's directory).
local _caller_pwd
_caller_pwd="$(pwd)"
install_coturn
local _coturn_rc=$?
cd "$_caller_pwd" || true
[ "$_coturn_rc" -ne 0 ] && { 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"
# The cache file surviving doesn't mean the username still exists in
# coturn's own live database — confirmed live: a coturn
# container/volume recreated without preserving ./db wipes the
# database while this file (a separate directory) survives
# untouched, silently orphaning every consumer's credentials until
# something re-registers them. Without this check, re-running the
# consumer's installer (fresh or update) never re-registers anything
# since it only ever hits the else branch below on a MISSING cache
# file — a stale-but-present one looked identical to a healthy one.
# A real "user[realm]" line never contains a space; turnadmin -l's
# own startup log lines do (confirmed live, at least one coturn
# build writes them to stdout, not stderr), so filtering on that
# keeps this robust across builds without needing to match a
# specific log format.
local _db_users
_db_users="$(docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>/dev/null | grep -v ' ' | sed -E 's/\[.*//' | awk 'NF')"
if ! grep -qx "$_u" <<< "$_db_users"; then
log_warning "coturn user '$_u' ($_consumer) has cached credentials but isn't in coturn's live database — re-registering with the same password."
if docker exec coturn turnadmin -a -u "$_u" -p "$_p" -r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then
log_success "Re-registered coturn user '$_u' for $_consumer"
else
log_warning "Could not re-register coturn user '$_u' for $_consumer — is the coturn container running?"
fi
fi
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"
}
+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 ""
+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) {
+9 -7
View File
@@ -172,8 +172,9 @@ fi
# Anveo-style ICE-enabled endpoints. Asterisk caches its OWN TURN_* values
# in its .env at the point it was configured — testing with those (not
# re-deriving fresh credentials) proves what Asterisk is actually set up
# to use, not just that the shared coturn instance works in general (that
# broader, multi-consumer check is tools/coturn-test-check.sh's job). ─────────
# to use. Every current install runs its own dedicated coturn; a box that
# still points at a legacy shared coturn instance predates that (see
# attic/coturn.sh) and can be spot-checked with attic/coturn-test-check.sh. ─────────
section "coturn (TURN relay for Asterisk)"
ASTERISK_ENV="$EA_DIR/.env"
@@ -197,11 +198,11 @@ else
if grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
COTURN_CONTAINER="easy-asterisk-coturn"
[[ "$CONTAINER" == *-do ]] && COTURN_CONTAINER="easy-asterisk-do-coturn"
ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER) — not the shared"
ok "instance, so tools/coturn-test-check.sh won't see this one; tested separately below."
ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER); tested separately below."
else
COTURN_CONTAINER="coturn"
ok "Using the shared coturn instance (also covered by tools/coturn-test-check.sh)"
ok "Using a legacy shared coturn instance (this repo no longer installs this shape —"
ok "see attic/coturn.sh; also covered by attic/coturn-test-check.sh)"
fi
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$COTURN_CONTAINER"; then
@@ -210,7 +211,8 @@ else
warn "turnutils_uclient not found in $COTURN_CONTAINER — skipping live allocation test"
else
# Plain UDP only — no -t/-T (TCP/TLS) flags. coturn is started with
# --no-tls --no-dtls (services/coturn.sh), so requesting an
# --no-tls --no-dtls (services/asterisk.sh's embedded coturn, and
# attic/coturn.sh's legacy shared one — same flags either way), so requesting an
# encrypted/TCP transport here just fails the allocation outright
# against a server that never offered one, misreporting a config
# problem that doesn't exist. Confirmed live: this was the actual
@@ -220,7 +222,7 @@ else
# turnutils_uclient also refuses to run at all without either -e
# <peer> or -y ("Either -e peer_address or -y must be specified",
# confirmed live). -e needs an actual reachable, non-loopback peer
# to relay through — services/coturn.sh never sets
# to relay through — this repo's coturn containers never set
# --allow-loopback-peers, so -e 127.0.0.1 gets rejected with
# "channel bind: error 403 (Forbidden IP)" (also confirmed live,
# against a real local coturn instance built to test this exact
+9
View File
@@ -134,6 +134,15 @@ mkdir -p "$CONFIG_DIR"
# Determine TURN/STUN server address
turn_server="${TURN_SERVER:-${DOMAIN_NAME:-$local_ip}:${TURN_PORT:-3478}}"
# TURN_SERVER may be set (e.g. carried over in .env from an older install)
# to a bare host with no port — the fallback above only appends one when
# TURN_SERVER is unset/empty entirely. Append it here too so every client
# that reads this config (Sipnetic QR export, the web admin's own display)
# always gets a host:port it can actually dial, not a host that silently
# depends on the client guessing the default port.
if [[ -n "$TURN_SERVER" && "$TURN_SERVER" != *:* ]]; then
turn_server="${TURN_SERVER}:${TURN_PORT:-3478}"
fi
cat > "$CONFIG_FILE" << EOF
# Easy Asterisk Configuration (Docker) - $(date)