Merge pull request #204 from outis1one/claude/sip-voip-integration-atsins

Claude/sip voip integration atsins
This commit is contained in:
Outis
2026-07-21 21:51:33 -04:00
committed by GitHub
5 changed files with 1482 additions and 84 deletions
+2 -1
View File
@@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
| Group | Services |
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `security-dashboard`, `sunshine` |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `pstn-trunk`, `security-dashboard`, `sunshine` |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
@@ -92,6 +92,7 @@ homelab
homeassistant
asterisk
asterisk-digital-ocean
pstn-trunk
security-dashboard
sunshine
+192 -38
View File
@@ -1,9 +1,59 @@
# PSTN Calling via VoIP.ms — Planning Notes
Research and decisions from a design discussion, saved here so the work can
be picked up in a fresh chat without re-deriving the background. **Nothing
has been implemented yet** — this is prep for a future `services/*.sh`
addition on top of `asterisk-digital-ocean`.
be picked up in a fresh chat without re-deriving the background.
**Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh
pstn-trunk` after `asterisk-digital-ocean` **or** `asterisk` (home/LAN) is
installed — both are supported, see the file for the static-IP caveat on the
LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't
hardcoded to it — any provider supporting IP authentication works. Covers:
- IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all.
- **Two independent concurrent-call caps**, one per direction (default 10
outbound / 10 inbound — bumped up from an initial default of 3 once roles
existed to gate who can even reach the trunk; "ability creep is real," so
the two caps stay the hard backstop regardless). Global per direction, not
per-extension.
- **Three-tier per-extension permission model**: `internal` (default — no
PSTN at all, but can always call/receive other extensions and internal
ring groups), `restricted` (also only pre-approved US numbers, both
directions), `full` (also any US number). Internal extension-to-extension
dialing is *never* gated by any tier — deliberately, even though VoIP.ms
itself offers free SIP-to-SIP calling, to avoid routing purely-internal
calls through an extra external hop for no benefit.
- **Permissions AND concurrency caps are both live, not baked into the
dialplan.** Stored in `pstn-permissions.conf` / `pstn-limits.conf`, read
by the dialplan via Asterisk's `AST_CONFIG()` on every call — editing
either file takes effect on the next call, no restart, no reinstall.
`services/pstn-trunk.sh`'s "update in place" mode deliberately never
touches either (same protection this repo's update-mode convention
already gives `.env`/firewall/Caddy config
elsewhere) — only a "fresh" reinstall (with confirmation) or the web UI
below change it.
- A configurable **inbound ring-group** (one extension or several), each
member's live tier/approved-numbers checked per inbound call via an
unrolled per-member dialplan block (no AGI needed).
- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab
shows both concurrency caps and every extension (parsed from
`pjsip.conf`) with its live tier and approved numbers, all editable with
no restart. This is what makes the tier model and caps actually
manageable day-to-day instead of needing a reinstall for every change.
- **ntfy alerts** on denied/rejected calls (immediate — permission denied,
number not approved, or either concurrency cap hit) and spend/volume
thresholds (hourly check: once/month on a spend threshold, every hour on
a call-burst threshold).
- Structural settings (server, DID, ring-group *membership*, ntfy,
rate/thresholds) persist to `.pstn-trunk.env` so "update in place"
reapplies them without re-prompting — the concurrency cap *numbers*
themselves are not structural, they live in `pstn-limits.conf` instead
(see above).
`services/pstn-trunk.sh`'s own header comment explains how the trunk/dialplan
config survives Easy Asterisk's regeneration, and why permissions are a
separate live file rather than baked in — both architectural wrinkles
discovered while implementing this, worth reading before touching either
file.
## Decision so far
- **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off
@@ -18,6 +68,31 @@ addition on top of `asterisk-digital-ocean`.
VoIP.ms requires for US routing, and again independently in Asterisk's own
dialplan (see below), so a compromised extension can't reach anything
outside the US even if the trunk itself would technically allow more later.
- **Inbound: wanted.** A DID is in scope, not outbound-only. Decide pay-per-minute
vs. unlimited DID plan based on expected inbound volume (see cost estimate
below), and decide E911 deliberately rather than skipping it by default —
VoIP.ms doesn't require it, but without it 911 dialed from the line either
fails or doesn't carry accurate address/location info.
## Cost estimate (100 min/month each direction, US-only)
Verified against VoIP.ms's public wiki/rate pages, not a live account — confirm
at sign-up since rates can change.
| Item | Rate | Monthly | Annual |
|---|---|---|---|
| DID (phone number), pay-per-minute plan | $0.85/mo flat | — | $10.20 |
| Inbound usage | $0.009/min | $0.90 | $10.80 |
| Outbound usage | $0.01/min | $1.00 | $12.00 |
| **Total** | | **~$2.75** | **~$33** |
- Skipping the DID (outbound-only) drops this to ~$12/year.
- Adding E911 adds a $1.50 one-time fee plus **$1.50/month** regulatory fee
(~$18/year) — pushes the total above to ~$51/year.
- **Funding minimum:** VoIP.ms requires a **$15 minimum deposit** to activate
calling — a one-time balance top-up, not a recurring charge. At ~$2.75/month
usage that balance lasts ~5 months before a refill is needed (longer at
lower volume). Leave auto-recharge **off** per the toll-fraud design above.
## Why this matters (toll fraud)
A compromised Asterisk box can dial premium-rate or international numbers
@@ -32,30 +107,95 @@ anyone notices. Two independent layers matter more than either alone:
This is the first line of defense and should exist independent of the
trunk's own capabilities.
**Important nuance: these two layers bound different things, and neither
alone bounds both.** NANP-only restriction bounds *cost-per-minute* (a
compromised box can only ever reach $0.01/min US numbers, never $25/min
international/premium destinations) — that risk is fully closed. It does
**not** bound *how fast* the prepaid balance gets burned: nothing stops a
compromised box from opening many concurrent US-destination calls in
parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min =
1,500 minutes total, which 20 concurrent legs could burn through in under
an hour). The prepaid-balance-off-auto-recharge layer bounds the *dollar*
ceiling; only a concurrent-call cap bounds the *speed* of a breach. Treat
the concurrent-call cap and spend/volume alert below as required before
funding a live trunk, not optional hardening.
**Implemented:** the concurrent-call caps in `services/pstn-trunk.sh` are
*global* per direction (default 10 outbound / 10 inbound, each tracked via
its own `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all
extensions) — not per-extension. Inbound didn't have a cap at all until
this was pointed out as a gap (outbound's cap doesn't protect against an
inbound call-flood, which also costs money per-minute on VoIP.ms) — both
directions are covered symmetrically now. The spend/volume alert is also
implemented: an hourly cron script reads a
call log the dialplan appends to directly (not Asterisk's CDR — see the
service file's own comments for why) and alerts via ntfy once per month when
estimated spend crosses a threshold, and every hour that call volume in the
last hour looks like a burst. Denied/rejected calls alert immediately,
separately from that hourly check.
## What it takes technically (asterisk-digital-ocean)
- A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `auth` / `identify`
sections in the pjsip config, using either IP authentication or SIP
registration — VoIP.ms supports both. IP auth is simpler for a droplet
(it has a static IP already) and avoids storing a SIP password in the
config at all — worth confirming with VoIP.ms which they actually
recommend before choosing.
- An outbound dialplan route matching US numbers only, e.g. `_1NXXNXXXXX`
(11-digit NANP with leading 1) or `_NXXNXXXXX`, depending on how numbers
get dialed from the existing extensions, routed to the VoIP.ms trunk. No
catch-all `_X.` pattern — an explicit NANP pattern is itself a hard block
on non-US destinations at the dialplan level.
- VoIP.ms-specific setup that isn't scriptable (user does this manually):
create the account, decide whether a DID is needed (this research was
outbound-only — inbound PSTN wasn't discussed/decided), pick a VoIP.ms
POP/server (affects the trunk hostname), fund the prepaid balance, turn
off auto-recharge.
- Defense-in-depth to design alongside the trunk (not yet designed):
- Per-extension concurrent-call cap in the dialplan (`GROUP()` /
`GROUP_COUNT()`) so one compromised extension can't open dozens of
simultaneous outbound legs at once.
- A simple outbound call-count/spend alert — could live in the existing
`security-dashboard` service (see `services/security-dashboard.sh`) or
as a separate CDR-based check. Not designed yet.
- A PJSIP trunk: `endpoint` / `aor` / `identify` sections in the pjsip
config. **Implemented with IP authentication** (no `auth` section, no SIP
password stored anywhere) — see `services/pstn-trunk.sh`. Provider name,
server hostname, and DID are all prompted at install time (VoIP.ms is only
the suggested default), so any provider supporting IP auth works.
- An outbound dialplan route matching US numbers only — **implemented**:
`_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit,
auto-prefixed with 1), both routed to the trunk. No catch-all `_X.`
pattern.
- **Three-tier permission model — implemented**, superseding an earlier flat
allow-list design. `internal` / `restricted` / `full` per extension, read
live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked
into the dialplan text — no changes needed to Easy Asterisk's own
per-device pjsip.conf sections, since the gate lives entirely in files
this repo already owns. Internal extension-to-extension dialing is never
gated by any tier — only the two NANP patterns (outbound) and the
ring-group (inbound) are. Numbers are stored pipe-separated specifically
because they're used as a `REGEX()` alternation pattern in the dialplan
see the security note below on why the untrusted call-time value must
never be interpolated into the *pattern* side of that check.
- **Inbound ring-group — implemented.** A space-separated list of
extensions to ring for inbound calls (one, or several for a ring group via
`Dial(PJSIP/a&PJSIP/b,20)`), prompted at install. Each member's tier is
checked live per inbound call (an unrolled dialplan block per member —
full always rings, restricted only rings if the caller's number is on
that member's approved list, internal never rings).
- **Security note on the REGEX() checks**: an inbound Caller-ID (or an
outbound dialed number) is attacker-influenced data and must never be
interpolated into the *pattern* argument of `REGEX()` — only ever the
string being tested. Doing it backwards would let a crafted Caller-ID
(e.g. containing regex metacharacters) forge a match against an unrelated
approved-numbers entry. Both checks in `services/pstn-trunk.sh` put the
admin-controlled approved-list in the pattern position and the live call
data in the tested-string position — worth keeping that direction if this
is ever refactored.
- **Web UI — implemented.** `services/security-dashboard.sh`'s "PSTN Trunk"
tab lists every extension (parsed from `pjsip.conf`, the same marker
format Easy Asterisk's own `rebuild_dialplan()` uses) with a tier dropdown
and approved-numbers field, saving straight to `pstn-permissions.conf`.
Tested end-to-end with a real running instance of the (stdlib-only)
Python app: extension parsing, tier changes, number normalization/
validation, and persistence all verified with actual HTTP requests against
a live server in a sandboxed test — not just read through.
- Provider-specific setup that isn't scriptable (user does this manually):
create the account, order a DID, decide pay-per-minute vs. unlimited DID
plan and whether to add E911, pick a server/POP, fund the prepaid balance
($15 minimum for VoIP.ms), turn off auto-recharge. `services/pstn-trunk.sh`
prompts for the server hostname, DID, allowed extensions, ring extensions,
both concurrency caps, ntfy topic, and spend-alert settings at install
time (the cap *numbers* are then live/web-editable afterward — see above).
- Defense-in-depth alongside the trunk:
- **Implemented:** independent outbound/inbound concurrent-call caps in
the dialplan (`GROUP()`/`GROUP_COUNT()`, default 10/10) so an
unauthorized or compromised extension can't open dozens of simultaneous
legs in either direction. Global per direction, not per-extension —
see the note above.
- **Implemented:** an outbound call-count/spend alert via ntfy — a
self-contained call log (not Asterisk's CDR) plus an hourly cron script.
Denied/rejected calls also alert immediately. See
`services/pstn-trunk.sh`'s "Spend/volume alerts" README section for the
exact mechanics and why CDR wasn't used.
- Worth being explicit that CrowdSec's existing `asterisk_bf` /
`asterisk_user_enum` scenarios (see `services/crowdsec.sh`) cover
registration brute-force, which is a *different* threat model from a
@@ -73,15 +213,29 @@ anyone notices. Two independent layers matter more than either alone:
across the space, VoIP.ms included.
## Open items for whoever picks this up next
1. Decide: new `services/voipms-trunk.sh`, or an optional trunk section
added directly to `services/asterisk-digital-ocean.sh`? Leaning toward a
separate service file so trunk config isn't forced on installs that
don't want PSTN calling, matching this repo's one-feature-per-file
convention (see CLAUDE.md).
2. IP auth vs. registration — confirm which VoIP.ms recommends for a single
fixed-IP droplet.
3. Exact NANP dial pattern(s) and any prefix-stripping VoIP.ms requires.
4. Whether inbound (a DID) is wanted at all, or outbound-only for now — not
discussed yet.
5. Design the concurrent-call cap and any spend/volume alerting mentioned
above.
1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file,
generalized to any IP-auth SIP provider (VoIP.ms is just the default).
2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored.
3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`.
4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group
supported), each checked live per-call against its own tier. ~~Permission
model~~ Done — superseded the original flat allow-list with a 3-tier
model (internal/restricted/full) managed live via
`pstn-permissions.conf` + the Security Dashboard web UI, no reinstall
needed to change. ~~Generic Asterisk target~~ Done —
`services/pstn-trunk.sh` now supports either `asterisk-digital-ocean` or
the home/LAN `asterisk` install (the latter with a static-IP caveat for
the provider's IP authentication). Still unresolved: pick pay-per-minute
vs. unlimited DID plan on VoIP.ms's side based on real expected volume,
and decide on E911 (see cost estimate).
5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real
gap, since it also costs money per-minute and outbound's cap doesn't
cover it), default 10/10, global not per-extension, live-editable via
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, hourly
threshold + burst check, plus immediate alerts on denied/rejected calls.
6. Verify against a live VoIP.ms account: auto-recharge-off behavior at
sign-up, and that the chosen POP server's actual source IP for inbound
calls matches what `services/pstn-trunk.sh` resolved via DNS at install
time (VoIP.ms's docs mention some redundancy/failover between servers —
if inbound calls ever stop matching the `identify` section, this is the
first thing to check).
+889
View File
@@ -0,0 +1,889 @@
#!/bin/bash
# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean
# (or the home/LAN asterisk install): US-only outbound (NANP dialplan
# restriction), independent outbound/inbound concurrent-call caps, a 3-tier
# permission model per extension (internal-only / restricted to pre-approved
# numbers / full US calling), a configurable inbound ring-group,
# IP-authenticated trunk (no SIP password stored), ntfy alerts on
# denied/rejected calls, and a periodic spend/volume check.
#
# Internal extension-to-extension calling (and internal ring groups) is
# never gated by any of the above, regardless of tier — the trunk is purely
# an additional path out to/in from the real phone network.
#
# Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/
# cost background this is built from) but isn't hardcoded to it — any SIP
# trunk provider that supports IP authentication works the same way.
#
# Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh
# install — this adds a PSTN trunk on top of one of them and does not stand
# alone. Permission tiers AND concurrency caps are managed live (no restart
# needed) via pstn-permissions.conf / pstn-limits.conf — editable by hand, or
# from services/security-dashboard.sh's "PSTN Trunk" tab if that's installed.
#
# Part of the modular post-install system (sourced by setup.sh).
register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (defaults to VoIP.ms)"
# ── Surviving Easy Asterisk's regeneration ──────────────────────────────────
# Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh
# build 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, _pstn_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/asterisk 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 base install update.
#
# ── Why permissions are a separate live file, not baked into the dialplan ──
# pstn-permissions.conf holds each extension's tier (internal/restricted/full)
# and, for restricted, its pipe-separated approved-number list. The dialplan
# reads it via Asterisk's AST_CONFIG() function, which re-reads the file from
# disk on every call — so editing this file (by hand, or via the Security
# Dashboard web UI) takes effect on the very next call, no Asterisk restart
# and no re-running this installer needed. "update in place" (below) never
# touches this file once it exists, for the same reason CLAUDE.md's
# update-mode convention protects .env/firewall/Caddy config — only "fresh"
# reinstall or the web UI change it. This is also why numbers are stored
# pipe-separated, not comma-separated: they're used directly as a regex
# alternation pattern in the dialplan, and the caller-supplied number being
# checked against them must never itself be interpolated into the PATTERN
# side of a REGEX() call (that would let a crafted Caller-ID/dialed-string
# forge a match) — this file's contents are always the pattern, the
# live call data is always the string being tested, never the reverse.
# ── 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.
_pstn_patch_vendor_files() {
local EA_DIR="$1"
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
local EASY1="$EA_DIR/easy-asterisk.sh"
local EASY2
EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)"
[[ -z "$EASY2" ]] && 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 the base Asterisk install fully set up?"; return 1; }
done
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
if ! grep -q 'pstn-trunk-pjsip.conf' "$f"; then
if grep -q '^user_agent=EasyAsterisk$' "$f"; then
sed -i '/^user_agent=EasyAsterisk$/a #include pstn-trunk-pjsip.conf' "$f"
else
log_warning "$(basename "$f"): 'user_agent=EasyAsterisk' anchor not found — vendor template changed upstream."
log_warning " Add '#include pstn-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 'pstn-trunk-dialplan.conf' "$f"; then
if grep -q '^\[intercom\]$' "$f"; then
sed -i '/^\[intercom\]$/a #include pstn-trunk-dialplan.conf' "$f"
else
log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream."
log_warning " Add '#include pstn-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 PSTN trunk config."
}
# ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ───
_pstn_write_pjsip_include() {
local FILE="$1" SERVER="$2" SERVER_IP="$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.
[pstn-trunk]
type=aor
contact=sip:__PSTN_SERVER__
qualify_frequency=60
[pstn-trunk]
type=identify
endpoint=pstn-trunk
match=__PSTN_SERVER_IP__
[pstn-trunk]
type=endpoint
context=from-pstn-trunk
disallow=all
allow=ulaw,alaw
aors=pstn-trunk
from_user=__PSTN_DID__
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"
}
# ── Shared: one inbound ring-group member's live permission check ─────────
# Emits a block that only adds this extension to PSTN_RING_LIST if it's
# "full" tier, or "restricted" tier AND the inbound Caller-ID is on its
# approved list. Uses a single-quoted heredoc (fully literal — no bash
# expansion) captured into a variable, then a pure bash string replace for
# the extension number placeholder — safer than sed here since it needs no
# escaping at all (the extension is plain digits, but this avoids relying on
# that fact staying true).
_pstn_ring_member_block() {
local EXT="$1"
local block
# ring__EXT__/skip__EXT__ are named priorities WITHIN this same extension
# (declared below via "same => n(label),..."), not separate exten =>
# entries — Goto/GotoIf must use the single-argument label form here
# (bare "?label" / "Goto(label)"), not "label,1" (which addresses a
# different, nonexistent extension named "label" instead).
block=$(cat << 'MEMBER'
same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier)})
same => n,GotoIf($["${PSTN_M_TIER}" = "full"]?ring__EXT__)
same => n,Set(PSTN_M_ALLOWED=${AST_CONFIG(pstn-permissions.conf,__EXT__,allowed_numbers)})
same => n,GotoIf($["${PSTN_M_TIER}" = "restricted" & ${REGEX("^(${PSTN_M_ALLOWED})$" ${CALLERID(num)})}=1]?ring__EXT__)
same => n,Goto(skip__EXT__)
same => n(ring__EXT__),Set(PSTN_RING_LIST=${PSTN_RING_LIST}${PSTN_RING_SEP}PJSIP/__EXT__)
same => n,Set(PSTN_RING_SEP=&)
same => n(skip__EXT__),NoOp()
MEMBER
)
echo "${block//__EXT__/$EXT}"
}
# ── Shared: outbound/inbound dialplan ───────────────────────────────────────
# 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-pstn-trunk]
# below is a separate context, for calls arriving from the trunk.
#
# Role model: internal intercom dialing (extension-to-extension) is NEVER
# gated here — everyone keeps that, regardless of PSTN tier. Only the two
# NANP patterns (the trunk route) and the inbound ring-group are gated, both
# via a LIVE read of pstn-permissions.conf (see the file-level comment above
# for why that's a separate file rather than baked in here).
#
# Calls are logged to pstn-trunk-calls.log (epoch|direction|who|what|seconds)
# for the usage-alert script — not Asterisk's own CDR, to avoid depending on
# whether cdr_csv is enabled/configured on a given image, and to sidestep
# CDR CSV's comma-quoting entirely (our own pipe-delimited format has no
# embedded-delimiter risk since every field here is digits/hostnames).
_pstn_write_dialplan_include() {
local FILE="$1" DID="$2" RING_EXTS="$3" NTFY_URL="$4"
cat > "$FILE" << 'EOF'
; PSTN outbound/inbound — US-only (NANP). Concurrent-call caps (both
; directions) AND tiered permissions (internal / restricted / full) are read
; LIVE from pstn-limits.conf / pstn-permissions.conf via AST_CONFIG() — edit
; either there, or via the Security Dashboard web UI, with no restart
; needed. Regenerated by services/pstn-trunk.sh — edit there, not here
; directly, or a reinstall/update will overwrite this file (pstn-limits.conf
; and pstn-permissions.conf are NOT touched by "update", only by a "fresh"
; reinstall or the web UI).
;
; No catch-all pattern here on purpose: only these two NANP patterns route
; to the trunk, so an unauthorized or 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 toll-fraud reasoning.
exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN})
same => n,Set(PSTN_CALLER=${CHANNEL(peername)})
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)})
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1)
same => n,GotoIf($["${PSTN_TIER}" = "restricted"]?pstn_check_allow_out,1)
same => n,NoOp(Denied - ${PSTN_CALLER} has no PSTN permission, tier: ${PSTN_TIER})
__ALERT_DENY_TIER_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1)
same => n,Goto(1${EXTEN},1)
exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)})
same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${EXTEN})} = 1]?pstn_check_busy,1)
same => n,NoOp(Denied - ${EXTEN} not on ${PSTN_CALLER}'s approved number list)
__ALERT_DENY_NUMBER_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => pstn_check_busy,1,Set(PSTN_MAX_OUT=${AST_CONFIG(pstn-limits.conf,limits,max_outbound)})
same => n,Set(PSTN_MAX_OUT=${IF($["${PSTN_MAX_OUT}" = ""]?10:${PSTN_MAX_OUT})})
same => n,GotoIf($[${GROUP_COUNT(pstn-out)} >= ${PSTN_MAX_OUT}]?pstn_busy,1)
same => n,Set(GROUP()=pstn-out)
same => n,Set(CALLERID(num)=__PSTN_DID__)
same => n,Set(PSTN_START=${EPOCH})
same => n,Dial(PJSIP/${EXTEN}@pstn-trunk,60)
same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}])
same => n,System(printf '%s|out|%s|%s|%s\n' "${PSTN_START}" "${PSTN_CALLER}" "${EXTEN}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log)
same => n,Hangup()
exten => pstn_busy,1,NoOp(PSTN trunk - outbound concurrent-call cap reached, rejecting)
__ALERT_BUSY_LINE__
same => n,Busy(15)
same => n,Hangup()
EOF
sed -i "s/__PSTN_DID__/${DID}/g" "$FILE"
if [[ -n "$NTFY_URL" ]]; then
local _esc_url="${NTFY_URL//&/\\&}"
sed -i "s#__ALERT_DENY_TIER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - no PSTN permission.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_DENY_NUMBER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - number not pre-approved.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
else
sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE"
fi
# ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member.
# Permission check (is anyone in the ring group authorized for this
# caller) happens before the concurrency check, mirroring outbound's
# own ordering (permission gate, then busy gate).
cat >> "$FILE" << 'EOF'
[from-pstn-trunk]
exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)})
same => n,Set(PSTN_RING_LIST=)
same => n,Set(PSTN_RING_SEP=)
EOF
local _ext
for _ext in $RING_EXTS; do
_pstn_ring_member_block "$_ext" >> "$FILE"
done
cat >> "$FILE" << 'EOF'
same => n,GotoIf($["${PSTN_RING_LIST}" = ""]?pstn_in_denied,1)
same => n,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)})
same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})})
same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1)
same => n,Set(GROUP()=pstn-in)
same => n,Set(PSTN_START=${EPOCH})
same => n,Dial(${PSTN_RING_LIST},20)
same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}])
same => n,System(printf '%s|in|%s|ring-group|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log)
same => n,Hangup()
exten => pstn_in_denied,1,NoOp(Inbound PSTN call from ${CALLERID(num)} - no ring target authorized for this caller)
__ALERT_DENY_INBOUND_LINE__
same => n,Hangup()
exten => pstn_in_busy,1,NoOp(PSTN trunk - inbound concurrent-call cap reached, rejecting)
__ALERT_BUSY_IN_LINE__
same => n,Busy(15)
same => n,Hangup()
EOF
if [[ -n "$NTFY_URL" ]]; then
local _esc_url2="${NTFY_URL//&/\\&}"
sed -i "s#__ALERT_DENY_INBOUND_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound call rejected - caller not approved for any ring target.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_BUSY_IN_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound concurrent-call cap reached - a call was rejected.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE"
else
sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d" "$FILE"
fi
}
# ── Shared: initial concurrency limits (fresh install / explicit reset only
# — same "update never touches it" protection as pstn-permissions.conf, see
# the file-level comment above) ─────────────────────────────────────────────
_pstn_write_limits_file() {
local FILE="$1" MAX_OUT="$2" MAX_IN="$3"
{
echo "; PSTN concurrent-call caps, both directions."
echo "; Read LIVE by the dialplan on every call (AST_CONFIG()) — no Asterisk"
echo "; restart needed when this changes. Edit here directly, via the Security"
echo "; Dashboard web UI's \"PSTN Trunk\" tab (if installed), or by re-running"
echo "; 'sudo ./setup.sh pstn-trunk' and choosing a FRESH reinstall (\"update in"
echo "; place\" leaves this file alone on purpose)."
echo ""
echo "[limits]"
echo "max_outbound=${MAX_OUT}"
echo "max_inbound=${MAX_IN}"
} > "$FILE"
chmod 664 "$FILE"
}
# ── Shared: initial permission tiers (fresh install / explicit reset only —
# "update in place" never calls this, matching how .env/firewall/Caddy config
# are protected elsewhere in this repo; see file-level comment above) ──────
# Args: FILE, space-separated FULL_EXTS, then "ext" "pipe|separated|numbers"
# pairs for each restricted extension.
_pstn_write_permissions_file() {
local FILE="$1" FULL_EXTS="$2"
shift 2
{
echo "; PSTN permission tiers — internal / restricted / full."
echo "; Read LIVE by the dialplan on every call (AST_CONFIG()) — no Asterisk"
echo "; restart needed when this changes. Edit here directly, via the Security"
echo "; Dashboard web UI's \"PSTN Trunk\" tab (if installed), or by re-running"
echo "; 'sudo ./setup.sh pstn-trunk' and choosing a FRESH reinstall (\"update in"
echo "; place\" leaves this file alone on purpose)."
echo "; Any extension not listed here is internal-only (no PSTN) by default —"
echo "; it can still call/receive other Asterisk extensions and join internal"
echo "; ring groups, just not the PSTN trunk."
echo ""
local _ext
for _ext in $FULL_EXTS; do
echo "[$_ext]"
echo "tier=full"
echo ""
done
while [[ $# -gt 0 ]]; do
_ext="$1"; local _nums="$2"
shift 2
echo "[$_ext]"
echo "tier=restricted"
echo "allowed_numbers=${_nums}"
echo ""
done
} > "$FILE"
chmod 664 "$FILE"
}
# ── Shared: periodic spend/volume checker (run hourly via cron) ────────────
_pstn_write_usage_alert_script() {
local FILE="$1" EA_DIR="$2" RATE="$3" MONTH_THRESHOLD="$4" BURST_THRESHOLD="$5" NTFY_URL="$6"
cat > "$FILE" << 'EOF'
#!/bin/bash
# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run
# the installer instead. Run hourly via /etc/cron.d/pstn-trunk-usage.
# Reads the call log pstn-trunk-dialplan.conf appends to and alerts via
# ntfy when month-to-date estimated spend crosses a threshold (alerted once
# per month) or when call volume in the last hour looks like a burst.
LOG_FILE="__EA_DIR__/logs/pstn-trunk-calls.log"
STATE_FILE="__EA_DIR__/.pstn-trunk-alert-state"
RATE="__PSTN_RATE__"
MONTH_THRESHOLD="__PSTN_MONTH_THRESHOLD__"
BURST_THRESHOLD="__PSTN_BURST_THRESHOLD__"
NTFY_URL="__PSTN_NTFY_URL__"
[[ -f "$LOG_FILE" ]] || exit 0
now_epoch=$(date +%s)
current_month=$(date +%Y-%m)
one_hour_ago=$((now_epoch - 3600))
month_start_epoch=$(date -d "$(date +%Y-%m-01)" +%s)
month_seconds=$(awk -F'|' -v start="$month_start_epoch" '$2=="out" && $1+0>=start {sum+=$5} END{print sum+0}' "$LOG_FILE")
month_minutes=$(awk -v s="$month_seconds" 'BEGIN{printf "%.1f", s/60}')
month_cost=$(awk -v m="$month_minutes" -v r="$RATE" 'BEGIN{printf "%.2f", m*r}')
hour_calls=$(awk -F'|' -v start="$one_hour_ago" '$2=="out" && $1+0>=start {c++} END{print c+0}' "$LOG_FILE")
send_ntfy() {
[[ -n "$NTFY_URL" ]] && curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1
}
last_alert_month=""
[[ -f "$STATE_FILE" ]] && last_alert_month=$(cat "$STATE_FILE")
if awk -v c="$month_cost" -v t="$MONTH_THRESHOLD" 'BEGIN{exit !(c>=t)}'; then
if [[ "$last_alert_month" != "$current_month" ]]; then
send_ntfy "PSTN trunk: estimated spend this month (\$${month_cost}) has crossed the \$${MONTH_THRESHOLD} threshold. ${month_minutes} minutes so far."
echo "$current_month" > "$STATE_FILE"
fi
fi
if [[ "$hour_calls" -ge "$BURST_THRESHOLD" ]]; then
send_ntfy "PSTN trunk: $hour_calls outbound calls placed in the last hour - check for unusual activity."
fi
EOF
sed -i "s#__EA_DIR__#${EA_DIR}#g; s/__PSTN_RATE__/${RATE}/g; s/__PSTN_MONTH_THRESHOLD__/${MONTH_THRESHOLD}/g; s/__PSTN_BURST_THRESHOLD__/${BURST_THRESHOLD}/g" "$FILE"
sed -i "s#__PSTN_NTFY_URL__#${NTFY_URL}#g" "$FILE"
chmod 755 "$FILE"
}
# ── Shared: structural settings only (used by fresh install AND update) ────
# Does NOT touch pstn-permissions.conf — see the file-level comment above
# 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 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_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"
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}
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/.pstn-trunk.env" 2>/dev/null || true
if command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then
cat > /etc/cron.d/pstn-trunk-usage << CRON
0 * * * * root /bin/bash $EA_DIR/pstn-trunk-usage-alert.sh >> $EA_DIR/logs/pstn-trunk-usage-alert.log 2>&1
CRON
log_success "Hourly spend/volume check installed (cron.d)."
else
log_warning "cron not available — run $EA_DIR/pstn-trunk-usage-alert.sh manually/periodically for spend/volume alerts."
fi
}
install_pstn-trunk() {
require_docker || return 1
local EA_DIR="" ASTERISK_KIND=""
if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then
EA_DIR="$DOCKER_DIR/asterisk-digital-ocean"
ASTERISK_KIND="asterisk-digital-ocean"
elif [[ -f "$DOCKER_DIR/asterisk/docker-compose.yml" ]]; then
EA_DIR="$DOCKER_DIR/asterisk"
ASTERISK_KIND="asterisk"
fi
local ASTERISK_DIR="$EA_DIR/config/asterisk"
local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf"
local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf"
local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf"
local LIMITS_FILE="$ASTERISK_DIR/pstn-limits.conf"
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
local CONTAINER_NAME="easy-asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install"
echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname, DID,"
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 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"
echo "[DRY-RUN] Would offer 'update in place' (structural settings only — never touches"
echo "[DRY-RUN] pstn-permissions.conf or pstn-limits.conf) instead of a fresh install if"
echo "[DRY-RUN] already configured"
echo "[DRY-RUN] Would restart the asterisk container to apply"
return 0
fi
if [[ -z "$EA_DIR" ]]; then
log_error "Neither asterisk-digital-ocean nor asterisk (LAN) is installed — install one first:"
log_error " sudo ./setup.sh asterisk-digital-ocean (recommended — public droplet, static IP)"
log_error " sudo ./setup.sh asterisk (home/LAN — see the static-IP caveat below)"
log_error "This service adds a PSTN trunk on top of one of them; it doesn't stand alone."
return 1
fi
if [[ "$ASTERISK_KIND" == "asterisk" ]]; then
echo ""
log_warning "Using the home/LAN asterisk install. IP authentication needs a STABLE public IP —"
log_warning "if this box is behind a dynamic home IP, your provider's IP allow-list goes stale"
log_warning "whenever your ISP rotates it, breaking calls until you update it there yourself."
log_warning "A static IP from your ISP avoids that; asterisk-digital-ocean sidesteps it entirely."
fi
log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (defaults to VoIP.ms)."
log_info "US-only outbound (NANP dialplan), a concurrent-call cap, per-extension permission"
log_info "tiers, an inbound ring-group, and ntfy alerts on denied/rejected calls plus"
log_info "spend/volume checks."
echo ""
log_warning "Before continuing, on your provider's side you should already have: created an"
log_warning "account, funded and set up prepaid billing with auto-recharge OFF (VoIP.ms: Client"
log_warning "Area -> Balance Management), ordered a DID with IP authentication pointed at this"
log_warning "box's public IP, and picked a server/POP. Also restrict outbound routing to"
log_warning "US/NANP on the provider's own side if it offers that — this dialplan is the second,"
log_warning "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 PSTN trunk config found."
local REINSTALL_MODE=""
prompt_reinstall_mode REINSTALL_MODE
case "$REINSTALL_MODE" in
update)
if [[ -f "$SETTINGS_FILE" ]]; then
# shellcheck disable=SC1090
source "$SETTINGS_FILE"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \
"$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
( cd "$EA_DIR" && docker compose restart asterisk ) \
&& log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \
|| log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk"
log_info "pstn-permissions.conf and pstn-limits.conf were NOT touched — edit them"
log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them."
return 0
else
log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)."
fi
;;
cancel)
log_info "Leaving the existing PSTN trunk config as-is."
return 0
;;
fresh)
if [[ -f "$PERMISSIONS_FILE" || -f "$LIMITS_FILE" ]]; then
log_warning "pstn-permissions.conf and/or pstn-limits.conf already exist and may have"
log_warning "been edited since (directly, or via the Security Dashboard). A fresh"
log_warning "reinstall OVERWRITES both with whatever you enter below."
local _confirm_reset=""
prompt_yn "Continue and reset permission tiers + concurrency caps? (y/n):" "n" _confirm_reset
if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then
log_info "Cancelled — nothing changed."
return 0
fi
fi
log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch."
;;
esac
fi
# ── Prompts — provider account details aren't scriptable, set up manually
# on the provider's own site first (see warning above) ───────────────────
local PROVIDER_NAME=""
prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME
local TRUNK_SERVER=""
prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this box from your provider's server list):" "" TRUNK_SERVER
if [[ -z "$TRUNK_SERVER" ]]; then
log_error "A server hostname is required — aborting."
return 1
fi
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."
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."
return 1
fi
else
log_success "Resolved $TRUNK_SERVER -> $TRUNK_SERVER_IP"
fi
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
log_error "That doesn't look like a 10-digit US number — aborting."
return 1
fi
# ── Permission tiers ───────────────────────────────────────────────────
echo ""
echo " Three tiers, per extension:"
echo " internal — call/receive other Asterisk extensions + internal ring"
echo " groups only. No PSTN at all. Default for anything not"
echo " listed below."
echo " restricted — internal, PLUS call/receive ONLY pre-approved US numbers."
echo " full — internal, PLUS call/receive ANY US number."
echo " These are managed LIVE after install (pstn-permissions.conf) — via the"
echo " Security Dashboard web UI if installed, or by hand — with no restart or"
echo " reinstall needed to change them later."
local FULL_EXTS=""
prompt_text "Full-PSTN extensions (space-separated, blank = none):" "" FULL_EXTS
local RESTRICTED_EXTS=""
prompt_text "Restricted-PSTN extensions (space-separated, blank = none):" "" RESTRICTED_EXTS
local RESTRICTED_ARGS=()
if [[ -n "$RESTRICTED_EXTS" ]]; then
local _ext _raw_nums _clean_nums
for _ext in $RESTRICTED_EXTS; do
prompt_text " Approved numbers for extension $_ext (comma/space-separated, 11-digit US numbers, e.g. 15551234567):" "" _raw_nums
_clean_nums="$(echo "$_raw_nums" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | paste -sd'|' - 2>/dev/null)"
if [[ -z "$_clean_nums" ]]; then
log_warning "No valid 11-digit numbers entered for $_ext — it will be restricted with an EMPTY"
log_warning "approved list, meaning no PSTN number can currently reach/be reached by it until"
log_warning "you add some (via the Security Dashboard or by editing pstn-permissions.conf)."
fi
RESTRICTED_ARGS+=("$_ext" "$_clean_nums")
done
fi
echo ""
echo " Concurrent-call caps (both directions) are also live — changeable later via"
echo " the Security Dashboard or by hand, no restart needed."
local MAX_OUTBOUND=""
prompt_text "Max simultaneous outbound PSTN calls allowed:" "10" MAX_OUTBOUND
if [[ ! "$MAX_OUTBOUND" =~ ^[0-9]+$ ]]; then
log_warning "Not a number — defaulting to 10."
MAX_OUTBOUND=10
fi
local MAX_INBOUND=""
prompt_text "Max simultaneous inbound PSTN calls allowed:" "10" MAX_INBOUND
if [[ ! "$MAX_INBOUND" =~ ^[0-9]+$ ]]; then
log_warning "Not a number — defaulting to 10."
MAX_INBOUND=10
fi
local _suggested_ring
_suggested_ring="$(echo "$FULL_EXTS $RESTRICTED_EXTS" | xargs)"
local RING_EXTS=""
prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring):" "$_suggested_ring" RING_EXTS
if [[ -z "$RING_EXTS" ]]; then
log_error "At least one extension is required for inbound routing — aborting."
return 1
fi
echo ""
local WANT_NTFY=""
prompt_yn "Send an ntfy alert when a call is denied (permission tier/approved-number check failed) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY
local NTFY_URL=""
if [[ "$WANT_NTFY" =~ ^[Yy]$ ]]; then
# Prefer a locally-installed ntfy's own base-url as the default, same
# detection pattern services/crowdsec.sh uses for its own ntfy alerts.
local _ntfy_default="https://ntfy.sh/pstn-trunk-alerts"
if [ -f "$DOCKER_DIR/ntfy/config/server.yml" ]; then
local _local_base_url
_local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)"
if [ -n "$_local_base_url" ] && [ "$_local_base_url" != "https://ntfy.example.com" ]; then
_ntfy_default="${_local_base_url}/pstn-trunk-alerts"
log_info "Detected a configured local ntfy instance at $_local_base_url — using it as the default."
fi
fi
if [ "$_ntfy_default" = "https://ntfy.sh/pstn-trunk-alerts" ]; then
log_info "No configured local ntfy instance detected — defaulting to the public ntfy.sh."
log_info "If you have one hosted elsewhere, enter its topic URL instead."
fi
prompt_text " ntfy topic URL:" "$_ntfy_default" NTFY_URL
fi
echo ""
log_info "Spend/volume alert settings (used only to estimate cost and flag unusual usage —"
log_info "not billing-accurate, just a safety net)."
local RATE_PER_MIN=""
prompt_text " Outbound per-minute rate in USD (VoIP.ms US rate is 0.01):" "0.01" RATE_PER_MIN
local MONTH_THRESHOLD=""
prompt_text " Alert once when estimated spend this month reaches (USD):" "10" MONTH_THRESHOLD
local BURST_THRESHOLD=""
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" \
"$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
_pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}"
_pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND"
ensure_docker_dir_ownership "$ASTERISK_DIR"
# No new firewall rules: the base install already opens SIP (5060/5061)
# and RTP (10000-20000) to the internet, and providers' 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 — the base install already owns README.md in
# this same directory via write_readme, so don't overwrite it) ─────────
local DOC_FILE="$EA_DIR/README-pstn-trunk.md"
cat > "$DOC_FILE" << MD
# SIP PSTN trunk (add-on to $ASTERISK_KIND)
US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works
with any IP-authenticated provider), per-extension permission tiers, a
configurable concurrent-call cap, and an inbound ring-group. 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 |
|---|---|
| Provider | ${PROVIDER_NAME} |
| Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) |
| DID | ${TRUNK_DID} |
| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all |
| Full-PSTN extensions | ${FULL_EXTS:-none} |
| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} |
| Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) |
| Inbound ring-group | ${RING_EXTS} |
| ntfy alerts | ${NTFY_URL:-disabled} |
| Estimated rate | \$${RATE_PER_MIN}/min |
| Monthly spend alert threshold | \$${MONTH_THRESHOLD} |
| Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour |
## Permission tiers
Every extension can always call and receive calls from other Asterisk
extensions, and join internal ring groups — that's unchanged and never
gated by anything below. Three tiers control PSTN (real phone number)
access specifically:
- **internal** (default — anything not listed as full/restricted): no PSTN
at all, in or out.
- **restricted**: can only call and be called by numbers on its own
pre-approved list.
- **full**: can call/receive any US number.
Stored in \`config/asterisk/pstn-permissions.conf\`, read **live** by the
dialplan via Asterisk's \`AST_CONFIG()\` on every call — editing this file
(by hand, or via the Security Dashboard's "PSTN Trunk" tab, if that service
is installed) takes effect on the next call, no restart needed. Re-running
this installer in "update" mode never touches this file — only a "fresh"
reinstall (with confirmation) or the web UI change it, the same protection
CLAUDE.md's update-mode convention gives \`.env\`/firewall/Caddy config
elsewhere in this repo.
## Concurrent-call caps
Two independent caps, one per direction — outbound (\`${MAX_OUTBOUND}\`) and
inbound (\`${MAX_INBOUND}\`), tracked separately (\`GROUP()\`/\`GROUP_COUNT()\`
on \`pstn-out\`/\`pstn-in\`). A cap being hit rejects the *next* call over the
limit with a busy signal (and an ntfy alert, if enabled) — existing calls
are never affected.
Stored in \`config/asterisk/pstn-limits.conf\`, read **live** the same way as
permission tiers — editable by hand, via the Security Dashboard, with no
restart needed, and likewise untouched by "update in place" (only "fresh"
reinstall or the web UI change it).
## 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 files of its own,
\`#include\`'d from the generated files:
- \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/
\`endpoint\` sections (IP-authenticated, no password stored).
- \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing,
ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound context. Reads
permission tiers from \`pstn-permissions.conf\` and concurrency caps from
\`pstn-limits.conf\` (both above) live, rather than baking either in,
specifically so they can change without touching this file.
The \`#include\` lines themselves are patched into Easy Asterisk's *generator
functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, and its versioned
copy) so they get re-emitted every time those functions regenerate the
config, instead of being wiped.
**Caveat:** if the base $ASTERISK_KIND service is ever updated independently
(\`sudo ./setup.sh $ASTERISK_KIND\`, choosing "update in place" — that path
re-copies fresh vendor files), this patch is wiped along with it. Re-run
\`sudo ./setup.sh pstn-trunk\` afterward (update mode reapplies the patch and
rewrites structural settings from \`.pstn-trunk.env\`, no re-prompting, and
without touching \`pstn-permissions.conf\` or \`pstn-limits.conf\`).
## Spend/volume alerts
\`pstn-trunk-usage-alert.sh\` runs hourly (\`/etc/cron.d/pstn-trunk-usage\`) and
reads \`logs/pstn-trunk-calls.log\` (appended to directly by the dialplan, not
Asterisk's own CDR — a deliberate choice to avoid depending on whether this
image's CDR modules are enabled/configured, and to sidestep CDR CSV's
comma-quoting). It sends an ntfy alert:
- **Once per calendar month** the first time estimated spend crosses
\$${MONTH_THRESHOLD} (state tracked in \`.pstn-trunk-alert-state\` so it
doesn't repeat every hour).
- **Every hour** that outbound call volume exceeds ${BURST_THRESHOLD}
calls/hour — this is the faster tripwire for a burst/abuse scenario,
independent of whether it's crossed the monthly dollar threshold yet.
Separately, denied calls (no permission / number not pre-approved) and
rejected calls (either concurrency cap hit) alert **immediately**, not on
the hourly schedule.
These are cost *estimates* (call count/duration × your entered rate), not
real billing data — treat them as a safety net, not a substitute for
checking your provider's own balance/usage dashboard.
## Managing this from a web UI
If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab
shows both the per-extension permission tiers and the outbound/inbound
concurrency caps, all editable live — no restart, no reinstall. Install/
update it any time with \`sudo ./setup.sh security-dashboard\`; it
auto-detects this install.
## Manual edits
Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` /
\`pstn-trunk-usage-alert.sh\` directly if you plan to re-run this installer
later — it overwrites all three unconditionally from \`.pstn-trunk.env\` on
both fresh and update. \`pstn-permissions.conf\` and \`pstn-limits.conf\` are
different — see "Permission tiers" / "Concurrent-call caps" above, both are
safe to hand-edit any time. For one-off testing, restart the container
instead of running the installer:
\`\`\`bash
docker compose -f $EA_DIR/docker-compose.yml restart asterisk
\`\`\`
## Verifying it's working
\`\`\`bash
docker exec -it $CONTAINER_NAME asterisk -rx "pjsip show endpoint pstn-trunk"
docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show intercom"
docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show from-pstn-trunk"
tail -f $EA_DIR/logs/pstn-trunk-calls.log
\`\`\`
A full-tier device should be able to dial a 10-digit or 11-digit US number
and reach the trunk; a restricted-tier device should only reach numbers on
its approved list; an internal-tier device should get a busy signal (and an
ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from an approved/any US
number (depending on tier) should ring: ${RING_EXTS}.
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 "PSTN trunk configured."
echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)"
echo " DID: $TRUNK_DID"
echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls"
echo " Inbound: max $MAX_INBOUND concurrent calls"
echo " Full-PSTN extensions: ${FULL_EXTS:-none}"
echo " Restricted extensions: ${RESTRICTED_EXTS:-none}"
echo " Inbound ring-group: $RING_EXTS"
echo " ntfy alerts: ${NTFY_URL:-disabled}"
echo " Docs: $DOC_FILE"
echo ""
}
+398 -45
View File
@@ -69,27 +69,40 @@ register_service security-dashboard homelab "Security dashboard: Asterisk failed
install_security-dashboard() {
local APP_DIR="/opt/security-dashboard"
local DASHBOARD_PORT=8092
local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs"
local SVC_USER="secdash"
# Either Asterisk flavor works — prefer asterisk-digital-ocean if both
# happen to be installed, matching services/pstn-trunk.sh's own
# preference order for consistency.
local ASTERISK_EA_DIR=""
if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then
ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean"
elif [ -d "$DOCKER_DIR/asterisk" ]; then
ASTERISK_EA_DIR="$DOCKER_DIR/asterisk"
fi
local ASTERISK_LOG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/logs}"
local ASTERISK_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/asterisk}"
local ASTERISK_ADMIN_URL=""
if [ -f "$DOCKER_DIR/asterisk-digital-ocean/.env" ]; then
if [ -n "$ASTERISK_EA_DIR" ] && [ -f "$ASTERISK_EA_DIR/.env" ]; then
local _ea_domain
_ea_domain="$(grep -E '^DOMAIN_NAME=' "$DOCKER_DIR/asterisk-digital-ocean/.env" | cut -d= -f2-)"
_ea_domain="$(grep -E '^DOMAIN_NAME=' "$ASTERISK_EA_DIR/.env" | cut -d= -f2-)"
[ -n "$_ea_domain" ] && ASTERISK_ADMIN_URL="https://${_ea_domain}"
fi
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ SECURITY DASHBOARD │"
echo "│ Asterisk failed-connection log + CrowdSec decisions, one page. │"
echo "│ Runs natively on the host (not Docker) so it can call cscli and │"
echo "│ read Asterisk's security log directly. Authelia-protected. │"
echo "│ Asterisk failed-connection log + CrowdSec decisions + PSTN │"
echo "│ trunk permissions, one page. Runs natively on the host (not │"
echo "│ Docker) so it can call cscli and read Asterisk's files │"
echo "│ directly. Authelia-protected. │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
if [ ! -d "$ASTERISK_LOG_DIR" ]; then
log_warning "No asterisk-digital-ocean install detected at $ASTERISK_LOG_DIR."
log_warning "The Security Log tab will just be empty — CrowdSec's tab still works fine."
if [ -z "$ASTERISK_EA_DIR" ]; then
log_warning "No asterisk-digital-ocean or asterisk install detected."
log_warning "The Security Log and PSTN Trunk tabs will just be empty — CrowdSec's tab still works fine."
fi
if [ "$DRY_RUN" = true ]; then
@@ -97,6 +110,7 @@ install_security-dashboard() {
echo "[DRY-RUN] Would write $APP_DIR/app.py"
echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl only)"
echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)"
echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the PSTN Trunk tab)"
echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for"
return 0
fi
@@ -112,9 +126,11 @@ install_security-dashboard() {
}
case "$MODE" in
update)
log_info "Refreshing app code + sudoers rule (no config/domain changes)..."
log_info "Refreshing app code + sudoers rule + systemd unit (no Caddy/domain changes)..."
_secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR"
_secdash_write_app "$APP_DIR"
_secdash_write_sudoers "$SVC_USER"
_secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL"
systemctl restart security-dashboard 2>/dev/null \
&& log_success "security-dashboard restarted" \
|| log_warning "Restart failed — check: systemctl status security-dashboard"
@@ -142,45 +158,14 @@ install_security-dashboard() {
log_success "Created system user $SVC_USER"
fi
# Read access to the Asterisk security log without running as root or the
# actual user — add secdash to the group that owns the log files instead.
if [ -d "$ASTERISK_LOG_DIR" ]; then
local _log_group
_log_group="$(stat -c '%G' "$ASTERISK_LOG_DIR" 2>/dev/null || echo "$ACTUAL_USER")"
usermod -aG "$_log_group" "$SVC_USER" 2>/dev/null || true
chmod 750 "$ASTERISK_LOG_DIR" 2>/dev/null || true
fi
_secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR"
mkdir -p "$APP_DIR"
_secdash_write_app "$APP_DIR"
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
_secdash_write_sudoers "$SVC_USER"
# ── systemd unit ──────────────────────────────────────────────────────────
cat > /etc/systemd/system/security-dashboard.service << SDSVC
[Unit]
Description=Security dashboard (Asterisk security log + CrowdSec decisions)
After=network.target
[Service]
Type=simple
User=$SVC_USER
Group=$SVC_USER
Environment=DASHBOARD_PORT=$DASHBOARD_PORT
Environment=ASTERISK_LOG=$ASTERISK_LOG_DIR/full
Environment=ASTERISK_ADMIN_URL=$ASTERISK_ADMIN_URL
ExecStart=/usr/bin/python3 $APP_DIR/app.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=false
ProtectSystem=strict
ReadOnlyPaths=$ASTERISK_LOG_DIR
ReadWritePaths=/etc/crowdsec/scenarios
[Install]
WantedBy=multi-user.target
SDSVC
_secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL"
systemctl daemon-reload
systemctl enable security-dashboard >/dev/null 2>&1
@@ -221,6 +206,13 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
- **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
where you don't want to wait for it to misbehave again.
- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — the
outbound/inbound concurrent-call caps, and every known extension (parsed
from \`pjsip.conf\`) with its current permission tier (internal /
restricted / full) and, for restricted, its approved numbers — all
editable live, no Asterisk restart, no reinstall. Writes directly to
\`pstn-limits.conf\` / \`pstn-permissions.conf\`, which the dialplan reads
fresh on every call.
- Link to the Asterisk web admin itself (doesn't embed it, just links out).
## Manage
@@ -257,6 +249,77 @@ README_MD
echo ""
}
# Grants secdash read/write access to wherever Asterisk's config lives
# without running the dashboard as root or the actual user — added to the
# group that already owns those directories (ensure_docker_dir_ownership
# elsewhere in this repo sets both owner AND group to ACTUAL_USER, so the log
# dir and config dir normally share one group already; handled separately
# anyway in case that ever changes). Separate function, called from both
# "update" and fresh-install, so a PSTN trunk installed *after* this
# dashboard (or an asterisk-digital-ocean/asterisk swap) reaches an existing
# install on its next update instead of silently only applying to new ones.
_secdash_grant_asterisk_access() {
local _svc_user="$1" _log_dir="$2" _config_dir="$3"
local _dir
for _dir in "$_log_dir" "$_config_dir"; do
[ -n "$_dir" ] && [ -d "$_dir" ] || continue
local _group
_group="$(stat -c '%G' "$_dir" 2>/dev/null || echo "$ACTUAL_USER")"
usermod -aG "$_group" "$_svc_user" 2>/dev/null || true
chmod 750 "$_dir" 2>/dev/null || true
done
# pstn-permissions.conf specifically needs group WRITE (750 above is
# read+execute for the group, not write) — the file itself is written
# group-writable (664) by services/pstn-trunk.sh, but the containing
# directory also needs the group execute+write bit for a new file save
# (configparser writes a fresh temp file then renames it into place) to
# succeed. 770 only on the config dir, not the log dir (no reason for
# secdash to ever create files in the log dir).
if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then
chmod 770 "$_config_dir" 2>/dev/null || true
fi
}
# Systemd unit — separate function so "update" mode can refresh it too
# (Environment= vars and ReadWritePaths depend on which Asterisk flavor is
# detected, which can change between installs — e.g. a PSTN trunk or a
# different Asterisk flavor installed after this dashboard's first setup).
# ProtectSystem=strict makes the whole filesystem read-only for this unit
# except the paths explicitly listed below, regardless of Unix permissions —
# both layers (this AND the group access above) need to agree, or writes
# fail even when Unix permissions alone would have allowed them.
_secdash_write_systemd_unit() {
local _app_dir="$1" _svc_user="$2" _port="$3" _log_dir="$4" _config_dir="$5" _admin_url="$6"
local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios"
[ -n "$_log_dir" ] && _read_only_paths="$_log_dir"
[ -n "$_config_dir" ] && _read_write_paths="$_read_write_paths $_config_dir"
cat > /etc/systemd/system/security-dashboard.service << SDSVC
[Unit]
Description=Security dashboard (Asterisk security log + CrowdSec decisions + PSTN trunk permissions)
After=network.target
[Service]
Type=simple
User=$_svc_user
Group=$_svc_user
Environment=DASHBOARD_PORT=$_port
Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full}
Environment=ASTERISK_CONFIG_DIR=$_config_dir
Environment=ASTERISK_ADMIN_URL=$_admin_url
ExecStart=/usr/bin/python3 $_app_dir/app.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=false
ProtectSystem=strict
ReadOnlyPaths=$_read_only_paths
ReadWritePaths=$_read_write_paths
[Install]
WantedBy=multi-user.target
SDSVC
}
# Scoped sudo — only the exact commands the app needs, nothing else. Numeric-
# only glob on the decision ID; Python subprocess calls always pass args as a
# list (no shell=True anywhere), so there's no shell-metachar injection
@@ -473,6 +536,7 @@ _secdash_write_app() {
Stdlib only, deliberately — this runs on a small droplet alongside Asterisk,
Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead.
"""
import configparser
import json
import os
import re
@@ -482,6 +546,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "")
ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "")
ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "")
ASN_SCENARIO_FILES = [
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
"/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml",
@@ -493,6 +558,11 @@ ASN_FILTER_RE = re.compile(r"ASNNumber in \[([^\]]*)\]\)")
ID_RE = re.compile(r"^\d+$")
ASN_RE = re.compile(r"^\d+$")
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
DEVICE_MARKER_RE = re.compile(r"^; === Device: (.+?)(?:\s*\[AA:(?:yes|no)\])?\s*\((.+?)\)\s*===\s*$")
EXT_HEADER_RE = re.compile(r"^\[(\d+)\]")
EXTEN_RE = re.compile(r"^\d+$")
TIER_RE = re.compile(r"^(internal|restricted|full)$")
NUMBER_RE = re.compile(r"^\d{11}$")
def parse_security_log(limit=200):
@@ -711,6 +781,184 @@ def ban_asn(asn):
}
def list_extensions():
"""Extension numbers + display names, parsed from pjsip.conf the same
way Easy Asterisk's own rebuild_dialplan() finds them: a
"; === Device: NAME (category) ===" comment immediately followed (once
other lines are skipped) by that device's "[extnum]" section header.
Read-only, best-effort — an unparseable/missing file just means an empty
list, not an error, same convention as parse_security_log."""
if not ASTERISK_CONFIG_DIR:
return []
path = os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf")
if not os.path.isfile(path):
return []
try:
with open(path, "r", errors="replace") as f:
lines = f.readlines()
except OSError:
return []
extensions = []
pending_name = None
for line in lines:
line = line.rstrip("\n")
m = DEVICE_MARKER_RE.match(line)
if m:
pending_name = m.group(1).strip()
continue
m = EXT_HEADER_RE.match(line)
if m and pending_name is not None:
extensions.append({"ext": m.group(1), "name": pending_name})
pending_name = None
return extensions
def _permissions_path():
return os.path.join(ASTERISK_CONFIG_DIR, "pstn-permissions.conf") if ASTERISK_CONFIG_DIR else None
def _read_permissions_cp():
cp = configparser.ConfigParser(delimiters=("=",))
path = _permissions_path()
if path and os.path.isfile(path):
try:
cp.read(path)
except configparser.Error:
pass
return cp
def get_all_permissions():
"""{ext: {"tier": ..., "allowed_numbers": "num|num|..."}} for every
extension with a non-internal tier on record. Extensions with no section
are implicitly "internal" — the dialplan's AST_CONFIG() lookup treats a
missing section as empty/denied the same way, so there's nothing to
return for them here; the UI fills in "internal" as the default for any
known extension (from list_extensions()) not present in this dict."""
cp = _read_permissions_cp()
result = {}
for section in cp.sections():
if not EXTEN_RE.match(section):
continue
result[section] = {
"tier": cp.get(section, "tier", fallback="internal"),
"allowed_numbers": cp.get(section, "allowed_numbers", fallback=""),
}
return result
def write_permission(ext, tier, numbers_raw):
"""Saves one extension's tier + (for restricted) approved-number list.
Numbers are normalized to a pipe-separated list of 11-digit US numbers —
pipe, not comma, because the dialplan uses this value directly as a
REGEX() alternation pattern (see services/pstn-trunk.sh's file-level
comment on why the untrusted call data is always the string being
tested, never interpolated into the pattern side)."""
if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box"
ext = str(ext).strip()
if not EXTEN_RE.match(ext):
return False, "Invalid extension"
if not TIER_RE.match(tier):
return False, "Invalid tier"
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
clean_numbers = [t for t in tokens if NUMBER_RE.match(t)]
numbers = "|".join(clean_numbers)
cp = _read_permissions_cp()
if tier == "internal":
if cp.has_section(ext):
cp.remove_section(ext)
else:
if not cp.has_section(ext):
cp.add_section(ext)
cp.set(ext, "tier", tier)
if tier == "restricted":
cp.set(ext, "allowed_numbers", numbers)
elif cp.has_option(ext, "allowed_numbers"):
cp.remove_option(ext, "allowed_numbers")
path = _permissions_path()
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w") as f:
f.write(
"; PSTN permission tiers - internal / restricted / full.\n"
"; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n"
"; Asterisk restart needed. Managed here (Security Dashboard); also\n"
"; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n"
"; never touches this file, only a fresh reinstall does.\n"
"; Any extension not listed here is internal-only (no PSTN) by default.\n\n"
)
cp.write(f)
os.replace(tmp_path, path)
except OSError as e:
try:
os.remove(tmp_path)
except OSError:
pass
return False, "Failed writing %s: %s" % (path, e)
if tier == "restricted" and not clean_numbers:
return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet."
return True, "Saved"
LIMIT_RE = re.compile(r"^\d+$")
def get_limits():
"""Current outbound/inbound concurrent-call caps. Defaults (10/10) match
what the dialplan itself falls back to (via AST_CONFIG()+IF()) if this
file is missing or a key is absent, so a display here is never wrong
even before pstn-limits.conf exists."""
if not ASTERISK_CONFIG_DIR:
return {"max_outbound": 10, "max_inbound": 10}
path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf")
cp = configparser.ConfigParser(delimiters=("=",))
if os.path.isfile(path):
try:
cp.read(path)
except configparser.Error:
pass
return {
"max_outbound": cp.getint("limits", "max_outbound", fallback=10),
"max_inbound": cp.getint("limits", "max_inbound", fallback=10),
}
def write_limits(max_outbound, max_inbound):
if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box"
max_outbound, max_inbound = str(max_outbound).strip(), str(max_inbound).strip()
if not LIMIT_RE.match(max_outbound) or not LIMIT_RE.match(max_inbound):
return False, "Both caps must be whole numbers"
path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf")
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w") as f:
f.write(
"; PSTN concurrent-call caps, both directions.\n"
"; Read LIVE by the dialplan on every call (AST_CONFIG()) - no Asterisk\n"
"; restart needed. Managed here (Security Dashboard); also safe to edit\n"
"; by hand. 'sudo ./setup.sh pstn-trunk' update mode never touches this\n"
"; file, only a fresh reinstall does.\n\n"
"[limits]\n"
"max_outbound=%s\n"
"max_inbound=%s\n" % (max_outbound, max_inbound)
)
os.replace(tmp_path, path)
except OSError as e:
try:
os.remove(tmp_path)
except OSError:
pass
return False, "Failed writing %s: %s" % (path, e)
return True, "Saved"
INDEX_HTML = """<!doctype html>
<html><head><meta charset="utf-8">
<title>Security Dashboard</title>
@@ -744,6 +992,7 @@ INDEX_HTML = """<!doctype html>
<nav>
<button class="tab-btn active" data-tab="security">Security Log</button>
<button class="tab-btn" data-tab="crowdsec">CrowdSec</button>
<button class="tab-btn" data-tab="pstn">PSTN Trunk</button>
</nav>
<a id="admin-link" href="#" target="_blank" style="display:none">Asterisk Web Admin &#8599;</a>
</header>
@@ -770,16 +1019,40 @@ INDEX_HTML = """<!doctype html>
<div id="msg"></div>
</div>
</div>
<div id="tab-pstn" style="display:none">
<div class="card">
<h3 style="margin-top:0">Concurrent-call caps</h3>
<p class="muted">A call over either cap gets a busy signal (and an ntfy alert, if enabled) — existing calls are never affected. Changes apply live, on the next call.</p>
<div class="row">
<label class="muted" style="white-space:nowrap">Max outbound<br><input type="text" id="limit-out" style="width:5rem"></label>
<label class="muted" style="white-space:nowrap">Max inbound<br><input type="text" id="limit-in" style="width:5rem"></label>
<button class="action" id="limits-save" style="align-self:flex-end">Save</button>
</div>
<div id="limits-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
<div class="card">
<h3 style="margin-top:0">PSTN permission tiers</h3>
<p class="muted">
<b>internal</b> — no PSTN, can still call/receive other extensions and internal ring groups.
<b>restricted</b> — internal, plus only pre-approved US numbers.
<b>full</b> — internal, plus any US number.
Changes apply live, on the next call — no Asterisk restart needed.
</p>
<table id="pstn-table"><thead><tr><th>Ext</th><th>Name</th><th>Tier</th><th>Approved numbers (restricted only)</th><th></th></tr></thead><tbody></tbody></table>
<div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
</div>
</main>
<script>
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
const TABS = ["security", "crowdsec", "pstn"];
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.getElementById("tab-security").style.display = btn.dataset.tab === "security" ? "" : "none";
document.getElementById("tab-crowdsec").style.display = btn.dataset.tab === "crowdsec" ? "" : "none";
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
if (btn.dataset.tab === "pstn") { loadPstnLimits(); loadPstnPermissions(); }
});
});
@@ -880,6 +1153,68 @@ async function banAsn(asn) {
loadDecisions();
}
async function loadPstnLimits() {
const res = await fetch("/api/pstn-limits");
const data = await res.json();
document.getElementById("limit-out").value = data.max_outbound;
document.getElementById("limit-in").value = data.max_inbound;
}
document.getElementById("limits-save").addEventListener("click", async () => {
const maxOut = document.getElementById("limit-out").value;
const maxIn = document.getElementById("limit-in").value;
const res = await fetch("/api/pstn-limits", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({max_outbound: maxOut, max_inbound: maxIn}),
});
const data = await res.json();
document.getElementById("limits-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
loadPstnLimits();
});
async function loadPstnPermissions() {
const res = await fetch("/api/pstn-permissions");
const data = await res.json();
const exts = data.extensions || [];
const tbody = document.querySelector("#pstn-table tbody");
if (!exts.length) {
tbody.innerHTML = '<tr><td colspan=5 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
return;
}
tbody.innerHTML = exts.map(e => `<tr data-ext="${esc(e.ext)}">
<td>${esc(e.ext)}</td>
<td>${esc(e.name)}</td>
<td>
<select class="pstn-tier">
<option value="internal" ${e.tier === "internal" ? "selected" : ""}>internal</option>
<option value="restricted" ${e.tier === "restricted" ? "selected" : ""}>restricted</option>
<option value="full" ${e.tier === "full" ? "selected" : ""}>full</option>
</select>
</td>
<td><input type="text" class="pstn-numbers" value="${esc(e.allowed_numbers)}" placeholder="15551234567,15559876543" ${e.tier === "restricted" ? "" : "disabled"}></td>
<td><button class="action" onclick="savePstnPermission('${esc(e.ext)}')">Save</button></td>
</tr>`).join("");
tbody.querySelectorAll("tr").forEach(row => {
const tierSel = row.querySelector(".pstn-tier");
const numsInput = row.querySelector(".pstn-numbers");
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
});
}
async function savePstnPermission(ext) {
const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`);
const tier = row.querySelector(".pstn-tier").value;
const numbers = row.querySelector(".pstn-numbers").value;
const res = await fetch("/api/pstn-permissions", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers}),
});
const data = await res.json();
document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")";
loadPstnPermissions();
}
const adminUrl = "__ASTERISK_ADMIN_URL__";
if (adminUrl) {
const link = document.getElementById("admin-link");
@@ -928,6 +1263,16 @@ class Handler(BaseHTTPRequestHandler):
for asn, name in get_alert_history_names().items():
known_names.setdefault(asn, name)
self._json({"asns": get_asn_exempt(known_names)})
elif self.path == "/api/pstn-permissions":
perms = get_all_permissions()
extensions = []
for e in list_extensions():
p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": ""})
extensions.append({"ext": e["ext"], "name": e["name"],
"tier": p["tier"], "allowed_numbers": p["allowed_numbers"]})
self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits":
self._json(get_limits())
else:
self._json({"error": "not found"}, 404)
@@ -947,6 +1292,14 @@ class Handler(BaseHTTPRequestHandler):
self._json({"ok": ok, "message": message})
elif self.path == "/api/asn-exempt/ban":
self._json(ban_asn(payload.get("asn", "")))
elif self.path == "/api/pstn-permissions":
ok, message = write_permission(
payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "")
)
self._json({"ok": ok, "message": message})
elif self.path == "/api/pstn-limits":
ok, message = write_limits(payload.get("max_outbound", ""), payload.get("max_inbound", ""))
self._json({"ok": ok, "message": message})
else:
self._json({"error": "not found"}, 404)
+1
View File
@@ -89,6 +89,7 @@ is_installed() {
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;;
ssh-config) false ;; # repeatable management tool, never shows [installed]
*) [ -e "$DOCKER_DIR/$1" ] ;;
esac