From 2ac2982e3820e34e96a355a55296f3b4dbc0cdba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:13:59 +0000 Subject: [PATCH] Add voipms-trunk service: US-only outbound PSTN, max 3 concurrent calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a VoIP.ms SIP trunk on top of asterisk-digital-ocean: IP-authenticated trunk (no password stored), NANP-only outbound dialplan, a global 3-call concurrent cap via GROUP()/GROUP_COUNT(), and inbound routing to one extension. Config lives in its own include files rather than being appended directly to pjsip.conf/extensions.conf, since Easy Asterisk fully regenerates both from its own internal state — the includes are patched into the vendor's generator functions so they survive that regeneration. Wires the new service into setup.sh's is_installed() and README's services table, and updates docs/pstn-calling-voipms-plan.md to reflect what's now implemented vs. still open (spend/volume alerting, live-account verification). --- README.md | 3 +- docs/pstn-calling-voipms-plan.md | 90 ++++--- services/voipms-trunk.sh | 389 +++++++++++++++++++++++++++++++ setup.sh | 1 + 4 files changed, 445 insertions(+), 38 deletions(-) create mode 100644 services/voipms-trunk.sh diff --git a/README.md b/README.md index 7daf1da..a280f6c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,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`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `voipms-trunk`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `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` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | @@ -92,6 +92,7 @@ homelab homeassistant asterisk asterisk-digital-ocean + voipms-trunk security-dashboard sunshine diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 5a6176c..2916480 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -1,9 +1,15 @@ # PSTN Calling via VoIP.ms — Planning Notes Research and decisions from a design discussion, saved here so the work can -be picked up in a fresh chat without re-deriving the background. **Nothing -has been implemented yet** — this is prep for a future `services/*.sh` -addition on top of `asterisk-digital-ocean`. +be picked up in a fresh chat without re-deriving the background. + +**Implemented** — see `services/voipms-trunk.sh` (run `sudo ./setup.sh +voipms-trunk` after `asterisk-digital-ocean` is installed). IP-authenticated +trunk, US/NANP-only outbound dialplan, 3-concurrent-call cap, inbound to one +extension. That file's own header comment explains how it survives Easy +Asterisk's config regeneration (an architectural wrinkle discovered while +implementing this — worth reading before touching either file). The +outbound spend/volume alert mentioned below is still not implemented. ## Decision so far - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off @@ -67,33 +73,38 @@ parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min = 1,500 minutes total, which 20 concurrent legs could burn through in under an hour). The prepaid-balance-off-auto-recharge layer bounds the *dollar* ceiling; only a concurrent-call cap bounds the *speed* of a breach. Treat -the per-extension concurrent-call cap and spend/volume alert below as -required before funding a live trunk, not optional hardening. +the concurrent-call cap and spend/volume alert below as required before +funding a live trunk, not optional hardening. + +**Implemented:** the concurrent-call cap in `services/voipms-trunk.sh` is a +*global* cap (max 3 outbound legs total via the trunk, via +`GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all extensions) — +not per-extension. That was the explicit ask when this got built; a +per-extension cap layered on top is still a possible future refinement, not +done. The spend/volume alert is still not implemented. ## What it takes technically (asterisk-digital-ocean) -- A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `auth` / `identify` - sections in the pjsip config, using either IP authentication or SIP - registration — VoIP.ms supports both. IP auth is simpler for a droplet - (it has a static IP already) and avoids storing a SIP password in the - config at all — worth confirming with VoIP.ms which they actually - recommend before choosing. -- An outbound dialplan route matching US numbers only, e.g. `_1NXXNXXXXX` - (11-digit NANP with leading 1) or `_NXXNXXXXX`, depending on how numbers - get dialed from the existing extensions, routed to the VoIP.ms trunk. No - catch-all `_X.` pattern — an explicit NANP pattern is itself a hard block - on non-US destinations at the dialplan level. +- A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `identify` sections in the + pjsip config. **Implemented with IP authentication** (no `auth` section, + no SIP password stored anywhere) — see `services/voipms-trunk.sh`. +- An outbound dialplan route matching US numbers only — **implemented**: + `_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit, + auto-prefixed with 1), both routed to the VoIP.ms trunk. No catch-all + `_X.` pattern. - VoIP.ms-specific setup that isn't scriptable (user does this manually): create the account, order a DID (inbound is wanted — see above), decide pay-per-minute vs. unlimited DID plan and whether to add E911, pick a VoIP.ms POP/server (affects the trunk hostname), fund the prepaid balance - ($15 minimum), turn off auto-recharge. -- Defense-in-depth to design alongside the trunk (not yet designed): - - Per-extension concurrent-call cap in the dialplan (`GROUP()` / - `GROUP_COUNT()`) so one compromised extension can't open dozens of - simultaneous outbound legs at once. - - A simple outbound call-count/spend alert — could live in the existing - `security-dashboard` service (see `services/security-dashboard.sh`) or - as a separate CDR-based check. Not designed yet. + ($15 minimum), turn off auto-recharge. `services/voipms-trunk.sh` prompts + for the POP hostname, DID, and a ring extension for inbound at install time. +- Defense-in-depth alongside the trunk: + - **Implemented:** a global concurrent-call cap in the dialplan + (`GROUP()`/`GROUP_COUNT()`, max 3 outbound legs via the trunk at once) + so a compromised extension can't open dozens of simultaneous outbound + legs. Global, not per-extension — see the note above. + - **Not implemented:** a simple outbound call-count/spend alert — could + live in the existing `security-dashboard` service (see + `services/security-dashboard.sh`) or as a separate CDR-based check. - Worth being explicit that CrowdSec's existing `asterisk_bf` / `asterisk_user_enum` scenarios (see `services/crowdsec.sh`) cover registration brute-force, which is a *different* threat model from a @@ -111,16 +122,21 @@ required before funding a live trunk, not optional hardening. across the space, VoIP.ms included. ## Open items for whoever picks this up next -1. Decide: new `services/voipms-trunk.sh`, or an optional trunk section - added directly to `services/asterisk-digital-ocean.sh`? Leaning toward a - separate service file so trunk config isn't forced on installs that - don't want PSTN calling, matching this repo's one-feature-per-file - convention (see CLAUDE.md). -2. IP auth vs. registration — confirm which VoIP.ms recommends for a single - fixed-IP droplet. -3. Exact NANP dial pattern(s) and any prefix-stripping VoIP.ms requires. -4. Inbound is decided (wanted) — still need to pick pay-per-minute vs. - unlimited DID plan based on real expected volume, and decide on E911. -5. Design the concurrent-call cap and any spend/volume alerting mentioned - above — treat as required before funding a live trunk, not optional - (see toll-fraud nuance above: NANP-only bounds cost/min, not burn speed). +1. ~~Decide: new `services/voipms-trunk.sh`...~~ Done — separate service file. +2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored. +3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. +4. ~~Inbound~~ Done — rings one extension, prompted at install time. Still + unresolved: pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side + based on real expected volume, and decide on E911 (see cost estimate). +5. ~~Concurrent-call cap~~ Done — global 3-call cap. Still not implemented: + the spend/volume alert (security-dashboard integration or CDR-based + check) — treat as still-required before fully trusting this against a + sustained breach, not optional (see toll-fraud nuance above: the NANP + restriction + call cap bound cost/min and burn speed, but nothing here + yet notices a breach in progress or alerts on unusual volume). +6. Verify against a live VoIP.ms account: auto-recharge-off behavior at + sign-up, and that the chosen POP server's actual source IP for inbound + calls matches what `services/voipms-trunk.sh` resolved via DNS at install + time (VoIP.ms's docs mention some redundancy/failover between servers — + if inbound calls ever stop matching the `identify` section, this is the + first thing to check). diff --git a/services/voipms-trunk.sh b/services/voipms-trunk.sh new file mode 100644 index 0000000..929b3d6 --- /dev/null +++ b/services/voipms-trunk.sh @@ -0,0 +1,389 @@ +#!/bin/bash +# services/voipms-trunk.sh — VoIP.ms SIP trunk add-on for asterisk-digital-ocean: +# US-only outbound (NANP dialplan restriction), capped at 3 concurrent +# outbound calls, IP-authenticated trunk (no SIP password stored), plus +# inbound routing to one extension. +# +# Requires an existing services/asterisk-digital-ocean.sh install — this adds +# a PSTN trunk on top of it and does not stand alone. See +# docs/pstn-calling-voipms-plan.md for the design/cost background this is +# built from. +# +# Part of the modular post-install system (sourced by setup.sh). + +register_service voipms-trunk homelab "VoIP.ms SIP trunk for asterisk-digital-ocean — US-only outbound, max 3 concurrent calls" + +# ── Surviving Easy Asterisk's regeneration ────────────────────────────────── +# Easy Asterisk (the vendor project asterisk-digital-ocean.sh builds on) fully +# OVERWRITES both pjsip.conf and extensions.conf from its own internal state: +# - extensions.conf: rebuilt by rebuild_dialplan() on every container start, +# and whenever a device/room is added or removed via the web admin. +# - pjsip.conf: rewritten by generate_pjsip_conf() whenever VLAN/domain/TLS +# settings are changed via the CLI menu (docker exec ... easy-asterisk). +# It restores only its own "; === Device:"-marked sections from backup — +# a hand-appended trunk section would be silently wiped the next time +# that runs. +# So the trunk/dialplan content below lives in its own files and is +# #include'd from the generated files instead of appended directly. To make +# the #include itself survive regeneration too, _voipms_patch_vendor_files +# (below) patches it into the vendor's *generator functions* — the same +# technique this repo already uses for the logger.conf security-logging fix +# in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). +# +# Caveat: if the base asterisk-digital-ocean install is later refreshed +# ("update in place", which re-copies fresh vendor files) independently of +# this service, the patch is wiped along with it and needs reapplying — run +# this service again (fresh or update mode both reapply it) after any +# asterisk-digital-ocean update. + +# ── Shared: patch vendor generator functions to #include our config ──────── +# Anchors on "user_agent=EasyAsterisk" (pjsip.conf's [global] section) and +# "[intercom]" (extensions.conf) — each confirmed to appear exactly once per +# file in the vendor source, so this is safe regardless of what else changes +# around it upstream. Idempotent: skips files that already have the include. +_voipms_patch_vendor_files() { + local EA_DIR="$1" + local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" + local EASY1="$EA_DIR/easy-asterisk.sh" + local EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + local f + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || { log_error "$f not found — is asterisk-digital-ocean fully installed?"; return 1; } + done + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + if ! grep -q 'voipms-trunk-pjsip.conf' "$f"; then + if grep -q '^user_agent=EasyAsterisk$' "$f"; then + sed -i '/^user_agent=EasyAsterisk$/a #include voipms-trunk-pjsip.conf' "$f" + else + log_warning "$(basename "$f"): 'user_agent=EasyAsterisk' anchor not found — vendor template changed upstream." + log_warning " Add '#include voipms-trunk-pjsip.conf' manually after [global] in this file's pjsip.conf heredoc." + fi + fi + done + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + if ! grep -q 'voipms-trunk-dialplan.conf' "$f"; then + if grep -q '^\[intercom\]$' "$f"; then + sed -i '/^\[intercom\]$/a #include voipms-trunk-dialplan.conf' "$f" + else + log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." + log_warning " Add '#include voipms-trunk-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." + fi + fi + done + + log_success "Vendor generator functions patched to include the VoIP.ms trunk config." +} + +# ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── +_voipms_write_pjsip_include() { + local FILE="$1" SERVER="$2" SERVER_IP="$3" DID="$4" + cat > "$FILE" << 'EOF' +; VoIP.ms trunk — IP authentication, no password stored (see +; docs/pstn-calling-voipms-plan.md). Regenerated by services/voipms-trunk.sh — +; edit there, not here directly, or a reinstall/update will overwrite this. +; +; match= below is the resolved IP of the server hostname at install time. +; VoIP.ms's servers are fixed per-POP, but if calls stop matching after a +; provider-side change, re-run this service to re-resolve and rewrite it. + +[voipms-trunk] +type=aor +contact=sip:__VOIPMS_SERVER__ +qualify_frequency=60 + +[voipms-trunk] +type=identify +endpoint=voipms-trunk +match=__VOIPMS_SERVER_IP__ + +[voipms-trunk] +type=endpoint +context=from-voipms +disallow=all +allow=ulaw,alaw +aors=voipms-trunk +from_user=__VOIPMS_DID__ +from_domain=__VOIPMS_SERVER__ +callerid=__VOIPMS_DID__ +direct_media=no +EOF + sed -i "s/__VOIPMS_SERVER_IP__/${SERVER_IP}/g; s/__VOIPMS_SERVER__/${SERVER}/g; s/__VOIPMS_DID__/${DID}/g" "$FILE" +} + +# ── Shared: outbound/inbound dialplan (NANP-only, 3-concurrent cap) ──────── +# Continues in the [intercom] context established just above this include +# (rebuild_dialplan() writes "[intercom]" then this #include right after it), +# so existing extensions can dial out through it directly. [from-voipms] +# below is a separate context, for calls arriving from the trunk. +_voipms_write_dialplan_include() { + local FILE="$1" DID="$2" RING_EXT="$3" + cat > "$FILE" << 'EOF' +; VoIP.ms outbound/inbound — US-only (NANP), max 3 concurrent outbound calls. +; Regenerated by services/voipms-trunk.sh — edit there, not here directly. +; +; No catch-all pattern here on purpose: only these two NANP patterns route +; to the trunk, so a compromised extension can't reach anything else even if +; the trunk itself would technically allow more. See +; docs/pstn-calling-voipms-plan.md for the full toll-fraud reasoning. + +exten => _1NXXNXXXXX,1,NoOp(VoIP.ms outbound call to ${EXTEN}) + same => n,GotoIf($[${GROUP_COUNT(voipms-out)} >= 3]?voipms_busy,1) + same => n,Set(GROUP()=voipms-out) + same => n,Set(CALLERID(num)=__VOIPMS_DID__) + same => n,Dial(PJSIP/${EXTEN}@voipms-trunk,60) + same => n,Hangup() + +exten => _NXXNXXXXX,1,NoOp(Assuming NANP, adding leading 1) + same => n,Goto(1${EXTEN},1) + +exten => voipms_busy,1,NoOp(VoIP.ms: 3 concurrent outbound calls already active — rejecting) + same => n,Busy(15) + same => n,Hangup() + +[from-voipms] +exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) + same => n,Dial(PJSIP/__VOIPMS_RING_EXT__,20) + same => n,Hangup() +EOF + sed -i "s/__VOIPMS_DID__/${DID}/g; s/__VOIPMS_RING_EXT__/${RING_EXT}/g" "$FILE" +} + +install_voipms-trunk() { + require_docker || return 1 + + local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" + local ASTERISK_DIR="$EA_DIR/config/asterisk" + local PJSIP_INCLUDE="$ASTERISK_DIR/voipms-trunk-pjsip.conf" + local DIALPLAN_INCLUDE="$ASTERISK_DIR/voipms-trunk-dialplan.conf" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would require an existing asterisk-digital-ocean install at $EA_DIR" + echo "[DRY-RUN] Would prompt for VoIP.ms server/POP hostname, DID, and ring extension" + echo "[DRY-RUN] Would resolve the server hostname to an IP for inbound call matching" + echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" + echo "[DRY-RUN] Would write $PJSIP_INCLUDE and $DIALPLAN_INCLUDE" + echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if already configured" + echo "[DRY-RUN] Would restart the asterisk container to apply" + return 0 + fi + + if [[ ! -f "$EA_DIR/docker-compose.yml" ]]; then + log_error "asterisk-digital-ocean isn't installed at $EA_DIR — install it first:" + log_error " sudo ./setup.sh asterisk-digital-ocean" + log_error "This service adds a VoIP.ms PSTN trunk on top of it; it doesn't stand alone." + return 1 + fi + + log_info "Configuring VoIP.ms SIP trunk for asterisk-digital-ocean..." + log_info "US-only outbound (NANP dialplan), max 3 concurrent outbound calls, IP-authenticated trunk." + echo "" + log_warning "Before continuing, on VoIP.ms's side you should already have: created an" + log_warning "account, funded the \$15 minimum prepaid balance, turned OFF auto-recharge" + log_warning "(Client Area -> Balance Management), ordered a DID with IP authentication" + log_warning "pointed at this droplet's public IP, and picked a server/POP. Also restrict" + log_warning "outbound routing to US/NANP on VoIP.ms's own side if it offers that — this" + log_warning "dialplan is the second, independent layer, not a substitute for the first." + log_warning "See docs/pstn-calling-voipms-plan.md for the full background." + echo "" + + # ── Existing install? Offer update-in-place instead of a full reinstall ── + if [[ -f "$PJSIP_INCLUDE" && -f "$DIALPLAN_INCLUDE" ]]; then + log_info "Existing VoIP.ms trunk config found." + local REINSTALL_MODE="" + prompt_reinstall_mode REINSTALL_MODE + case "$REINSTALL_MODE" in + update) + local _SERVER _SERVER_IP _DID _RING_EXT + _SERVER="$(grep -m1 '^from_domain=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + _SERVER_IP="$(grep -m1 '^match=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + _DID="$(grep -m1 '^from_user=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + # Match only the inbound line (Dial(PJSIP/,20)) — the + # outbound line matches Dial(PJSIP/${EXTEN}@voipms-trunk,60), + # which starts with "$" right after PJSIP/, not a digit. + _RING_EXT="$(grep -m1 -E 'Dial\(PJSIP/[0-9]+,20\)' "$DIALPLAN_INCLUDE" | sed -E 's/.*Dial\(PJSIP\/([0-9]+),20\).*/\1/')" + if [[ -z "$_SERVER" || -z "$_DID" || -z "$_RING_EXT" ]]; then + log_warning "Couldn't parse existing settings back out — falling back to a fresh install (every prompt below)." + else + _voipms_patch_vendor_files "$EA_DIR" || return 1 + _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$_SERVER" "$_SERVER_IP" "$_DID" + _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$_DID" "$_RING_EXT" + ensure_docker_dir_ownership "$ASTERISK_DIR" + ( cd "$EA_DIR" && docker compose restart asterisk ) \ + && log_success "Updated — settings unchanged (server $_SERVER, DID $_DID, ring ext $_RING_EXT)." \ + || log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" + return 0 + fi + ;; + cancel) + log_info "Leaving the existing VoIP.ms trunk config as-is." + return 0 + ;; + fresh) + log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch." + ;; + esac + fi + + # ── Prompts — VoIP.ms account details aren't scriptable, user sets these + # up manually on VoIP.ms's own site first (see warning above) ─────────── + local VOIPMS_SERVER="" + prompt_text "VoIP.ms server/POP hostname (e.g. atlanta2.voip.ms — pick the one closest to this droplet from VoIP.ms's server list):" "" VOIPMS_SERVER + if [[ -z "$VOIPMS_SERVER" ]]; then + log_error "A VoIP.ms server hostname is required — aborting." + return 1 + fi + + local VOIPMS_SERVER_IP="" + VOIPMS_SERVER_IP="$(getent ahostsv4 "$VOIPMS_SERVER" 2>/dev/null | awk '{print $1}' | head -1)" + if [[ -z "$VOIPMS_SERVER_IP" ]]; then + log_warning "Couldn't resolve $VOIPMS_SERVER — the identify section needs an IP to match inbound calls against." + prompt_text "Enter its IP manually (check VoIP.ms's server list page):" "" VOIPMS_SERVER_IP + if [[ -z "$VOIPMS_SERVER_IP" ]]; then + log_error "No IP available — aborting." + return 1 + fi + else + log_success "Resolved $VOIPMS_SERVER -> $VOIPMS_SERVER_IP" + fi + + local VOIPMS_DID="" + prompt_text "VoIP.ms DID (the 10-digit US phone number assigned to this trunk, digits only):" "" VOIPMS_DID + if [[ ! "$VOIPMS_DID" =~ ^[0-9]{10}$ ]]; then + log_error "That doesn't look like a 10-digit US number — aborting." + return 1 + fi + + local RING_EXT="" + prompt_text "Local extension to ring for inbound calls (must already exist in Easy Asterisk):" "" RING_EXT + if [[ -z "$RING_EXT" ]]; then + log_error "An extension number is required for inbound routing — aborting." + return 1 + fi + + # ── Patch vendor generator functions, then write the include files ─────── + _voipms_patch_vendor_files "$EA_DIR" || return 1 + + mkdir -p "$ASTERISK_DIR" + _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$VOIPMS_SERVER" "$VOIPMS_SERVER_IP" "$VOIPMS_DID" + _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$VOIPMS_DID" "$RING_EXT" + ensure_docker_dir_ownership "$ASTERISK_DIR" + chmod 644 "$PJSIP_INCLUDE" "$DIALPLAN_INCLUDE" + + # No new firewall rules: asterisk-digital-ocean.sh already opens SIP + # (5060/5061) and RTP (10000-20000) to the internet, and VoIP.ms's source + # IPs vary by POP/redundancy, so there's no single IP to scope this to + # even if narrowing it were otherwise worthwhile. + + # ── Docs (separate file — asterisk-digital-ocean already owns README.md + # in this same directory via write_readme, so don't overwrite it) ─────── + local DOC_FILE="$EA_DIR/README-voipms-trunk.md" + cat > "$DOC_FILE" << MD +# VoIP.ms SIP trunk (add-on to asterisk-digital-ocean) + +US-only outbound PSTN calling over a VoIP.ms trunk, capped at 3 concurrent +outbound calls, plus inbound routing to one extension. See +\`docs/pstn-calling-voipms-plan.md\` in the repo for the full design +background, cost estimate, and toll-fraud reasoning. + +## Current settings + +| Setting | Value | +|---|---| +| VoIP.ms server/POP | ${VOIPMS_SERVER} (${VOIPMS_SERVER_IP}) | +| DID | ${VOIPMS_DID} | +| Inbound rings extension | ${RING_EXT} | +| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | +| Concurrency cap | 3 simultaneous outbound calls (GROUP()/GROUP_COUNT() in the dialplan); a 4th gets a busy signal | + +## How this survives Easy Asterisk's own regeneration + +Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own +internal state (device list, network settings) rather than treating them as +hand-edited files. Trunk/dialplan config here lives in two files of its own, +\`#include\`'d from the generated files: + +- \`config/asterisk/voipms-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ + \`endpoint\` sections (IP-authenticated, no password stored). +- \`config/asterisk/voipms-trunk-dialplan.conf\` — the NANP-only outbound + routing + concurrency cap, and the \`[from-voipms]\` inbound context. + +The \`#include\` lines themselves are patched into Easy Asterisk's *generator +functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, +\`easy-asterisk-v0.10.0.sh\`) so they get re-emitted every time those functions +regenerate the config, instead of being wiped. + +**Caveat:** if the base \`asterisk-digital-ocean\` service is ever updated +independently (\`sudo ./setup.sh asterisk-digital-ocean\`, choosing "update in +place" — that path re-copies fresh vendor files), this patch is wiped along +with it. Re-run \`sudo ./setup.sh voipms-trunk\` afterward (update mode +reapplies the patch and rewrites both include files with your existing +settings, no re-prompting). + +## Changing settings + +Re-run \`sudo ./setup.sh voipms-trunk\` and choose "reinstall in place" — +current settings are read back out of the include files above and reapplied. +Choose "full install" instead to re-prompt for everything (new server, DID, +or ring extension). + +## Manual edits + +Don't hand-edit \`voipms-trunk-pjsip.conf\` / \`voipms-trunk-dialplan.conf\` +directly if you plan to re-run this installer later — it overwrites both +files unconditionally. If you do need one-off manual changes (e.g. testing a +second POP as a failover \`match=\` line), edit them directly and just don't +re-run the installer until you're ready to lose those changes, or restart +the container instead of running the installer to test them: + +\`\`\`bash +docker compose -f $EA_DIR/docker-compose.yml restart asterisk +\`\`\` + +## What the 3-call cap does and doesn't cover + +It bounds *how many* outbound calls can be active at once — the first line +of defense against a compromised extension opening dozens of parallel legs. +It does not bound total spend by itself; that's still the prepaid-balance + +auto-recharge-off layer on VoIP.ms's side. Both matter — see the toll-fraud +section of \`docs/pstn-calling-voipms-plan.md\`. + +## Verifying it's working + +\`\`\`bash +docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint voipms-trunk" +docker exec -it easy-asterisk-do asterisk -rx "dialplan show intercom" +docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-voipms" +\`\`\` + +A registered device should be able to dial a 10-digit or 11-digit US number +and reach the VoIP.ms trunk; a call to \`${VOIPMS_DID}\` from outside should +ring extension \`${RING_EXT}\`. +MD + chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true + + # ── Apply ────────────────────────────────────────────────────────────── + echo "" + local RESTART_NOW="" + prompt_yn "Restart the asterisk container now to apply the trunk config? (y/n):" "y" RESTART_NOW + if [[ "$RESTART_NOW" =~ ^[Yy]$ ]]; then + if ( cd "$EA_DIR" && docker compose restart asterisk ); then + log_success "Asterisk restarted — trunk config applied." + else + log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" + fi + else + log_info "Apply later with: docker compose -f $EA_DIR/docker-compose.yml restart asterisk" + fi + + echo "" + log_success "VoIP.ms trunk configured." + echo " Server: $VOIPMS_SERVER ($VOIPMS_SERVER_IP)" + echo " DID: $VOIPMS_DID" + echo " Outbound: US/NANP only, max 3 concurrent calls (dialplan-enforced)" + echo " Inbound rings: extension $RING_EXT" + echo " Docs: $DOC_FILE" + echo "" +} diff --git a/setup.sh b/setup.sh index 3a7b304..509aecd 100755 --- a/setup.sh +++ b/setup.sh @@ -89,6 +89,7 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; + voipms-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/voipms-trunk-pjsip.conf" ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; esac