Split the PSTN permission tier into independent inbound and outbound axes
One tier and one allowed_numbers list governed both directions, with the same list matched against dialled numbers going out and caller IDs coming in. Those are different sets, so "dial anyone but only accept calls from a short list" — and its reverse — were not expressible at all. pstn-permissions.conf now carries tier_out/allowed_out and tier_in/allowed_in. The dialplan reads them in the four places that gate a call: the outbound NANP pattern, the international leg, each ring-group member block, and personal-DID inbound routing — plus the group-ring shell helper. Internal extension-to-extension calling and ring groups remain ungated by either, as before. Existing installs are migrated in place by _pstn_migrate_permissions_split, which copies the old single tier into both directions (reproducing the box's current behaviour exactly), backs the file up first, and is idempotent. It runs alongside the dialplan write rather than after a reload, since the new dialplan reading un-migrated keys would fail closed and deny every call. tier/allowed_numbers keep being written as a mirror of the outbound values so that rolling back to a pre-split pstn-trunk.sh degrades to the old semantics instead of breaking. The dashboard's Extensions table gains Outbound/Can-dial and Inbound/Can-be- called-by columns, each number field enabled only by its own tier, tier columns sorting by permissiveness rather than alphabetically, and a save that posts the whole permission record so an unchanged direction isn't reset. Verified: the migration on a hand-written config (idempotent on rerun, messaging and personal_did preserved); generated dialplan and group-ring helper reading the split keys; and the group-ring helper resolving a real three-extension case — out=full/in=restricted rings only for a whitelisted caller, out=restricted/in=full rings for everyone, in=internal never rings — plus the browser round-trip writing tier_out=full with tier_in=restricted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
This commit is contained in:
+106
-19
@@ -2,8 +2,10 @@
|
|||||||
# 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 3-tier
|
||||||
# permission model per extension (internal-only / restricted to pre-approved
|
# permission model applied SEPARATELY per direction (each of outbound and
|
||||||
# numbers / full US calling), a configurable inbound ring-group,
|
# inbound is internal-only / restricted to a list / unrestricted, so "dial
|
||||||
|
# anyone, only take calls from a short list" and the reverse are both
|
||||||
|
# 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.
|
||||||
#
|
#
|
||||||
@@ -24,7 +26,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 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 inbound/outbound permission tiers, 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
|
||||||
@@ -50,7 +52,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 tier (internal/restricted/full)
|
# pstn-permissions.conf holds each extension's per-direction tiers
|
||||||
|
# (tier_out/allowed_out for what it may dial, tier_in/allowed_in for which
|
||||||
|
# caller IDs may reach it; 'tier'/'allowed_numbers' persist only as a
|
||||||
|
# rollback mirror of the outbound values)
|
||||||
# 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
|
||||||
@@ -267,8 +272,10 @@ 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" tier, or "restricted" tier AND the inbound Caller-ID is on its
|
# "full" INBOUND tier, or "restricted" inbound tier AND the caller ID is on
|
||||||
# approved list. Uses a single-quoted heredoc (fully literal — no bash
|
# its inbound approved list (allowed_in). The outbound tier is not consulted
|
||||||
|
# here at all — an extension may dial anywhere and still accept calls from
|
||||||
|
# only a handful of numbers, or 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
|
||||||
@@ -282,9 +289,9 @@ _pstn_ring_member_block() {
|
|||||||
# (bare "?label" / "Goto(label)"), not "label,1" (which addresses a
|
# (bare "?label" / "Goto(label)"), not "label,1" (which addresses a
|
||||||
# different, nonexistent extension named "label" instead).
|
# different, nonexistent extension named "label" instead).
|
||||||
block=$(cat << 'MEMBER'
|
block=$(cat << 'MEMBER'
|
||||||
same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier)})
|
same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier_in)})
|
||||||
same => n,GotoIf($["${PSTN_M_TIER}" = "full"]?ring__EXT__)
|
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,Set(PSTN_M_ALLOWED=${AST_CONFIG(pstn-permissions.conf,__EXT__,allowed_in)})
|
||||||
same => n,GotoIf($["${PSTN_M_TIER}" = "restricted" & ${REGEX("^(${PSTN_M_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?ring__EXT__)
|
same => n,GotoIf($["${PSTN_M_TIER}" = "restricted" & ${REGEX("^(${PSTN_M_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?ring__EXT__)
|
||||||
same => n,Goto(skip__EXT__)
|
same => n,Goto(skip__EXT__)
|
||||||
same => n(ring__EXT__),Set(PSTN_RING_LIST=${PSTN_RING_LIST}${PSTN_RING_SEP}PJSIP/__EXT__)
|
same => n(ring__EXT__),Set(PSTN_RING_LIST=${PSTN_RING_LIST}${PSTN_RING_SEP}PJSIP/__EXT__)
|
||||||
@@ -350,10 +357,10 @@ exten => _1NXXNXXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL} to ${EXT
|
|||||||
same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1)
|
same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1)
|
||||||
same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)})
|
same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)})
|
||||||
same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)})
|
same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)})
|
||||||
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)})
|
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier_out)})
|
||||||
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1)
|
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1)
|
||||||
same => n,GotoIf($["${PSTN_TIER}" = "restricted"]?pstn_check_allow_out,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})
|
same => n,NoOp(Denied - ${PSTN_CALLER} has no outbound PSTN permission, tier_out: ${PSTN_TIER})
|
||||||
__ALERT_DENY_TIER_LINE__
|
__ALERT_DENY_TIER_LINE__
|
||||||
same => n,Busy(15)
|
same => n,Busy(15)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -392,9 +399,9 @@ exten => _011X.,1,NoOp(PSTN international outbound call attempt from ${CHANNEL}
|
|||||||
same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1)
|
same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1)
|
||||||
same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)})
|
same => n,Set(PSTN_CALLER=${CUT(CHANNEL,/,2)})
|
||||||
same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)})
|
same => n,Set(PSTN_CALLER=${CUT(PSTN_CALLER,-,1)})
|
||||||
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)})
|
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier_out)})
|
||||||
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_intl_check_country,1)
|
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_intl_check_country,1)
|
||||||
same => n,NoOp(Denied intl - ${PSTN_CALLER} tier ${PSTN_TIER} not eligible for international calling)
|
same => n,NoOp(Denied intl - ${PSTN_CALLER} tier_out ${PSTN_TIER} not eligible for international calling)
|
||||||
__ALERT_DENY_INTL_TIER_LINE__
|
__ALERT_DENY_INTL_TIER_LINE__
|
||||||
same => n,Busy(15)
|
same => n,Busy(15)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -410,7 +417,7 @@ __ALERT_DENY_INTL_COUNTRY_LINE__
|
|||||||
same => n,Busy(15)
|
same => n,Busy(15)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)})
|
exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_out)})
|
||||||
same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${PSTN_DIALED})} = 1]?pstn_check_busy,1)
|
same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${PSTN_DIALED})} = 1]?pstn_check_busy,1)
|
||||||
same => n,NoOp(Denied - ${PSTN_DIALED} not on ${PSTN_CALLER}'s approved number list)
|
same => n,NoOp(Denied - ${PSTN_DIALED} not on ${PSTN_CALLER}'s approved number list)
|
||||||
__ALERT_DENY_NUMBER_LINE__
|
__ALERT_DENY_NUMBER_LINE__
|
||||||
@@ -608,9 +615,9 @@ __ALERT_KILLED_IN_LINE__
|
|||||||
; restart — see restart_asterisk_container() in services/security-dashboard.sh)
|
; restart — see restart_asterisk_container() in services/security-dashboard.sh)
|
||||||
; before AST_CONFIG() actually returns the new value.
|
; before AST_CONFIG() actually returns the new value.
|
||||||
exten => pstn_personal_inbound,1,GotoIf($["${PSTN_PERSONAL_OWNER:0:1}" = "@"]?pstn_personal_group_ring,1)
|
exten => pstn_personal_inbound,1,GotoIf($["${PSTN_PERSONAL_OWNER:0:1}" = "@"]?pstn_personal_group_ring,1)
|
||||||
same => n,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)})
|
same => n,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier_in)})
|
||||||
same => n,GotoIf($["${PSTN_OWNER_TIER}" = "full"]?pstn_personal_ring,1)
|
same => n,GotoIf($["${PSTN_OWNER_TIER}" = "full"]?pstn_personal_ring,1)
|
||||||
same => n,Set(PSTN_OWNER_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},allowed_numbers)})
|
same => n,Set(PSTN_OWNER_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},allowed_in)})
|
||||||
same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?pstn_personal_ring,1)
|
same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${PSTN_CALLERID_NORM})}=1]?pstn_personal_ring,1)
|
||||||
same => n,NoOp(Denied - personal DID ${PSTN_DID_CALLED}'s owner ${PSTN_PERSONAL_OWNER} not authorized for this caller)
|
same => n,NoOp(Denied - personal DID ${PSTN_DID_CALLED}'s owner ${PSTN_PERSONAL_OWNER} not authorized for this caller)
|
||||||
__ALERT_DENY_PERSONAL_LINE__
|
__ALERT_DENY_PERSONAL_LINE__
|
||||||
@@ -734,11 +741,11 @@ IFS=',' read -ra MEMBERS <<< "$MEMBERS_RAW"
|
|||||||
for _ext in "${MEMBERS[@]}"; do
|
for _ext in "${MEMBERS[@]}"; do
|
||||||
_ext="$(echo "$_ext" | xargs)"
|
_ext="$(echo "$_ext" | xargs)"
|
||||||
[[ -z "$_ext" ]] && continue
|
[[ -z "$_ext" ]] && continue
|
||||||
_tier="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "tier")"
|
_tier="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "tier_in")"
|
||||||
if [[ "$_tier" == "full" ]]; then
|
if [[ "$_tier" == "full" ]]; then
|
||||||
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
|
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
|
||||||
elif [[ "$_tier" == "restricted" ]]; then
|
elif [[ "$_tier" == "restricted" ]]; then
|
||||||
_allowed="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "allowed_numbers")"
|
_allowed="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "allowed_in")"
|
||||||
if [[ -n "$_allowed" ]] && [[ "$CALLER" =~ ^(${_allowed})$ ]]; then
|
if [[ -n "$_allowed" ]] && [[ "$CALLER" =~ ^(${_allowed})$ ]]; then
|
||||||
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
|
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
|
||||||
fi
|
fi
|
||||||
@@ -748,6 +755,62 @@ printf '%s' "$RING_LIST"
|
|||||||
SCRIPT
|
SCRIPT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Migration: single tier → separate inbound/outbound tiers ───────────────
|
||||||
|
# Installs made before the split have only 'tier' and 'allowed_numbers', which
|
||||||
|
# the new dialplan doesn't read — leaving them untouched would fail closed and
|
||||||
|
# silently deny every PSTN call in both directions. Copies the old values into
|
||||||
|
# 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
|
||||||
|
# safely on every update. Backs the file up first — unlike most of this
|
||||||
|
# installer, it edits a file the user may have hand-tuned.
|
||||||
|
_pstn_migrate_permissions_split() {
|
||||||
|
local FILE="$1"
|
||||||
|
[[ -f "$FILE" ]] || return 0
|
||||||
|
grep -q '^[[:space:]]*tier[[:space:]]*=' "$FILE" || return 0
|
||||||
|
grep -q '^[[:space:]]*tier_out[[:space:]]*=' "$FILE" && return 0
|
||||||
|
|
||||||
|
cp "$FILE" "$FILE.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
local TMP
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
awk '
|
||||||
|
# Emit the split keys immediately after each legacy key, preserving
|
||||||
|
# whatever spacing style the file already uses around "=".
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
if (match(line, /^[ \t]*tier[ \t]*=[ \t]*/)) {
|
||||||
|
val = line; sub(/^[ \t]*tier[ \t]*=[ \t]*/, "", val)
|
||||||
|
print "tier_out=" val
|
||||||
|
print "tier_in=" val
|
||||||
|
print line
|
||||||
|
next
|
||||||
|
}
|
||||||
|
if (match(line, /^[ \t]*allowed_numbers[ \t]*=[ \t]*/)) {
|
||||||
|
val = line; sub(/^[ \t]*allowed_numbers[ \t]*=[ \t]*/, "", val)
|
||||||
|
print "allowed_out=" val
|
||||||
|
print "allowed_in=" val
|
||||||
|
print line
|
||||||
|
next
|
||||||
|
}
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
' "$FILE" > "$TMP"
|
||||||
|
|
||||||
|
if [[ -s "$TMP" ]]; then
|
||||||
|
mv "$TMP" "$FILE"
|
||||||
|
chmod 664 "$FILE"
|
||||||
|
log_success "Migrated pstn-permissions.conf to separate inbound/outbound tiers (backup saved alongside it)."
|
||||||
|
log_info "Every extension kept its existing behaviour — both directions were set to"
|
||||||
|
log_info "whatever its single tier used to be. Split them per direction in the"
|
||||||
|
log_info "Security Dashboard's Extensions tab."
|
||||||
|
else
|
||||||
|
rm -f "$TMP"
|
||||||
|
log_warning "Permission migration produced an empty file — left the original alone."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ── Shared: initial concurrency limits (fresh install / explicit reset only
|
# ── Shared: initial concurrency limits (fresh install / explicit reset only
|
||||||
# — same "update never touches it" protection as pstn-permissions.conf, see
|
# — same "update never touches it" protection as pstn-permissions.conf, see
|
||||||
# the file-level comment above) ─────────────────────────────────────────────
|
# the file-level comment above) ─────────────────────────────────────────────
|
||||||
@@ -784,8 +847,22 @@ _pstn_write_permissions_file() {
|
|||||||
done
|
done
|
||||||
local _written_exts=""
|
local _written_exts=""
|
||||||
{
|
{
|
||||||
echo "; PSTN permission tiers — internal / restricted / full — PLUS two independent"
|
echo "; PSTN permission tiers — internal / restricted / full — set SEPARATELY per"
|
||||||
echo "; axes per extension:"
|
echo "; direction:"
|
||||||
|
echo "; - 'tier_out' + 'allowed_out' gate calls this extension PLACES. allowed_out"
|
||||||
|
echo "; holds numbers it may DIAL."
|
||||||
|
echo "; - 'tier_in' + 'allowed_in' gate calls this extension RECEIVES (ring group"
|
||||||
|
echo "; membership and personal-DID routing). allowed_in holds CALLER IDs allowed"
|
||||||
|
echo "; to reach it."
|
||||||
|
echo "; The two are independent, so 'dial anyone, only take calls from a short list'"
|
||||||
|
echo "; and 'answer anyone, only dial a short list' are both expressible. Both lists"
|
||||||
|
echo "; are pipe-separated 11-digit numbers (a REGEX() alternation, see this file's"
|
||||||
|
echo "; own comments on why untrusted call data is never the pattern side)."
|
||||||
|
echo "; 'tier'/'allowed_numbers' are also written, mirroring the OUTBOUND values, so"
|
||||||
|
echo "; that rolling back to a pre-split pstn-trunk.sh keeps working with the old"
|
||||||
|
echo "; single-tier semantics. Nothing reads them once the split dialplan is in."
|
||||||
|
echo ";"
|
||||||
|
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"
|
||||||
echo "; SMS, no PSTN, no cost — a separate axis from PSTN calling, since the risk"
|
echo "; SMS, no PSTN, no cost — a separate axis from PSTN calling, since the risk"
|
||||||
echo "; profile is different: an extension can be internal-tier for calling and"
|
echo "; profile is different: an extension can be internal-tier for calling and"
|
||||||
@@ -808,6 +885,8 @@ _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 "tier_out=full"
|
||||||
|
echo "tier_in=full"
|
||||||
echo "tier=full"
|
echo "tier=full"
|
||||||
[[ " $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]}"
|
||||||
@@ -818,6 +897,10 @@ _pstn_write_permissions_file() {
|
|||||||
_ext="$1"; local _nums="$2"
|
_ext="$1"; local _nums="$2"
|
||||||
shift 2
|
shift 2
|
||||||
echo "[$_ext]"
|
echo "[$_ext]"
|
||||||
|
echo "tier_out=restricted"
|
||||||
|
echo "allowed_out=${_nums}"
|
||||||
|
echo "tier_in=restricted"
|
||||||
|
echo "allowed_in=${_nums}"
|
||||||
echo "tier=restricted"
|
echo "tier=restricted"
|
||||||
echo "allowed_numbers=${_nums}"
|
echo "allowed_numbers=${_nums}"
|
||||||
[[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes"
|
[[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes"
|
||||||
@@ -1458,6 +1541,10 @@ _pstn_apply_settings() {
|
|||||||
_pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$NTFY_URL"
|
_pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$NTFY_URL"
|
||||||
_pstn_write_inbound_dialplan_include "$ASTERISK_DIR/pstn-trunk-inbound-dialplan.conf" "$RING_EXTS" "$NTFY_URL"
|
_pstn_write_inbound_dialplan_include "$ASTERISK_DIR/pstn-trunk-inbound-dialplan.conf" "$RING_EXTS" "$NTFY_URL"
|
||||||
_pstn_write_personal_group_ring_script "$ASTERISK_DIR/pstn-personal-group-ring.sh"
|
_pstn_write_personal_group_ring_script "$ASTERISK_DIR/pstn-personal-group-ring.sh"
|
||||||
|
# Must run alongside the dialplan write, not after a reload: the dialplan
|
||||||
|
# above reads tier_out/tier_in, so an un-migrated permissions file would
|
||||||
|
# deny every call in the window between the two.
|
||||||
|
_pstn_migrate_permissions_split "$ASTERISK_DIR/pstn-permissions.conf"
|
||||||
_pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$ASTERISK_DIR" \
|
_pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$ASTERISK_DIR" \
|
||||||
"$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" "$CONTAINER_NAME"
|
"$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" "$CONTAINER_NAME"
|
||||||
ensure_docker_dir_ownership "$ASTERISK_DIR"
|
ensure_docker_dir_ownership "$ASTERISK_DIR"
|
||||||
|
|||||||
+189
-81
@@ -226,9 +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 Tier/Approved-numbers if a PSTN trunk dialplan
|
container is present, then four PSTN columns if a trunk dialplan is
|
||||||
is installed, then Messaging (always — internal SIP texting has no PSTN
|
installed — **Outbound** tier + "Can dial", and **Inbound** tier + "Can be
|
||||||
dependency at all: no cost, no carrier, no DID).
|
called by" — then Messaging (always: internal SIP texting has no PSTN
|
||||||
|
dependency at all — no cost, no carrier, no DID).
|
||||||
|
|
||||||
|
The two PSTN directions are independent. Each is \`internal\` (no PSTN that
|
||||||
|
way), \`restricted\` (only the numbers beside it) or \`full\` (any US
|
||||||
|
number), and each has its own list, because they hold different things:
|
||||||
|
"Can dial" is numbers this extension may call, "Can be called by" is caller
|
||||||
|
IDs allowed to reach it. Internal extension-to-extension calling and ring
|
||||||
|
groups are never gated by either.
|
||||||
|
|
||||||
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
|
||||||
@@ -1134,7 +1142,12 @@ def _write_ini_cp(path, header, cp):
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS_HEADER = (
|
PERMISSIONS_HEADER = (
|
||||||
"; PSTN permission tiers - internal / restricted / full - PLUS two\n"
|
"; PSTN permission tiers - internal / restricted / full - set SEPARATELY\n"
|
||||||
|
"; per direction: tier_out/allowed_out gate what an extension may DIAL,\n"
|
||||||
|
"; tier_in/allowed_in gate which CALLER IDs may reach it. 'tier' and\n"
|
||||||
|
"; 'allowed_numbers' mirror the outbound values so a rollback to a\n"
|
||||||
|
"; pre-split pstn-trunk.sh still works; nothing reads them otherwise.\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"
|
||||||
"; routing for personal DIDs lives in pstn-personal-dids.conf).\n"
|
"; routing for personal DIDs lives in pstn-personal-dids.conf).\n"
|
||||||
@@ -1173,70 +1186,124 @@ def _read_permissions_cp():
|
|||||||
|
|
||||||
|
|
||||||
def get_all_permissions():
|
def get_all_permissions():
|
||||||
"""{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging":
|
"""{ext: {"tier_out", "allowed_out", "tier_in", "allowed_in",
|
||||||
bool}} for every extension with a non-default record. Extensions with
|
"messaging"}} for every extension with a non-default record.
|
||||||
no section are implicitly "internal"/messaging-disabled — the
|
|
||||||
dialplan's AST_CONFIG() lookup treats a missing section/key as empty/
|
Outbound and inbound are independent axes: tier_out/allowed_out gate what
|
||||||
denied the same way, so there's nothing to return for them here; the
|
an extension may DIAL, tier_in/allowed_in gate which CALLER IDs may reach
|
||||||
UI fills in the defaults for any known extension (from
|
it (ring-group membership and personal-DID routing). That's what makes
|
||||||
list_extensions()) not present in this dict."""
|
"dial anyone, only accept calls from a list" and "answer anyone, only
|
||||||
|
dial a list" both expressible.
|
||||||
|
|
||||||
|
Falls back to the pre-split 'tier'/'allowed_numbers' keys for either
|
||||||
|
direction that hasn't been migrated yet, so the UI reads correctly even
|
||||||
|
on a box where services/pstn-trunk.sh hasn't been re-run — the migration
|
||||||
|
itself lives there, not here.
|
||||||
|
|
||||||
|
Extensions with no section are implicitly internal/messaging-disabled —
|
||||||
|
the dialplan's AST_CONFIG() lookup treats a missing section/key as
|
||||||
|
empty/denied the same way, so there's nothing to return for them; the UI
|
||||||
|
fills in defaults for any known extension not present in this dict."""
|
||||||
cp = _read_permissions_cp()
|
cp = _read_permissions_cp()
|
||||||
result = {}
|
result = {}
|
||||||
for section in cp.sections():
|
for section in cp.sections():
|
||||||
if not EXTEN_RE.match(section):
|
if not EXTEN_RE.match(section):
|
||||||
continue
|
continue
|
||||||
|
legacy_tier = cp.get(section, "tier", fallback="internal")
|
||||||
|
legacy_nums = cp.get(section, "allowed_numbers", fallback="")
|
||||||
result[section] = {
|
result[section] = {
|
||||||
"tier": cp.get(section, "tier", fallback="internal"),
|
"tier_out": cp.get(section, "tier_out", fallback=legacy_tier),
|
||||||
"allowed_numbers": cp.get(section, "allowed_numbers", fallback=""),
|
"allowed_out": cp.get(section, "allowed_out", fallback=legacy_nums),
|
||||||
|
"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 write_permission(ext, tier, numbers_raw, messaging_enabled=False):
|
def _apply_direction(cp, ext, prefix, tier, numbers_raw):
|
||||||
"""Saves one extension's tier + (for restricted) approved-number list +
|
"""Write one direction's tier + number list, returning the cleaned list.
|
||||||
messaging flag in one action — messaging is an independent axis from
|
|
||||||
the calling tier (see pstn-trunk.sh's file-level comment: an extension
|
prefix is "out" or "in". Setting a direction to internal drops only that
|
||||||
can be internal-tier for calling and still messaging-enabled, or vice
|
direction's two keys, never the whole section — an extension can
|
||||||
versa), so it's set/cleared regardless of which tier branch runs below.
|
independently carry messaging=yes, a personal_did, and a permissive
|
||||||
Numbers are normalized to a pipe-separated list of 11-digit US numbers
|
setting in the OTHER direction, all of which must survive. (Removing the
|
||||||
(a bare 10-digit entry gets a leading "1" added, not dropped — see
|
section wholesale here was a real, confirmed bug in the single-tier
|
||||||
_normalize_nanp_number) — pipe, not comma, because the dialplan uses
|
version of this function.)"""
|
||||||
this value directly as a REGEX() alternation pattern (see
|
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
|
||||||
services/pstn-trunk.sh's file-level comment on why the untrusted call
|
clean = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
|
||||||
data is always the string being tested, never interpolated into the
|
joined = "|".join(clean)
|
||||||
pattern side)."""
|
|
||||||
|
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,
|
||||||
|
messaging_enabled=False):
|
||||||
|
"""Saves one extension's outbound tier + numbers, inbound tier + numbers,
|
||||||
|
and messaging flag in one action.
|
||||||
|
|
||||||
|
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
|
||||||
|
comment: an extension can be internal for calling and still
|
||||||
|
messaging-enabled, or vice versa), so it's set/cleared regardless of
|
||||||
|
either tier.
|
||||||
|
|
||||||
|
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
|
||||||
|
_normalize_nanp_number). Pipe, not comma, because the dialplan uses the
|
||||||
|
value directly as a REGEX() alternation — see services/pstn-trunk.sh on
|
||||||
|
why untrusted call data is always the string being tested, never
|
||||||
|
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):
|
if not TIER_RE.match(tier_out or ""):
|
||||||
return False, "Invalid tier"
|
return False, "Invalid outbound tier"
|
||||||
|
if not TIER_RE.match(tier_in or ""):
|
||||||
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
|
return False, "Invalid inbound tier"
|
||||||
clean_numbers = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
|
|
||||||
numbers = "|".join(clean_numbers)
|
|
||||||
|
|
||||||
cp = _read_permissions_cp()
|
cp = _read_permissions_cp()
|
||||||
if tier == "internal":
|
clean_out = _apply_direction(cp, ext, "out", tier_out, allowed_out_raw)
|
||||||
# Only drop the tier/allowed_numbers keys, NOT the whole section —
|
clean_in = _apply_direction(cp, ext, "in", tier_in, allowed_in_raw)
|
||||||
# an extension can independently have messaging=yes and/or a
|
|
||||||
# personal_did assigned, and those must survive a tier change back
|
# Rollback mirror — outbound values under the pre-split key names.
|
||||||
# to internal. Confirmed live as a real bug: cp.remove_section(ext)
|
if tier_out == "internal":
|
||||||
# here used to silently discard both whenever tier was set to
|
|
||||||
# internal.
|
|
||||||
if cp.has_section(ext):
|
if cp.has_section(ext):
|
||||||
if cp.has_option(ext, "tier"):
|
for key in ("tier", "allowed_numbers"):
|
||||||
cp.remove_option(ext, "tier")
|
if cp.has_option(ext, key):
|
||||||
if cp.has_option(ext, "allowed_numbers"):
|
cp.remove_option(ext, key)
|
||||||
cp.remove_option(ext, "allowed_numbers")
|
|
||||||
else:
|
else:
|
||||||
if not cp.has_section(ext):
|
cp.set(ext, "tier", tier_out)
|
||||||
cp.add_section(ext)
|
if tier_out == "restricted":
|
||||||
cp.set(ext, "tier", tier)
|
cp.set(ext, "allowed_numbers", "|".join(clean_out))
|
||||||
if tier == "restricted":
|
|
||||||
cp.set(ext, "allowed_numbers", numbers)
|
|
||||||
elif cp.has_option(ext, "allowed_numbers"):
|
elif cp.has_option(ext, "allowed_numbers"):
|
||||||
cp.remove_option(ext, "allowed_numbers")
|
cp.remove_option(ext, "allowed_numbers")
|
||||||
|
|
||||||
@@ -1247,9 +1314,9 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
|
|||||||
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
|
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
|
||||||
cp.remove_option(ext, "messaging")
|
cp.remove_option(ext, "messaging")
|
||||||
|
|
||||||
# Drop the section entirely once nothing (tier, numbers, messaging,
|
# Drop the section entirely once nothing is left in it — only reachable
|
||||||
# personal_did) is left in it — only reached this way when tier is
|
# when both directions are internal, messaging is off, and no
|
||||||
# internal, messaging is off, and no personal_did was ever assigned.
|
# personal_did was ever 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)
|
||||||
|
|
||||||
@@ -1257,8 +1324,14 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return False, err
|
return False, err
|
||||||
|
|
||||||
if tier == "restricted" and not clean_numbers:
|
empty = []
|
||||||
return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet."
|
if tier_out == "restricted" and not clean_out:
|
||||||
|
empty.append("outbound")
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
@@ -2602,7 +2675,8 @@ 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>Tier</b> — <b>internal</b>: no PSTN, can still call/receive other extensions and internal ring groups. <b>restricted</b>: internal, plus only the pre-approved US numbers in the next column. <b>full</b>: internal, plus any US number. Usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.</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 full + Inbound restricted, and the reverse is just as valid.</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>
|
||||||
<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>
|
||||||
|
|
||||||
@@ -2613,8 +2687,10 @@ 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">Tier</th>
|
<th class="sortable pstn-only" data-sort="tier_out">Outbound</th>
|
||||||
<th class="pstn-only">Approved numbers</th>
|
<th class="pstn-only">Can dial</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>
|
||||||
@@ -3093,7 +3169,9 @@ async function loadExtensions() {
|
|||||||
const permData = await fetch("/api/pstn-permissions").then(r => r.json());
|
const permData = await fetch("/api/pstn-permissions").then(r => r.json());
|
||||||
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, tier: e.tier, allowed_numbers: e.allowed_numbers,
|
ext: e.ext, name: e.name,
|
||||||
|
tier_out: e.tier_out, allowed_out: e.allowed_out,
|
||||||
|
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: "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -3103,7 +3181,10 @@ async function loadExtensions() {
|
|||||||
eaStatusMap = devData.status || {};
|
eaStatusMap = devData.status || {};
|
||||||
eaDevices.forEach(d => {
|
eaDevices.forEach(d => {
|
||||||
const row = byExt.get(d.extension) || {
|
const row = byExt.get(d.extension) || {
|
||||||
ext: d.extension, name: d.name, tier: "internal", allowed_numbers: "", messaging: false,
|
ext: d.extension, name: d.name,
|
||||||
|
tier_out: "internal", allowed_out: "",
|
||||||
|
tier_in: "internal", allowed_in: "",
|
||||||
|
messaging: false,
|
||||||
};
|
};
|
||||||
row.ea = true;
|
row.ea = true;
|
||||||
row.name = row.name || d.name;
|
row.name = row.name || d.name;
|
||||||
@@ -3124,9 +3205,15 @@ 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
|
||||||
|
// permissive it is rather than alphabetically (full would otherwise sort
|
||||||
|
// between the other two).
|
||||||
|
const TIER_ORDER = { internal: 0, restricted: 1, full: 2 };
|
||||||
|
|
||||||
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;
|
||||||
return (e[key] || "").toString().toLowerCase();
|
return (e[key] || "").toString().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3135,11 +3222,18 @@ function setCount(id, n) {
|
|||||||
if (el) el.textContent = n ? "(" + n + ")" : "";
|
if (el) el.textContent = n ? "(" + n + ")" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tierSelect(cls, value) {
|
||||||
|
return `<select class="${cls}">` +
|
||||||
|
["internal", "restricted", "full"].map(t =>
|
||||||
|
`<option value="${t}" ${value === t ? "selected" : ""}>${t}</option>`).join("") +
|
||||||
|
"</select>";
|
||||||
|
}
|
||||||
|
|
||||||
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=9 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan=11 class=empty>No extensions found — no Asterisk install detected, or pjsip.conf has no devices yet.</td></tr>';
|
||||||
updateDirtyState();
|
updateDirtyState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3179,14 +3273,10 @@ 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">
|
<td class="pstn-only">${tierSelect("ext-tier-out", e.tier_out)}</td>
|
||||||
<select class="ext-tier">
|
<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>
|
||||||
<option value="internal" ${e.tier === "internal" ? "selected" : ""}>internal</option>
|
<td class="pstn-only">${tierSelect("ext-tier-in", e.tier_in)}</td>
|
||||||
<option value="restricted" ${e.tier === "restricted" ? "selected" : ""}>restricted</option>
|
<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>
|
||||||
<option value="full" ${e.tier === "full" ? "selected" : ""}>full</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543" ${e.tier === "restricted" ? "" : "disabled"} style="width:15rem"></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)}')">×</button>
|
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">×</button>
|
||||||
@@ -3195,11 +3285,14 @@ function renderExtensions() {
|
|||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
tbody.querySelectorAll("tr").forEach(row => {
|
tbody.querySelectorAll("tr").forEach(row => {
|
||||||
const tierSel = row.querySelector(".ext-tier");
|
// Each direction's number field follows its own tier, not the other's.
|
||||||
const numsInput = row.querySelector(".ext-numbers");
|
[["out"], ["in"]].forEach(([dir]) => {
|
||||||
|
const tierSel = row.querySelector(".ext-tier-" + dir);
|
||||||
|
const numsInput = row.querySelector(".ext-numbers-" + dir);
|
||||||
if (tierSel && numsInput) {
|
if (tierSel && numsInput) {
|
||||||
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
|
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);
|
||||||
@@ -3219,14 +3312,18 @@ 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 tierEl = tr.querySelector(".ext-tier");
|
const tierOutEl = tr.querySelector(".ext-tier-out");
|
||||||
const numsEl = tr.querySelector(".ext-numbers");
|
const numsOutEl = tr.querySelector(".ext-numbers-out");
|
||||||
|
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 (tierEl && tierEl.value !== model.tier) edits.tier = tierEl.value;
|
if (tierOutEl && tierOutEl.value !== model.tier_out) edits.tier_out = tierOutEl.value;
|
||||||
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
if (numsOutEl && numsOutEl.value !== model.allowed_out) edits.allowed_out = numsOutEl.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};
|
||||||
}
|
}
|
||||||
@@ -3275,14 +3372,20 @@ 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");
|
||||||
}
|
}
|
||||||
if ("tier" in edits || "allowed_numbers" in edits || "messaging" in edits) {
|
const permKeys = ["tier_out", "allowed_out", "tier_in", "allowed_in", "messaging"];
|
||||||
|
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 —
|
||||||
|
// the endpoint rewrites both directions, so omitting an unchanged
|
||||||
|
// one would silently reset it.
|
||||||
r = await postJSON("/api/pstn-permissions", {
|
r = await postJSON("/api/pstn-permissions", {
|
||||||
ext: ext,
|
ext: ext,
|
||||||
tier: "tier" in edits ? edits.tier : model.tier,
|
tier_out: "tier_out" in edits ? edits.tier_out : model.tier_out,
|
||||||
allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
|
allowed_out: "allowed_out" in edits ? edits.allowed_out : model.allowed_out,
|
||||||
|
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 {
|
||||||
@@ -3815,9 +3918,12 @@ 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": "internal", "allowed_numbers": "", "messaging": False})
|
p = perms.get(e["ext"], {"tier_out": "internal", "allowed_out": "",
|
||||||
extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"],
|
"tier_in": "internal", "allowed_in": "", "messaging": False})
|
||||||
"allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]})
|
extensions.append({"ext": e["ext"], "name": e["name"],
|
||||||
|
"tier_out": p["tier_out"], "allowed_out": p["allowed_out"],
|
||||||
|
"tier_in": p["tier_in"], "allowed_in": p["allowed_in"],
|
||||||
|
"messaging": p["messaging"]})
|
||||||
self._json({"extensions": extensions})
|
self._json({"extensions": extensions})
|
||||||
elif self.path == "/api/pstn-limits":
|
elif self.path == "/api/pstn-limits":
|
||||||
self._json(get_limits())
|
self._json(get_limits())
|
||||||
@@ -3860,8 +3966,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json(ban_asn(payload.get("asn", "")))
|
self._json(ban_asn(payload.get("asn", "")))
|
||||||
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("tier", ""), payload.get("allowed_numbers", ""),
|
payload.get("ext", ""),
|
||||||
bool(payload.get("messaging", False))
|
payload.get("tier_out", ""), payload.get("allowed_out", ""),
|
||||||
|
payload.get("tier_in", ""), payload.get("allowed_in", ""),
|
||||||
|
bool(payload.get("messaging", False)),
|
||||||
)
|
)
|
||||||
self._json({"ok": ok, "message": message})
|
self._json({"ok": ok, "message": message})
|
||||||
elif self.path == "/api/pstn-limits":
|
elif self.path == "/api/pstn-limits":
|
||||||
|
|||||||
Reference in New Issue
Block a user