#!/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 "" }