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).
390 lines
19 KiB
Bash
390 lines
19 KiB
Bash
#!/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/<digits>,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 ""
|
|
}
|