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

Claude/sip voip integration atsins
This commit is contained in:
Outis
2026-07-23 23:11:17 -04:00
committed by GitHub
3 changed files with 152 additions and 22 deletions
+8
View File
@@ -159,6 +159,14 @@ In the Security Dashboard's PSTN Trunk tab:
DID instead of the shared trunk DID. Enter the DID as **10 digits, no DID instead of the shared trunk DID. Enter the DID as **10 digits, no
leading 1** (matches the same convention as the trunk DID above) — leading 1** (matches the same convention as the trunk DID above) —
the dashboard's own input validates against exactly that format. the dashboard's own input validates against exactly that format.
You can assign the DID to a **group** instead of a single extension —
pick `Group: <name>` in the owner dropdown (create the group first
under Groups if it doesn't exist yet). Every current member whose own
tier/approved-numbers authorize the caller rings, checked fresh on
every call, so membership changes apply immediately with no reinstall.
A group has no single extension to hang an outbound Caller-ID override
on, so the Caller-ID-override part above only applies to
single-extension assignments.
## 7. Test ## 7. Test
+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
+48 -16
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
@@ -1603,7 +1625,7 @@ INDEX_HTML = """<!doctype html>
<div class="card"> <div class="card">
<h3 style="margin-top:0">Personal numbers</h3> <h3 style="margin-top:0">Personal numbers</h3>
<p class="muted"> <p class="muted">
Multiple DIDs can share this one trunk. Assigning a DID to an extension routes inbound calls to that DID straight to its owner (still gated by the owner's own tier/approved-numbers above — no ring-group fallback), and makes that extension's outbound calls show this DID as Caller-ID instead of the shared trunk DID. The shared DID/ring-group keeps working regardless. Multiple DIDs can share this one trunk. Assigning a DID to an extension routes inbound calls to that DID straight to its owner (still gated by the owner's own tier/approved-numbers above — no ring-group fallback), and makes that extension's outbound calls show this DID as Caller-ID instead of the shared trunk DID. You can also assign a DID to a <b>group</b> instead of a single extension — every current member whose own tier/approved-numbers authorize the caller rings (checked fresh on every call, so membership changes apply immediately); a group has no single extension to hang the outbound Caller-ID override on, so that part only applies to single-extension assignments. The shared DID/ring-group keeps working regardless.
</p> </p>
<div class="row"> <div class="row">
<input type="text" id="pd-did" placeholder="DID, e.g. 5551234567 (10 digits, no leading 1)" style="width:12rem"> <input type="text" id="pd-did" placeholder="DID, e.g. 5551234567 (10 digits, no leading 1)" style="width:12rem">
@@ -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 () => {