Merge pull request #221 from outis1one/claude/sip-voip-integration-atsins
Claude/sip voip integration atsins
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user