Allow assigning a personal number to a group, not just a single extension

The dashboard's Personal Numbers card now accepts a group (stored as
"@GroupName", unambiguous against a same-named numeric extension) as well
as a plain extension. A group-owned DID rings every CURRENT member whose
own tier/approved-numbers authorize the caller, computed fresh on every
call by a generated pstn-personal-group-ring.sh (invoked via the
dialplan's SHELL() function) rather than unrolled at install time, since
group membership can change any time via the dashboard with no reinstall
- unlike the shared ring-group, which is fixed at install/update time.

Applies the identical per-member permission check the shared ring-group
already bakes into the dialplan, just computed in a plain shell loop
against the same two config files - group ownership doesn't bypass the
tier/approved-numbers model. Tested standalone against mock config data
(full/restricted/internal mix, matching/non-matching caller, empty and
nonexistent groups) - all four cases behaved correctly. The dialplan's own
SHELL() invocation is still unverified against a real call.

Group ownership never touches pstn-permissions.conf's personal_did
(outbound Caller-ID override) field, since there's no single extension to
hang that on for a group.
This commit is contained in:
Claude
2026-07-24 03:00:13 +00:00
parent f5e93ec35b
commit dd3ea131ac
2 changed files with 143 additions and 21 deletions
+96 -6
View File
@@ -439,12 +439,25 @@ exten => pstn_in_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, re
__ALERT_KILLED_IN_LINE__ __ALERT_KILLED_IN_LINE__
same => n,Hangup() same => n,Hangup()
; Personal DID inbound routing — rings ONLY the assigned owner, gated by ; Personal DID inbound routing — rings the assigned owner, gated by the
; that owner's own tier/approved-numbers (same check every ring-group ; owner's own tier/approved-numbers (same check every ring-group member
; member gets, just for a single specific target instead of a list, and ; gets, just for a single specific target instead of a list, and with no
; with no fallback to the shared ring-group if the owner can't take this ; fallback to the shared ring-group if the owner can't take this call —
; call — it's their own number, not the shared line). ; it's their own number, not the shared line). The owner may also be a
exten => pstn_personal_inbound,1,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)}) ; GROUP, written "@GroupName" by the Security Dashboard (the '@' keeps it
; unambiguous against a same-named numeric extension) — routed to a
; separate branch below since a group needs the SAME per-member check as
; every individual owner does, but for a variable number of members. That
; can't be unrolled at install time the way the shared ring-group above
; is (RING_EXTS is fixed then; a group's membership can change any time
; via the dashboard with no reinstall) — so it's computed fresh on every
; call by pstn-personal-group-ring.sh instead, which reads the same two
; live config files and applies the identical tier/allowed_numbers logic
; in a plain shell loop. UNVERIFIED: SHELL() invoking that script hasn't
; been confirmed against a live call yet — test a group-assigned personal
; number before relying on it.
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,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_numbers)})
same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${CALLERID(num)})}=1]?pstn_personal_ring,1) same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${CALLERID(num)})}=1]?pstn_personal_ring,1)
@@ -452,6 +465,23 @@ exten => pstn_personal_inbound,1,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissio
__ALERT_DENY_PERSONAL_LINE__ __ALERT_DENY_PERSONAL_LINE__
same => n,Hangup() same => n,Hangup()
exten => pstn_personal_group_ring,1,Set(PSTN_GROUP_NAME=${CUT(PSTN_PERSONAL_OWNER,@,2)})
same => n,Set(PSTN_RING_LIST=${SHELL(/etc/asterisk/pstn-personal-group-ring.sh "${CALLERID(num)}" "${PSTN_GROUP_NAME}")})
same => n,GotoIf($["${PSTN_RING_LIST}" = ""]?pstn_personal_denied_group,1)
same => n,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)})
same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})})
same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1)
same => n,Set(GROUP()=pstn-in)
same => n,Set(PSTN_START=${EPOCH})
same => n,Dial(${PSTN_RING_LIST},20)
same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}])
same => n,System(printf '%s|in|%s|%s|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${PSTN_DID_CALLED}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log)
same => n,Hangup()
exten => pstn_personal_denied_group,1,NoOp(Denied - personal DID ${PSTN_DID_CALLED}'s group ${PSTN_GROUP_NAME} has no member authorized for this caller)
__ALERT_DENY_PERSONAL_LINE__
same => n,Hangup()
exten => pstn_personal_ring,1,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)}) exten => pstn_personal_ring,1,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)})
same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})}) same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})})
same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1) same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1)
@@ -474,6 +504,64 @@ EOF
fi fi
} }
# ── Shared: per-member permission check for a group-owned personal DID ─────
# Invoked fresh on every inbound call via the dialplan's ${SHELL()} function
# (see pstn_personal_group_ring above) rather than unrolled at install time
# — a group's membership can change any time via the Security Dashboard,
# with no reinstall, unlike the shared ring-group's fixed RING_EXTS. Applies
# the IDENTICAL tier/allowed_numbers logic _pstn_ring_member_block bakes
# into the dialplan for the shared ring-group, just in plain shell against
# the same two live config files, so a member who's internal-tier (or
# restricted without this caller on their approved list) never rings here
# either — group ownership doesn't bypass the permission model, same as
# every other path into this trunk.
# Safe REGEX direction: $allowed is admin-entered (pstn-permissions.conf),
# $caller is the incoming Caller-ID — pattern is always the admin data,
# string is always the caller-controlled data, never the reverse.
_pstn_write_personal_group_ring_script() {
local FILE="$1"
cat > "$FILE" << 'SCRIPT'
#!/bin/bash
# Auto-generated by services/pstn-trunk.sh — rerun the installer to refresh,
# don't edit directly. Usage: pstn-personal-group-ring.sh <caller_num> <group_name>
# Prints a &-joined PJSIP dial string for every group member whose own
# tier/approved-numbers authorize this caller; empty output = nobody
# authorized (the dialplan treats that as "deny").
CALLER="$1"
GROUP="$2"
CONF_DIR="/etc/asterisk"
_ini_get() {
# _ini_get <file> <section> <key>
awk -F'=' -v want="[$2]" -v key="$3" '
$0 == want { found=1; next }
/^\[/ { found=0 }
found && $1 == key { sub(/^[^=]*=/, ""); print; exit }
' "$1" 2>/dev/null
}
[[ -z "$CALLER" || -z "$GROUP" ]] && exit 0
MEMBERS_RAW="$(_ini_get "$CONF_DIR/pstn-groups.conf" "$GROUP" "members")"
RING_LIST=""
IFS=',' read -ra MEMBERS <<< "$MEMBERS_RAW"
for _ext in "${MEMBERS[@]}"; do
_ext="$(echo "$_ext" | xargs)"
[[ -z "$_ext" ]] && continue
_tier="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "tier")"
if [[ "$_tier" == "full" ]]; then
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
elif [[ "$_tier" == "restricted" ]]; then
_allowed="$(_ini_get "$CONF_DIR/pstn-permissions.conf" "$_ext" "allowed_numbers")"
if [[ -n "$_allowed" ]] && [[ "$CALLER" =~ ^(${_allowed})$ ]]; then
RING_LIST="${RING_LIST}${RING_LIST:+&}PJSIP/${_ext}"
fi
fi
done
echo "$RING_LIST"
SCRIPT
}
# ── 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) ─────────────────────────────────────────────
@@ -1182,10 +1270,12 @@ _pstn_apply_settings() {
mkdir -p "$ASTERISK_DIR" mkdir -p "$ASTERISK_DIR"
_pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID" _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID"
_pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$RING_EXTS" "$NTFY_URL" _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$RING_EXTS" "$NTFY_URL"
_pstn_write_personal_group_ring_script "$ASTERISK_DIR/pstn-personal-group-ring.sh"
_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"
chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf"
chmod 755 "$ASTERISK_DIR/pstn-personal-group-ring.sh"
# Values are double-quoted: this file is `source`d back in on "update" # Values are double-quoted: this file is `source`d back in on "update"
# (and RING_EXTS/TRUNK_SERVER_IPS are space-separated whenever there's # (and RING_EXTS/TRUNK_SERVER_IPS are space-separated whenever there's
+47 -15
View File
@@ -1395,24 +1395,42 @@ def write_personal_did(did, owner):
it, and giving an extension a new personal DID drops whichever one it it, and giving an extension a new personal DID drops whichever one it
had before — this always leaves a clean 1:1 mapping in both files, had before — this always leaves a clean 1:1 mapping in both files,
rather than requiring the caller to clean up the old assignment rather than requiring the caller to clean up the old assignment
itself.""" itself.
owner may also be a group reference, written as "@GroupName" (the '@'
makes it unambiguous against a same-named numeric extension - group
names are free text and could otherwise collide, e.g. a group literally
named "201"). A group-owned DID rings every CURRENT member whose own
tier/approved-numbers authorize the caller, computed fresh on every
call (see pstn-personal-group-ring.sh) rather than baked in at
assignment time - membership changes take effect immediately, unlike
the Groups card's other bulk actions. Group ownership has no single
extension to hang an outbound Caller-ID override on, so it never
touches pstn-permissions.conf the way a single-extension owner does."""
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"
did = str(did).strip() did = str(did).strip()
owner = str(owner).strip() owner = str(owner).strip()
if not PERSONAL_DID_RE.match(did): if not PERSONAL_DID_RE.match(did):
return False, "DID must be a 10-digit US number" return False, "DID must be a 10-digit US number"
if not EXTEN_RE.match(owner):
is_group = owner.startswith("@")
group_name = owner[1:] if is_group else ""
if is_group:
if not group_name or not _read_groups_cp().has_section(group_name):
return False, "Group '%s' not found" % group_name
elif not EXTEN_RE.match(owner):
return False, "Invalid owner extension" return False, "Invalid owner extension"
dids_cp = _read_personal_dids_cp() dids_cp = _read_personal_dids_cp()
perms_cp = _read_permissions_cp() perms_cp = _read_permissions_cp()
for section in perms_cp.sections(): if not is_group:
if section != owner and perms_cp.get(section, "personal_did", fallback="") == did: for section in perms_cp.sections():
perms_cp.remove_option(section, "personal_did") if section != owner and perms_cp.get(section, "personal_did", fallback="") == did:
if not perms_cp.options(section): perms_cp.remove_option(section, "personal_did")
perms_cp.remove_section(section) if not perms_cp.options(section):
perms_cp.remove_section(section)
for section in list(dids_cp.sections()): for section in list(dids_cp.sections()):
if section != did and dids_cp.get(section, "owner", fallback="") == owner: if section != did and dids_cp.get(section, "owner", fallback="") == owner:
@@ -1422,13 +1440,17 @@ def write_personal_did(did, owner):
dids_cp.add_section(did) dids_cp.add_section(did)
dids_cp.set(did, "owner", owner) dids_cp.set(did, "owner", owner)
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
if not ok:
return False, err
if is_group:
return True, "Assigned %s to group %s" % (did, group_name)
if not perms_cp.has_section(owner): if not perms_cp.has_section(owner):
perms_cp.add_section(owner) perms_cp.add_section(owner)
perms_cp.set(owner, "personal_did", did) perms_cp.set(owner, "personal_did", did)
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
if not ok:
return False, err
ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp) ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp)
if not ok: if not ok:
return False, err return False, err
@@ -1941,9 +1963,14 @@ async function loadPstnPermissions() {
const data = await res.json(); const data = await res.json();
const exts = data.extensions || []; const exts = data.extensions || [];
const grpRes = await fetch("/api/pstn-groups");
const grpData = await grpRes.json();
const groups = grpData.groups || [];
const ownerSel = document.getElementById("pd-owner"); const ownerSel = document.getElementById("pd-owner");
ownerSel.innerHTML = exts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("") const extOptions = exts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("");
|| '<option value="">No extensions found</option>'; const groupOptions = groups.map(g => `<option value="@${esc(g.name)}">Group: ${esc(g.name)}</option>`).join("");
ownerSel.innerHTML = (extOptions + groupOptions) || '<option value="">No extensions found</option>';
const tbody = document.querySelector("#pstn-table tbody"); const tbody = document.querySelector("#pstn-table tbody");
if (!exts.length) { if (!exts.length) {
@@ -1991,11 +2018,16 @@ async function loadPersonalDids() {
const data = await res.json(); const data = await res.json();
const dids = data.dids || []; const dids = data.dids || [];
const tbody = document.querySelector("#pd-table tbody"); const tbody = document.querySelector("#pd-table tbody");
tbody.innerHTML = dids.map(d => `<tr> tbody.innerHTML = dids.map(d => {
const ownerDisplay = d.owner.startsWith("@")
? "Group: " + esc(d.owner.slice(1))
: esc(d.owner) + (d.owner_name ? " — " + esc(d.owner_name) : "");
return `<tr>
<td>${esc(d.did)}</td> <td>${esc(d.did)}</td>
<td>${esc(d.owner)}${d.owner_name ? " — " + esc(d.owner_name) : ""}</td> <td>${ownerDisplay}</td>
<td><button class="action" onclick="removePersonalDid('${esc(d.did)}')">Remove</button></td> <td><button class="action" onclick="removePersonalDid('${esc(d.did)}')">Remove</button></td>
</tr>`).join("") || "<tr><td colspan=3 class=muted>No personal numbers assigned — every extension shares the main trunk DID.</td></tr>"; </tr>`;
}).join("") || "<tr><td colspan=3 class=muted>No personal numbers assigned — every extension shares the main trunk DID.</td></tr>";
} }
document.getElementById("pd-save").addEventListener("click", async () => { document.getElementById("pd-save").addEventListener("click", async () => {