diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 0f0143a..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 @@ -2511,13 +2517,13 @@ INDEX_HTML = """

Security Dashboard

-
+ -