Support providers with multiple inbound signaling IPs (e.g. Anveo Direct)
VoIP.ms's one-POP-one-hostname model meant a single resolved IP was enough for the trunk's identify match=, but Anveo Direct (researched as an alternative after finding VoIP.ms's ToS selectively requires government ID at signup) sends inbound signaling from a fixed set of published IPs instead. Generalizes the pjsip include to accept multiple match= lines in one identify object (PJSIP allows repeating it to build one match set - no separate config objects needed per IP), with a new install prompt for any additional known source IPs beyond the auto-resolved one. No dial-prefix support was added despite earlier research suggesting Anveo Direct needed one - verified against their current official FAQ (fetched directly rather than trusting a stale search-result claim) that plain number dialing works with no prefix. Fixed a real bug caught while testing the update-mode round trip: .pstn-trunk.env was written with unquoted values, which breaks `source` entirely once any value contains a space - a multi-IP list or a multi-word provider name (e.g. "Anveo Direct") would make bash try to run the second word as a command. Predates this change (a multi-extension ring group has the same shape); only surfaced by actually re-sourcing the file and reapplying, not by inspecting generator output. Fixed by quoting every value. Updated docs/pstn-calling-voipms-plan.md with the Anveo Direct/DIDLogic/ SIP.US research findings and what's now implemented vs. still open (real-time balance enforcement at Anveo specifically is unconfirmed).
This commit is contained in:
@@ -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).
|
||||
|
||||
+62
-26
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user