diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 48f6bbd..3ef64e0 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -439,12 +439,25 @@ exten => pstn_in_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, re __ALERT_KILLED_IN_LINE__ same => n,Hangup() -; Personal DID inbound routing — rings ONLY the assigned owner, gated by -; that owner's own tier/approved-numbers (same check every ring-group -; member gets, just for a single specific target instead of a list, and -; with no fallback to the shared ring-group if the owner can't take this -; call — it's their own number, not the shared line). -exten => pstn_personal_inbound,1,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)}) +; Personal DID inbound routing — rings the assigned owner, gated by the +; owner's own tier/approved-numbers (same check every ring-group member +; gets, just for a single specific target instead of a list, and with no +; fallback to the shared ring-group if the owner can't take this call — +; it's their own number, not the shared line). The owner may also be a +; 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,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) @@ -452,6 +465,23 @@ exten => pstn_personal_inbound,1,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissio __ALERT_DENY_PERSONAL_LINE__ 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)}) 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) @@ -474,6 +504,64 @@ EOF 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 +# 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
+ 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 # — same "update never touches it" protection as pstn-permissions.conf, see # the file-level comment above) ───────────────────────────────────────────── @@ -1182,10 +1270,12 @@ _pstn_apply_settings() { mkdir -p "$ASTERISK_DIR" _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_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" \ "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" "$CONTAINER_NAME" ensure_docker_dir_ownership "$ASTERISK_DIR" 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" # (and RING_EXTS/TRUNK_SERVER_IPS are space-separated whenever there's diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 038bbf9..812b271 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -1395,24 +1395,42 @@ def write_personal_did(did, owner): 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, 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: return False, "No Asterisk install detected on this box" did = str(did).strip() owner = str(owner).strip() if not PERSONAL_DID_RE.match(did): 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" dids_cp = _read_personal_dids_cp() perms_cp = _read_permissions_cp() - for section in perms_cp.sections(): - if section != owner and perms_cp.get(section, "personal_did", fallback="") == did: - perms_cp.remove_option(section, "personal_did") - if not perms_cp.options(section): - perms_cp.remove_section(section) + if not is_group: + for section in perms_cp.sections(): + if section != owner and perms_cp.get(section, "personal_did", fallback="") == did: + perms_cp.remove_option(section, "personal_did") + if not perms_cp.options(section): + perms_cp.remove_section(section) for section in list(dids_cp.sections()): 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.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): perms_cp.add_section(owner) 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) if not ok: return False, err @@ -1941,9 +1963,14 @@ async function loadPstnPermissions() { const data = await res.json(); 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"); - ownerSel.innerHTML = exts.map(e => ``).join("") - || ''; + const extOptions = exts.map(e => ``).join(""); + const groupOptions = groups.map(g => ``).join(""); + ownerSel.innerHTML = (extOptions + groupOptions) || ''; const tbody = document.querySelector("#pstn-table tbody"); if (!exts.length) { @@ -1991,11 +2018,16 @@ async function loadPersonalDids() { const data = await res.json(); const dids = data.dids || []; const tbody = document.querySelector("#pd-table tbody"); - tbody.innerHTML = dids.map(d => ` + 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 ` ${esc(d.did)} - ${esc(d.owner)}${d.owner_name ? " — " + esc(d.owner_name) : ""} + ${ownerDisplay} - `).join("") || "No personal numbers assigned — every extension shares the main trunk DID."; + `; + }).join("") || "No personal numbers assigned — every extension shares the main trunk DID."; } document.getElementById("pd-save").addEventListener("click", async () => {