Rework PSTN permissions to one whitelist plus a direction mode

The previous commit gave each extension two independent lists — numbers it
may dial and caller IDs that may reach it. That was more than asked for: the
whitelist is one set of numbers per extension, and what varies is which
direction(s) it constrains.

pstn-permissions.conf now has an authored pair, 'restrict' and
'allowed_numbers', where restrict is one of:

  none  no PSTN at all           open  unrestricted both ways
  out   may only dial the list   in    may only be called by the list
  both  the list applies both ways

tier_out/allowed_out/tier_in/allowed_in are now derived from that pair rather
than authored directly, and remain what the dialplan reads — so the dialplan
is unchanged from the previous commit and stays tested. 'tier' still mirrors
tier_out for rollback. Keeping the compiled keys means the file has one place
a human edits and one place Asterisk reads, which is the same authored/
compiled split a named-number-list feature would need later.

The migration handles both prior shapes: a genuinely legacy single-tier file
(full becomes open, restricted becomes both — reproducing what the old
dialplan did), and the short-lived two-list shape from the previous commit
(inferred back to a mode, preferring the more restrictive reading). Still
idempotent, still backs up first.

The dashboard drops from two dropdowns and two fields to one of each, with
the whitelist greyed out for the modes that don't use one, and the PSTN
column sorting by how much reach a mode grants rather than alphabetically.

