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:
@@ -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
@@ -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
@@ -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 ↗</a>
|
<a id="admin-link" href="#" target="_blank" style="display:none">Asterisk Web Admin ↗</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 => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user