From 3359898b4e454fc40ddcce190f88fc4825219cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 06:05:37 +0000 Subject: [PATCH] Add named extension groups and Security Log column filtering Groups: a new always-available "Groups" card in the PSTN tab (same place as Internal SIP messaging, no dependency on a PSTN trunk being installed) lets you name a set of extensions and bulk-enable/disable messaging for all of them at once. Purely a management-layer convenience - pstn-groups.conf is never read by the dialplan, which only ever looks at per-extension keys in pstn-permissions.conf. Applying a group action just calls the same write_messaging() each individual checkbox uses, once per current member. Editing membership never retroactively changes anything already applied, and deleting a group never touches members' own settings - confirmed with tests covering create/apply/edit-membership/re-apply/delete. Security Log: added a per-column filter row (Time/Event/Account/Remote/ Severity), live as you type, filtering client-side against the already-fetched events rather than re-querying - persists correctly across the existing 30-second auto-refresh. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- services/security-dashboard.sh | 245 +++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 10 deletions(-) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 6daa0db..87e568c 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -195,7 +195,8 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. ## Tabs - **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures - (wrong password, unknown extension, etc.) with timestamp/account/remote IP. + (wrong password, unknown extension, etc.) with timestamp/account/remote IP, + filterable per column (each header has its own text filter, live as you type). - **CrowdSec** — current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns, and management of the ASN-exempt Asterisk brute-force scenarios (see \`services/crowdsec.sh\`'s "Exempt @@ -214,8 +215,14 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. Asterisk's native SIP texting, independent of PSTN calling entirely (no cost, no carrier, no DID, no dependency on \`services/pstn-trunk.sh\` having been run — see its "Known gap" note on messaging for what this - flag does and doesn't do yet at the Asterisk level). Below that, the - rest of the tab detects whether \`services/pstn-trunk.sh\`'s dialplan is + flag does and doesn't do yet at the Asterisk level). Right below it, a + **Groups** card (also always available) lets you 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. Below that, the rest of the tab detects + whether \`services/pstn-trunk.sh\`'s dialplan is actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a clear "not installed" message instead of the calling-permissions editor if not, so it never shows real-looking-but-unenforced defaults. When @@ -1112,6 +1119,103 @@ def write_messaging(ext, enabled): return True, "Saved" +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" +) + + +def _groups_path(): + return os.path.join(ASTERISK_CONFIG_DIR, "pstn-groups.conf") if ASTERISK_CONFIG_DIR else None + + +def _read_groups_cp(): + cp = configparser.ConfigParser(delimiters=("=",)) + path = _groups_path() + if path and os.path.isfile(path): + try: + cp.read(path) + except configparser.Error: + pass + 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" + name = str(name).strip() + if not GROUP_NAME_RE.match(name): + return False, "Group name must be 1-40 characters (letters, digits, spaces, - or _)" + clean_members = sorted(set(str(m).strip() for m in members if EXTEN_RE.match(str(m).strip()))) + + cp = _read_groups_cp() + if not cp.has_section(name): + cp.add_section(name) + cp.set(name, "members", ",".join(clean_members)) + + ok, err = _write_ini_cp(_groups_path(), GROUPS_HEADER, cp) + if not ok: + return False, err + return True, "Saved group '%s' with %d member(s)" % (name, len(clean_members)) + + +def delete_group(name): + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + name = str(name).strip() + cp = _read_groups_cp() + if cp.has_section(name): + cp.remove_section(name) + ok, err = _write_ini_cp(_groups_path(), GROUPS_HEADER, cp) + if not ok: + return False, err + 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) + + LIMIT_RE = re.compile(r"^\d+$") @@ -1307,6 +1411,8 @@ INDEX_HTML = """ th.sortable { cursor: pointer; user-select: none; } th.sortable:hover { color: #e6e6e6; } th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; } + .filter-row th { padding-top: 0; padding-bottom: 0.5rem; font-weight: normal; } + .filter-row input { width: 100%; box-sizing: border-box; background: #0f1115; border: 1px solid #2a2e38; color: #e6e6e6; border-radius: 4px; padding: 0.25rem 0.4rem; font-size: 0.8rem; } .sev-Error { color: #ff6b6b; } .sev-Warning { color: #f5b342; } .sev-Informational { color: #7fbf7f; } @@ -1334,7 +1440,16 @@ INDEX_HTML = """

Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.

-
TimeEventAccountRemoteSeverity
+ + + + + + + + + +
TimeEventAccountRemoteSeverity
+
+

Groups

+

+ 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 checkbox above would, one time — it isn't a runtime concept the dialplan knows about, and membership changes never retroactively affect anything already applied. +

+
+ + +
+
+
GroupMembers
+
+