diff --git a/CLAUDE.md b/CLAUDE.md index e07aee3..4e2d9dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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. diff --git a/README.md b/README.md index 7911739..f426677 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/attic/README.md b/attic/README.md index f57b0e5..1a85a95 100644 --- a/attic/README.md +++ b/attic/README.md @@ -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. diff --git a/tools/coturn-test-check.sh b/attic/coturn-test-check.sh similarity index 97% rename from tools/coturn-test-check.sh rename to attic/coturn-test-check.sh index dfcb7ac..2791443 100755 --- a/tools/coturn-test-check.sh +++ b/attic/coturn-test-check.sh @@ -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 diff --git a/services/coturn.sh b/attic/coturn.sh similarity index 91% rename from services/coturn.sh rename to attic/coturn.sh index 08dbb94..cd33c13 100644 --- a/services/coturn.sh +++ b/attic/coturn.sh @@ -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 diff --git a/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index d0c130b..b0ed2fb 100644 --- a/docs/vps-sizing-recommendations.md +++ b/docs/vps-sizing-recommendations.md @@ -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." diff --git a/lib/common.sh b/lib/common.sh index 016aaf5..4c54ad7 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -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 "" -# -# 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/.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" -} diff --git a/services/asterisk.sh b/services/asterisk.sh index c0f04d1..3922847 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -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:-}}" [ -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) | | 10000–20000 | UDP | RTP media streams | -$( [[ "$USE_EMBEDDED_COTURN" == true ]] \ - && echo "| 3478 | UDP/TCP | TURN/STUN (this install's own dedicated coturn) | -| 49152–49252 | 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, , 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 "" diff --git a/services/mattermost.sh b/services/mattermost.sh index e98614f..958559b 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -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}\"}]" diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 40bd0ff..4070982 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -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 = """ } 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 = """ .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 = """

Ring dials every member at once — first to answer gets the call, everyone else stops ringing. Page 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.

You don't need Page 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 answermode 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 baresip in Answer Mode: Auto) picks up everything routed to it instantly and unconditionally, while ordinary phones in the same plain Ring group just keep ringing until a person answers — no extra setting needed here for that mix.

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 baresip-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: download the installer script, then see docs/kiosk-paging-setup.md in this repo for the full walkthrough.

-

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.

+

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.

@@ -4106,6 +4184,14 @@ INDEX_HTML = """
+
+
+ +
+

Scan with Sipnetic (Add Account → Scan QR Code) to auto-fill this extension's SIP and TURN settings.

+

Contains the extension's password and TURN credentials in plain text — treat the image like the password itself.

+
+