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
leading 1** (matches the same convention as the trunk DID above) —
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
+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__
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 <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
# — 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
+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
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
@@ -1603,7 +1625,7 @@ INDEX_HTML = """<!doctype html>
<div class="card">
<h3 style="margin-top:0">Personal numbers</h3>
<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>
<div class="row">
<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 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 => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("")
|| '<option value="">No extensions found</option>';
const extOptions = exts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("");
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");
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 => `<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.owner)}${d.owner_name ? " — " + esc(d.owner_name) : ""}</td>
<td>${ownerDisplay}</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 () => {