Verified: migration from both shapes; the dashboard round-trip writing
restrict=in with the list compiled to allowed_in only; and the group-ring
helper across all four modes — Restrict inbound rings only for a whitelisted
caller, Restrict outbound rings for everyone, Restrict both rings only for
its own list, No PSTN never rings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
This commit is contained in:
Claude
2026-07-25 10:51:29 +00:00
parent 5747ad7fda
commit e96df78ab5
2 changed files with 278 additions and 219 deletions
+104 -60
View File
@@ -1,11 +1,10 @@
#!/bin/bash #!/bin/bash
# services/pstn-trunk.sh — SIP PSTN trunk add-on for services/asterisk.sh: # services/pstn-trunk.sh — SIP PSTN trunk add-on for services/asterisk.sh:
# US-only outbound (NANP dialplan # US-only outbound (NANP dialplan
# restriction), independent outbound/inbound concurrent-call caps, a 3-tier # restriction), independent outbound/inbound concurrent-call caps, a
# permission model applied SEPARATELY per direction (each of outbound and # per-extension permission model built on ONE whitelist plus a mode saying
# inbound is internal-only / restricted to a list / unrestricted, so "dial # which direction(s) it applies to (none / unrestricted / restrict outbound /
# anyone, only take calls from a short list" and the reverse are both # restrict inbound / restrict both), a configurable inbound ring-group,
# expressible), a configurable inbound ring-group,
# IP-authenticated trunk (no SIP password stored), ntfy alerts on # IP-authenticated trunk (no SIP password stored), ntfy alerts on
# denied/rejected calls, and a periodic spend/volume check. # denied/rejected calls, and a periodic spend/volume check.
# #
@@ -26,7 +25,7 @@
# #
# 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 — US-only, per-extension inbound/outbound permission tiers, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" register_service pstn-trunk homelab "SIP PSTN trunk for asterisk — US-only, per-extension whitelist restricting inbound and/or outbound, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)"
# ── Surviving Easy Asterisk's regeneration ────────────────────────────────── # ── Surviving Easy Asterisk's regeneration ──────────────────────────────────
# Easy Asterisk (the vendor project services/asterisk.sh builds on) fully OVERWRITES both pjsip.conf and extensions.conf from its own # Easy Asterisk (the vendor project services/asterisk.sh builds on) fully OVERWRITES both pjsip.conf and extensions.conf from its own
@@ -52,10 +51,10 @@ register_service pstn-trunk homelab "SIP PSTN trunk for asterisk — US-only, pe
# after any base install update. # after any base install update.
# #
# ── Why permissions are a separate live file, not baked into the dialplan ── # ── Why permissions are a separate live file, not baked into the dialplan ──
# pstn-permissions.conf holds each extension's per-direction tiers # pstn-permissions.conf holds each extension's 'restrict' mode and its single
# (tier_out/allowed_out for what it may dial, tier_in/allowed_in for which # 'allowed_numbers' whitelist (the authored pair), plus the
# caller IDs may reach it; 'tier'/'allowed_numbers' persist only as a # tier_out/allowed_out/tier_in/allowed_in derived from them that the dialplan
# rollback mirror of the outbound values) # actually reads, plus a legacy 'tier' mirror for rollback
# and, for restricted, its pipe-separated approved-number list. The dialplan # 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 # 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 # disk on every call — so editing this file (by hand, or via the Security
@@ -273,9 +272,9 @@ EOF
# ── Shared: one inbound ring-group member's live permission check ───────── # ── 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 # Emits a block that only adds this extension to PSTN_RING_LIST if it's
# "full" INBOUND tier, or "restricted" inbound tier AND the caller ID is on # "full" INBOUND tier, or "restricted" inbound tier AND the caller ID is on
# its inbound approved list (allowed_in). The outbound tier is not consulted # allowed_in. The outbound side is not consulted here at all, which is what
# here at all — an extension may dial anywhere and still accept calls from # lets "Restrict inbound" mean "dial anywhere, but only these numbers get
# only a handful of numbers, or the reverse. Uses a single-quoted heredoc (fully literal — no bash # through to me" — and "Restrict outbound" the reverse. Uses a single-quoted heredoc (fully literal — no bash
# expansion) captured into a variable, then a pure bash string replace for # expansion) captured into a variable, then a pure bash string replace for
# the extension number placeholder — safer than sed here since it needs no # 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 # escaping at all (the extension is plain digits, but this avoids relying on
@@ -755,56 +754,96 @@ printf '%s' "$RING_LIST"
SCRIPT SCRIPT
} }
# ── Migration: single tier → separate inbound/outbound tiers ─────────────── # ── Migration: legacy single tier → 'restrict' mode + derived keys ─────────
# Installs made before the split have only 'tier' and 'allowed_numbers', which # Installs made before the per-direction split have only 'tier' and
# the new dialplan doesn't read — leaving them untouched would fail closed and # 'allowed_numbers'. The dialplan now reads tier_out/tier_in, so leaving them
# silently deny every PSTN call in both directions. Copies the old values into # alone would fail closed and silently deny every PSTN call both ways.
# both directions, which reproduces the exact behaviour the box had before,
# then leaves the originals in place as the rollback mirror (see the header
# _pstn_write_permissions_file writes).
# #
# Idempotent: an extension that already has tier_out is skipped, so this runs # Writes the authored 'restrict' key plus the derived tier_out/allowed_out/
# safely on every update. Backs the file up first — unlike most of this # tier_in/allowed_in, reproducing exactly the behaviour the box already had:
# a legacy tier of "full" becomes restrict=open, "restricted" becomes
# restrict=both (the old single list applied in both directions, which is
# what the old dialplan did), anything else becomes restrict=none.
#
# Idempotent: an extension that already has 'restrict' is left alone, so this
# runs safely on every update. Backs the file up first — unlike most of this
# installer, it edits a file the user may have hand-tuned. # installer, it edits a file the user may have hand-tuned.
_pstn_migrate_permissions_split() { _pstn_migrate_permissions_split() {
local FILE="$1" local FILE="$1"
[[ -f "$FILE" ]] || return 0 [[ -f "$FILE" ]] || return 0
grep -q '^[[:space:]]*tier[[:space:]]*=' "$FILE" || return 0 grep -qE '^[[:space:]]*(tier|tier_out)[[:space:]]*=' "$FILE" || return 0
grep -q '^[[:space:]]*tier_out[[:space:]]*=' "$FILE" && return 0 grep -qE '^[[:space:]]*restrict[[:space:]]*=' "$FILE" && return 0
cp "$FILE" "$FILE.backup.$(date +%Y%m%d-%H%M%S)" cp "$FILE" "$FILE.backup.$(date +%Y%m%d-%H%M%S)"
local TMP local TMP
TMP="$(mktemp)" TMP="$(mktemp)"
# Buffer per section: 'restrict' depends on the tier, and the whitelist
# may appear on either side of it, so the whole section has to be read
# before any of it can be rewritten.
awk ' awk '
# Emit the split keys immediately after each legacy key, preserving function flush_section() {
# whatever spacing style the file already uses around "=". if (!have) return
{ if (header != "") print header
line = $0 mode = "none"
if (match(line, /^[ \t]*tier[ \t]*=[ \t]*/)) { if (tier == "full") mode = "open"
val = line; sub(/^[ \t]*tier[ \t]*=[ \t]*/, "", val) else if (tier == "restricted") mode = "both"
print "tier_out=" val # An install that predates the split has no tier_out; one made
print "tier_in=" val # between the split and this change may, so honour it if present.
print line if (tier_out != "" || tier_in != "") {
next o = (tier_out != "" ? tier_out : tier)
i = (tier_in != "" ? tier_in : tier)
if (o == "full" && i == "full") mode = "open"
else if (o == "restricted" && i == "restricted") mode = "both"
else if (o == "restricted") mode = "out"
else if (i == "restricted") mode = "in"
else mode = "none"
} }
if (match(line, /^[ \t]*allowed_numbers[ \t]*=[ \t]*/)) { print "restrict=" mode
val = line; sub(/^[ \t]*allowed_numbers[ \t]*=[ \t]*/, "", val) if (nums != "") print "allowed_numbers=" nums
print "allowed_out=" val if (mode == "open") { print "tier_out=full"; print "tier_in=full"; print "tier=full" }
print "allowed_in=" val else if (mode == "out") {
print line print "tier_out=restricted"; print "allowed_out=" nums
next print "tier_in=full"; print "tier=restricted"
} }
print line else if (mode == "in") {
print "tier_out=full"; print "tier_in=restricted"
print "allowed_in=" nums; print "tier=full"
}
else if (mode == "both") {
print "tier_out=restricted"; print "allowed_out=" nums
print "tier_in=restricted"; print "allowed_in=" nums
print "tier=restricted"
}
for (k = 1; k <= nkeep; k++) print keep[k]
header = ""; tier = ""; tier_out = ""; tier_in = ""; nums = ""
nkeep = 0; have = 0
} }
/^[ \t]*\[/ { flush_section(); header = $0; have = 1; next }
{
if (!have) { print; next }
line = $0
key = line; sub(/=.*$/, "", key); gsub(/^[ \t]+|[ \t]+$/, "", key)
val = line
if (index(line, "=") > 0) { sub(/^[^=]*=[ \t]*/, "", val) } else { val = "" }
gsub(/[ \t]+$/, "", val)
if (key == "tier") { tier = val; next }
if (key == "tier_out") { tier_out = val; next }
if (key == "tier_in") { tier_in = val; next }
if (key == "allowed_numbers" || key == "allowed_out" || key == "allowed_in") {
if (nums == "" && val != "") nums = val
next
}
keep[++nkeep] = line
}
END { flush_section() }
' "$FILE" > "$TMP" ' "$FILE" > "$TMP"
if [[ -s "$TMP" ]]; then if [[ -s "$TMP" ]]; then
mv "$TMP" "$FILE" mv "$TMP" "$FILE"
chmod 664 "$FILE" chmod 664 "$FILE"
log_success "Migrated pstn-permissions.conf to separate inbound/outbound tiers (backup saved alongside it)." log_success "Migrated pstn-permissions.conf to the 'restrict' model (backup saved alongside it)."
log_info "Every extension kept its existing behaviour — both directions were set to" log_info "Every extension kept its existing behaviour. Pick which direction(s) each"
log_info "whatever its single tier used to be. Split them per direction in the" log_info "whitelist applies to in the Security Dashboard's Extensions tab."
log_info "Security Dashboard's Extensions tab."
else else
rm -f "$TMP" rm -f "$TMP"
log_warning "Permission migration produced an empty file — left the original alone." log_warning "Permission migration produced an empty file — left the original alone."
@@ -847,20 +886,23 @@ _pstn_write_permissions_file() {
done done
local _written_exts="" local _written_exts=""
{ {
echo "; PSTN permission tiers — internal / restricted / full — set SEPARATELY per" echo "; PSTN permissions. Each extension has ONE whitelist and a 'restrict' mode"
echo "; direction:" echo "; saying which direction(s) that whitelist applies to:"
echo "; - 'tier_out' + 'allowed_out' gate calls this extension PLACES. allowed_out" echo "; none - no PSTN at all (internal extension calling still works)"
echo "; holds numbers it may DIAL." echo "; open - unrestricted both ways"
echo "; - 'tier_in' + 'allowed_in' gate calls this extension RECEIVES (ring group" echo "; out - may only DIAL numbers on the whitelist; anyone may call in"
echo "; membership and personal-DID routing). allowed_in holds CALLER IDs allowed" echo "; in - may only be CALLED BY numbers on the whitelist; may dial anywhere"
echo "; to reach it." echo "; both - the whitelist applies in both directions"
echo "; The two are independent, so 'dial anyone, only take calls from a short list'" echo "; 'allowed_numbers' is that whitelist: pipe-separated 11-digit numbers, used"
echo "; and 'answer anyone, only dial a short list' are both expressible. Both lists" echo "; directly as a REGEX() alternation (see this file's own comments on why"
echo "; are pipe-separated 11-digit numbers (a REGEX() alternation, see this file's" echo "; untrusted call data is always the string being tested, never the pattern)."
echo "; own comments on why untrusted call data is never the pattern side)." echo ";"
echo "; 'tier'/'allowed_numbers' are also written, mirroring the OUTBOUND values, so" echo "; 'restrict' + 'allowed_numbers' are the AUTHORED form — the two keys to edit"
echo "; that rolling back to a pre-split pstn-trunk.sh keeps working with the old" echo "; by hand. tier_out/allowed_out/tier_in/allowed_in below them are DERIVED from"
echo "; single-tier semantics. Nothing reads them once the split dialplan is in." echo "; those and are what the dialplan actually reads; 'tier' is a rollback mirror"
echo "; of tier_out for pre-split versions of this installer. Change the authored"
echo "; keys and re-run this installer (or use the Security Dashboard, which keeps"
echo "; all of them in step) rather than editing the derived ones directly."
echo ";" echo ";"
echo "; PLUS two further independent axes per extension:" echo "; PLUS two further independent axes per extension:"
echo "; - 'messaging' for Asterisk's native internal SIP MESSAGE texting (no carrier" echo "; - 'messaging' for Asterisk's native internal SIP MESSAGE texting (no carrier"
@@ -885,6 +927,7 @@ _pstn_write_permissions_file() {
local _ext local _ext
for _ext in $FULL_EXTS; do for _ext in $FULL_EXTS; do
echo "[$_ext]" echo "[$_ext]"
echo "restrict=open"
echo "tier_out=full" echo "tier_out=full"
echo "tier_in=full" echo "tier_in=full"
echo "tier=full" echo "tier=full"
@@ -897,12 +940,13 @@ _pstn_write_permissions_file() {
_ext="$1"; local _nums="$2" _ext="$1"; local _nums="$2"
shift 2 shift 2
echo "[$_ext]" echo "[$_ext]"
echo "restrict=both"
echo "allowed_numbers=${_nums}"
echo "tier_out=restricted" echo "tier_out=restricted"
echo "allowed_out=${_nums}" echo "allowed_out=${_nums}"
echo "tier_in=restricted" echo "tier_in=restricted"
echo "allowed_in=${_nums}" echo "allowed_in=${_nums}"
echo "tier=restricted" echo "tier=restricted"
echo "allowed_numbers=${_nums}"
[[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes"
[[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}"
echo "" echo ""
+174 -159
View File
@@ -226,17 +226,17 @@ which tab a given extension's settings live on.
- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which - **Extensions** — one row per extension, merged from \`pjsip.conf\` (which
always works) and, when the Easy Asterisk container is reachable, its own always works) and, when the Easy Asterisk container is reachable, its own
device list. Columns: Ext, Name, then Category/Status/Transport if that device list. Columns: Ext, Name, then Category/Status/Transport if that
container is present, then four PSTN columns if a trunk dialplan is container is present, then **PSTN** + **Whitelist** if a trunk dialplan is
installed — **Outbound** tier + "Can dial", and **Inbound** tier + "Can be installed, then Messaging (always: internal SIP texting has no PSTN
called by" — then Messaging (always: internal SIP texting has no PSTN
dependency at all — no cost, no carrier, no DID). dependency at all — no cost, no carrier, no DID).
The two PSTN directions are independent. Each is \`internal\` (no PSTN that Each extension has one whitelist and a mode saying which direction(s) it
way), \`restricted\` (only the numbers beside it) or \`full\` (any US applies to: **No PSTN**, **Unrestricted**, **Restrict outbound** (may only
number), and each has its own list, because they hold different things: dial the list, anyone can call in), **Restrict inbound** (may dial
"Can dial" is numbers this extension may call, "Can be called by" is caller anywhere, only the list can call in), or **Restrict both**. The whitelist
IDs allowed to reach it. Internal extension-to-extension calling and ring field greys out for the two modes that don't use one. Internal
groups are never gated by either. extension-to-extension calling and ring groups are never gated by any of
this.
Name and Category are edited **in place**; every cell feeds one batched Name and Category are edited **in place**; every cell feeds one batched
save. Rows you've touched get a highlight and a left rail, a sticky bar save. Rows you've touched get a highlight and a left rail, a sticky bar
@@ -1142,11 +1142,13 @@ def _write_ini_cp(path, header, cp):
PERMISSIONS_HEADER = ( PERMISSIONS_HEADER = (
"; PSTN permission tiers - internal / restricted / full - set SEPARATELY\n" "; PSTN permissions. Each extension has ONE whitelist (allowed_numbers)\n"
"; per direction: tier_out/allowed_out gate what an extension may DIAL,\n" "; and a 'restrict' mode saying which direction(s) it applies to:\n"
"; tier_in/allowed_in gate which CALLER IDs may reach it. 'tier' and\n" "; none / open / out (may only dial the list) / in (may only be called\n"
"; 'allowed_numbers' mirror the outbound values so a rollback to a\n" "; by the list) / both.\n"
"; pre-split pstn-trunk.sh still works; nothing reads them otherwise.\n" "; restrict + allowed_numbers are the authored pair; tier_out/allowed_out\n"
"; and tier_in/allowed_in are DERIVED from them and are what the dialplan\n"
"; reads; 'tier' is a rollback mirror for a pre-split pstn-trunk.sh.\n"
"; PLUS two\n" "; PLUS two\n"
"; independent per-extension axes: messaging (internal SIP MESSAGE\n" "; independent per-extension axes: messaging (internal SIP MESSAGE\n"
"; texting) and personal_did (outbound Caller-ID override; inbound\n" "; texting) and personal_did (outbound Caller-ID override; inbound\n"
@@ -1185,25 +1187,45 @@ def _read_permissions_cp():
return cp return cp
RESTRICT_RE = re.compile(r"^(none|open|out|in|both)$")
def _derive_restrict(tier_out, tier_in):
"""Infer the authored mode from the derived per-direction tiers.
Only needed for a config written before 'restrict' existed. Fails toward
the more restrictive reading: anything that isn't clearly open in a
direction is treated as restricted or none, never widened."""
if tier_out == "full" and tier_in == "full":
return "open"
if tier_out == "restricted" and tier_in == "restricted":
return "both"
if tier_out == "restricted":
return "out"
if tier_in == "restricted":
return "in"
return "none"
def get_all_permissions(): def get_all_permissions():
"""{ext: {"tier_out", "allowed_out", "tier_in", "allowed_in", """{ext: {"restrict", "allowed_numbers", "messaging"}} for every extension
"messaging"}} for every extension with a non-default record. with a non-default record.
Outbound and inbound are independent axes: tier_out/allowed_out gate what Each extension has ONE whitelist and a mode saying which direction(s) it
an extension may DIAL, tier_in/allowed_in gate which CALLER IDs may reach applies to: none (no PSTN), open (unrestricted), out (may only dial the
it (ring-group membership and personal-DID routing). That's what makes list), in (may only be called by the list), both. That authored pair is
"dial anyone, only accept calls from a list" and "answer anyone, only what this returns and what the UI edits; the dialplan reads the derived
dial a list" both expressible. tier_out/allowed_out/tier_in/allowed_in that write_permission keeps in
step with it.
Falls back to the pre-split 'tier'/'allowed_numbers' keys for either Older configs are read by deriving the mode from whatever they do have —
direction that hasn't been migrated yet, so the UI reads correctly even the per-direction tiers, or the pre-split single 'tier' — so the UI is
on a box where services/pstn-trunk.sh hasn't been re-run — the migration correct even on a box where services/pstn-trunk.sh hasn't been re-run.
itself lives there, not here.
Extensions with no section are implicitly internal/messaging-disabled Extensions with no section are implicitly no-PSTN/messaging-disabled: the
the dialplan's AST_CONFIG() lookup treats a missing section/key as dialplan's AST_CONFIG() lookup treats a missing section as denied the same
empty/denied the same way, so there's nothing to return for them; the UI way, so there's nothing to return for them; the UI fills in defaults for
fills in defaults for any known extension not present in this dict.""" any known extension not present here."""
cp = _read_permissions_cp() cp = _read_permissions_cp()
result = {} result = {}
for section in cp.sections(): for section in cp.sections():
@@ -1211,101 +1233,100 @@ def get_all_permissions():
continue continue
legacy_tier = cp.get(section, "tier", fallback="internal") legacy_tier = cp.get(section, "tier", fallback="internal")
legacy_nums = cp.get(section, "allowed_numbers", fallback="") legacy_nums = cp.get(section, "allowed_numbers", fallback="")
restrict = cp.get(section, "restrict", fallback="")
if not RESTRICT_RE.match(restrict):
restrict = _derive_restrict(
cp.get(section, "tier_out", fallback=legacy_tier),
cp.get(section, "tier_in", fallback=legacy_tier),
)
numbers = legacy_nums
if not numbers:
numbers = (cp.get(section, "allowed_out", fallback="")
or cp.get(section, "allowed_in", fallback=""))
result[section] = { result[section] = {
"tier_out": cp.get(section, "tier_out", fallback=legacy_tier), "restrict": restrict,
"allowed_out": cp.get(section, "allowed_out", fallback=legacy_nums), "allowed_numbers": numbers,
"tier_in": cp.get(section, "tier_in", fallback=legacy_tier),
"allowed_in": cp.get(section, "allowed_in", fallback=legacy_nums),
"messaging": cp.getboolean(section, "messaging", fallback=False), "messaging": cp.getboolean(section, "messaging", fallback=False),
} }
return result return result
def _apply_direction(cp, ext, prefix, tier, numbers_raw): # Which per-direction tiers each authored mode compiles down to. The dialplan
"""Write one direction's tier + number list, returning the cleaned list. # only ever reads the compiled keys; this table is the single place the
# mapping is defined.
prefix is "out" or "in". Setting a direction to internal drops only that _RESTRICT_TIERS = {
direction's two keys, never the whole section — an extension can "none": ("internal", "internal"),
independently carry messaging=yes, a personal_did, and a permissive "open": ("full", "full"),
setting in the OTHER direction, all of which must survive. (Removing the "out": ("restricted", "full"),
section wholesale here was a real, confirmed bug in the single-tier "in": ("full", "restricted"),
version of this function.)""" "both": ("restricted", "restricted"),
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip()) }
clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
joined = "|".join(clean)
tier_key = "tier_" + prefix
nums_key = "allowed_" + prefix
if tier == "internal":
if cp.has_section(ext):
for key in (tier_key, nums_key):
if cp.has_option(ext, key):
cp.remove_option(ext, key)
return clean
if not cp.has_section(ext):
cp.add_section(ext)
cp.set(ext, tier_key, tier)
if tier == "restricted":
cp.set(ext, nums_key, joined)
elif cp.has_option(ext, nums_key):
cp.remove_option(ext, nums_key)
return clean
def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw, def _set_or_clear(cp, ext, key, value):
messaging_enabled=False): if value:
"""Saves one extension's outbound tier + numbers, inbound tier + numbers, cp.set(ext, key, value)
and messaging flag in one action. elif cp.has_option(ext, key):
cp.remove_option(ext, key)
The two directions are independent: allowed_out holds numbers this
extension may DIAL, allowed_in holds CALLER IDs allowed to reach it. They
used to be one field serving both, which made "outbound to anyone,
inbound from a short list" impossible to express.
Messaging is a third independent axis (see pstn-trunk.sh's file-level def write_permission(ext, restrict, numbers_raw, messaging_enabled=False):
comment: an extension can be internal for calling and still """Saves one extension's PSTN restriction mode, its single whitelist, and
messaging-enabled, or vice versa), so it's set/cleared regardless of its messaging flag in one action.
either tier.
One list, not two: the whitelist is "the numbers this extension deals
with", and the mode says whether that constrains dialling out, being
called, or both. Modes are none / open / out / in / both.
Writes three layers, all derived from those two authored values:
restrict, allowed_numbers what a human edits (and what this reads back)
tier_out/allowed_out,
tier_in/allowed_in what the dialplan reads
tier, allowed_numbers rollback mirror for a pre-split installer
Messaging is an independent axis (see pstn-trunk.sh's file-level comment:
an extension can have no PSTN at all and still be messaging-enabled, or
vice versa), so it's set/cleared regardless of the mode.
Numbers normalize to a pipe-separated list of 11-digit US numbers (a bare Numbers normalize to a pipe-separated list of 11-digit US numbers (a bare
10-digit entry gains a leading "1" rather than being dropped — see 10-digit entry gains a leading "1" rather than being dropped — see
_normalize_nanp_number). Pipe, not comma, because the dialplan uses the _normalize_nanp_number). Pipe, not comma, because the dialplan uses the
value directly as a REGEX() alternation — see services/pstn-trunk.sh on value directly as a REGEX() alternation — see services/pstn-trunk.sh on
why untrusted call data is always the string being tested, never why untrusted call data is always the string being tested, never
interpolated into the pattern side. interpolated into the pattern side."""
'tier'/'allowed_numbers' are also written, mirroring the outbound values,
purely so that rolling back to a pre-split pstn-trunk.sh keeps working
with the old single-tier semantics. Nothing reads them once the split
dialplan is installed."""
if not ASTERISK_CONFIG_DIR: if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box" return False, "No Asterisk install detected on this box"
ext = str(ext).strip() ext = str(ext).strip()
if not EXTEN_RE.match(ext): if not EXTEN_RE.match(ext):
return False, "Invalid extension" return False, "Invalid extension"
if not TIER_RE.match(tier_out or ""): if not RESTRICT_RE.match(restrict or ""):
return False, "Invalid outbound tier" return False, "Invalid restriction mode"
if not TIER_RE.match(tier_in or ""):
return False, "Invalid inbound tier" tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
numbers = "|".join(clean)
tier_out, tier_in = _RESTRICT_TIERS[restrict]
cp = _read_permissions_cp() cp = _read_permissions_cp()
clean_out = _apply_direction(cp, ext, "out", tier_out, allowed_out_raw) if restrict == "none":
clean_in = _apply_direction(cp, ext, "in", tier_in, allowed_in_raw) # Clear the PSTN keys only, never the whole section — messaging and
# personal_did are independent and must survive. (Removing the
# Rollback mirror — outbound values under the pre-split key names. # section here was a real, confirmed bug in an earlier version.)
if tier_out == "internal":
if cp.has_section(ext): if cp.has_section(ext):
for key in ("tier", "allowed_numbers"): for key in ("restrict", "allowed_numbers", "tier_out", "allowed_out",
"tier_in", "allowed_in", "tier"):
if cp.has_option(ext, key): if cp.has_option(ext, key):
cp.remove_option(ext, key) cp.remove_option(ext, key)
else: else:
if not cp.has_section(ext):
cp.add_section(ext)
cp.set(ext, "restrict", restrict)
_set_or_clear(cp, ext, "allowed_numbers", numbers if restrict != "open" else "")
cp.set(ext, "tier_out", tier_out)
cp.set(ext, "tier_in", tier_in)
_set_or_clear(cp, ext, "allowed_out", numbers if tier_out == "restricted" else "")
_set_or_clear(cp, ext, "allowed_in", numbers if tier_in == "restricted" else "")
cp.set(ext, "tier", tier_out) cp.set(ext, "tier", tier_out)
if tier_out == "restricted":
cp.set(ext, "allowed_numbers", "|".join(clean_out))
elif cp.has_option(ext, "allowed_numbers"):
cp.remove_option(ext, "allowed_numbers")
if messaging_enabled: if messaging_enabled:
if not cp.has_section(ext): if not cp.has_section(ext):
@@ -1315,8 +1336,8 @@ def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw,
cp.remove_option(ext, "messaging") cp.remove_option(ext, "messaging")
# Drop the section entirely once nothing is left in it — only reachable # Drop the section entirely once nothing is left in it — only reachable
# when both directions are internal, messaging is off, and no # when the mode is none, messaging is off, and no personal_did was ever
# personal_did was ever assigned. # assigned.
if cp.has_section(ext) and not cp.options(ext): if cp.has_section(ext) and not cp.options(ext):
cp.remove_section(ext) cp.remove_section(ext)
@@ -1324,14 +1345,9 @@ def write_permission(ext, tier_out, allowed_out_raw, tier_in, allowed_in_raw,
if not ok: if not ok:
return False, err return False, err
empty = [] if restrict != "none" and restrict != "open" and not clean:
if tier_out == "restricted" and not clean_out: return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN "
empty.append("outbound") "number is permitted in the restricted direction yet.")
if tier_in == "restricted" and not clean_in:
empty.append("inbound")
if empty:
return True, ("Saved, but the " + " and ".join(empty) +
" list is restricted and EMPTY — no PSTN number is permitted in that direction yet.")
return True, "Saved" return True, "Saved"
@@ -2675,8 +2691,15 @@ INDEX_HTML = """<!doctype html>
<details class="help"> <details class="help">
<summary>What these columns mean</summary> <summary>What these columns mean</summary>
<p class="muted"><b>Name</b> and <b>Category</b> are editable in place — click, type, then Save. Adding an extension generates a random password and reloads PJSIP + rebuilds the dialplan automatically; the password is shown once, at the top of this card.</p> <p class="muted"><b>Name</b> and <b>Category</b> are editable in place — click, type, then Save. Adding an extension generates a random password and reloads PJSIP + rebuilds the dialplan automatically; the password is shown once, at the top of this card.</p>
<p class="muted pstn-only"><b>Outbound</b> and <b>Inbound</b> are independent. Each is <b>internal</b> (no PSTN in that direction — internal extension-to-extension calling and ring groups always keep working either way), <b>restricted</b> (only the numbers listed beside it), or <b>full</b> (any US number). So "dial anyone, only take calls from family" is Outbound&nbsp;full + Inbound&nbsp;restricted, and the reverse is just as valid.</p> <p class="muted pstn-only"><b>PSTN</b> sets how the outside phone network reaches this extension, and the <b>Whitelist</b> beside it is the one list of numbers that mode applies to:</p>
<p class="muted pstn-only"><b>Can dial</b> holds numbers this extension is allowed to <i>call</i>; <b>Can be called by</b> holds caller IDs allowed to <i>reach</i> it — different lists, which is why they're separate fields. Each is only editable when its own side is set to restricted. Usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.</p> <ul class="muted pstn-only" style="margin:0 0 var(--sp-2); padding-left:1.2rem">
<li><b>No PSTN</b> — outside calls neither in nor out.</li>
<li><b>Unrestricted</b> — dial anyone, anyone can call.</li>
<li><b>Restrict outbound</b> — may only dial the whitelist; anyone can call in.</li>
<li><b>Restrict inbound</b> — may dial anywhere; only the whitelist can call in.</li>
<li><b>Restrict both</b> — the whitelist applies in both directions.</li>
</ul>
<p class="muted pstn-only">Internal extension-to-extension calling and ring groups are never gated by any of this. Changes are usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.</p>
<p class="muted"><b>Messaging</b> — Asterisk's native SIP texting between extensions: no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk at all (which is why this column is here even with no trunk installed). Independent of the calling tier. Enforced live by a dedicated dialplan context — see <code>services/asterisk.sh</code>'s README, including its caveat that the sender-extraction logic still needs real-traffic confirmation. If this box predates that wiring, rerun <code>sudo ./setup.sh asterisk</code>.</p> <p class="muted"><b>Messaging</b> — Asterisk's native SIP texting between extensions: no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk at all (which is why this column is here even with no trunk installed). Independent of the calling tier. Enforced live by a dedicated dialplan context — see <code>services/asterisk.sh</code>'s README, including its caveat that the sender-extraction logic still needs real-traffic confirmation. If this box predates that wiring, rerun <code>sudo ./setup.sh asterisk</code>.</p>
</details> </details>
@@ -2687,10 +2710,8 @@ INDEX_HTML = """<!doctype html>
<th class="ea-only">Category</th> <th class="ea-only">Category</th>
<th class="sortable ea-only" data-sort="status">Status</th> <th class="sortable ea-only" data-sort="status">Status</th>
<th class="ea-only">Transport</th> <th class="ea-only">Transport</th>
<th class="sortable pstn-only" data-sort="tier_out">Outbound</th> <th class="sortable pstn-only" data-sort="restrict">PSTN</th>
<th class="pstn-only">Can dial</th> <th class="pstn-only">Whitelist</th>
<th class="sortable pstn-only" data-sort="tier_in">Inbound</th>
<th class="pstn-only">Can be called by</th>
<th class="sortable" data-sort="messaging">Messaging</th> <th class="sortable" data-sort="messaging">Messaging</th>
<th></th> <th></th>
</tr></thead><tbody></tbody></table> </tr></thead><tbody></tbody></table>
@@ -3170,8 +3191,7 @@ async function loadExtensions() {
const byExt = new Map(); const byExt = new Map();
(permData.extensions || []).forEach(e => byExt.set(e.ext, { (permData.extensions || []).forEach(e => byExt.set(e.ext, {
ext: e.ext, name: e.name, ext: e.ext, name: e.name,
tier_out: e.tier_out, allowed_out: e.allowed_out, restrict: e.restrict, allowed_numbers: e.allowed_numbers,
tier_in: e.tier_in, allowed_in: e.allowed_in,
messaging: e.messaging, ea: false, category: "", status: "", transport: "", encryption: "", messaging: e.messaging, ea: false, category: "", status: "", transport: "", encryption: "",
})); }));
@@ -3182,8 +3202,7 @@ async function loadExtensions() {
eaDevices.forEach(d => { eaDevices.forEach(d => {
const row = byExt.get(d.extension) || { const row = byExt.get(d.extension) || {
ext: d.extension, name: d.name, ext: d.extension, name: d.name,
tier_out: "internal", allowed_out: "", restrict: "none", allowed_numbers: "",
tier_in: "internal", allowed_in: "",
messaging: false, messaging: false,
}; };
row.ea = true; row.ea = true;
@@ -3205,15 +3224,21 @@ async function loadExtensions() {
let extRows = []; let extRows = [];
let extSort = { key: null, dir: 1 }; let extSort = { key: null, dir: 1 };
// internal < restricted < full, so sorting a tier column groups by how // Sort the PSTN column by how much reach the mode grants, not alphabetically
// permissive it is rather than alphabetically (full would otherwise sort // — "open" would otherwise land between "both" and "in".
// between the other two). const RESTRICT_ORDER = { none: 0, both: 1, in: 2, out: 3, open: 4 };
const TIER_ORDER = { internal: 0, restricted: 1, full: 2 }; const RESTRICT_LABELS = [
["none", "No PSTN"],
["open", "Unrestricted"],
["out", "Restrict outbound"],
["in", "Restrict inbound"],
["both", "Restrict both"],
];
function extSortValue(e, key) { function extSortValue(e, key) {
if (key === "ext") return parseInt(e.ext, 10); if (key === "ext") return parseInt(e.ext, 10);
if (key === "messaging") return e.messaging ? 1 : 0; if (key === "messaging") return e.messaging ? 1 : 0;
if (key === "tier_out" || key === "tier_in") return TIER_ORDER[e[key]] ?? -1; if (key === "restrict") return RESTRICT_ORDER[e[key]] ?? -1;
return (e[key] || "").toString().toLowerCase(); return (e[key] || "").toString().toLowerCase();
} }
@@ -3222,18 +3247,21 @@ function setCount(id, n) {
if (el) el.textContent = n ? "(" + n + ")" : ""; if (el) el.textContent = n ? "(" + n + ")" : "";
} }
function tierSelect(cls, value) { function restrictSelect(value) {
return `<select class="${cls}">` + return '<select class="ext-restrict">' +
["internal", "restricted", "full"].map(t => RESTRICT_LABELS.map(([v, label]) =>
`<option value="${t}" ${value === t ? "selected" : ""}>${t}</option>`).join("") + `<option value="${v}" ${value === v ? "selected" : ""}>${esc(label)}</option>`).join("") +
"</select>"; "</select>";
} }
// The whitelist is meaningless for "no PSTN" and "unrestricted".
function restrictUsesList(mode) { return mode === "out" || mode === "in" || mode === "both"; }
function renderExtensions() { function renderExtensions() {
const tbody = document.querySelector("#ext-table tbody"); const tbody = document.querySelector("#ext-table tbody");
setCount("ext-count", extRows.length); setCount("ext-count", extRows.length);
if (!extRows.length) { if (!extRows.length) {
tbody.innerHTML = '<tr><td colspan=11 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>'; tbody.innerHTML = '<tr><td colspan=9 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>';
updateDirtyState(); updateDirtyState();
return; return;
} }
@@ -3273,10 +3301,8 @@ function renderExtensions() {
<td class="ea-only">${catCell}</td> <td class="ea-only">${catCell}</td>
<td class="ea-only">${statusCell}</td> <td class="ea-only">${statusCell}</td>
<td class="ea-only muted">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td> <td class="ea-only muted">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td>
<td class="pstn-only">${tierSelect("ext-tier-out", e.tier_out)}</td> <td class="pstn-only">${restrictSelect(e.restrict)}</td>
<td class="pstn-only"><input type="text" class="ext-numbers-out" value="${esc(e.allowed_out)}" placeholder="5551234567,5559876543" ${e.tier_out === "restricted" ? "" : "disabled"} style="width:13rem" aria-label="Numbers extension ${esc(e.ext)} may dial"></td> <td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543" ${restrictUsesList(e.restrict) ? "" : "disabled"} style="width:16rem" aria-label="Whitelist for extension ${esc(e.ext)}"></td>
<td class="pstn-only">${tierSelect("ext-tier-in", e.tier_in)}</td>
<td class="pstn-only"><input type="text" class="ext-numbers-in" value="${esc(e.allowed_in)}" placeholder="5551234567,5559876543" ${e.tier_in === "restricted" ? "" : "disabled"} style="width:13rem" aria-label="Caller IDs allowed to reach extension ${esc(e.ext)}"></td>
<td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""} aria-label="Messaging for extension ${esc(e.ext)}"></td> <td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""} aria-label="Messaging for extension ${esc(e.ext)}"></td>
<td class="actions"> <td class="actions">
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">&times;</button> <button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">&times;</button>
@@ -3285,14 +3311,11 @@ function renderExtensions() {
}).join(""); }).join("");
tbody.querySelectorAll("tr").forEach(row => { tbody.querySelectorAll("tr").forEach(row => {
// Each direction's number field follows its own tier, not the other's. const modeSel = row.querySelector(".ext-restrict");
[["out"], ["in"]].forEach(([dir]) => { const numsInput = row.querySelector(".ext-numbers");
const tierSel = row.querySelector(".ext-tier-" + dir); if (modeSel && numsInput) {
const numsInput = row.querySelector(".ext-numbers-" + dir); modeSel.addEventListener("change", () => { numsInput.disabled = !restrictUsesList(modeSel.value); });
if (tierSel && numsInput) { }
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
}
});
row.querySelectorAll("input, select").forEach(ctl => { row.querySelectorAll("input, select").forEach(ctl => {
ctl.addEventListener("input", updateDirtyState); ctl.addEventListener("input", updateDirtyState);
ctl.addEventListener("change", updateDirtyState); ctl.addEventListener("change", updateDirtyState);
@@ -3312,18 +3335,14 @@ function rowEdits(tr) {
if (!model) return null; if (!model) return null;
const nameEl = tr.querySelector(".ext-name"); const nameEl = tr.querySelector(".ext-name");
const catEl = tr.querySelector(".ext-category"); const catEl = tr.querySelector(".ext-category");
const tierOutEl = tr.querySelector(".ext-tier-out"); const modeEl = tr.querySelector(".ext-restrict");
const numsOutEl = tr.querySelector(".ext-numbers-out"); const numsEl = tr.querySelector(".ext-numbers");
const tierInEl = tr.querySelector(".ext-tier-in");
const numsInEl = tr.querySelector(".ext-numbers-in");
const msgEl = tr.querySelector(".ext-messaging"); const msgEl = tr.querySelector(".ext-messaging");
const edits = {}; const edits = {};
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== model.name) edits.name = nameEl.value.trim(); if (nameEl && !nameEl.disabled && nameEl.value.trim() !== model.name) edits.name = nameEl.value.trim();
if (catEl && catEl.value !== model.category) edits.category = catEl.value; if (catEl && catEl.value !== model.category) edits.category = catEl.value;
if (tierOutEl && tierOutEl.value !== model.tier_out) edits.tier_out = tierOutEl.value; if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
if (numsOutEl && numsOutEl.value !== model.allowed_out) edits.allowed_out = numsOutEl.value; if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
if (tierInEl && tierInEl.value !== model.tier_in) edits.tier_in = tierInEl.value;
if (numsInEl && numsInEl.value !== model.allowed_in) edits.allowed_in = numsInEl.value;
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked; if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
return {ext, model, edits, tr, count: Object.keys(edits).length}; return {ext, model, edits, tr, count: Object.keys(edits).length};
} }
@@ -3372,20 +3391,18 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
const r = await postJSON("/api/ea-devices/category", {extension: ext, category: edits.category}); const r = await postJSON("/api/ea-devices/category", {extension: ext, category: edits.category});
if (!r.ok) throw new Error(r.message || "category change failed"); if (!r.ok) throw new Error(r.message || "category change failed");
} }
const permKeys = ["tier_out", "allowed_out", "tier_in", "allowed_in", "messaging"]; const permKeys = ["restrict", "allowed_numbers", "messaging"];
if (permKeys.some(k => k in edits)) { if (permKeys.some(k => k in edits)) {
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging; const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
let r; let r;
if (pstnInstalled) { if (pstnInstalled) {
// Send the whole permission record, not just the changed fields — // Send the whole permission record, not just the changed fields —
// the endpoint rewrites both directions, so omitting an unchanged // the endpoint rewrites all of it, so omitting an unchanged value
// one would silently reset it. // would silently reset it.
r = await postJSON("/api/pstn-permissions", { r = await postJSON("/api/pstn-permissions", {
ext: ext, ext: ext,
tier_out: "tier_out" in edits ? edits.tier_out : model.tier_out, restrict: "restrict" in edits ? edits.restrict : model.restrict,
allowed_out: "allowed_out" in edits ? edits.allowed_out : model.allowed_out, allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
tier_in: "tier_in" in edits ? edits.tier_in : model.tier_in,
allowed_in: "allowed_in" in edits ? edits.allowed_in : model.allowed_in,
messaging: messaging, messaging: messaging,
}); });
} else { } else {
@@ -3918,11 +3935,10 @@ class Handler(BaseHTTPRequestHandler):
perms = get_all_permissions() perms = get_all_permissions()
extensions = [] extensions = []
for e in list_extensions(): for e in list_extensions():
p = perms.get(e["ext"], {"tier_out": "internal", "allowed_out": "", p = perms.get(e["ext"], {"restrict": "none", "allowed_numbers": "", "messaging": False})
"tier_in": "internal", "allowed_in": "", "messaging": False})
extensions.append({"ext": e["ext"], "name": e["name"], extensions.append({"ext": e["ext"], "name": e["name"],
"tier_out": p["tier_out"], "allowed_out": p["allowed_out"], "restrict": p["restrict"],
"tier_in": p["tier_in"], "allowed_in": p["allowed_in"], "allowed_numbers": p["allowed_numbers"],
"messaging": p["messaging"]}) "messaging": p["messaging"]})
self._json({"extensions": extensions}) self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits": elif self.path == "/api/pstn-limits":
@@ -3967,8 +3983,7 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/pstn-permissions": elif self.path == "/api/pstn-permissions":
ok, message = write_permission( ok, message = write_permission(
payload.get("ext", ""), payload.get("ext", ""),
payload.get("tier_out", ""), payload.get("allowed_out", ""), payload.get("restrict", ""), payload.get("allowed_numbers", ""),
payload.get("tier_in", ""), payload.get("allowed_in", ""),
bool(payload.get("messaging", False)), bool(payload.get("messaging", False)),
) )
self._json({"ok": ok, "message": message}) self._json({"ok": ok, "message": message})