diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 28e1943..c391020 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -224,9 +224,52 @@ separately from that hourly check. legitimately-registered extension being used for toll fraud — nobody should assume CrowdSec alone already covers this. -## Provider landscape (for reference — not chosen) -- **SIP.US** — also prepaid, flat per-channel rate, built-in fraud - detection. Considered, not chosen. +## Provider landscape (for reference) +Looked at as alternatives to VoIP.ms specifically because VoIP.ms's own ToS +confirms it *selectively* requires a government ID scan at signup (VPN/proxy +use, certain number types, internal risk scoring — not universal, but real). +None of these were verified to *never* require ID either; KYC requirements +are trending industry-wide (FCC STIR/SHAKEN, Robocall Mitigation Database), +so treat "no ID requirement found" as exactly that, not confirmation. + +- **DIDLogic** — **ruled out**: has a $20/month minimum, confirmed directly + by checking their portal (not something search results surfaced). Would + otherwise have needed zero code changes — same IP-auth model and + single-IP-per-regional-POP hostname pattern this service already handles. +- **Anveo Direct** (anveodirect.com — a *different product* from plain + Anveo/anveo.com, its hosted-PBX sibling with monthly subscription tiers; + don't confuse the two, their pricing models aren't comparable) — leading + candidate. ~$0.001/min outbound, no minimum commitment, $25 minimum to + fund the account (close to VoIP.ms's $15). Confirmed via their current + official FAQ (fetched directly, not via search — search results had gone + stale on this exact point): + - IP authentication, no SIP registration — matches this service's + architecture with zero changes needed to the auth model. + - **No dial-prefix requirement** — a stale/outdated forum claim (found via + search) suggested one was needed; the *current* official FAQ's own + sample config shows plain number dialing, nothing prepended. Don't + trust that claim if it resurfaces. + - **Sends inbound signaling from multiple fixed IPs**, not one: + `169.48.232.158`, `204.216.109.55`, `176.9.39.206`, `72.9.149.25` (per + their FAQ as of this writing — confirmed different from what web search + surfaced independently, which was a different, apparently stale set of + 5 IPs). This is what prompted the multi-IP `identify` support below. + - STIR/SHAKEN signing is handled by Anveo as intermediate provider for + personal use (originating your own calls, not reselling to customers) — + no FCC registration/certificate needed on our side. + - Still open: does Anveo actually block *new* outbound calls in real time + at $0 balance (matching VoIP.ms's documented behavior), or only enforce + via the recurring-fee grace-period mechanism their FAQ describes? Not + confirmed either way — ask their support before funding a real account, + since it's the crux of the toll-fraud backstop assumption. +- **SIP.US** — prepaid, but a flat ~$24.95/mo per-channel (unlimited minutes + on that channel) model, not pay-per-minute — **not actually + similarly-priced** at this project's volume (100 min/month costs ~$1-3/mo + pay-per-minute vs. $24.95/mo flat). Notable anyway for automated real-time + fraud detection (kills in-progress unauthorized calls, auto-disables + international calling on suspicious activity) — a provider-side version of + what this repo hand-built in the dialplan, if that trade-off (higher + price, less to maintain yourself) ever appeals. - Several providers (Nextiva, IDT Express) advertise AI/ML-based fraud monitoring as a second layer on top of normal billing — an extra net, not a substitute for a hard prepaid ceiling. @@ -234,6 +277,34 @@ separately from that hourly check. prepaid-balance + auto-recharge-off pattern is the de facto mechanism across the space, VoIP.ms included. +## Multi-provider support beyond VoIP.ms — implemented +`services/pstn-trunk.sh` now generalizes two things that were originally +built VoIP.ms-shaped, driven directly by researching Anveo Direct as an +alternative: +- **Multiple inbound `identify` match IPs**, not just one. The install + prompt still auto-resolves the server hostname to an IP (VoIP.ms's + one-POP-one-hostname model), but now also asks for any *additional* known + source IPs a provider might publish (Anveo Direct's case) — PJSIP allows + repeating `match=` within a single `identify` object to build one match + set against the trunk endpoint, so this needed no new config objects, just + a loop emitting one `match=` line per IP. +- No dial-prefix support was added — turned out not to be needed for Anveo + Direct once verified against their current official FAQ rather than a + stale search result. If a future provider genuinely needs one, that's the + next generalization point (a prefix prepended to `${EXTEN}` before + `Dial()`). + +**Bug caught while testing this**: `.pstn-trunk.env` (the structural +settings file "update in place" reapplies) was being written with unquoted +values. Harmless as long as every value happened to be a single word, but a +multi-IP list (always space-separated once there's more than one) or a +multi-word provider name (e.g. "Anveo Direct") broke `source`-ing it +entirely — bash treats the second word as a command to run ("Direct: +command not found"). This was a latent bug that predates the multi-IP work +(a multi-extension ring group has the same shape), only surfaced by +actually exercising the update-mode round trip instead of just inspecting +generator output. Fixed by quoting every value in that heredoc. + ## Open items for whoever picks this up next 1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file, generalized to any IP-auth SIP provider (VoIP.ms is just the default). diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 9ded13f..e3ab521 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -110,19 +110,24 @@ _pstn_patch_vendor_files() { } # ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── +# SERVER_IPS is space-separated — one IP is the common case (one POP, one +# hostname resolution, e.g. VoIP.ms), but some providers (e.g. Anveo Direct) +# send inbound signaling from a fixed set of published IPs regardless of +# which hostname you dial out to. PJSIP's identify object allows repeating +# match= to build one match set against a single endpoint — no separate +# identify/endpoint objects needed per IP, unlike the older chan_sip +# peer-per-source-IP pattern some providers' sample configs still show. _pstn_write_pjsip_include() { - local FILE="$1" SERVER="$2" SERVER_IP="$3" DID="$4" + local FILE="$1" SERVER="$2" SERVER_IPS="$3" DID="$4" cat > "$FILE" << 'EOF' ; SIP PSTN trunk — IP authentication, no password stored (see ; docs/pstn-calling-voipms-plan.md). Regenerated by services/pstn-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. -; Providers sometimes send inbound INVITEs from a different IP than the one -; their hostname resolves to (load balancing / multiple servers per POP) — -; if inbound calls stop matching after a provider-side change, re-run this -; service to re-resolve and rewrite it, or add extra "type=identify" / -; "match=" lines here by hand for additional known source IPs. +; match= lines below are the known/resolved source IP(s) for inbound calls. +; If inbound calls stop matching after a provider-side change, re-run this +; service to re-resolve/re-enter them, or add extra match= lines here by +; hand for additional known source IPs. [pstn-trunk] type=aor @@ -132,7 +137,12 @@ qualify_frequency=60 [pstn-trunk] type=identify endpoint=pstn-trunk -match=__PSTN_SERVER_IP__ +EOF + local _ip + for _ip in $SERVER_IPS; do + echo "match=${_ip}" >> "$FILE" + done + cat >> "$FILE" << 'EOF' [pstn-trunk] type=endpoint @@ -145,7 +155,7 @@ from_domain=__PSTN_SERVER__ callerid=__PSTN_DID__ direct_media=no EOF - sed -i "s/__PSTN_SERVER_IP__/${SERVER_IP}/g; s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" + sed -i "s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" } # ── Shared: one inbound ring-group member's live permission check ───────── @@ -422,29 +432,35 @@ EOF # for why that file is managed separately. _pstn_apply_settings() { local EA_DIR="$1" ASTERISK_DIR="$2" - local SERVER="$3" SERVER_IP="$4" DID="$5" + local SERVER="$3" SERVER_IPS="$4" DID="$5" local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}" local PROVIDER_NAME="${11}" _pstn_patch_vendor_files "$EA_DIR" || return 1 mkdir -p "$ASTERISK_DIR" - _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IP" "$DID" + _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID" _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$RING_EXTS" "$NTFY_URL" _pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$NTFY_URL" ensure_docker_dir_ownership "$ASTERISK_DIR" chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" + # Values are double-quoted: this file is `source`d back in on "update" + # (and RING_EXTS/TRUNK_SERVER_IPS are space-separated whenever there's + # more than one entry, and PROVIDER_NAME can be multi-word, e.g. "Anveo + # Direct") — unquoted, bash's `source` would treat the second word of + # any such value as a command to run ("Direct: command not found"), + # confirmed live while testing the multi-IP change. cat > "$EA_DIR/.pstn-trunk.env" << ENV -PROVIDER_NAME=${PROVIDER_NAME} -TRUNK_SERVER=${SERVER} -TRUNK_SERVER_IP=${SERVER_IP} -TRUNK_DID=${DID} -RING_EXTS=${RING_EXTS} -NTFY_URL=${NTFY_URL} -RATE_PER_MIN=${RATE} -MONTH_THRESHOLD=${MONTH_THRESHOLD} -BURST_THRESHOLD=${BURST_THRESHOLD} +PROVIDER_NAME="${PROVIDER_NAME}" +TRUNK_SERVER="${SERVER}" +TRUNK_SERVER_IPS="${SERVER_IPS}" +TRUNK_DID="${DID}" +RING_EXTS="${RING_EXTS}" +NTFY_URL="${NTFY_URL}" +RATE_PER_MIN="${RATE}" +MONTH_THRESHOLD="${MONTH_THRESHOLD}" +BURST_THRESHOLD="${BURST_THRESHOLD}" ENV chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/.pstn-trunk.env" 2>/dev/null || true @@ -485,7 +501,8 @@ install_pstn-trunk() { echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers," echo "[DRY-RUN] max concurrent outbound/inbound calls (default 10/10), inbound ring-group extensions," echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds" - echo "[DRY-RUN] Would resolve the server hostname to an IP for inbound call matching" + echo "[DRY-RUN] Would resolve the server hostname to an IP, plus prompt for any additional" + echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf + pstn-limits.conf" echo "[DRY-RUN] (fresh install only), and an hourly usage-alert script + cron.d entry" @@ -537,7 +554,7 @@ install_pstn-trunk() { # shellcheck disable=SC1090 source "$SETTINGS_FILE" _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 ( cd "$EA_DIR" && docker compose restart asterisk ) \ @@ -586,7 +603,7 @@ install_pstn-trunk() { local TRUNK_SERVER_IP="" TRUNK_SERVER_IP="$(getent ahostsv4 "$TRUNK_SERVER" 2>/dev/null | awk '{print $1}' | head -1)" if [[ -z "$TRUNK_SERVER_IP" ]]; then - log_warning "Couldn't resolve $TRUNK_SERVER — the identify section needs an IP to match inbound calls against." + log_warning "Couldn't resolve $TRUNK_SERVER — the identify section needs at least one IP to match inbound calls against." prompt_text "Enter its IP manually (check your provider's server list page):" "" TRUNK_SERVER_IP if [[ -z "$TRUNK_SERVER_IP" ]]; then log_error "No IP available — aborting." @@ -596,6 +613,24 @@ install_pstn-trunk() { log_success "Resolved $TRUNK_SERVER -> $TRUNK_SERVER_IP" fi + # Some providers send inbound signaling from a fixed set of published IPs + # that don't necessarily match what the server hostname resolves to (e.g. + # Anveo Direct publishes 4 signaling IPs regardless of which hostname you + # dial out to) — the resolved IP above always gets included, this just + # adds any others the provider documents. + local EXTRA_IPS="" + prompt_text "Any additional known source IPs for inbound calls, space-separated (check your provider's docs — e.g. a firewall/signaling IP list; blank if the resolved IP above is the only one):" "" EXTRA_IPS + local _octet='(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' + local _ip_re="^${_octet}\\.${_octet}\\.${_octet}\\.${_octet}\$" + local TRUNK_SERVER_IPS="$TRUNK_SERVER_IP" _ip + for _ip in $EXTRA_IPS; do + if [[ "$_ip" =~ $_ip_re ]]; then + TRUNK_SERVER_IPS="${TRUNK_SERVER_IPS} ${_ip}" + else + log_warning "Skipping '$_ip' — doesn't look like an IPv4 address." + fi + done + local TRUNK_DID="" prompt_text "DID (the 10-digit US phone number assigned to this trunk, digits only):" "" TRUNK_DID if [[ ! "$TRUNK_DID" =~ ^[0-9]{10}$ ]]; then @@ -694,7 +729,7 @@ install_pstn-trunk() { prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 @@ -724,7 +759,7 @@ background, cost estimate, and toll-fraud reasoning. | Setting | Value | |---|---| | Provider | ${PROVIDER_NAME} | -| Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) | +| Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) | | DID | ${TRUNK_DID} | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | | Full-PSTN extensions | ${FULL_EXTS:-none} | @@ -876,7 +911,8 @@ MD echo "" log_success "PSTN trunk configured." - echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" + echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER)" + echo " Inbound match IPs: $TRUNK_SERVER_IPS" echo " DID: $TRUNK_DID" echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls" echo " Inbound: max $MAX_INBOUND concurrent calls"