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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
Claude
2026-07-23 06:05:37 +00:00
parent 7851e2befd
commit 3359898b4e
+235 -10
View File
@@ -195,7 +195,8 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
## Tabs ## Tabs
- **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures - **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 - **CrowdSec** — current bans (\`cscli decisions list\`), a delete/unban button
per entry, carrier/ASN + country columns, and management of the ASN-exempt per entry, carrier/ASN + country columns, and management of the ASN-exempt
Asterisk brute-force scenarios (see \`services/crowdsec.sh\`'s "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 Asterisk's native SIP texting, independent of PSTN calling entirely (no
cost, no carrier, no DID, no dependency on \`services/pstn-trunk.sh\` 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 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 flag does and doesn't do yet at the Asterisk level). Right below it, a
rest of the tab detects whether \`services/pstn-trunk.sh\`'s dialplan is **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 actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a
clear "not installed" message instead of the calling-permissions editor clear "not installed" message instead of the calling-permissions editor
if not, so it never shows real-looking-but-unenforced defaults. When if not, so it never shows real-looking-but-unenforced defaults. When
@@ -1112,6 +1119,103 @@ def write_messaging(ext, enabled):
return True, "Saved" 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=<enabled> 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+$") LIMIT_RE = re.compile(r"^\d+$")
@@ -1307,6 +1411,8 @@ INDEX_HTML = """<!doctype html>
th.sortable { cursor: pointer; user-select: none; } th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: #e6e6e6; } th.sortable:hover { color: #e6e6e6; }
th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; } 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-Error { color: #ff6b6b; }
.sev-Warning { color: #f5b342; } .sev-Warning { color: #f5b342; }
.sev-Informational { color: #7fbf7f; } .sev-Informational { color: #7fbf7f; }
@@ -1334,7 +1440,16 @@ INDEX_HTML = """<!doctype html>
<div id="tab-security"> <div id="tab-security">
<div class="card"> <div class="card">
<p class="muted">Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.</p> <p class="muted">Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.</p>
<table id="sec-table"><thead><tr><th>Time</th><th>Event</th><th>Account</th><th>Remote</th><th>Severity</th></tr></thead><tbody></tbody></table> <table id="sec-table"><thead>
<tr><th>Time</th><th>Event</th><th>Account</th><th>Remote</th><th>Severity</th></tr>
<tr class="filter-row">
<th><input type="text" class="sec-filter" data-field="timestamp" placeholder="filter…"></th>
<th><input type="text" class="sec-filter" data-field="event" placeholder="filter…"></th>
<th><input type="text" class="sec-filter" data-field="account" placeholder="filter…"></th>
<th><input type="text" class="sec-filter" data-field="remote" placeholder="filter…"></th>
<th><input type="text" class="sec-filter" data-field="severity" placeholder="filter…"></th>
</tr>
</thead><tbody></tbody></table>
</div> </div>
</div> </div>
<div id="tab-crowdsec" style="display:none"> <div id="tab-crowdsec" style="display:none">
@@ -1370,6 +1485,19 @@ INDEX_HTML = """<!doctype html>
<table id="msg-table"><thead><tr><th>Ext</th><th>Name</th><th>Enabled</th><th></th></tr></thead><tbody></tbody></table> <table id="msg-table"><thead><tr><th>Ext</th><th>Name</th><th>Enabled</th><th></th></tr></thead><tbody></tbody></table>
<div id="msg-msg" class="muted" style="margin-top:0.5rem"></div> <div id="msg-msg" class="muted" style="margin-top:0.5rem"></div>
</div> </div>
<div class="card">
<h3 style="margin-top:0">Groups</h3>
<p class="muted">
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.
</p>
<div class="row">
<input type="text" id="grp-name" placeholder="Group name, e.g. Sales" style="width:12rem">
<button class="action" id="grp-save">Save group</button>
</div>
<div id="grp-members" class="row" style="flex-wrap:wrap;margin-top:0.5rem"></div>
<table id="grp-table" style="margin-top:0.75rem"><thead><tr><th>Group</th><th>Members</th><th></th></tr></thead><tbody></tbody></table>
<div id="grp-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
<div class="card" id="pstn-not-installed" style="display:none"> <div class="card" id="pstn-not-installed" style="display:none">
<h3 style="margin-top:0">PSTN trunk not installed</h3> <h3 style="margin-top:0">PSTN trunk not installed</h3>
<p class="muted">No PSTN trunk dialplan was found on this box — <code>sudo ./setup.sh pstn-trunk</code> hasn't been run (or its config was removed). The calling permissions/caps/personal-numbers below aren't enforced yet; install it first to use them. Internal SIP messaging above works independently of this.</p> <p class="muted">No PSTN trunk dialplan was found on this box — <code>sudo ./setup.sh pstn-trunk</code> hasn't been run (or its config was removed). The calling permissions/caps/personal-numbers below aren't enforced yet; install it first to use them. Internal SIP messaging above works independently of this.</p>
@@ -1424,21 +1552,39 @@ document.querySelectorAll(".tab-btn").forEach(btn => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; }); TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
if (btn.dataset.tab === "pstn") { loadPstnStatus(); loadMessaging(); } if (btn.dataset.tab === "pstn") { loadPstnStatus(); loadMessaging(); loadGroups(); }
}); });
}); });
async function loadSecurity() { let lastSecurityEvents = [];
const res = await fetch("/api/security-events");
const events = await res.json(); function renderSecurity() {
const filters = {};
document.querySelectorAll(".sec-filter").forEach(inp => {
const v = inp.value.trim().toLowerCase();
if (v) filters[inp.dataset.field] = v;
});
const rows = lastSecurityEvents.filter(e =>
Object.entries(filters).every(([field, v]) => (e[field] || "").toLowerCase().includes(v))
);
const tbody = document.querySelector("#sec-table tbody"); const tbody = document.querySelector("#sec-table tbody");
tbody.innerHTML = events.map(e => `<tr> tbody.innerHTML = rows.map(e => `<tr>
<td>${esc(e.timestamp)}</td> <td>${esc(e.timestamp)}</td>
<td>${esc(e.event)}</td> <td>${esc(e.event)}</td>
<td>${esc(e.account)}</td> <td>${esc(e.account)}</td>
<td>${esc(e.remote)}</td> <td>${esc(e.remote)}</td>
<td class="sev-${esc(e.severity)}">${esc(e.severity)}</td> <td class="sev-${esc(e.severity)}">${esc(e.severity)}</td>
</tr>`).join("") || "<tr><td colspan=5 class=muted>No events found.</td></tr>"; </tr>`).join("") || `<tr><td colspan=5 class=muted>${lastSecurityEvents.length ? "No events match the current filters." : "No events found."}</td></tr>`;
}
document.querySelectorAll(".sec-filter").forEach(inp => {
inp.addEventListener("input", renderSecurity);
});
async function loadSecurity() {
const res = await fetch("/api/security-events");
lastSecurityEvents = await res.json();
renderSecurity();
} }
let lastDecisions = []; let lastDecisions = [];
@@ -1594,6 +1740,14 @@ async function loadMessaging() {
const res = await fetch("/api/pstn-permissions"); const res = await fetch("/api/pstn-permissions");
const data = await res.json(); const data = await res.json();
const exts = data.extensions || []; const exts = data.extensions || [];
const grpMembers = document.getElementById("grp-members");
grpMembers.innerHTML = exts.map(e => `
<label class="muted" style="white-space:nowrap">
<input type="checkbox" class="grp-member-cb" value="${esc(e.ext)}"> ${esc(e.ext)} — ${esc(e.name)}
</label>
`).join("") || '<span class="muted">No extensions found</span>';
const tbody = document.querySelector("#msg-table tbody"); const tbody = document.querySelector("#msg-table tbody");
if (!exts.length) { if (!exts.length) {
tbody.innerHTML = '<tr><td colspan=4 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>'; tbody.innerHTML = '<tr><td colspan=4 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
@@ -1619,6 +1773,66 @@ async function saveMessagingRow(ext) {
loadMessaging(); loadMessaging();
} }
let lastGroups = [];
async function loadGroups() {
const res = await fetch("/api/pstn-groups");
const data = await res.json();
lastGroups = data.groups || [];
const tbody = document.querySelector("#grp-table tbody");
tbody.innerHTML = lastGroups.map(g => `<tr data-group="${esc(g.name)}">
<td>${esc(g.name)}</td>
<td>${g.members.map(esc).join(", ") || '<span class="muted">none</span>'}</td>
<td>
<button class="action" onclick="editGroup('${esc(g.name)}')">Edit</button>
<button class="action" onclick="applyGroupMessaging('${esc(g.name)}', true)">Enable messaging</button>
<button class="action" onclick="applyGroupMessaging('${esc(g.name)}', false)">Disable messaging</button>
<button class="action" onclick="deleteGroup('${esc(g.name)}')">Delete</button>
</td>
</tr>`).join("") || '<tr><td colspan=3 class=muted>No groups yet.</td></tr>';
}
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();
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
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();
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Applied" : "Failed");
loadMessaging();
}
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();
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
loadGroups();
}
document.getElementById("limits-save").addEventListener("click", async () => { document.getElementById("limits-save").addEventListener("click", async () => {
const maxOut = document.getElementById("limit-out").value; const maxOut = document.getElementById("limit-out").value;
const maxIn = document.getElementById("limit-in").value; const maxIn = document.getElementById("limit-in").value;
@@ -1781,6 +1995,8 @@ class Handler(BaseHTTPRequestHandler):
self._json({"dids": dids}) self._json({"dids": dids})
elif self.path == "/api/pstn-status": elif self.path == "/api/pstn-status":
self._json({"installed": pstn_installed()}) self._json({"installed": pstn_installed()})
elif self.path == "/api/pstn-groups":
self._json({"groups": list_groups()})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
@@ -1818,6 +2034,15 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/pstn-messaging": elif self.path == "/api/pstn-messaging":
ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False))) ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False)))
self._json({"ok": ok, "message": message}) 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})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)