diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 75d59e6..1063814 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -103,7 +103,7 @@ install_security-dashboard() { echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ SECURITY DASHBOARD │" echo "│ Asterisk failed-connection log + one Extensions tab (devices, │" - echo "│ categories, rooms, groups, PSTN tiers, DIDs) + CrowdSec bans, │" + echo "│ ring groups, PSTN tiers, personal DIDs) + CrowdSec bans, │" echo "│ one page. Runs natively on the host (not Docker) so it can call │" echo "│ cscli and read Asterisk's files directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" @@ -248,45 +248,47 @@ which tab a given extension's settings live on. first. Delete keeps its own control per row, since it's destructive and must not ride along with a batch. - Everything below the table — Categories, Rooms, Groups, Concurrent-call - caps, Personal numbers — is a collapsed section with an item count in its - header, so the tab opens on the extensions table rather than on six - expanded cards. Long explanations sit behind "what this means" - disclosures for the same reason. The table scrolls horizontally inside - its own card, so the page never scrolls sideways on a phone. - - **Extensions** — add/rename/delete a SIP extension, reassign its category; - live registered/unregistered status per device. This is a native - reimplementation of Easy Asterisk's own vendored web admin - (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s device/category/room - management), not a link or an iframe to that separate process — one page, - one login. Reads \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\` directly - (same formats the vendor's own \`easy-asterisk --rebuild-dialplan\` CLI - still generates the dialplan from); writes go through - \`docker exec ... tee\` (root, sudo-gated) instead of a direct host-side - file write, since Easy Asterisk's container writes these as its own - internal user and a host-side write would just be fighting that ownership - again on the next restart. Every write reloads PJSIP and/or rebuilds the - dialplan automatically, the same way the vendored admin's own actions do. - - **Categories** — device profiles (an auto-answer default + description). - - **Rooms** — ring groups/paging groups; add/remove members per room. - - **Groups** — name a set of extensions and bulk-enable/disable messaging - for all of them at once. A management convenience only, not a runtime - concept: applying an action just writes the same per-extension - \`pstn-permissions.conf\` key each member's own checkbox would, and - membership changes never retroactively affect anything already applied. - (A group owning a personal DID *is* evaluated live against current - membership, though — see below.) - - **Concurrent-call caps** and **Personal numbers** appear only once - \`services/pstn-trunk.sh\`'s dialplan is actually installed - (\`pstn-trunk-dialplan.conf\` present), so the page never shows a - real-looking-but-unenforced editor. Caps are the outbound/inbound - concurrent-call limits; personal numbers map a DID to an owner extension - or group, additive to the shared trunk DID. Writes go directly to - \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, - which the dialplan reads fresh on every call. The spend-cap kill-switch - and international-calling allow-list are deliberately **not** managed - here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more - security-sensitive than what this tab already exposes. + Everything below the table — Ring Groups, Personal numbers — is a + collapsed section with an item count in its header, so the tab opens on + the extensions table rather than on several expanded cards. Long + explanations sit behind "what this means" disclosures for the same + reason. The table scrolls horizontally inside its own card, so the page + never scrolls sideways on a phone. + - **Extensions** — add/rename/delete a SIP extension, tag it as a mobile + device (enables RTP NAT-keepalive tuning); live registered/unregistered + status per device. This is a native reimplementation of Easy Asterisk's + own vendored web admin (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s + device/room management), not a link or an iframe to that separate + process — one page, one login. Reads \`pjsip.conf\`/\`rooms.conf\` + directly (same formats the vendor's own \`easy-asterisk + --rebuild-dialplan\` CLI still generates the dialplan from); writes go + through \`docker exec ... tee\` (root, sudo-gated) instead of a direct + host-side file write, since Easy Asterisk's container writes these as + its own internal user and a host-side write would just be fighting that + ownership again on the next restart. Every write reloads PJSIP and/or + rebuilds the dialplan automatically, the same way the vendored admin's + own actions do. + - **Ring Groups** — pick extensions, name them, and they ring or page + together as one real, dialable Easy Asterisk extension (the vendor's + own "rooms" concept, renamed here since that's clearer about what it + does). Can also be assigned a personal DID below instead of a single + extension — every current member whose own tier/approved-numbers + authorize the caller rings, checked fresh on every call. A hidden + \`pstn-groups.conf\` mirror of current membership (see + \`sync_room_group_mirror()\` in app.py) is what actually makes that + live-checked lookup possible without teaching \`pstn-trunk.sh\` + anything about \`rooms.conf\`'s format — there's no separate UI for it, + editing a Ring Group's membership here keeps it in step automatically. + - **Personal numbers** appears only once \`services/pstn-trunk.sh\`'s + dialplan is actually installed (\`pstn-trunk-dialplan.conf\` present), + so the page never shows a real-looking-but-unenforced editor. Maps a + DID to an owner extension or Ring Group, additive to the shared trunk + DID. Writes go directly to \`pstn-permissions.conf\` / + \`pstn-personal-dids.conf\`, which the dialplan reads fresh on every + call. Concurrent-call caps, the spend-cap kill-switch, and the + international-calling allow-list are deliberately **not** managed here + — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since all three are + more security-sensitive than what this tab already exposes. - **CrowdSec** — its nav button only appears once \`cscli\` is detected on this host. Current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns (sortable per column), and @@ -323,7 +325,7 @@ sudo journalctl -u security-dashboard -f Asterisk-scenario YAMLs, since \`secdash\` has no write access to those root-owned files directly and shouldn't). Extension/device management (only added if an Asterisk install is detected): \`docker exec -i tee\` against - exactly \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\`, plus + exactly \`pjsip.conf\`/\`rooms.conf\`, plus \`asterisk -rx "module reload res_pjsip.so"\`, \`asterisk -rx "pjsip show endpoints"\`, and \`easy-asterisk --rebuild-dialplan\` — all scoped to the one Asterisk @@ -1390,16 +1392,13 @@ def write_messaging(ext, enabled): GROUP_NAME_RE = re.compile(r"^[A-Za-z0-9_ -]{1,40}$") GROUPS_HEADER = ( - "; Named extension groups - a management convenience only, NEVER read by\n" - "; the dialplan itself (which only ever looks at per-extension keys in\n" - "; pstn-permissions.conf - see that file). Applying a group action (e.g.\n" - "; \"enable messaging\") writes those same per-extension keys for every\n" - "; CURRENT member, exactly as if each had been checked individually - it's\n" - "; a one-time bulk write, not an ongoing binding. Editing membership here\n" - "; does not retroactively change anything already applied to former\n" - "; members, and adding someone to a group does not automatically apply\n" - "; the group's settings - use the dashboard's \"Enable/Disable\" actions\n" - "; for that, any time membership changes.\n\n" + "; Auto-generated mirror of Ring Groups' membership (see sync_room_group_\n" + "; mirror() in app.py) - NOT a user-facing feature of its own. There is no\n" + "; dashboard UI for this file; edit Ring Group membership on the\n" + "; Extensions tab instead and this file follows automatically. Its only\n" + "; reader is pstn-personal-group-ring.sh (services/pstn-trunk.sh), which\n" + "; looks up a Ring Group's members by name when a personal DID is owned\n" + "; by that group rather than a single extension.\n\n" ) @@ -1418,18 +1417,6 @@ def _read_groups_cp(): return cp -def list_groups(): - """[{"name": ..., "members": [ext, ...]}], sorted by name.""" - cp = _read_groups_cp() - result = [] - for section in cp.sections(): - members_raw = cp.get(section, "members", fallback="") - members = [m.strip() for m in members_raw.split(",") if m.strip()] - result.append({"name": section, "members": members}) - result.sort(key=lambda g: g["name"].lower()) - return result - - def write_group(name, members): if not ASTERISK_CONFIG_DIR: return False, "No Asterisk install detected on this box" @@ -1462,26 +1449,27 @@ def delete_group(name): return True, "Deleted group '%s' (members' own settings were not changed)" % name -def apply_group_messaging(name, enabled): - """Sets messaging= for every CURRENT member of the group, one - at a time via write_messaging() - the exact same write path an - individual checkbox uses. Returns a summary of how many succeeded.""" - groups = {g["name"]: g["members"] for g in list_groups()} - if name not in groups: - return False, "Group not found" - members = groups[name] - if not members: - return True, "Group '%s' has no members - nothing to change" % name - failed = [] - for ext in members: - ok, _msg = write_messaging(ext, enabled) - if not ok: - failed.append(ext) - if failed: - return False, "Applied to %d/%d member(s) - failed: %s" % ( - len(members) - len(failed), len(members), ", ".join(failed)) - return True, "Messaging %s for all %d member(s) of '%s'" % ( - "enabled" if enabled else "disabled", len(members), name) +def sync_room_group_mirror(room_name, members, old_name=None): + """Keeps pstn-groups.conf in step with a Ring Group's (Easy Asterisk + Room's) membership, so pstn-trunk.sh's existing group-owned-personal-DID + machinery (pstn-personal-group-ring.sh, unchanged) can point at a Ring + Group by name without pstn-trunk.sh ever needing to know rooms.conf's + format. Ring Groups are now the only UI for building a named extension + set — this mirror, silent to the admin, is the only reason + pstn-groups.conf still exists on disk at all. + + old_name is passed on rename/delete so the STALE section (under the + previous name) gets removed rather than left orphaned alongside the + new one — pstn-personal-group-ring.sh looks sections up by exact name, + so a leftover old-name section is inert, just clutter, not a routing + risk; still, no reason to leave it. + + room_name=None means the room itself was deleted; only the old_name + cleanup runs in that case.""" + if old_name and old_name != room_name: + delete_group(old_name) + if room_name is not None: + write_group(room_name, members) LIMIT_RE = re.compile(r"^\d+$") @@ -1701,14 +1689,14 @@ def remove_personal_did(did): return True, "Removed %s" % did -# ── Easy Asterisk device management (devices, categories, rooms/ring-groups) ── +# ── Easy Asterisk device management (devices, rooms/ring-groups) ────────── # Full reimplementation of vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's # vendored web admin (its own separate process, normally reached via its own # port/domain) as native code here instead — one tab, one process, no # separate app to proxy or embed. Keeps writing the EXACT same file formats # (pjsip.conf's "; === Device: Name (category) [AA:yes/no] ===" comment + -# bracket-section convention, categories.conf/rooms.conf's pipe-delimited -# rows) the vendor's own `easy-asterisk --rebuild-dialplan` CLI still reads +# bracket-section convention, rooms.conf's pipe-delimited rows) the vendor's +# own `easy-asterisk --rebuild-dialplan` CLI still reads # to generate the dialplan — this is a new front door onto the same # underlying config, not a fork of dialplan generation itself. # @@ -2122,6 +2110,12 @@ def ea_create_room(extension, name, room_type="ring", timeout="60"): return False, "Invalid extension" if not name: return False, "Name required" + # Same pattern write_group() requires — a Ring Group's name doubles as + # its pstn-groups.conf mirror section name (see sync_room_group_mirror), + # so anything not valid there would silently fail to sync the moment + # this room gets assigned a personal number. + if not GROUP_NAME_RE.match(name): + return False, "Name must be 1-40 characters (letters, digits, spaces, - or _)" current = "" if os.path.isfile(path): @@ -2142,6 +2136,7 @@ def ea_create_room(extension, name, room_type="ring", timeout="60"): if not ok: return False, err ea_rebuild_dialplan() + sync_room_group_mirror(name, []) return True, "Room created" @@ -2149,6 +2144,7 @@ def ea_delete_room(extension): path = _ea_rooms_host_path() if not path or not os.path.isfile(path): return False, "Rooms file not found" + old_room = next((r for r in ea_list_rooms() if r["extension"] == extension), None) with open(path) as f: lines = f.readlines() new_lines = [] @@ -2165,6 +2161,8 @@ def ea_delete_room(extension): if not ok: return False, err ea_rebuild_dialplan() + if old_room: + sync_room_group_mirror(None, [], old_name=old_room["name"]) return True, "Room deleted" @@ -2175,6 +2173,9 @@ def ea_rename_room(extension, new_name): new_name = (new_name or "").strip() if not new_name: return False, "Name required" + if not GROUP_NAME_RE.match(new_name): + return False, "Name must be 1-40 characters (letters, digits, spaces, - or _)" + old_room = next((r for r in ea_list_rooms() if r["extension"] == extension), None) with open(path) as f: lines = f.readlines() new_lines = [] @@ -2195,6 +2196,8 @@ def ea_rename_room(extension, new_name): if not ok: return False, err ea_rebuild_dialplan() + old_members = [m for m in (old_room["members"] if old_room else "").split(",") if m] + sync_room_group_mirror(new_name, old_members, old_name=old_room["name"] if old_room else None) return True, "Room renamed" @@ -2202,6 +2205,7 @@ def _ea_update_room_members(extension, new_members): path = _ea_rooms_host_path() if not path or not os.path.isfile(path): return False, "Rooms file not found" + room_name = next((r["name"] for r in ea_list_rooms() if r["extension"] == extension), None) with open(path) as f: lines = f.readlines() new_lines = [] @@ -2222,6 +2226,8 @@ def _ea_update_room_members(extension, new_members): if not ok: return False, err ea_rebuild_dialplan() + if room_name: + sync_room_group_mirror(room_name, [m for m in new_members.split(",") if m]) return True, "Room members updated" @@ -2491,7 +2497,7 @@ INDEX_HTML = """ #msg { margin-top: var(--sp-2); font-size: 0.85rem; } /* Capability gating for the Extensions tab. Everything that needs the Easy - Asterisk container (device/category/room writes) is .ea-only; everything + Asterisk container (device/room writes) is .ea-only; everything that needs a PSTN trunk dialplan is .pstn-only. Both classes start ON the body so nothing flashes before /api/ea-status and /api/pstn-status answer, and they're removed once those confirm. Marking cells rather than juggling @@ -2644,18 +2650,18 @@ INDEX_HTML = """
Easy Asterisk device setup
- Rooms (ring groups) + Ring Groups
-

A shared extension that rings (or pages) every member device at once.

+

Pick extensions, give them a name, and they ring (or page/auto-answer) together as one dialable extension — and can optionally be assigned a personal number below, so an inbound call to that number rings every current member.

- + - +
@@ -2670,30 +2676,7 @@ INDEX_HTML = """ -
Groups & personal numbers (PSTN permissions)
- -
- Groups -
-
- What groups do -

Named sets of extensions for bulk actions — e.g. enable messaging for everyone in "Sales" at once. A management convenience only: applying an action writes the same per-extension setting each member's own Messaging checkbox above would, one time. It isn't a runtime concept the dialplan knows about, and membership changes never retroactively affect anything already applied. A group can also own a personal number below, which is evaluated live against current membership on every call.

-

Not the same thing as a Room above: a Room is a real, dialable ring-group extension that rings its members live on every call. A Group has no extension of its own and can't be dialed — it only exists to save you re-clicking the same setting on several extensions, and to optionally own a personal number.

-
-
- - -
-
-
-
- - - -
GroupMembers
-
-
-
+
Personal numbers (PSTN permissions)
Personal numbers @@ -2701,7 +2684,7 @@ INDEX_HTML = """
How personal numbers route

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 group instead of a single extension — every current member whose own tier/approved-numbers authorize the caller rings, checked fresh against the group's current membership on every call. 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.

+

You can also assign a DID to a Ring Group above instead of a single extension — every current member whose own tier/approved-numbers authorize the caller rings, checked fresh against the group's current membership on every call. A Ring 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.

Reassigning a DID's owner has been confirmed to sometimes need "Commit changes" (at the top) before Asterisk actually uses the new owner.

@@ -2737,7 +2720,7 @@ document.querySelectorAll(".tab-btn").forEach(btn => { // What this box can actually do, resolved once per page load. Everything the // Extensions tab shows is gated on these two rather than on separate nav // buttons: the extension list itself only needs pjsip.conf, so it's always -// worth showing, while device/category/room editing needs a reachable Easy +// worth showing, while device/room editing needs a reachable Easy // Asterisk container and tier/DID editing needs a PSTN trunk dialplan. One // tab that grows columns and cards as those appear beats three tabs that // each list the same extensions from a different angle. @@ -2943,11 +2926,10 @@ async function initExtensionsTab() { // Sequenced (not parallel) — room rows render a member-add picker that needs // eaDevices (populated by loadExtensions), and the personal-DID owner picker -// needs both extensions and groups. +// needs both extensions and rooms. async function refreshExtensionsTab() { await loadExtensions(); if (eaInstalled) await loadEaRooms(); - await loadGroups(); if (pstnInstalled) await loadPersonalDids(); } @@ -2999,7 +2981,6 @@ async function loadExtensions() { extRows = Array.from(byExt.values()); renderExtensions(); - renderGroupMemberPickers(); renderPersonalDidOwnerOptions(); } @@ -3286,6 +3267,7 @@ async function loadEaRooms() { const data = await res.json(); eaRooms = data.rooms || []; renderEaRooms(); + renderPersonalDidOwnerOptions(); } function eaRoomSortValue(r, key) { @@ -3366,10 +3348,20 @@ document.getElementById("ea-room-save").addEventListener("click", async () => { loadEaRooms(); }); +// Whether a Ring Group's membership change is PSTN-relevant right now — only +// true once it's actually assigned as a personal number's owner (see +// sync_room_group_mirror() in app.py). Most Ring Groups never are, and +// prompting a restart on every ordinary membership tweak would be noise; +// this keeps the restart nudge scoped to edits that can actually go stale. +function roomOwnsPersonalDid(roomName) { + return lastPersonalDids.some(d => d.owner === "@" + roomName); +} + async function renameEaRoom(ext) { const cur = eaRooms.find(r => r.extension === ext); const name = prompt("New name for room " + ext + ":", cur ? cur.name : ""); if (name === null || !name.trim()) return; + const wasPstnRelevant = cur && pstnInstalled && roomOwnsPersonalDid(cur.name); const res = await fetch("/api/ea-rooms/rename", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({extension: ext, name: name.trim()}), @@ -3377,10 +3369,13 @@ async function renameEaRoom(ext) { const data = await res.json(); toast(data.message || (data.ok ? "Room renamed" : "Failed"), data.ok ? "ok" : "err"); loadEaRooms(); + if (data.ok && wasPstnRelevant) { markPstnDirty(); await offerPstnRestart(); } } async function deleteEaRoom(ext) { if (!confirm("Delete room " + ext + "? This cannot be undone.")) return; + const cur = eaRooms.find(r => r.extension === ext); + const wasPstnRelevant = cur && pstnInstalled && roomOwnsPersonalDid(cur.name); const res = await fetch("/api/ea-rooms/delete", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({extension: ext}), @@ -3388,12 +3383,15 @@ async function deleteEaRoom(ext) { const data = await res.json(); toast(data.message || (data.ok ? "Room deleted" : "Failed"), data.ok ? "ok" : "err"); loadEaRooms(); + if (data.ok && wasPstnRelevant) { markPstnDirty(); await offerPstnRestart(); } } async function addEaRoomMemberFromRow(roomExt, btn) { const select = btn.previousElementSibling; const device = select.value; if (!device) return; + const cur = eaRooms.find(r => r.extension === roomExt); + const pstnRelevant = cur && pstnInstalled && roomOwnsPersonalDid(cur.name); const res = await fetch("/api/ea-rooms/members/add", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({room: roomExt, device}), @@ -3401,9 +3399,12 @@ async function addEaRoomMemberFromRow(roomExt, btn) { const data = await res.json(); toast(data.message || (data.ok ? "Member added" : "Failed"), data.ok ? "ok" : "err"); loadEaRooms(); + if (data.ok && pstnRelevant) { markPstnDirty(); await offerPstnRestart(); } } async function removeEaRoomMember(roomExt, device) { + const cur = eaRooms.find(r => r.extension === roomExt); + const pstnRelevant = cur && pstnInstalled && roomOwnsPersonalDid(cur.name); const res = await fetch("/api/ea-rooms/members/remove", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({room: roomExt, device}), @@ -3411,116 +3412,7 @@ async function removeEaRoomMember(roomExt, device) { const data = await res.json(); toast(data.message || (data.ok ? "Member removed" : "Failed"), data.ok ? "ok" : "err"); loadEaRooms(); -} - -// The Groups card's membership picker. Driven by the same merged extension -// list the table above renders, so a group can never offer an extension the -// table doesn't show (or miss one it does). -function renderGroupMemberPickers() { - const grpMembers = document.getElementById("grp-members"); - grpMembers.innerHTML = extRows.map(e => ` - - `).join("") || 'No extensions found'; -} - -let lastGroups = []; -let grpSort = { key: null, dir: 1 }; - -function grpSortValue(g, key) { - if (key === "members") return g.members.join(", ").toLowerCase(); - return (g.name || "").toLowerCase(); -} - -function renderGroups() { - let rows = lastGroups.slice(); - if (grpSort.key) { - rows.sort((a, b) => { - const av = grpSortValue(a, grpSort.key), bv = grpSortValue(b, grpSort.key); - if (av < bv) return -1 * grpSort.dir; - if (av > bv) return 1 * grpSort.dir; - return 0; - }); - } - document.querySelectorAll("#grp-table th.sortable .arrow").forEach(a => a.remove()); - if (grpSort.key) { - const th = document.querySelector(`#grp-table th[data-sort="${grpSort.key}"]`); - if (th) th.insertAdjacentHTML("beforeend", `${grpSort.dir === 1 ? "▲" : "▼"}`); - } - setCount("grp-count", lastGroups.length); - const tbody = document.querySelector("#grp-table tbody"); - tbody.innerHTML = rows.map(g => ` - ${esc(g.name)} - ${g.members.map(esc).join(", ") || 'none'} - - - - - - - `).join("") || 'No groups yet.'; -} - -document.querySelectorAll("#grp-table th.sortable").forEach(th => { - th.addEventListener("click", () => { - const key = th.dataset.sort; - grpSort.dir = (grpSort.key === key) ? -grpSort.dir : 1; - grpSort.key = key; - renderGroups(); - }); -}); - -async function loadGroups() { - const res = await fetch("/api/pstn-groups"); - const data = await res.json(); - lastGroups = data.groups || []; - renderGroups(); - renderPersonalDidOwnerOptions(); -} - -function editGroup(name) { - const g = lastGroups.find(x => x.name === name); - if (!g) return; - document.getElementById("grp-name").value = g.name; - document.querySelectorAll(".grp-member-cb").forEach(cb => { cb.checked = g.members.includes(cb.value); }); -} - -document.getElementById("grp-save").addEventListener("click", async () => { - const name = document.getElementById("grp-name").value.trim(); - const members = Array.from(document.querySelectorAll(".grp-member-cb:checked")).map(cb => cb.value); - const res = await fetch("/api/pstn-groups", { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({name: name, members: members}), - }); - const data = await res.json(); - toast(data.message || (data.ok ? "Group saved" : "Failed"), data.ok ? "ok" : "err"); - if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); } - loadGroups(); -}); - -async function applyGroupMessaging(name, enabled) { - if (!confirm(`${enabled ? "Enable" : "Disable"} messaging for every current member of "${name}"?`)) return; - const res = await fetch("/api/pstn-groups/apply-messaging", { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({name: name, enabled: enabled}), - }); - const data = await res.json(); - toast(data.message || (data.ok ? "Applied to group" : "Failed"), data.ok ? "ok" : "err"); - if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); } - loadExtensions(); -} - -async function deleteGroup(name) { - if (!confirm(`Delete group "${name}"? This does not change any member's current settings.`)) return; - const res = await fetch("/api/pstn-groups/delete", { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({name: name}), - }); - const data = await res.json(); - toast(data.message || (data.ok ? "Group deleted" : "Failed"), data.ok ? "ok" : "err"); - if (data.ok && pstnInstalled) { markPstnDirty(); await offerPstnRestart(); } - loadGroups(); + if (data.ok && pstnRelevant) { markPstnDirty(); await offerPstnRestart(); } } // "Commit Changes" — see restart_asterisk_container()'s comment in app.py @@ -3576,12 +3468,14 @@ async function offerPstnRestart() { // The personal-DID owner picker spans both lists, so it re-renders from // whichever of the two finished last rather than being owned by either. +// "@name" mirrors what sync_room_group_mirror()/pstn-personal-group-ring.sh +// expect — a Ring Group's NAME, not its extension, is the join key. function renderPersonalDidOwnerOptions() { const ownerSel = document.getElementById("pd-owner"); if (!ownerSel) return; const extOptions = extRows.map(e => ``).join(""); - const groupOptions = lastGroups.map(g => ``).join(""); - ownerSel.innerHTML = (extOptions + groupOptions) || ''; + const roomOptions = eaRooms.map(r => ``).join(""); + ownerSel.innerHTML = (extOptions + roomOptions) || ''; } let lastPersonalDids = []; @@ -3733,8 +3627,6 @@ class Handler(BaseHTTPRequestHandler): self._json({"dids": dids}) elif self.path == "/api/pstn-status": self._json({"installed": pstn_installed()}) - elif self.path == "/api/pstn-groups": - self._json({"groups": list_groups()}) elif self.path == "/api/crowdsec-status": self._json({"installed": crowdsec_installed()}) elif self.path == "/api/ea-status": @@ -3781,15 +3673,6 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/pstn-messaging": ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False))) self._json({"ok": ok, "message": message}) - elif self.path == "/api/pstn-groups": - ok, message = write_group(payload.get("name", ""), payload.get("members", [])) - self._json({"ok": ok, "message": message}) - elif self.path == "/api/pstn-groups/delete": - ok, message = delete_group(payload.get("name", "")) - self._json({"ok": ok, "message": message}) - elif self.path == "/api/pstn-groups/apply-messaging": - ok, message = apply_group_messaging(payload.get("name", ""), bool(payload.get("enabled", False))) - self._json({"ok": ok, "message": message}) elif self.path == "/api/asterisk-restart": ok, message = restart_asterisk_container() self._json({"ok": ok, "message": message})