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

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