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:
Claude
2026-07-22 03:58:41 +00:00
parent 8e2552ae73
commit c8cc2271a7
2 changed files with 136 additions and 29 deletions
+62 -26
View File
@@ -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"