From efb86fd92dcb0016d721181484fd4ed72cd70a35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:24:48 +0000 Subject: [PATCH 1/5] Add provider-portal checklist with real values to the PSTN health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side config was fully verifiable already; what wasn't is the provider-account side (Anveo's authorized-IP list, DID routing, SMS forward-URL) since that lives entirely outside this box. Rather than leave "go check the portal" as a vague pointer, compute and print the exact values each portal field needs to match: this box's public IP, the trunk DID (from .pstn-trunk.env), and the SMS forward URL read straight from /opt/sms-inbound/settings.env (SMS_FORWARD_URL) instead of making the user reconstruct or hunt for a value the installer already generated and stored. Anveo-specific field-by-field checklist when PROVIDER_NAME matches; generic fallback otherwise. Verified the .pstn-trunk.env / settings.env sourcing against mock files matching the real generated format, including the literal $[from]$-style Anveo placeholders in SMS_FORWARD_URL, which must survive `source` under `set -u` without triggering bash's legacy $[...] arithmetic expansion — same guard pattern services/pstn-trunk.sh's own update path already uses. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- tools/pstn-test-check.sh | 82 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/tools/pstn-test-check.sh b/tools/pstn-test-check.sh index 7aaac1b..61cd6c8 100755 --- a/tools/pstn-test-check.sh +++ b/tools/pstn-test-check.sh @@ -163,12 +163,90 @@ for log in pstn-trunk-calls.log sip-messages.log; do fi done +# ── Provider portal checklist ───────────────────────────────────────────────── +# Everything above is server-side and this script's own checks; the provider +# account/portal side (authorized IPs, DID routing, the SMS forward URL) is +# configured entirely outside this box and can't be queried from here. What +# CAN be done is computing the exact values Anveo's portal fields need to +# match, so you're checking against real numbers instead of hunting for them +# across two docs while tabbed into the portal. +section "Provider portal checklist — values to verify in Anveo (or your provider's portal)" + +PSTN_ENV="$EA_DIR/.pstn-trunk.env" +TRUNK_DID="" PROVIDER_NAME="" +if [ -f "$PSTN_ENV" ]; then + set +u + # shellcheck disable=SC1090 + source "$PSTN_ENV" + set -u + TRUNK_DID="${TRUNK_DID:-}" + PROVIDER_NAME="${PROVIDER_NAME:-}" +fi + +PUBLIC_IP="$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || true)" + +if [ -n "$TRUNK_DID" ]; then + echo " This box's DID: $TRUNK_DID" +else + echo " This box's DID: (not found — is pstn-trunk installed?)" +fi +if [ -n "$PUBLIC_IP" ]; then + echo " This box's public IP: $PUBLIC_IP" +else + echo " This box's public IP: (couldn't reach ifconfig.me — check manually: curl -4 ifconfig.me)" +fi +echo "" + +if [[ "$PROVIDER_NAME" == *Anveo* ]]; then + echo " Confirm in the Anveo portal (docs/anveo-direct-setup-guide.md has the full walkthrough):" + echo "" + echo " 1. Outbound Trunks -> your Call Termination Trunk -> Authorized IP Addresses" + echo " includes: $PUBLIC_IP" + echo " 2. Account Options -> SIP Trunk (inbound) -> Primary SIP URI is exactly:" + echo " \$[E164]\$@${PUBLIC_IP}:5060" + echo " 3. Phone Numbers -> $TRUNK_DID -> Call Options -> Destination SIP Trunk" + echo " is set to that SIP Trunk object (or Account Options -> Service Defaults ->" + echo " Default Destination Trunk is set, which covers every DID automatically)" + echo " 4. Account balance is funded and NOT at \$0 (calls silently block at \$0 balance)" + echo " 5. Phone Numbers -> $TRUNK_DID -> SMS tab -> \"Forward to URL\" is ticked and" + echo " set to exactly the string below (see 'SMS webhook' just below if it's blank)" +else + echo " Provider not detected as Anveo Direct (PROVIDER_NAME='${PROVIDER_NAME:-unset}') —" + echo " generic checklist, check your provider's own portal for the equivalents:" + echo "" + echo " 1. This box's public IP ($PUBLIC_IP) is on the trunk's authorized/allowed IP list" + echo " 2. The DID ($TRUNK_DID) routes inbound SIP to ${PUBLIC_IP}:5060" + echo " 3. Account balance isn't at \$0 or suspended" + echo " 4. SMS forwarding (if used) points at the URL below" +fi + +echo "" +echo " SMS webhook (from /opt/sms-inbound/settings.env, if installed):" +SMS_SETTINGS="/opt/sms-inbound/settings.env" +if [ -f "$SMS_SETTINGS" ]; then + set +u + # shellcheck disable=SC1090 + source "$SMS_SETTINGS" + set -u + if [ -n "${SMS_FORWARD_URL:-}" ]; then + echo " ${SMS_FORWARD_URL}" + echo " (paste exactly as shown — press SAVE not RETURN on Anveo's SMS tab, then" + echo " reopen it to confirm the whole string came back, it's long)" + else + echo " sms-inbound is installed but no SMS_FORWARD_URL found in $SMS_SETTINGS" + echo " — re-run: sudo ./setup.sh sms-inbound" + fi +else + echo " sms-inbound not installed — nothing to configure on the SMS tab yet." +fi + # ── Summary ─────────────────────────────────────────────────────────────────── section "Summary" echo " $PASS passed, $WARN warnings, $FAIL failed." echo "" echo " This covers everything that can be checked without placing a real call" -echo " or sending a real text. For those, and for what each result above means," -echo " see docs/pstn-sms-test-checklist.md." +echo " or sending a real text, plus the provider-side values above that only a" +echo " human can confirm inside the portal itself. For the call/SMS test steps" +echo " and what each result means, see docs/pstn-sms-test-checklist.md." [ "$FAIL" -eq 0 ] From a28e9a5a1c0329163590c9fed0604fc6a6deda44 Mon Sep 17 00:00:00 2001 From: Outis Date: Tue, 11 Aug 2026 16:29:08 -0400 Subject: [PATCH 2/5] Fix drum rhythm Christmas audio --- services/drum-rhythm-game.sh | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/services/drum-rhythm-game.sh b/services/drum-rhythm-game.sh index 9d467c6..311e26c 100644 --- a/services/drum-rhythm-game.sh +++ b/services/drum-rhythm-game.sh @@ -235,6 +235,119 @@ install_drum-rhythm-game() { || { log_error "Clone failed — check network and git access"; return 1; } fi + # Apply local runtime fixes for the upstream single-page game. The game is + # intentionally static, so we patch index.html after clone/pull instead of + # forking the deployment source. The shim is defensive: it fixes Web Audio + # resume/unlock behavior, guarantees every visible drum pad has an audible + # fallback, and replaces Christmas melody metadata with well-known public + # domain tunes using MIDI pitches. + log_info "Applying drum-rhythm-game Christmas/audio fixes..." + cat > "$DRUM_DIR/html/ubuntu-post-install-fixes.js" <<'DRUM_FIXES_JS' +(() => { + 'use strict'; + + const NOTE_TO_MIDI = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; + const CHRISTMAS_TUNES = { + 'jingle bells': { + title: 'Jingle Bells', bpm: 112, + melody: 'E4 E4 E4 R E4 E4 E4 R E4 G4 C4 D4 E4 R F4 F4 F4 F4 F4 E4 E4 E4 E4 D4 D4 E4 D4 R G4 R E4 E4 E4 R E4 E4 E4 R E4 G4 C4 D4 E4 R F4 F4 F4 F4 F4 E4 E4 E4 G4 G4 F4 D4 C4', + beats: '1 1 2 1 1 1 2 1 1 1 1 1 4 1 1 1 1 1 1 1 0.5 0.5 1 1 1 1 2 1 2 1 1 2 1 1 1 2 1 1 1 1 1 4 1 1 1 1 1 1 1 1 1 1 1 1 4' + }, + 'silent night': { + title: 'Silent Night', bpm: 76, + melody: 'G4 A4 G4 E4 R G4 A4 G4 E4 R D5 D5 B4 R C5 C5 G4 R A4 A4 C5 B4 A4 G4 A4 G4 E4 R A4 A4 C5 B4 A4 G4 A4 G4 E4 R D5 D5 F5 D5 B4 C5 E5 R C5 G4 E4 G4 F4 D4 C4', + beats: '1.5 0.5 1 3 1 1.5 0.5 1 3 1 2 1 3 1 2 1 3 1 2 1 1.5 0.5 1 1.5 0.5 1 3 1 2 1 1.5 0.5 1 1.5 0.5 1 3 1 2 1 1.5 0.5 1 1 1 3 1 1 1 1.5 0.5 1 1 1 3' + }, + 'deck the halls': { + title: 'Deck the Halls', bpm: 128, + melody: 'G4 F4 E4 D4 C4 D4 E4 C4 D4 E4 F4 D4 E4 D4 C4 B3 C4 R D4 E4 F4 D4 E4 F4 G4 D4 E4 F4 G4 A4 B4 C5 B4 A4 G4 F4 E4 D4 C4', + beats: '1 0.5 0.5 1 1 1 1 1 0.5 0.5 1 1 0.5 0.5 1 1 2 1 1 0.5 0.5 1 0.5 0.5 1 1 0.5 0.5 1 1 1 1 0.5 0.5 1 1 1 1 2' + }, + 'we wish you a merry christmas': { + title: 'We Wish You a Merry Christmas', bpm: 120, + melody: 'D4 G4 G4 A4 G4 F#4 E4 E4 E4 A4 A4 B4 A4 G4 F#4 D4 D4 B4 B4 C5 B4 A4 G4 E4 D4 D4 E4 A4 F#4 G4', + beats: '1 1 0.5 0.5 0.5 0.5 1 1 1 1 0.5 0.5 0.5 0.5 1 1 1 1 0.5 0.5 0.5 0.5 1 1 0.5 0.5 1 1 1 2' + }, + 'joy to the world': { + title: 'Joy to the World', bpm: 112, + melody: 'C5 B4 A4 G4 F4 E4 D4 C4 G4 A4 A4 B4 B4 C5 C5 C5 C5 B4 A4 G4 G4 F4 E4 C5 C5 B4 A4 G4 G4 F4 E4 E4 E4 E4 E4 F4 G4 F4 E4 D4 D4 D4 D4 E4 F4 E4 D4 C4', + beats: '2 1.5 0.5 2 2 2 2 4 1.5 0.5 1.5 0.5 1.5 0.5 4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 4' + } + }; + + const toMidi = (note) => { + if (!note || note === 'R') return null; + const match = /^([A-G])([#b]?)(-?\d+)$/.exec(note); + if (!match) return null; + const accidental = match[2] === '#' ? 1 : match[2] === 'b' ? -1 : 0; + return 12 * (Number(match[3]) + 1) + NOTE_TO_MIDI[match[1]] + accidental; + }; + const freq = (midi) => 440 * (2 ** ((midi - 69) / 12)); + const tuneNotes = (tune) => tune.melody.split(/\s+/).map((note, idx) => ({ note, midi: toMidi(note), frequency: toMidi(note) == null ? 0 : freq(toMidi(note)), beat: Number(tune.beats.split(/\s+/)[idx] || 1) })); + + const findAudioContext = () => { + const AudioCtor = window.AudioContext || window.webkitAudioContext; + return AudioCtor ? Object.values(window).find((value) => value instanceof AudioCtor) : null; + }; + const unlock = () => { + const AudioCtor = window.AudioContext || window.webkitAudioContext; + window.__drumRhythmAudioContext = window.__drumRhythmAudioContext || (AudioCtor ? new AudioCtor() : null); + [window.__drumRhythmAudioContext, findAudioContext()].filter(Boolean).forEach((ctx) => ctx.state === 'suspended' && ctx.resume()); + }; + ['pointerdown', 'keydown', 'gamepadconnected', 'touchstart', 'click'].forEach((eventName) => window.addEventListener(eventName, unlock, { passive: true })); + + const padSound = (index = 0) => { + unlock(); + const ctx = window.__drumRhythmAudioContext; + if (!ctx) return; + const now = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = ['sine', 'triangle', 'square', 'sawtooth'][index % 4]; + osc.frequency.setValueAtTime([110, 146.83, 196, 246.94, 329.63, 392][index % 6], now); + gain.gain.setValueAtTime(0.24, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13); + osc.connect(gain).connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.14); + }; + window.ubuntuPostInstallDrumPadSound = padSound; + + document.addEventListener('pointerdown', (event) => { + const pad = event.target.closest('[data-pad], .pad, .drum-pad, button'); + if (!pad || /play|start|stop|pause|song/i.test(pad.textContent || '')) return; + padSound([...document.querySelectorAll('[data-pad], .pad, .drum-pad, button')].indexOf(pad)); + }, true); + + const normalize = (name) => String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + const patchSongs = () => { + for (const root of [window, ...Object.values(window).filter((value) => value && typeof value === 'object')]) { + for (const key of Object.keys(root)) { + const list = root[key]; + if (!Array.isArray(list)) continue; + for (const song of list) { + const title = normalize(song && (song.title || song.name)); + const tuneKey = Object.keys(CHRISTMAS_TUNES).find((candidate) => title.includes(candidate)); + if (!tuneKey) continue; + const tune = CHRISTMAS_TUNES[tuneKey]; + song.title = song.title || tune.title; + song.name = song.name || tune.title; + song.bpm = tune.bpm; + song.notes = tuneNotes(tune); + song.melody = tune.melody; + song.beats = tune.beats; + } + } + } + }; + window.addEventListener('load', () => { unlock(); patchSongs(); setTimeout(patchSongs, 250); }); + setInterval(patchSongs, 2000); +})(); +DRUM_FIXES_JS + if [ -f "$DRUM_DIR/html/index.html" ] && ! grep -q "ubuntu-post-install-fixes.js" "$DRUM_DIR/html/index.html"; then + sed -i 's## \n#' "$DRUM_DIR/html/index.html" + fi + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DRUM_DIR/html" # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): From 5c18cfa3647ef356d66eba00228464fcb388861a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:32:02 +0000 Subject: [PATCH 3/5] Add SMS test reminder, "which box handled it" note, and a coturn health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from live testing on this session's actual VPS: - tools/pstn-test-check.sh's SMS section printed the Forward-to-URL value to configure but never said what to do next — add the "text this DID, then watch journalctl -u sms-inbound -f" step right after it. - docs/pstn-sms-test-checklist.md: the "which box actually handled this" question has a simple answer (a DID's inbound routing targets exactly one IP:port, so there's no ambiguity to resolve, only a portal setting to confirm) — written up so it doesn't need re-deriving. Also fixed the --list example to cd into the repo first; ./setup.sh is a relative path and silently fails with "command not found" from any other directory, confirmed live in this session. - New tools/coturn-test-check.sh: health-checks the shared coturn instance (services/coturn.sh) and every consumer registered against it (Asterisk, any number of Mattermost instances) — container/identity, each cached consumer credential cross-checked against coturn's own live user database (catches the container/volume-recreated-without-db drift case), UFW rules for both the TURN port and the relay range, a capacity explanation reasoned from the actual port-range math instead of a guess, and a real TURN allocation test per consumer via turnutils_uclient — the only way to prove credentials + port range + firewall all actually work together, not just that each looks right in isolation. Deliberately does not attempt a concurrent load test, since that would consume real relay ports other services may be actively using. Verified the turnadmin -l output parsing, UFW rule matching, and the empty-array-under-set–u loop pattern against mock data before shipping. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- docs/pstn-sms-test-checklist.md | 25 ++++- tools/coturn-test-check.sh | 186 ++++++++++++++++++++++++++++++++ tools/pstn-test-check.sh | 6 ++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100755 tools/coturn-test-check.sh diff --git a/docs/pstn-sms-test-checklist.md b/docs/pstn-sms-test-checklist.md index b481957..2b16a7e 100644 --- a/docs/pstn-sms-test-checklist.md +++ b/docs/pstn-sms-test-checklist.md @@ -12,6 +12,24 @@ Asterisk or PSTN trunk reinstall, or just periodically to catch drift (a provider-side change, an expired international allow-list, a forgotten kill-switch trip). +## How do I know a test call/text actually used THIS box? + +Short answer: there's no ambiguity to resolve — a DID can only point at one +place. In the Anveo (or any IP-auth) portal, the DID's inbound routing +targets one specific IP:port (`$[E164]$@:5060`), and +the Outbound Trunk's Authorized IP Addresses list is what lets *this* box's +outbound calls out. If you have multiple boxes, only the one whose IP is +actually configured in the portal can send or receive on that DID at all — +there's nothing to "make sure" beyond confirming the portal points at this +box's current IP (§ Provider portal checklist output from +`tools/pstn-test-check.sh` prints it directly). + +The practical way to *watch* it happen on this box specifically, live, +while you place the test: run `docker exec -it $CONTAINER asterisk -rvvv` +or `journalctl -u sms-inbound -f` in one terminal, then place the call/text +from another phone in real time. If it shows up here as it happens, this +box handled it — no separate confirmation needed. + ## 0. Before you start `$CONTAINER`/`$EA_DIR` only live in the shell session where you set them — @@ -34,10 +52,13 @@ If `$CONTAINER` prints empty, the container isn't running at all — check Re-run this block at the start of every new terminal session, not just once — it's cheap and removes the whole class of failure above. -Check the three services this checklist covers are actually installed: +Check the three services this checklist covers are actually installed — +run this from the repo directory itself (`~/ubuntu-post-install`, not +`~/docker/asterisk` or wherever you happen to be — `./setup.sh` is a +relative path and fails with "command not found" from anywhere else): ```bash -sudo ./setup.sh --list | grep -E "asterisk|pstn-trunk|sms-inbound|security-dashboard" +cd ~/ubuntu-post-install && sudo ./setup.sh --list | grep -E "asterisk|pstn-trunk|sms-inbound|security-dashboard" ``` Read your box's own current settings before testing — this file has your diff --git a/tools/coturn-test-check.sh b/tools/coturn-test-check.sh new file mode 100755 index 0000000..bb61c0d --- /dev/null +++ b/tools/coturn-test-check.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# 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 +# ensure_coturn_user() in lib/common.sh). +# +# Checks: container up, identity/.env readable, every registered consumer +# actually exists in coturn's own user database (not just a cached +# users/.env file — the two can drift, e.g. a container recreated from +# an older image/db), UFW has the TURN port + relay range open, and — the +# part nothing else in this repo does — a REAL TURN allocation test per +# consumer via turnutils_uclient (bundled in the coturn/coturn image), which +# is the only way to prove credentials + port range + firewall all actually +# work together end to end, not just that each piece looks right in isolation. +# +# Does NOT attempt a concurrent load test (e.g. opening dozens of allocations +# at once) — that would consume real relay ports on a server other services +# may be actively using. See "Capacity" in the output for how the port range +# bounds concurrent capacity, reasoned from the numbers instead of guessed at. +# +# Usage: +# sudo bash tools/coturn-test-check.sh +# +# Safe to run any time — the one allocation test per consumer opens and +# immediately releases a single relay port, the same as a single real call +# briefly would. + +set -uo pipefail + +PASS=0 +WARN=0 +FAIL=0 + +ok() { printf ' [OK] %s\n' "$1"; PASS=$((PASS + 1)); } +warn() { printf ' [WARN] %s\n' "$1"; WARN=$((WARN + 1)); } +fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); } +section() { printf '\n== %s ==\n' "$1"; } + +if [ "$(id -u)" -ne 0 ]; then + echo "Run with sudo — needs docker exec." >&2 + exec sudo bash "$0" "$@" +fi + +ACTUAL_USER="${SUDO_USER:-${USER:-root}}" +ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/root")" +DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" +COTURN_DIR="$DOCKER_DIR/coturn" + +# ── Container + identity ────────────────────────────────────────────────────── +section "Detecting install" + +if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx coturn; then + fail "No running 'coturn' container found — is services/coturn.sh installed and started?" + echo "" + echo " $PASS passed, $WARN warnings, $FAIL failed. Stopping." + exit 1 +fi +ok "Container running: coturn" + +if [ ! -f "$COTURN_DIR/.env" ]; then + fail "$COTURN_DIR/.env not found — can't read realm/host/port range." + exit 1 +fi +set +u +# shellcheck disable=SC1090 +source "$COTURN_DIR/.env" +set -u +COTURN_REALM="${COTURN_REALM:-}" +COTURN_HOST="${COTURN_HOST:-}" +COTURN_PORT="${COTURN_PORT:-3478}" +COTURN_MIN_PORT="${COTURN_MIN_PORT:-49152}" +COTURN_MAX_PORT="${COTURN_MAX_PORT:-49452}" +ok "Realm: ${COTURN_REALM:-} Host: ${COTURN_HOST:-} Port: $COTURN_PORT" +ok "Relay port range: ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}" + +# ── Registered consumers ────────────────────────────────────────────────────── +section "Registered consumers" + +DB_USERS="$(docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>/dev/null | sed -E 's/\[.*//' | awk 'NF' | sort -u)" +if [ -z "$DB_USERS" ]; then + warn "No users found in coturn's own database — nothing has actually registered yet, or turnadmin -l's output format changed. Raw:" + docker exec coturn turnadmin -l -b /var/lib/coturn/turndb 2>&1 | sed 's/^/ /' +fi + +CACHED_CONSUMERS=() +if [ -d "$COTURN_DIR/users" ]; then + while IFS= read -r f; do + CACHED_CONSUMERS+=("$(basename "$f" .env)") + done < <(find "$COTURN_DIR/users" -maxdepth 1 -name '*.env' -type f 2>/dev/null | sort) +fi + +if [ "${#CACHED_CONSUMERS[@]}" -eq 0 ]; then + warn "No cached consumer credentials in $COTURN_DIR/users — nothing has registered via ensure_coturn_user() yet." +else + for c in "${CACHED_CONSUMERS[@]}"; do + if grep -qx "$c" <<< "$DB_USERS"; then + ok "Consumer '$c' — cached credentials present AND found in coturn's live database" + else + fail "Consumer '$c' has a cached users/${c}.env but is NOT in coturn's database — its calls will fail 401 Unauthorized. Likely cause: the coturn container/volume was recreated without preserving ./db. Fix: sudo docker exec coturn turnadmin -a -u $c -p -r $COTURN_REALM -b /var/lib/coturn/turndb" + fi + done +fi + +# Flag anything in the live DB with no cached file too — orphaned/manually +# added users aren't wrong, just worth knowing about. +while IFS= read -r u; do + [ -z "$u" ] && continue + found=false + for c in "${CACHED_CONSUMERS[@]:-}"; do [ "$c" = "$u" ] && found=true && break; done + [ "$found" = false ] && warn "Database has user '$u' with no matching users/${u}.env — added manually, or a leftover from a removed service." +done <<< "$DB_USERS" + +# ── Firewall ─────────────────────────────────────────────────────────────────── +section "Firewall (UFW)" +if command -v ufw &>/dev/null; then + UFW_STATUS="$(ufw status 2>/dev/null)" + if grep -qE "^${COTURN_PORT}(/udp|/tcp)?\b.*ALLOW" <<< "$UFW_STATUS"; then + ok "TURN listening port ${COTURN_PORT} allowed" + else + fail "TURN listening port ${COTURN_PORT} not found in 'ufw status' — clients may not reach it" + fi + if grep -qE "^${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp\b.*ALLOW" <<< "$UFW_STATUS"; then + ok "Relay port range ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp allowed" + else + fail "Relay port range ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp not found in 'ufw status' — allocated relay ports would be unreachable, breaking media even after a successful TURN allocation" + fi +else + warn "ufw not installed — can't confirm the relay range is actually open (may be fine if this box has no firewall, or one outside UFW)" +fi + +# ── Capacity ─────────────────────────────────────────────────────────────────── +section "Capacity" +RANGE_SIZE=$((COTURN_MAX_PORT - COTURN_MIN_PORT + 1)) +CONSUMER_COUNT="${#CACHED_CONSUMERS[@]}" +echo " Relay range holds ${RANGE_SIZE} ports. Each concurrent relayed call/leg typically" +echo " uses one allocation (roughly one port) for its lifetime — released when the call" +echo " ends, not held permanently. With ${CONSUMER_COUNT} registered consumer(s), the range" +echo " would need all of them to have ~$((RANGE_SIZE / (CONSUMER_COUNT > 0 ? CONSUMER_COUNT : 1))) simultaneous relayed calls each, at the same" +echo " moment, before it runs out — for Asterisk + a handful of Mattermost instances at" +echo " personal/small-team scale, that ceiling is not realistically reachable in normal" +echo " use. If you ever DO expect that much simultaneous WebRTC/SIP relay traffic, raise" +echo " COTURN_MIN_PORT/COTURN_MAX_PORT in $COTURN_DIR/.env, update the matching UFW rule," +echo " and restart coturn — no consumer reconfiguration needed, they don't cache the range." +echo "" +echo " Note: not every call needs a TURN relay at all — TURN is the FALLBACK when two" +echo " peers can't reach each other directly (STUN/ICE finds a direct path first when" +echo " possible). Real relay usage is usually well below \"every concurrent call.\"" + +# ── Real allocation test per consumer ───────────────────────────────────────── +section "Live allocation test (one real TURN allocation per registered consumer)" +if ! docker exec coturn which turnutils_uclient &>/dev/null; then + warn "turnutils_uclient not found in the coturn image — skipping live allocation tests." +else + TEST_HOST="${COTURN_HOST:-127.0.0.1}" + for c in "${CACHED_CONSUMERS[@]:-}"; do + [ -z "$c" ] && continue + _u="$(grep '^COTURN_USER=' "$COTURN_DIR/users/${c}.env" 2>/dev/null | cut -d= -f2-)" + _p="$(grep '^COTURN_PASS=' "$COTURN_DIR/users/${c}.env" 2>/dev/null | cut -d= -f2-)" + if [ -z "$_u" ] || [ -z "$_p" ]; then + warn "$c: couldn't read cached credentials, skipping live test" + continue + fi + OUT="$(docker exec coturn timeout 10 turnutils_uclient -t -T -u "$_u" -w "$_p" "$TEST_HOST" -p "$COTURN_PORT" 2>&1)" + RC=$? + if [ "$RC" -eq 0 ]; then + ok "$c: TURN allocation succeeded (credentials + relay range + reachability all confirmed working)" + else + fail "$c: TURN allocation failed (exit $RC) — raw output:" + echo "$OUT" | tail -n 15 | sed 's/^/ /' + fi + done +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +section "Summary" +echo " $PASS passed, $WARN warnings, $FAIL failed." +echo "" +echo " A passing allocation test here proves TURN works end to end for that consumer." +echo " It does NOT by itself prove Asterisk or Mattermost are actually configured to USE" +echo " it — check each service's own .env for TURN_HOST/TURN_USERNAME (asterisk.sh) or" +echo " the Calls plugin's ICE Servers Configurations (mattermost.sh) matches what's" +echo " printed above, then place a real call from outside the LAN (the case TURN" +echo " actually exists for — two peers on the same LAN usually connect directly and never" +echo " touch the relay at all, so a same-LAN test call proves nothing about TURN)." + +[ "$FAIL" -eq 0 ] diff --git a/tools/pstn-test-check.sh b/tools/pstn-test-check.sh index 61cd6c8..dc76e0d 100755 --- a/tools/pstn-test-check.sh +++ b/tools/pstn-test-check.sh @@ -232,6 +232,12 @@ if [ -f "$SMS_SETTINGS" ]; then echo " ${SMS_FORWARD_URL}" echo " (paste exactly as shown — press SAVE not RETURN on Anveo's SMS tab, then" echo " reopen it to confirm the whole string came back, it's long)" + echo "" + echo " Once that's saved: text ${TRUNK_DID:-this DID} from any OTHER phone (not a" + echo " softphone registered to this Asterisk — an outside cell number), then watch:" + echo " journalctl -u sms-inbound -f" + echo " It should land in Sipnetic (or whichever softphone owns that DID/extension)" + echo " within a few seconds. See docs/pstn-sms-test-checklist.md §10 if it doesn't." else echo " sms-inbound is installed but no SMS_FORWARD_URL found in $SMS_SETTINGS" echo " — re-run: sudo ./setup.sh sms-inbound" From bb7aece023b91fa16c62250cbc7660884d670e54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:44:09 +0000 Subject: [PATCH 4/5] Test Asterisk's own coturn config and print softphone setup info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the PSTN health check: - New "coturn (TURN relay for Asterisk)" section reads Asterisk's own TURN_* values from its .env (not re-derived) and runs a live TURN allocation against whichever coturn Asterisk is actually configured to use — the shared instance, or its own embedded per-Asterisk coturn if that's what this box has (detected via the same "grep -q '^ coturn:' docker-compose.yml" check CLAUDE.md's migration guidance describes). Proves what Asterisk itself would use at call time, complementing tools/coturn-test-check.sh's broader multi-consumer check. - New "Softphone setup" section parses pjsip.conf directly and prints per-extension SIP server/username/password/port/transport, plus TURN credentials for any extension with ice_support=yes — the same values Sipnetic's "Add Account" screen needs, computed here so a client isn't installed just to read them out of the Security Dashboard. Also fixed a bug caught while building a mock test harness to verify both additions: the extension-registration parser grabbed state via a fixed field position ($3), silently truncating multi-word states like "Not in use" down to "Not". Replaced with a regex that captures everything between the extension and the trailing "N of inf" — verified against both single- and multi-word states. And a real syntax bug caught by bash -n before this ever shipped: an apostrophe inside a ${VAR:-default} expansion ("this box's IP") opens an unterminated single-quote context even inside double quotes — reworded to avoid the apostrophe entirely rather than fight bash's parser. Full mock run (fake docker/curl/systemctl/getent, real pjsip.conf/.env fixtures matching the actual generated format) confirmed both new sections and the registration fix all produce correct output end to end. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- tools/pstn-test-check.sh | 119 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/tools/pstn-test-check.sh b/tools/pstn-test-check.sh index dc76e0d..0a5785c 100755 --- a/tools/pstn-test-check.sh +++ b/tools/pstn-test-check.sh @@ -60,6 +60,10 @@ ok "Directory: $EA_DIR" ASTERISK_DIR="$EA_DIR/config/asterisk" LOGS_DIR="$EA_DIR/logs" +# Fetched once, reused by both the softphone-setup and provider-checklist +# sections below. +PUBLIC_IP="$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || true)" + # ── Registration ───────────────────────────────────────────────────────────── section "Extension registration" @@ -69,9 +73,12 @@ if [ -z "$ENDPOINTS_OUT" ]; then else # Endpoint lines look like " Endpoint: 101/101 Unavailable 0 of inf" — # skip the trunk itself (checked separately below) and the header/legend. + # State is captured with a regex, not a fixed field number: it's one or + # more words ("Unavailable", but also "Not in use" — a single $3 field + # grab truncated that to just "Not"). while IFS= read -r line; do ext="$(awk '{print $2}' <<< "$line" | cut -d/ -f1)" - state="$(awk '{print $3}' <<< "$line")" + state="$(sed -E 's/^ Endpoint:[[:space:]]+[^[:space:]]+[[:space:]]+(.*[^[:space:]])[[:space:]]+[0-9]+ of inf[[:space:]]*$/\1/' <<< "$line")" # Skip the column-header/legend line (" ") # printed once at the top of real output — it matches the same # "^ Endpoint:" grep as an actual endpoint row. @@ -139,6 +146,57 @@ else fi fi +# ── coturn (TURN) — used for remote/NAT'd extensions' media relay, and by +# 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). ───────── +section "coturn (TURN relay for Asterisk)" + +ASTERISK_ENV="$EA_DIR/.env" +TURN_SERVER="" TURN_USERNAME="" TURN_PASSWORD="" TURN_PORT="" +if [ -f "$ASTERISK_ENV" ]; then + set +u + # shellcheck disable=SC1090 + source "$ASTERISK_ENV" + set -u + TURN_SERVER="${TURN_SERVER:-}" + TURN_USERNAME="${TURN_USERNAME:-}" + TURN_PASSWORD="${TURN_PASSWORD:-}" + TURN_PORT="${TURN_PORT:-}" +fi + +if [ -z "$TURN_SERVER" ]; then + warn "Asterisk has no TURN configured — fine for LAN-only extensions, but a phone on" + warn "mobile data or behind restrictive NAT may get one-way or no audio without it." + warn "Add it via: sudo ./setup.sh asterisk (update mode)" +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." + else + COTURN_CONTAINER="coturn" + ok "Using the shared coturn instance (also covered by tools/coturn-test-check.sh)" + fi + + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$COTURN_CONTAINER"; then + fail "Container '$COTURN_CONTAINER' not running — Asterisk's TURN config points at it but it's down" + elif ! docker exec "$COTURN_CONTAINER" which turnutils_uclient &>/dev/null; then + warn "turnutils_uclient not found in $COTURN_CONTAINER — skipping live allocation test" + else + OUT="$(docker exec "$COTURN_CONTAINER" timeout 10 turnutils_uclient -t -T -u "$TURN_USERNAME" -w "$TURN_PASSWORD" 127.0.0.1 -p "${TURN_PORT:-3478}" 2>&1)" + if [ $? -eq 0 ]; then + ok "Live TURN allocation succeeded with Asterisk's own configured credentials (user '$TURN_USERNAME')" + else + fail "Live TURN allocation FAILED with Asterisk's configured credentials — raw output:" + echo "$OUT" | tail -n 15 | sed 's/^/ /' + fi + fi +fi + # ── SMS inbound ─────────────────────────────────────────────────────────────── section "SMS inbound" if systemctl list-unit-files sms-inbound.service &>/dev/null; then @@ -163,6 +221,63 @@ for log in pstn-trunk-calls.log sip-messages.log; do fi done +# ── Softphone setup (Sipnetic or any SIP client) ────────────────────────────── +# Same data the Security Dashboard's per-extension "info" panel shows +# (showEaDeviceDetails in services/security-dashboard.sh), read directly from +# pjsip.conf here so this is useful even without the dashboard installed. +# Passwords are read from the live config on this box, not regenerated — +# printing them is exactly as sensitive as the dashboard's own info panel. +section "Softphone setup — one block per extension (password shown, handle accordingly)" + +DOMAIN_NAME="" +[ -f "$EA_DIR/.env" ] && DOMAIN_NAME="$(grep -E '^DOMAIN_NAME=' "$EA_DIR/.env" | cut -d= -f2-)" +SIP_SERVER="${DOMAIN_NAME:-${PUBLIC_IP:-}}" + +PJSIP_CONF="$ASTERISK_DIR/pjsip.conf" +if [ ! -f "$PJSIP_CONF" ]; then + warn "pjsip.conf not found at $PJSIP_CONF — can't print softphone settings" +else + DEVICE_INFO="$(awk ' + /^\[[0-9]+\]$/ { ext = substr($0, 2, length($0)-2); cur_type=""; next } + /^type=endpoint/ { cur_type="endpoint"; next } + /^type=auth/ { cur_type="auth"; next } + /^type=aor/ { cur_type="aor"; next } + cur_type=="endpoint" && /^transport=/ { split($0,a,"="); transport[ext]=a[2] } + cur_type=="endpoint" && /^ice_support=yes/ { ice[ext]="yes" } + cur_type=="auth" && /^password=/ { split($0,a,"="); pass[ext]=a[2] } + END { + for (e in pass) printf "%s|%s|%s|%s\n", e, pass[e], transport[e], (ice[e] ? ice[e] : "no") + } + ' "$PJSIP_CONF" | sort)" + + if [ -z "$DEVICE_INFO" ]; then + warn "No devices found in pjsip.conf" + else + while IFS='|' read -r ext pass transport ice; do + [ -z "$ext" ] && continue + if [ "$transport" = "transport-tls" ]; then + port=5061; proto="tls" + else + port=5060; proto="udp" + fi + echo " Extension $ext:" + echo " SIP server: $SIP_SERVER" + echo " Username: $ext" + echo " Password: $pass" + echo " Port: $port" + echo " Transport: $proto" + if [ "$ice" = "yes" ] && [ -n "$TURN_SERVER" ]; then + echo " TURN server: $TURN_SERVER" + echo " TURN user: $TURN_USERNAME" + echo " TURN pass: $TURN_PASSWORD" + fi + echo "" + done <<< "$DEVICE_INFO" + ok "Printed setup info for $(wc -l <<< "$DEVICE_INFO") extension(s) — same values Sipnetic's" + ok "'Add Account' screen (or the dashboard's QR code / Download settings) needs" + fi +fi + # ── Provider portal checklist ───────────────────────────────────────────────── # Everything above is server-side and this script's own checks; the provider # account/portal side (authorized IPs, DID routing, the SMS forward URL) is @@ -183,8 +298,6 @@ if [ -f "$PSTN_ENV" ]; then PROVIDER_NAME="${PROVIDER_NAME:-}" fi -PUBLIC_IP="$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || true)" - if [ -n "$TRUNK_DID" ]; then echo " This box's DID: $TRUNK_DID" else From 5dbfcbd120af2aace11cc6379cf5e3c5f468b466 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:53:23 +0000 Subject: [PATCH 5/5] Fix false TURN allocation failure, add attention recap, one-at-a-time reprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug caught from a live run: the coturn allocation test passed -t -T (TCP/TLS) to turnutils_uclient, but services/coturn.sh always starts coturn with --no-tls --no-dtls — requesting an encrypted/TCP transport against a server that never offered one fails the allocation outright ("Cannot complete Allocation"), misreporting a config problem that didn't exist. Dropped both flags in both tools/pstn-test-check.sh and tools/coturn-test-check.sh so the test matches what the server actually supports (plain UDP). Also, from user feedback on the same run: - warn()/fail() now collect their messages into arrays; the Summary section prints a "Needs attention" recap of every FAIL/WARN together at the end, instead of leaving the user to scroll back through a long run to find what needs fixing. - The softphone-setup block now offers to reprint itself one extension at a time (paced with a keypress between each) after the main run, so a long device list isn't lost in the scrollback either. Factored the per-extension print into print_ext_info() so the full run and this reprint can't drift apart. Guarded with `[ -t 0 ]` so it's skipped automatically when the script isn't run interactively. Verified via a fuller mock harness (fake docker/curl/systemctl/getent, non-TTY stdin) that: the corrected turnutils_uclient invocation reports success, the recap correctly lists FAIL before WARN, and the interactive reprint prompt is skipped without hanging when stdin isn't a terminal. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- tools/coturn-test-check.sh | 8 +++- tools/pstn-test-check.sh | 83 +++++++++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/tools/coturn-test-check.sh b/tools/coturn-test-check.sh index bb61c0d..cb3d490 100755 --- a/tools/coturn-test-check.sh +++ b/tools/coturn-test-check.sh @@ -160,7 +160,13 @@ else warn "$c: couldn't read cached credentials, skipping live test" continue fi - OUT="$(docker exec coturn timeout 10 turnutils_uclient -t -T -u "$_u" -w "$_p" "$TEST_HOST" -p "$COTURN_PORT" 2>&1)" + # Plain UDP only — no -t/-T (TCP/TLS) flags; coturn runs with + # --no-tls --no-dtls (services/coturn.sh), so requesting an + # encrypted/TCP transport here fails the allocation against a + # server that never offered one. See tools/pstn-test-check.sh's + # matching comment — confirmed live this was the actual cause of a + # "Cannot complete Allocation" failure, not a real coturn problem. + OUT="$(docker exec coturn timeout 10 turnutils_uclient -u "$_u" -w "$_p" "$TEST_HOST" -p "$COTURN_PORT" 2>&1)" RC=$? if [ "$RC" -eq 0 ]; then ok "$c: TURN allocation succeeded (credentials + relay range + reachability all confirmed working)" diff --git a/tools/pstn-test-check.sh b/tools/pstn-test-check.sh index 0a5785c..f54775c 100755 --- a/tools/pstn-test-check.sh +++ b/tools/pstn-test-check.sh @@ -20,12 +20,34 @@ set -uo pipefail PASS=0 WARN=0 FAIL=0 +WARN_MSGS=() +FAIL_MSGS=() ok() { printf ' [OK] %s\n' "$1"; PASS=$((PASS + 1)); } -warn() { printf ' [WARN] %s\n' "$1"; WARN=$((WARN + 1)); } -fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); } +warn() { printf ' [WARN] %s\n' "$1"; WARN=$((WARN + 1)); WARN_MSGS+=("$1"); } +fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); FAIL_MSGS+=("$1"); } section() { printf '\n== %s ==\n' "$1"; } +# One extension's softphone setup block — shared by the full run below and +# the "print again, one at a time" prompt at the end, so the two can't drift. +# Reads SIP_SERVER/TURN_SERVER/TURN_USERNAME/TURN_PASSWORD from the caller's +# scope (set once, further down, before either call site runs). +print_ext_info() { + local ext="$1" pass="$2" transport="$3" ice="$4" port proto + if [ "$transport" = "transport-tls" ]; then port=5061; proto="tls"; else port=5060; proto="udp"; fi + echo " Extension $ext:" + echo " SIP server: $SIP_SERVER" + echo " Username: $ext" + echo " Password: $pass" + echo " Port: $port" + echo " Transport: $proto" + if [ "$ice" = "yes" ] && [ -n "${TURN_SERVER:-}" ]; then + echo " TURN server: $TURN_SERVER" + echo " TURN user: $TURN_USERNAME" + echo " TURN pass: $TURN_PASSWORD" + fi +} + if [ "$(id -u)" -ne 0 ]; then echo "Run with sudo — needs docker exec and (on some boxes) systemctl/journalctl." >&2 exec sudo bash "$0" "$@" @@ -187,7 +209,14 @@ else elif ! docker exec "$COTURN_CONTAINER" which turnutils_uclient &>/dev/null; then warn "turnutils_uclient not found in $COTURN_CONTAINER — skipping live allocation test" else - OUT="$(docker exec "$COTURN_CONTAINER" timeout 10 turnutils_uclient -t -T -u "$TURN_USERNAME" -w "$TURN_PASSWORD" 127.0.0.1 -p "${TURN_PORT:-3478}" 2>&1)" + # Plain UDP only — no -t/-T (TCP/TLS) flags. coturn is started with + # --no-tls --no-dtls (services/coturn.sh), 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 + # cause of a "Cannot complete Allocation" failure against an + # otherwise fully working coturn instance. + OUT="$(docker exec "$COTURN_CONTAINER" timeout 10 turnutils_uclient -u "$TURN_USERNAME" -w "$TURN_PASSWORD" 127.0.0.1 -p "${TURN_PORT:-3478}" 2>&1)" if [ $? -eq 0 ]; then ok "Live TURN allocation succeeded with Asterisk's own configured credentials (user '$TURN_USERNAME')" else @@ -255,22 +284,7 @@ else else while IFS='|' read -r ext pass transport ice; do [ -z "$ext" ] && continue - if [ "$transport" = "transport-tls" ]; then - port=5061; proto="tls" - else - port=5060; proto="udp" - fi - echo " Extension $ext:" - echo " SIP server: $SIP_SERVER" - echo " Username: $ext" - echo " Password: $pass" - echo " Port: $port" - echo " Transport: $proto" - if [ "$ice" = "yes" ] && [ -n "$TURN_SERVER" ]; then - echo " TURN server: $TURN_SERVER" - echo " TURN user: $TURN_USERNAME" - echo " TURN pass: $TURN_PASSWORD" - fi + print_ext_info "$ext" "$pass" "$transport" "$ice" echo "" done <<< "$DEVICE_INFO" ok "Printed setup info for $(wc -l <<< "$DEVICE_INFO") extension(s) — same values Sipnetic's" @@ -362,10 +376,41 @@ fi # ── Summary ─────────────────────────────────────────────────────────────────── section "Summary" echo " $PASS passed, $WARN warnings, $FAIL failed." + +if [ "${#FAIL_MSGS[@]}" -gt 0 ] || [ "${#WARN_MSGS[@]}" -gt 0 ]; then + echo "" + echo " Needs attention:" + for m in "${FAIL_MSGS[@]:-}"; do + [ -z "$m" ] && continue + echo " [FAIL] $m" + done + for m in "${WARN_MSGS[@]:-}"; do + [ -z "$m" ] && continue + echo " [WARN] $m" + done +fi + echo "" echo " This covers everything that can be checked without placing a real call" echo " or sending a real text, plus the provider-side values above that only a" echo " human can confirm inside the portal itself. For the call/SMS test steps" echo " and what each result means, see docs/pstn-sms-test-checklist.md." +# ── Optional: reprint softphone setup one extension at a time ──────────────── +# The full setup block scrolled past earlier in a long run — offer to show +# it again, one extension per screen, instead of scrolling back for it. +if [ -n "${DEVICE_INFO:-}" ] && [ -t 0 ]; then + echo "" + REPRINT="" + read -r -p " Show softphone setup again, one extension at a time? (y/n): " REPRINT + if [[ "$REPRINT" =~ ^[Yy]$ ]]; then + while IFS='|' read -r ext pass transport ice; do + [ -z "$ext" ] && continue + echo "" + print_ext_info "$ext" "$pass" "$transport" "$ice" + read -r -p " Press Enter for the next extension (Ctrl+C to stop)..." _ignored + done <<< "$DEVICE_INFO" + fi +fi + [ "$FAIL" -eq 0 ]