PSTN trunk: 3-tier live permissions + Security Dashboard web UI + dual target

Reworks the outbound permission model from a flat allow-list into three
per-extension tiers (internal / restricted / full), addressing the ask for
extensions that can only reach pre-approved numbers plus extensions with
full US calling, while internal extension-to-extension dialing and ring
groups stay ungated for everyone regardless of tier.

Permissions now live in pstn-permissions.conf, read by the dialplan via
Asterisk's AST_CONFIG() on every call instead of being baked into static
dialplan text - editing that file takes effect on the next call, no
Asterisk restart and no re-running the installer. "update in place" mode
never touches this file (same protection this repo's update-mode
convention already gives .env/firewall/Caddy config); only a "fresh"
reinstall (with confirmation) or the web UI change it.

Adds a "PSTN Trunk" tab to services/security-dashboard.sh: lists every
extension (parsed from pjsip.conf) with its live tier and approved numbers,
editable with no restart - this is what makes the tier model actually
manageable day to day. Extracted the dashboard's systemd-unit writing into
its own function so "update" mode refreshes it too (previously only fresh
installs did), and generalized both the dashboard and the trunk service to
detect either asterisk-digital-ocean or the home/LAN asterisk install.

Inbound ring-group membership now checks each member's tier live per call
via an unrolled per-member dialplan block (full always rings, restricted
only if the caller's number is approved, internal never rings) rather than
a single static Dial() string.

Caught and fixed two real bugs during testing against a sandboxed vendor
copy and a live instance of the (stdlib-only) Python dashboard app:
- Asterisk Goto/GotoIf argument parsing: ring<ext>/skip<ext> are named
  priorities within the same extension (declared via "same => n(label),..."),
  not separate exten => entries, so jumping to them needs the single-argument
  Goto(label) form - the two-argument Goto(label,1) form used initially
  addresses a different, nonexistent extension named "label" instead.
- A security-relevant REGEX() direction issue: the inbound Caller-ID check
  initially interpolated attacker-influenced call data into the PATTERN side
  of a REGEX() match rather than the tested-string side, which would let a
  crafted Caller-ID forge a match against an unrelated approved-numbers
  entry. Fixed by keeping the admin-controlled approved-list as the pattern
  and the live call data as the string being tested, consistently on both
  the outbound and inbound checks.

Verified end-to-end: dialplan/pjsip generation and vendor-file patching
(idempotent, syntax-checked) as before, plus the new permission-file
round-trip between bash and Python, and the dashboard's new API endpoints
exercised against a real running Python server (extension parsing, tier
changes, number normalization, invalid-input rejection, atomic file writes).
This commit is contained in:
Claude
2026-07-21 23:59:50 +00:00
parent 1e2a3743ab
commit 3bd952e55d
4 changed files with 736 additions and 220 deletions
+83 -26
View File
@@ -4,19 +4,47 @@ 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. be picked up in a fresh chat without re-deriving the background.
**Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh **Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh
pstn-trunk` after `asterisk-digital-ocean` is installed). Generic SIP trunk pstn-trunk` after `asterisk-digital-ocean` **or** `asterisk` (home/LAN) is
add-on that defaults to VoIP.ms but isn't hardcoded to it — any provider installed — both are supported, see the file for the static-IP caveat on the
supporting IP authentication works. Covers: IP-authenticated trunk, LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't
US/NANP-only outbound dialplan, a configurable concurrent-call cap (default hardcoded to it — any provider supporting IP authentication works. Covers:
3), **role-based outbound permission** (some extensions internal-only, some
PSTN-enabled — internal intercom dialing is never gated either way), a - IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all.
configurable **inbound ring-group** (one extension or several), **ntfy - A configurable concurrent-call cap (default 3, global not per-extension).
alerts** on denied/rejected calls (immediate) and spend/volume thresholds - **Three-tier per-extension permission model**: `internal` (default — no
(hourly check), and settings persisted to `.pstn-trunk.env` so "update in PSTN at all, but can always call/receive other extensions and internal
place" reapplies everything without re-prompting. That file's own header ring groups), `restricted` (also only pre-approved US numbers, both
comment explains how it survives Easy Asterisk's config regeneration (an directions), `full` (also any US number). Internal extension-to-extension
architectural wrinkle discovered while implementing this — worth reading dialing is *never* gated by any tier.
before touching either file). - **Permissions are live, not baked into the dialplan.** Stored in
`pstn-permissions.conf`, read by the dialplan via Asterisk's
`AST_CONFIG()` on every call — editing that file takes effect on the next
call, no restart, no reinstall. `services/pstn-trunk.sh`'s "update in
place" mode deliberately never touches it (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
lists every extension (parsed from `pjsip.conf`) with its live tier and
approved numbers, editable with no restart. This is what makes the tier
model actually manageable day-to-day instead of needing a reinstall for
every roster change.
- **ntfy alerts** on denied/rejected calls (immediate — permission denied,
number not approved, or 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, cap, ntfy,
rate/thresholds) persist to `.pstn-trunk.env` so "update in place"
reapplies them without re-prompting.
`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 ## Decision so far
- **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off
@@ -104,17 +132,40 @@ separately from that hourly check.
`_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit, `_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit,
auto-prefixed with 1), both routed to the trunk. No catch-all `_X.` auto-prefixed with 1), both routed to the trunk. No catch-all `_X.`
pattern. pattern.
- **Role-based outbound permission — implemented.** A space-separated list - **Three-tier permission model — implemented**, superseding an earlier flat
of extensions allowed to dial PSTN, prompted at install (blank = every allow-list design. `internal` / `restricted` / `full` per extension, read
extension, the original default before roles existed). Baked into the live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked
dialplan as a `REGEX()` check against `${CHANNEL(peername)}` — no changes into the dialplan text — no changes needed to Easy Asterisk's own
needed to Easy Asterisk's own per-device pjsip.conf sections, since the per-device pjsip.conf sections, since the gate lives entirely in files
gate lives entirely in code this repo already owns. Internal this repo already owns. Internal extension-to-extension dialing is never
extension-to-extension dialing is never gated by this, regardless of PSTN gated by any tier — only the two NANP patterns (outbound) and the
permission — only the two NANP patterns above are. 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 - **Inbound ring-group — implemented.** A space-separated list of
extensions to ring for inbound calls (one, or several for a ring group via extensions to ring for inbound calls (one, or several for a ring group via
`Dial(PJSIP/a&PJSIP/b,20)`), prompted at install. `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): - Provider-specific setup that isn't scriptable (user does this manually):
create the account, order a DID, decide pay-per-minute vs. unlimited DID 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 plan and whether to add E911, pick a server/POP, fund the prepaid balance
@@ -154,10 +205,16 @@ separately from that hourly check.
2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored. 2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored.
3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. 3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`.
4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group 4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group
supported), prompted at install time. ~~Role-based outbound permission~~ supported), each checked live per-call against its own tier. ~~Permission
Done — space-separated allow-list, blank = everyone. Still unresolved: model~~ Done — superseded the original flat allow-list with a 3-tier
pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side based on model (internal/restricted/full) managed live via
real expected volume, and decide on E911 (see cost estimate). `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 — configurable, default 3, global not 5. ~~Concurrent-call cap~~ Done — configurable, default 3, global not
per-extension. ~~Spend/volume alert~~ Done — ntfy, hourly threshold + per-extension. ~~Spend/volume alert~~ Done — ntfy, hourly threshold +
burst check, plus immediate alerts on denied/rejected calls. burst check, plus immediate alerts on denied/rejected calls.
+343 -148
View File
@@ -1,25 +1,30 @@
#!/bin/bash #!/bin/bash
# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean: # services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean
# US-only outbound (NANP dialplan restriction), a configurable concurrent-call # (or the home/LAN asterisk install): US-only outbound (NANP dialplan
# cap, role-based outbound permission (some extensions internal-only, some # restriction), a configurable concurrent-call cap, a 3-tier permission model
# PSTN-enabled), a configurable inbound ring-group, IP-authenticated trunk # per extension (internal-only / restricted to pre-approved numbers / full US
# (no SIP password stored), ntfy alerts on denied/rejected calls, and a # calling), a configurable inbound ring-group, IP-authenticated trunk (no SIP
# periodic spend/volume check. # password stored), ntfy alerts on denied/rejected calls, and a periodic
# spend/volume check.
# #
# Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/ # 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 # cost background this is built from) but isn't hardcoded to it — any SIP
# trunk provider that supports IP authentication works the same way. # trunk provider that supports IP authentication works the same way.
# #
# Requires an existing services/asterisk-digital-ocean.sh install — this adds # Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh
# a PSTN trunk on top of it and does not stand alone. # install — this adds a PSTN trunk on top of one of them and does not stand
# alone. Permission tiers are managed live (no restart needed) via
# pstn-permissions.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). # Part of the modular post-install system (sourced by setup.sh).
register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean — US-only, role-based permissions, spend/volume alerts (defaults to VoIP.ms)" 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 ────────────────────────────────── # ── Surviving Easy Asterisk's regeneration ──────────────────────────────────
# Easy Asterisk (the vendor project asterisk-digital-ocean.sh builds on) fully # Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh
# OVERWRITES both pjsip.conf and extensions.conf from its own internal state: # 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, # - extensions.conf: rebuilt by rebuild_dialplan() on every container start,
# and whenever a device/room is added or removed via the web admin. # and whenever a device/room is added or removed via the web admin.
# - pjsip.conf: rewritten by generate_pjsip_conf() whenever VLAN/domain/TLS # - pjsip.conf: rewritten by generate_pjsip_conf() whenever VLAN/domain/TLS
@@ -34,11 +39,28 @@ register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean
# technique this repo already uses for the logger.conf security-logging fix # technique this repo already uses for the logger.conf security-logging fix
# in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). # in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh).
# #
# Caveat: if the base asterisk-digital-ocean install is later refreshed # Caveat: if the base asterisk-digital-ocean/asterisk install is later
# ("update in place", which re-copies fresh vendor files) independently of # refreshed ("update in place", which re-copies fresh vendor files)
# this service, the patch is wiped along with it and needs reapplying — run # independently of this service, the patch is wiped along with it and needs
# this service again (fresh or update mode both reapply it) after any # reapplying — run this service again (fresh or update mode both reapply it)
# asterisk-digital-ocean update. # 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 ──────── # ── Shared: patch vendor generator functions to #include our config ────────
# Anchors on "user_agent=EasyAsterisk" (pjsip.conf's [global] section) and # Anchors on "user_agent=EasyAsterisk" (pjsip.conf's [global] section) and
@@ -49,11 +71,13 @@ _pstn_patch_vendor_files() {
local EA_DIR="$1" local EA_DIR="$1"
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
local EASY1="$EA_DIR/easy-asterisk.sh" local EASY1="$EA_DIR/easy-asterisk.sh"
local EASY2="$EA_DIR/easy-asterisk-v0.10.0.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 local f
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || { log_error "$f not found — is asterisk-digital-ocean fully installed?"; return 1; } [[ -f "$f" ]] || { log_error "$f not found — is the base Asterisk install fully set up?"; return 1; }
done done
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
@@ -120,6 +144,36 @@ 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_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 ─────────────────────────────────────── # ── Shared: outbound/inbound dialplan ───────────────────────────────────────
# Continues in the [intercom] context established just above this include # Continues in the [intercom] context established just above this include
# (rebuild_dialplan() writes "[intercom]" then this #include right after it), # (rebuild_dialplan() writes "[intercom]" then this #include right after it),
@@ -127,10 +181,10 @@ EOF
# below is a separate context, for calls arriving from the trunk. # below is a separate context, for calls arriving from the trunk.
# #
# Role model: internal intercom dialing (extension-to-extension) is NEVER # Role model: internal intercom dialing (extension-to-extension) is NEVER
# gated here — everyone keeps that, regardless of PSTN permission. Only the # gated here — everyone keeps that, regardless of PSTN tier. Only the two
# two NANP patterns (the trunk route) are gated by ALLOWED_REGEX. An empty # NANP patterns (the trunk route) and the inbound ring-group are gated, both
# allow-list at install time becomes ".*" (match anything), preserving # via a LIVE read of pstn-permissions.conf (see the file-level comment above
# "every extension can dial out" as the explicit opt-in default. # 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) # 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 # for the usage-alert script — not Asterisk's own CDR, to avoid depending on
@@ -138,11 +192,15 @@ EOF
# CDR CSV's comma-quoting entirely (our own pipe-delimited format has no # CDR CSV's comma-quoting entirely (our own pipe-delimited format has no
# embedded-delimiter risk since every field here is digits/hostnames). # embedded-delimiter risk since every field here is digits/hostnames).
_pstn_write_dialplan_include() { _pstn_write_dialplan_include() {
local FILE="$1" DID="$2" ALLOWED_REGEX="$3" MAX_CONCURRENT="$4" RING_DIAL="$5" NTFY_URL="$6" local FILE="$1" DID="$2" MAX_CONCURRENT="$3" RING_EXTS="$4" NTFY_URL="$5"
cat > "$FILE" << 'EOF' cat > "$FILE" << 'EOF'
; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, role-based ; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, tiered
; outbound permission. Regenerated by services/pstn-trunk.sh — edit there, ; permissions (internal / restricted / full) read LIVE from
; not here directly, or a reinstall/update will overwrite this. ; pstn-permissions.conf via AST_CONFIG() — edit permissions 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-permissions.conf itself is
; 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 ; 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 ; to the trunk, so an unauthorized or compromised extension can't reach
@@ -151,15 +209,24 @@ _pstn_write_dialplan_include() {
exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN})
same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) same => n,Set(PSTN_CALLER=${CHANNEL(peername)})
same => n,GotoIf($[${REGEX("^(__PSTN_ALLOWED_REGEX__)$" ${PSTN_CALLER})} = 1]?pstn_check_busy,1) same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)})
same => n,NoOp(Denied - ${PSTN_CALLER} is not authorized for PSTN outbound) same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1)
__ALERT_DENY_LINE__ 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,Busy(15)
same => n,Hangup() same => n,Hangup()
exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1) exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1)
same => n,Goto(1${EXTEN},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,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCURRENT__]?pstn_busy,1) exten => pstn_check_busy,1,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCURRENT__]?pstn_busy,1)
same => n,Set(GROUP()=pstn-out) same => n,Set(GROUP()=pstn-out)
same => n,Set(CALLERID(num)=__PSTN_DID__) same => n,Set(CALLERID(num)=__PSTN_DID__)
@@ -173,29 +240,88 @@ exten => pstn_busy,1,NoOp(PSTN trunk - concurrent-call cap reached, rejecting)
__ALERT_BUSY_LINE__ __ALERT_BUSY_LINE__
same => n,Busy(15) same => n,Busy(15)
same => n,Hangup() same => n,Hangup()
[from-pstn-trunk]
exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)})
same => n,Set(PSTN_START=${EPOCH})
same => n,Dial(__PSTN_RING_DIAL__,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()
EOF EOF
sed -i "s/__PSTN_ALLOWED_REGEX__/${ALLOWED_REGEX}/g; s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE" sed -i "s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE"
# RING_DIAL is "PJSIP/a&PJSIP/b&..." — the literal "&" must be escaped in
# a sed replacement (bare "&" means "the matched text", same gotcha as
# NTFY_URL below), or every "&" gets replaced with the placeholder itself.
local _esc_ring_dial="${RING_DIAL//&/\\&}"
sed -i "s#__PSTN_RING_DIAL__#${_esc_ring_dial}#g" "$FILE"
if [[ -n "$NTFY_URL" ]]; then if [[ -n "$NTFY_URL" ]]; then
local _esc_url="${NTFY_URL//&/\\&}" local _esc_url="${NTFY_URL//&/\\&}"
sed -i "s#__ALERT_DENY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - extension not authorized.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" 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: concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
else else
sed -i "/__ALERT_DENY_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE"
fi fi
# ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member
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_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()
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"
else
sed -i "/__ALERT_DENY_INBOUND_LINE__/d" "$FILE"
fi
}
# ── 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) ──────────── # ── Shared: periodic spend/volume checker (run hourly via cron) ────────────
@@ -251,31 +377,20 @@ EOF
chmod 755 "$FILE" chmod 755 "$FILE"
} }
# ── Shared: apply everything from a settings set (used by fresh + update) ── # ── 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() { _pstn_apply_settings() {
local EA_DIR="$1" ASTERISK_DIR="$2" local EA_DIR="$1" ASTERISK_DIR="$2"
local SERVER="$3" SERVER_IP="$4" DID="$5" ALLOWED_EXTS="$6" MAX_CONCURRENT="$7" local SERVER="$3" SERVER_IP="$4" DID="$5" MAX_CONCURRENT="$6"
local RING_EXTS="$8" NTFY_URL="$9" RATE="${10}" MONTH_THRESHOLD="${11}" BURST_THRESHOLD="${12}" local RING_EXTS="$7" NTFY_URL="$8" RATE="$9" MONTH_THRESHOLD="${10}" BURST_THRESHOLD="${11}"
local PROVIDER_NAME="${13}" local PROVIDER_NAME="${12}"
local ALLOWED_REGEX
if [[ -z "$ALLOWED_EXTS" ]]; then
ALLOWED_REGEX=".*"
else
ALLOWED_REGEX="$(echo "$ALLOWED_EXTS" | tr -s ' ' '|')"
fi
local RING_DIAL="" _ext
for _ext in $RING_EXTS; do
[[ -n "$RING_DIAL" ]] && RING_DIAL="${RING_DIAL}&"
RING_DIAL="${RING_DIAL}PJSIP/${_ext}"
done
_pstn_patch_vendor_files "$EA_DIR" || return 1 _pstn_patch_vendor_files "$EA_DIR" || return 1
mkdir -p "$ASTERISK_DIR" 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_IP" "$DID"
_pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$ALLOWED_REGEX" "$MAX_CONCURRENT" "$RING_DIAL" "$NTFY_URL" _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$MAX_CONCURRENT" "$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" _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" ensure_docker_dir_ownership "$ASTERISK_DIR"
chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf"
@@ -285,7 +400,6 @@ PROVIDER_NAME=${PROVIDER_NAME}
TRUNK_SERVER=${SERVER} TRUNK_SERVER=${SERVER}
TRUNK_SERVER_IP=${SERVER_IP} TRUNK_SERVER_IP=${SERVER_IP}
TRUNK_DID=${DID} TRUNK_DID=${DID}
PSTN_ALLOWED_EXTS=${ALLOWED_EXTS}
MAX_CONCURRENT=${MAX_CONCURRENT} MAX_CONCURRENT=${MAX_CONCURRENT}
RING_EXTS=${RING_EXTS} RING_EXTS=${RING_EXTS}
NTFY_URL=${NTFY_URL} NTFY_URL=${NTFY_URL}
@@ -308,41 +422,64 @@ CRON
install_pstn-trunk() { install_pstn-trunk() {
require_docker || return 1 require_docker || return 1
local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" 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 ASTERISK_DIR="$EA_DIR/config/asterisk"
local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf" local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf"
local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf"
local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf"
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" 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 if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would require an existing asterisk-digital-ocean install at $EA_DIR" 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," echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname, DID,"
echo "[DRY-RUN] DID, extensions allowed to dial out (blank=all), max concurrent calls (default 3)," echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers,"
echo "[DRY-RUN] extensions to ring inbound (space-separated, ring-group supported)," echo "[DRY-RUN] max concurrent calls (default 3), inbound ring-group extensions,"
echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds" 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 for inbound call matching"
echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config"
echo "[DRY-RUN] Would write $PJSIP_INCLUDE, $DIALPLAN_INCLUDE, and an hourly usage-alert script + cron.d entry" echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf (fresh install only),"
echo "[DRY-RUN] Would offer 'update in place' (reads settings back from $SETTINGS_FILE) instead of a fresh install if already configured" echo "[DRY-RUN] 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) instead of a fresh install if already configured"
echo "[DRY-RUN] Would restart the asterisk container to apply" echo "[DRY-RUN] Would restart the asterisk container to apply"
return 0 return 0
fi fi
if [[ ! -f "$EA_DIR/docker-compose.yml" ]]; then if [[ -z "$EA_DIR" ]]; then
log_error "asterisk-digital-ocean isn't installed at $EA_DIR — install it first:" log_error "Neither asterisk-digital-ocean nor asterisk (LAN) is installed — install one first:"
log_error " sudo ./setup.sh asterisk-digital-ocean" log_error " sudo ./setup.sh asterisk-digital-ocean (recommended — public droplet, static IP)"
log_error "This service adds a PSTN trunk on top of it; it doesn't stand alone." 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 return 1
fi fi
log_info "Configuring a SIP PSTN trunk for asterisk-digital-ocean (defaults to VoIP.ms)." if [[ "$ASTERISK_KIND" == "asterisk" ]]; then
log_info "US-only outbound (NANP dialplan), a concurrent-call cap, role-based outbound permission," echo ""
log_info "an inbound ring-group, and ntfy alerts on denied/rejected calls plus spend/volume checks." 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 "" echo ""
log_warning "Before continuing, on your provider's side you should already have: created an" 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 "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 "Area -> Balance Management), ordered a DID with IP authentication pointed at this"
log_warning "droplet's public IP, and picked a server/POP. Also restrict outbound routing to" 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 "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 "independent layer, not a substitute for the first."
log_warning "See docs/pstn-calling-voipms-plan.md for the full background." log_warning "See docs/pstn-calling-voipms-plan.md for the full background."
@@ -359,12 +496,14 @@ install_pstn-trunk() {
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$SETTINGS_FILE" source "$SETTINGS_FILE"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$MAX_CONCURRENT" \
"$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
( cd "$EA_DIR" && docker compose restart asterisk ) \ ( cd "$EA_DIR" && docker compose restart asterisk ) \
&& log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \ && 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_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk"
log_info "pstn-permissions.conf was NOT touched — edit it directly, via the Security"
log_info "Dashboard, or choose FRESH reinstall to reset it."
return 0 return 0
else else
log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)."
@@ -375,6 +514,17 @@ install_pstn-trunk() {
return 0 return 0
;; ;;
fresh) fresh)
if [[ -f "$PERMISSIONS_FILE" ]]; then
log_warning "pstn-permissions.conf already exists and may have been edited since"
log_warning "(directly, or via the Security Dashboard). A fresh reinstall OVERWRITES it"
log_warning "with whatever you enter below."
local _confirm_reset=""
prompt_yn "Continue and reset permission tiers? (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." log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch."
;; ;;
esac esac
@@ -386,7 +536,7 @@ install_pstn-trunk() {
prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME
local TRUNK_SERVER="" local TRUNK_SERVER=""
prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this droplet from your provider's server list):" "" 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 if [[ -z "$TRUNK_SERVER" ]]; then
log_error "A server hostname is required — aborting." log_error "A server hostname is required — aborting."
return 1 return 1
@@ -412,17 +562,36 @@ install_pstn-trunk() {
return 1 return 1
fi fi
# ── Permission tiers ───────────────────────────────────────────────────
echo "" echo ""
echo " Role model: EVERY extension can always call/receive calls from other" echo " Three tiers, per extension:"
echo " Asterisk extensions (internal intercom dialing is never restricted" echo " internal — call/receive other Asterisk extensions + internal ring"
echo " here). The setting below only controls PSTN (real phone number)" echo " groups only. No PSTN at all. Default for anything not"
echo " access — extensions left out behave exactly as they do today." echo " listed below."
local PSTN_ALLOWED_EXTS="" echo " restricted — internal, PLUS call/receive ONLY pre-approved US numbers."
prompt_text "Extensions allowed to dial PSTN numbers (space-separated, e.g. '1001 1002'; blank = every extension):" "" PSTN_ALLOWED_EXTS echo " full — internal, PLUS call/receive ANY US number."
if [[ -z "$PSTN_ALLOWED_EXTS" ]]; then echo " These are managed LIVE after install (pstn-permissions.conf) — via the"
log_info "No restriction entered — every extension will be able to dial PSTN numbers." echo " Security Dashboard web UI if installed, or by hand — with no restart or"
else echo " reinstall needed to change them later."
log_info "Only these extensions may dial PSTN numbers: $PSTN_ALLOWED_EXTS" 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 fi
local MAX_CONCURRENT="" local MAX_CONCURRENT=""
@@ -432,8 +601,10 @@ install_pstn-trunk() {
MAX_CONCURRENT=3 MAX_CONCURRENT=3
fi fi
local _suggested_ring
_suggested_ring="$(echo "$FULL_EXTS $RESTRICTED_EXTS" | xargs)"
local RING_EXTS="" local RING_EXTS=""
prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one extension, or several for a ring group):" "" 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 if [[ -z "$RING_EXTS" ]]; then
log_error "At least one extension is required for inbound routing — aborting." log_error "At least one extension is required for inbound routing — aborting."
return 1 return 1
@@ -441,7 +612,7 @@ install_pstn-trunk() {
echo "" echo ""
local WANT_NTFY="" local WANT_NTFY=""
prompt_yn "Send an ntfy alert when a call is denied (unauthorized extension) or rejected (concurrency cap hit)? (y/n):" "y" 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="" local NTFY_URL=""
if [[ "$WANT_NTFY" =~ ^[Yy]$ ]]; then if [[ "$WANT_NTFY" =~ ^[Yy]$ ]]; then
# Prefer a locally-installed ntfy's own base-url as the default, same # Prefer a locally-installed ntfy's own base-url as the default, same
@@ -473,23 +644,26 @@ install_pstn-trunk() {
prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" 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" \ _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$MAX_CONCURRENT" \
"$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
# No new firewall rules: asterisk-digital-ocean.sh already opens SIP _pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}"
# (5060/5061) and RTP (10000-20000) to the internet, and providers' source ensure_docker_dir_ownership "$ASTERISK_DIR"
# IPs vary by POP/redundancy, so there's no single IP to scope this to
# even if narrowing it were otherwise worthwhile.
# ── Docs (separate file — asterisk-digital-ocean already owns README.md # No new firewall rules: the base install already opens SIP (5060/5061)
# in this same directory via write_readme, so don't overwrite it) ─────── # 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" local DOC_FILE="$EA_DIR/README-pstn-trunk.md"
cat > "$DOC_FILE" << MD cat > "$DOC_FILE" << MD
# SIP PSTN trunk (add-on to asterisk-digital-ocean) # SIP PSTN trunk (add-on to $ASTERISK_KIND)
US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works
with any IP-authenticated provider), role-based outbound permission, a with any IP-authenticated provider), per-extension permission tiers, a
configurable concurrent-call cap, and an inbound ring-group. See configurable concurrent-call cap, and an inbound ring-group. See
\`docs/pstn-calling-voipms-plan.md\` in the repo for the full design \`docs/pstn-calling-voipms-plan.md\` in the repo for the full design
background, cost estimate, and toll-fraud reasoning. background, cost estimate, and toll-fraud reasoning.
@@ -502,47 +676,63 @@ background, cost estimate, and toll-fraud reasoning.
| Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) | | Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) |
| DID | ${TRUNK_DID} | | DID | ${TRUNK_DID} |
| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all |
| PSTN-allowed extensions | ${PSTN_ALLOWED_EXTS:-all extensions} | | Full-PSTN extensions | ${FULL_EXTS:-none} |
| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} |
| Concurrency cap | ${MAX_CONCURRENT} simultaneous outbound calls | | Concurrency cap | ${MAX_CONCURRENT} simultaneous outbound calls |
| Inbound rings | ${RING_EXTS} | | Inbound ring-group | ${RING_EXTS} |
| ntfy alerts | ${NTFY_URL:-disabled} | | ntfy alerts | ${NTFY_URL:-disabled} |
| Estimated rate | \$${RATE_PER_MIN}/min | | Estimated rate | \$${RATE_PER_MIN}/min |
| Monthly spend alert threshold | \$${MONTH_THRESHOLD} | | Monthly spend alert threshold | \$${MONTH_THRESHOLD} |
| Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour | | Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour |
## Role model ## Permission tiers
Every extension can always call and receive calls from other Asterisk Every extension can always call and receive calls from other Asterisk
extensions — that's unchanged and never gated. The PSTN-allowed list above extensions, and join internal ring groups — that's unchanged and never
only controls the two additional things a "PSTN-enabled" extension gets on gated by anything below. Three tiers control PSTN (real phone number)
top of that: dialing real phone numbers out, and being included in the access specifically:
inbound ring-group. Leaving the allow-list blank means every extension gets
PSTN access too (the original default before roles existed). - **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.
## How this survives Easy Asterisk's own regeneration ## How this survives Easy Asterisk's own regeneration
Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own
internal state (device list, network settings) rather than treating them as internal state (device list, network settings) rather than treating them as
hand-edited files. Trunk/dialplan config here lives in two files of its own, hand-edited files. Trunk/dialplan config here lives in files of its own,
\`#include\`'d from the generated files: \`#include\`'d from the generated files:
- \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ - \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/
\`endpoint\` sections (IP-authenticated, no password stored). \`endpoint\` sections (IP-authenticated, no password stored).
- \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing, - \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing,
the outbound permission gate, the concurrency cap, ntfy alert hooks, and the concurrency cap, ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound
the \`[from-pstn-trunk]\` inbound context. context. Reads permission tiers live from \`pstn-permissions.conf\` (above)
rather than baking them in, specifically so they can change without
touching this file.
The \`#include\` lines themselves are patched into Easy Asterisk's *generator The \`#include\` lines themselves are patched into Easy Asterisk's *generator
functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, and its versioned
\`easy-asterisk-v0.10.0.sh\`) so they get re-emitted every time those functions copy) so they get re-emitted every time those functions regenerate the
regenerate the config, instead of being wiped. config, instead of being wiped.
**Caveat:** if the base \`asterisk-digital-ocean\` service is ever updated **Caveat:** if the base $ASTERISK_KIND service is ever updated independently
independently (\`sudo ./setup.sh asterisk-digital-ocean\`, choosing "update in (\`sudo ./setup.sh $ASTERISK_KIND\`, choosing "update in place" — that path
place" — that path re-copies fresh vendor files), this patch is wiped along re-copies fresh vendor files), this patch is wiped along with it. Re-run
with it. Re-run \`sudo ./setup.sh pstn-trunk\` afterward (update mode \`sudo ./setup.sh pstn-trunk\` afterward (update mode reapplies the patch and
reapplies the patch and rewrites everything from \`.pstn-trunk.env\`, no rewrites structural settings from \`.pstn-trunk.env\`, no re-prompting, and
re-prompting). without touching \`pstn-permissions.conf\`).
## Spend/volume alerts ## Spend/volume alerts
@@ -559,27 +749,30 @@ comma-quoting). It sends an ntfy alert:
calls/hour — this is the faster tripwire for a burst/abuse scenario, calls/hour — this is the faster tripwire for a burst/abuse scenario,
independent of whether it's crossed the monthly dollar threshold yet. independent of whether it's crossed the monthly dollar threshold yet.
Separately, denied calls (unauthorized extension) and rejected calls Separately, denied calls (no permission / number not pre-approved) and
(concurrency cap hit) alert **immediately**, not on the hourly schedule — rejected calls (concurrency cap hit) alert **immediately**, not on the
see the dialplan file's \`__ALERT_DENY_LINE__\`/\`__ALERT_BUSY_LINE__\` sites. hourly schedule.
These are cost *estimates* (call count/duration × your entered rate), not These are cost *estimates* (call count/duration × your entered rate), not
real billing data — treat them as a safety net, not a substitute for real billing data — treat them as a safety net, not a substitute for
checking your provider's own balance/usage dashboard. checking your provider's own balance/usage dashboard.
## Changing settings ## Managing this from a web UI
Re-run \`sudo ./setup.sh pstn-trunk\` and choose "reinstall in place" — If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab
current settings are read from \`.pstn-trunk.env\` and reapplied exactly, lists every known extension (parsed from \`pjsip.conf\`) with its current
including regenerating the usage-alert script and cron entry. Choose "full tier and approved-numbers list, editable live — no restart, no reinstall.
install" instead to re-prompt for everything. Install/update it any time with \`sudo ./setup.sh security-dashboard\`; it
auto-detects this install.
## Manual edits ## Manual edits
Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` / 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 \`pstn-trunk-usage-alert.sh\` directly if you plan to re-run this installer
later — it overwrites all three unconditionally from \`.pstn-trunk.env\`. For later — it overwrites all three unconditionally from \`.pstn-trunk.env\` on
one-off testing, restart the container instead of running the installer: both fresh and update. \`pstn-permissions.conf\` is different — see
"Permission tiers" above, it's safe to hand-edit any time. For one-off
testing, restart the container instead of running the installer:
\`\`\`bash \`\`\`bash
docker compose -f $EA_DIR/docker-compose.yml restart asterisk docker compose -f $EA_DIR/docker-compose.yml restart asterisk
@@ -588,16 +781,17 @@ docker compose -f $EA_DIR/docker-compose.yml restart asterisk
## Verifying it's working ## Verifying it's working
\`\`\`bash \`\`\`bash
docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint pstn-trunk" docker exec -it $CONTAINER_NAME asterisk -rx "pjsip show endpoint pstn-trunk"
docker exec -it easy-asterisk-do asterisk -rx "dialplan show intercom" docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show intercom"
docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-pstn-trunk" docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show from-pstn-trunk"
tail -f $EA_DIR/logs/pstn-trunk-calls.log tail -f $EA_DIR/logs/pstn-trunk-calls.log
\`\`\` \`\`\`
A PSTN-allowed device should be able to dial a 10-digit or 11-digit US A full-tier device should be able to dial a 10-digit or 11-digit US number
number and reach the trunk; a non-allowed device should get a busy signal and reach the trunk; a restricted-tier device should only reach numbers on
(and an ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from outside its approved list; an internal-tier device should get a busy signal (and an
should ring: ${RING_EXTS}. ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from an approved/any US
number (depending on tier) should ring: ${RING_EXTS}.
MD MD
chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true
@@ -617,12 +811,13 @@ MD
echo "" echo ""
log_success "PSTN trunk configured." log_success "PSTN trunk configured."
echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)"
echo " DID: $TRUNK_DID" echo " DID: $TRUNK_DID"
echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls" echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls"
echo " PSTN-allowed: ${PSTN_ALLOWED_EXTS:-all extensions}" echo " Full-PSTN extensions: ${FULL_EXTS:-none}"
echo " Inbound rings: $RING_EXTS" echo " Restricted extensions: ${RESTRICTED_EXTS:-none}"
echo " ntfy alerts: ${NTFY_URL:-disabled}" echo " Inbound ring-group: $RING_EXTS"
echo " Docs: $DOC_FILE" echo " ntfy alerts: ${NTFY_URL:-disabled}"
echo " Docs: $DOC_FILE"
echo "" echo ""
} }
+309 -45
View File
@@ -69,27 +69,40 @@ register_service security-dashboard homelab "Security dashboard: Asterisk failed
install_security-dashboard() { install_security-dashboard() {
local APP_DIR="/opt/security-dashboard" local APP_DIR="/opt/security-dashboard"
local DASHBOARD_PORT=8092 local DASHBOARD_PORT=8092
local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs"
local SVC_USER="secdash" 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="" 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 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}" [ -n "$_ea_domain" ] && ASTERISK_ADMIN_URL="https://${_ea_domain}"
fi fi
echo "" echo ""
echo "┌─────────────────────────────────────────────────────────────────┐" echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ SECURITY DASHBOARD │" echo "│ SECURITY DASHBOARD │"
echo "│ Asterisk failed-connection log + CrowdSec decisions, one page. │" echo "│ Asterisk failed-connection log + CrowdSec decisions + PSTN │"
echo "│ Runs natively on the host (not Docker) so it can call cscli and │" echo "│ trunk permissions, one page. Runs natively on the host (not │"
echo "│ read Asterisk's security log directly. Authelia-protected. │" echo "│ Docker) so it can call cscli and read Asterisk's files │"
echo "│ directly. Authelia-protected. │"
echo "└─────────────────────────────────────────────────────────────────┘" echo "└─────────────────────────────────────────────────────────────────┘"
echo "" echo ""
if [ ! -d "$ASTERISK_LOG_DIR" ]; then if [ -z "$ASTERISK_EA_DIR" ]; then
log_warning "No asterisk-digital-ocean install detected at $ASTERISK_LOG_DIR." log_warning "No asterisk-digital-ocean or asterisk install detected."
log_warning "The Security Log tab will just be empty — CrowdSec's tab still works fine." log_warning "The Security Log and PSTN Trunk tabs will just be empty — CrowdSec's tab still works fine."
fi fi
if [ "$DRY_RUN" = true ]; then 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 $APP_DIR/app.py"
echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl only)" 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 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" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for"
return 0 return 0
fi fi
@@ -112,9 +126,11 @@ install_security-dashboard() {
} }
case "$MODE" in case "$MODE" in
update) 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_app "$APP_DIR"
_secdash_write_sudoers "$SVC_USER" _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 \ systemctl restart security-dashboard 2>/dev/null \
&& log_success "security-dashboard restarted" \ && log_success "security-dashboard restarted" \
|| log_warning "Restart failed — check: systemctl status security-dashboard" || log_warning "Restart failed — check: systemctl status security-dashboard"
@@ -142,45 +158,14 @@ install_security-dashboard() {
log_success "Created system user $SVC_USER" log_success "Created system user $SVC_USER"
fi fi
# Read access to the Asterisk security log without running as root or the _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR"
# 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
mkdir -p "$APP_DIR" mkdir -p "$APP_DIR"
_secdash_write_app "$APP_DIR" _secdash_write_app "$APP_DIR"
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
_secdash_write_sudoers "$SVC_USER" _secdash_write_sudoers "$SVC_USER"
_secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL"
# ── 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
systemctl daemon-reload systemctl daemon-reload
systemctl enable security-dashboard >/dev/null 2>&1 systemctl enable security-dashboard >/dev/null 2>&1
@@ -221,6 +206,12 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
- **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP - **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
where you don't want to wait for it to misbehave again. where you don't want to wait for it to misbehave again.
- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — every
known extension (parsed from \`pjsip.conf\`) with its current permission
tier (internal / restricted / full) and, for restricted, its approved
numbers, editable live — no Asterisk restart, no reinstall. Writes
directly to \`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). - Link to the Asterisk web admin itself (doesn't embed it, just links out).
## Manage ## Manage
@@ -257,6 +248,77 @@ README_MD
echo "" 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- # 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 # 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 # list (no shell=True anywhere), so there's no shell-metachar injection
@@ -473,6 +535,7 @@ _secdash_write_app() {
Stdlib only, deliberately — this runs on a small droplet alongside Asterisk, 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. Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead.
""" """
import configparser
import json import json
import os import os
import re import re
@@ -482,6 +545,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "")
ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "") ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "")
ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "")
ASN_SCENARIO_FILES = [ ASN_SCENARIO_FILES = [
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
"/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml", "/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml",
@@ -493,6 +557,11 @@ ASN_FILTER_RE = re.compile(r"ASNNumber in \[([^\]]*)\]\)")
ID_RE = re.compile(r"^\d+$") ID_RE = re.compile(r"^\d+$")
ASN_RE = re.compile(r"^\d+$") ASN_RE = re.compile(r"^\d+$")
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") 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): def parse_security_log(limit=200):
@@ -711,6 +780,130 @@ 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"
INDEX_HTML = """<!doctype html> INDEX_HTML = """<!doctype html>
<html><head><meta charset="utf-8"> <html><head><meta charset="utf-8">
<title>Security Dashboard</title> <title>Security Dashboard</title>
@@ -744,6 +937,7 @@ INDEX_HTML = """<!doctype html>
<nav> <nav>
<button class="tab-btn active" data-tab="security">Security Log</button> <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="crowdsec">CrowdSec</button>
<button class="tab-btn" data-tab="pstn">PSTN Trunk</button>
</nav> </nav>
<a id="admin-link" href="#" target="_blank" style="display:none">Asterisk Web Admin &#8599;</a> <a id="admin-link" href="#" target="_blank" style="display:none">Asterisk Web Admin &#8599;</a>
</header> </header>
@@ -770,16 +964,30 @@ INDEX_HTML = """<!doctype html>
<div id="msg"></div> <div id="msg"></div>
</div> </div>
</div> </div>
<div id="tab-pstn" style="display:none">
<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> </main>
<script> <script>
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); } function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
const TABS = ["security", "crowdsec", "pstn"];
document.querySelectorAll(".tab-btn").forEach(btn => { document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
document.getElementById("tab-security").style.display = btn.dataset.tab === "security" ? "" : "none"; TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
document.getElementById("tab-crowdsec").style.display = btn.dataset.tab === "crowdsec" ? "" : "none"; if (btn.dataset.tab === "pstn") loadPstnPermissions();
}); });
}); });
@@ -880,6 +1088,49 @@ async function banAsn(asn) {
loadDecisions(); loadDecisions();
} }
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__"; const adminUrl = "__ASTERISK_ADMIN_URL__";
if (adminUrl) { if (adminUrl) {
const link = document.getElementById("admin-link"); const link = document.getElementById("admin-link");
@@ -928,6 +1179,14 @@ class Handler(BaseHTTPRequestHandler):
for asn, name in get_alert_history_names().items(): for asn, name in get_alert_history_names().items():
known_names.setdefault(asn, name) known_names.setdefault(asn, name)
self._json({"asns": get_asn_exempt(known_names)}) 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})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
@@ -947,6 +1206,11 @@ class Handler(BaseHTTPRequestHandler):
self._json({"ok": ok, "message": message}) self._json({"ok": ok, "message": message})
elif self.path == "/api/asn-exempt/ban": elif self.path == "/api/asn-exempt/ban":
self._json(ban_asn(payload.get("asn", ""))) 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})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
+1 -1
View File
@@ -89,7 +89,7 @@ is_installed() {
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; 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" ] ;; 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" ] ;; 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] ssh-config) false ;; # repeatable management tool, never shows [installed]
*) [ -e "$DOCKER_DIR/$1" ] ;; *) [ -e "$DOCKER_DIR/$1" ] ;;
esac esac