Merge pull request #267 from outis1one/claude/asterisk-security-phone-numbers-osu937

security-dashboard: add Calls & Texts tab for PSTN calls and SIP/SMS …
This commit is contained in:
Outis
2026-08-08 21:02:58 -04:00
committed by GitHub
3 changed files with 288 additions and 34 deletions
+8
View File
@@ -633,6 +633,12 @@ _asterisk_migrate_existing_devices_message_context() {
# FROM_EXT ends up empty/wrong and the AST_CONFIG() lookup simply finds no # FROM_EXT ends up empty/wrong and the AST_CONFIG() lookup simply finds no
# match, which denies by default (same fail-closed behavior as an # match, which denies by default (same fail-closed behavior as an
# unlisted extension) rather than silently allowing anything through. # unlisted extension) rather than silently allowing anything through.
#
# Every attempt (delivered or denied) is appended to sip-messages.log
# (epoch|status|from_ext|to_ext) — same pipe-delimited, no-embedded-delimiter
# convention pstn-trunk.sh's own pstn-trunk-calls.log uses, for the same
# reason (no dependency on Asterisk's own CDR modules). Message bodies are
# never written. The Security Dashboard's Texts table reads this file.
_asterisk_write_messaging_dialplan() { _asterisk_write_messaging_dialplan() {
local FILE="$1" local FILE="$1"
cat > "$FILE" << 'EOF' cat > "$FILE" << 'EOF'
@@ -655,8 +661,10 @@ exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN})
same => n,Set(SENDER_OK=${AST_CONFIG(pstn-permissions.conf,${FROM_EXT},messaging)}) same => n,Set(SENDER_OK=${AST_CONFIG(pstn-permissions.conf,${FROM_EXT},messaging)})
same => n,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny) same => n,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny)
same => n(deliver),MessageSend(pjsip:${EXTEN},${FROM_URI}) same => n(deliver),MessageSend(pjsip:${EXTEN},${FROM_URI})
same => n,System(printf '%s|deliver|%s|%s\n' "${EPOCH}" "${FROM_EXT}" "${EXTEN}" >> /var/log/asterisk/sip-messages.log)
same => n,Hangup() same => n,Hangup()
same => n(deny),NoOp(Denied — extension ${FROM_EXT} is not messaging-enabled) same => n(deny),NoOp(Denied — extension ${FROM_EXT} is not messaging-enabled)
same => n,System(printf '%s|deny|%s|%s\n' "${EPOCH}" "${FROM_EXT}" "${EXTEN}" >> /var/log/asterisk/sip-messages.log)
same => n,Hangup() same => n,Hangup()
EOF EOF
} }
+9
View File
@@ -379,6 +379,12 @@ _pstn_write_sms_inbound_dialplan() {
; docker exec <container> asterisk -rx "core set verbose 5" ; docker exec <container> asterisk -rx "core set verbose 5"
; (send the text) ; (send the text)
; check the console/full log for the "SMS-over-SIP MESSAGE received" line ; check the console/full log for the "SMS-over-SIP MESSAGE received" line
;
; pstn-sms.log (epoch|in|from|to) feeds the Security Dashboard's Texts table
; — from/to prefer the X-ANVEO-SMS-* headers (the documented mechanism) and
; fall back to MESSAGE(from)/MESSAGE(to) if a header is empty, same
; unconfirmed-until-a-real-text caveat as everything else in this context.
; No message body is ever written to this file.
[pstn-sms-inbound] [pstn-sms-inbound]
exten => _X.,1,NoOp(SMS-over-SIP MESSAGE received — dialplan EXTEN=${EXTEN}) exten => _X.,1,NoOp(SMS-over-SIP MESSAGE received — dialplan EXTEN=${EXTEN})
same => n,Set(SMS_HDR_FROM=${PJSIP_HEADER(read,X-ANVEO-SMS-FROM)}) same => n,Set(SMS_HDR_FROM=${PJSIP_HEADER(read,X-ANVEO-SMS-FROM)})
@@ -387,6 +393,9 @@ exten => _X.,1,NoOp(SMS-over-SIP MESSAGE received — dialplan EXTEN=${EXTEN})
same => n,Set(SMS_MSG_TO=${MESSAGE(to)}) same => n,Set(SMS_MSG_TO=${MESSAGE(to)})
same => n,Set(SMS_BODY=${MESSAGE(body)}) same => n,Set(SMS_BODY=${MESSAGE(body)})
same => n,NoOp(DIAGNOSTIC (not yet routed) -- X-ANVEO-SMS-FROM=[${SMS_HDR_FROM}] X-ANVEO-SMS-TO=[${SMS_HDR_TO}] MESSAGE(from)=[${SMS_MSG_FROM}] MESSAGE(to)=[${SMS_MSG_TO}] EXTEN=[${EXTEN}] body=[${SMS_BODY}]) same => n,NoOp(DIAGNOSTIC (not yet routed) -- X-ANVEO-SMS-FROM=[${SMS_HDR_FROM}] X-ANVEO-SMS-TO=[${SMS_HDR_TO}] MESSAGE(from)=[${SMS_MSG_FROM}] MESSAGE(to)=[${SMS_MSG_TO}] EXTEN=[${EXTEN}] body=[${SMS_BODY}])
same => n,Set(SMS_FROM=${IF($["${SMS_HDR_FROM}"!=""]?${SMS_HDR_FROM}:${SMS_MSG_FROM})})
same => n,Set(SMS_TO=${IF($["${SMS_HDR_TO}"!=""]?${SMS_HDR_TO}:${SMS_MSG_TO})})
same => n,System(printf '%s|in|%s|%s\n' "${EPOCH}" "${SMS_FROM}" "${SMS_TO}" >> /var/log/asterisk/pstn-sms.log)
same => n,Hangup() same => n,Hangup()
EOF EOF
} }
+271 -34
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# services/security-dashboard.sh — Security dashboard: Asterisk failed-connection # services/security-dashboard.sh — Security dashboard: Asterisk failed-connection
# log + CrowdSec decisions (view/unban/ASN-exempt management), Authelia-protected. # log + PSTN call/text history + CrowdSec decisions (view/unban/ASN-exempt
# management), Authelia-protected.
# Part of the modular post-install system (sourced by setup.sh). # Part of the modular post-install system (sourced by setup.sh).
# #
# Can also be run standalone on any machine: # Can also be run standalone on any machine:
@@ -64,7 +65,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092 register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + call/text history + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092
install_security-dashboard() { install_security-dashboard() {
local APP_DIR="/opt/security-dashboard" local APP_DIR="/opt/security-dashboard"
@@ -102,8 +103,9 @@ install_security-dashboard() {
echo "" echo ""
echo "┌─────────────────────────────────────────────────────────────────┐" echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ SECURITY DASHBOARD │" echo "│ SECURITY DASHBOARD │"
echo "│ Asterisk failed-connection log + one Extensions tab (devices, │" echo "│ Asterisk failed-connection log + call/text history + one │"
echo "│ ring groups, PSTN tiers, personal DIDs) + CrowdSec bans, │" echo "│ Extensions tab (devices, ring groups, PSTN tiers, personal │"
echo "│ DIDs) + CrowdSec bans, │"
echo "│ one page. Runs natively on the host (not Docker) so it can call │" 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 "│ cscli and read Asterisk's files directly. Authelia-protected. │"
echo "└─────────────────────────────────────────────────────────────────┘" echo "└─────────────────────────────────────────────────────────────────┘"
@@ -111,8 +113,8 @@ install_security-dashboard() {
if [ -z "$ASTERISK_EA_DIR" ]; then if [ -z "$ASTERISK_EA_DIR" ]; then
log_warning "No Asterisk install detected." log_warning "No Asterisk install detected."
log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab" log_warning "The Security Log, Calls & Texts, and Extensions tabs will just be empty —"
log_warning "still works fine." log_warning "CrowdSec's tab still works fine."
fi fi
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
@@ -208,16 +210,17 @@ install_security-dashboard() {
write_readme "$APP_DIR" << README_MD write_readme "$APP_DIR" << README_MD
# Security Dashboard # Security Dashboard
Asterisk failed-connection log + CrowdSec ban management, one Authelia- Asterisk failed-connection log + call/text history + CrowdSec ban
protected page. Runs natively on the host (systemd service \`security-dashboard\`), management, one Authelia-protected page. Runs natively on the host
(systemd service \`security-dashboard\`),
not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
## Tabs ## Tabs
Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two are Four tabs: **Security Log**, **Calls & Texts**, **Extensions**, **CrowdSec**.
always there (they only need Asterisk itself, detected once at install time); The first three are always there (they only need Asterisk itself, detected
CrowdSec checks its own live install state on every page load and hides its once at install time); CrowdSec checks its own live install state on every
nav button if \`cscli\` isn't found. page load and hides its nav button if \`cscli\` isn't found.
Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions* Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions*
and *PSTN Trunk* — which between them listed the same extensions three times: and *PSTN Trunk* — which between them listed the same extensions three times:
@@ -232,6 +235,20 @@ which tab a given extension's settings live on.
- **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,
sortable per column (click a header to sort, click again to reverse). sortable per column (click a header to sort, click again to reverse).
- **Calls & Texts** — who called/texted whom, not just failed logins.
- **PSTN Calls** reads \`logs/pstn-trunk-calls.log\`, appended to directly
by \`pstn-trunk.sh\`'s own dialplan (not Asterisk's CDR — see that
script's comment on \`_pstn_write_dialplan_include\` for why). Time,
direction, from, to, duration. Empty until a PSTN trunk is installed and
has logged at least one call.
- **Texts** merges two sources, both metadata-only — no message body is
ever written to either log: internal SIP \`MESSAGE\`s between extensions
(\`logs/sip-messages.log\`, written by \`services/asterisk.sh\`'s
\`[sip-messaging]\` context, delivered or denied), and SMS-over-SIP
arrivals on the trunk DID (\`logs/pstn-sms.log\`, written by
\`pstn-trunk.sh\`'s \`[pstn-sms-inbound]\` context — Anveo-specific, and
diagnostic until its field-parsing is confirmed against a real text, see
that context's own comment).
- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which - **Extensions** — one row per extension, merged from \`pjsip.conf\` (which
always works) and, when the Easy Asterisk container is reachable, its own always works) and, when the Easy Asterisk container is reachable, its own
device list. Columns: Ext, Name, then Category/Status/Transport if that device list. Columns: Ext, Name, then Category/Status/Transport if that
@@ -506,6 +523,7 @@ User=$_svc_user
Group=$_svc_user Group=$_svc_user
Environment=DASHBOARD_PORT=$_port Environment=DASHBOARD_PORT=$_port
Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full} Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full}
Environment=ASTERISK_LOG_DIR=$_log_dir
Environment=ASTERISK_CONFIG_DIR=$_config_dir Environment=ASTERISK_CONFIG_DIR=$_config_dir
Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir
Environment=ASTERISK_EA_CONTAINER=$_ea_container Environment=ASTERISK_EA_CONTAINER=$_ea_container
@@ -873,6 +891,14 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "")
ASTERISK_LOG_DIR = os.environ.get("ASTERISK_LOG_DIR", "")
# Written directly by the dialplan (pstn-trunk.sh / asterisk.sh's System()
# calls), not read from Asterisk's own CDR — see those files' comments on
# pstn-trunk-calls.log for why. All three share ASTERISK_LOG_DIR since
# they live alongside logs/full in the same ./logs volume mount.
PSTN_CALLS_LOG = os.path.join(ASTERISK_LOG_DIR, "pstn-trunk-calls.log") if ASTERISK_LOG_DIR else ""
SIP_MESSAGES_LOG = os.path.join(ASTERISK_LOG_DIR, "sip-messages.log") if ASTERISK_LOG_DIR else ""
SMS_LOG = os.path.join(ASTERISK_LOG_DIR, "pstn-sms.log") if ASTERISK_LOG_DIR else ""
ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "") ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "")
ASN_SCENARIO_FILES = [ ASN_SCENARIO_FILES = [
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
@@ -925,34 +951,31 @@ def _normalize_nanp_number(token):
return None return None
SECURITY_LOG_TAIL_BYTES = 2 * 1024 * 1024 # comfortably enough for 5000 lines LOG_TAIL_BYTES = 2 * 1024 * 1024 # comfortably enough for 5000 lines
def parse_security_log(limit=200): def _tail_lines(path, max_lines=5000):
"""Tail ASTERISK_LOG and return the most recent SecurityEvent lines, """Reads only a bounded byte window from the END of a file, not the whole
newest first, as dicts. Missing file / no lines -> empty list, never an thing, and returns its lines oldest-first within that window. Shared by
error — this is a convenience view, not load-bearing. every log-backed tab (Security Log, PSTN Calls, Texts) — Asterisk's own
logs/full is unrotated console output that can grow to multiple GB, and
Reads only a bounded byte window from the END of the file, not the whole these tabs poll every 30 seconds from the browser. Confirmed live: on a
thing — this log is Asterisk's unrotated console/security output and can 1GB-RAM droplet with a 1.4GB log file, an earlier version that did
grow to multiple GB. The previous version did f.readlines() (loads the f.readlines() (loads the ENTIRE file into memory) ballooned this "stdlib
ENTIRE file into memory) before slicing the last 5000 lines, and this only, deliberately lightweight" process to 677MB RSS / 1.8GB peak swap,
tab polls every 30 seconds from the browser. Confirmed live: on a 1GB-RAM which left CrowdSec unable to even start (boot timeout) and contributed
droplet with a 1.4GB log file, that ballooned this "stdlib only,
deliberately lightweight" process to 677MB RSS / 1.8GB peak swap, which
left CrowdSec unable to even start (boot timeout) and contributed
directly to the droplet becoming unresponsive. Bounding this to a fixed directly to the droplet becoming unresponsive. Bounding this to a fixed
~2MB window keeps memory use constant regardless of how large the log ~2MB window keeps memory use constant regardless of how large the log
file grows. file grows. Missing file -> empty list, never an error — every caller is
a convenience view, not load-bearing.
""" """
if not ASTERISK_LOG or not os.path.isfile(ASTERISK_LOG): if not path or not os.path.isfile(path):
return [] return []
events = []
try: try:
with open(ASTERISK_LOG, "rb") as f: with open(path, "rb") as f:
f.seek(0, os.SEEK_END) f.seek(0, os.SEEK_END)
size = f.tell() size = f.tell()
start = max(0, size - SECURITY_LOG_TAIL_BYTES) start = max(0, size - LOG_TAIL_BYTES)
f.seek(start) f.seek(start)
data = f.read() data = f.read()
except OSError: except OSError:
@@ -961,8 +984,14 @@ def parse_security_log(limit=200):
lines = text.splitlines() lines = text.splitlines()
if start > 0 and lines: if start > 0 and lines:
lines = lines[1:] # first line is likely truncated mid-line lines = lines[1:] # first line is likely truncated mid-line
lines = lines[-5000:] return lines[-max_lines:]
for line in lines:
def parse_security_log(limit=200):
"""Return the most recent SecurityEvent lines from ASTERISK_LOG, newest
first, as dicts."""
events = []
for line in _tail_lines(ASTERISK_LOG):
if "SecurityEvent=" not in line: if "SecurityEvent=" not in line:
continue continue
ts_match = TS_RE.match(line) ts_match = TS_RE.match(line)
@@ -981,6 +1010,84 @@ def parse_security_log(limit=200):
return events[:limit] return events[:limit]
def _fmt_epoch(raw):
try:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(raw)))
except (ValueError, OSError):
return raw
def parse_pstn_calls(limit=200):
"""Reads pstn-trunk-calls.log (epoch|direction|who|what|seconds),
written directly by pstn-trunk.sh's dialplan — see that file's comment
on _pstn_write_dialplan_include for the format and why it isn't
Asterisk's own CDR. 'who'/'what' are direction-dependent: an outbound
row's who/what are the calling extension / dialed PSTN number, an
inbound row's are the caller's number / the DID (or "ring-group") that
was called — normalized to plain from/to here so the UI doesn't need to
know the difference."""
events = []
for line in _tail_lines(PSTN_CALLS_LOG):
parts = line.split("|")
if len(parts) != 5:
continue
epoch, direction, who, what, seconds = parts
events.append({
"epoch": epoch,
"timestamp": _fmt_epoch(epoch),
"direction": direction,
"from": who,
"to": what,
"duration": seconds,
})
events.reverse()
return events[:limit]
def parse_texts(limit=200):
"""Merges internal SIP-messaging deliveries/denials (sip-messages.log,
written by services/asterisk.sh's [sip-messaging] context) with
SMS-over-SIP arrivals (pstn-sms.log, written by pstn-trunk.sh's
[pstn-sms-inbound] context) into one newest-first list. Neither log ever
contains message bodies — metadata only (who/when), by design."""
events = []
for line in _tail_lines(SIP_MESSAGES_LOG):
parts = line.split("|")
if len(parts) != 4:
continue
epoch, status, from_ext, to_ext = parts
events.append({
"epoch": epoch,
"timestamp": _fmt_epoch(epoch),
"type": "internal",
"direction": "internal",
"from": from_ext,
"to": to_ext,
"status": "delivered" if status == "deliver" else "denied",
})
for line in _tail_lines(SMS_LOG):
parts = line.split("|")
if len(parts) != 4:
continue
epoch, direction, from_num, to_num = parts
events.append({
"epoch": epoch,
"timestamp": _fmt_epoch(epoch),
"type": "sms",
"direction": direction,
"from": from_num,
"to": to_num,
"status": "received" if direction == "in" else "sent",
})
def _epoch_key(e):
try:
return int(e["epoch"])
except ValueError:
return 0
events.sort(key=_epoch_key, reverse=True)
return events[:limit]
def run_sudo(args, timeout=15, input_text=None): def run_sudo(args, timeout=15, input_text=None):
"""Runs a whitelisted sudo command. Always list-form args, never """Runs a whitelisted sudo command. Always list-form args, never
shell=True — no shell metacharacter interpretation is possible regardless shell=True — no shell metacharacter interpretation is possible regardless
@@ -3162,6 +3269,7 @@ INDEX_HTML = """<!doctype html>
<h1>Security Dashboard</h1> <h1>Security Dashboard</h1>
<nav> <nav>
<button class="tab-btn" data-tab="security">Security Log</button> <button class="tab-btn" data-tab="security">Security Log</button>
<button class="tab-btn" data-tab="comms">Calls &amp; Texts</button>
<button class="tab-btn active" data-tab="extensions">Extensions</button> <button class="tab-btn active" data-tab="extensions">Extensions</button>
<button class="tab-btn" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button> <button class="tab-btn" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button>
</nav> </nav>
@@ -3183,6 +3291,39 @@ INDEX_HTML = """<!doctype html>
</div> </div>
</div> </div>
</div> </div>
<div id="tab-comms" style="display:none">
<div class="card">
<div class="card-head"><h3>PSTN Calls</h3></div>
<div class="card-body">
<p class="muted" style="margin-top:0">Calls placed or received through the PSTN trunk, newest first. Numbers and duration only — no recordings, no call content. Empty until <code>pstn-trunk</code> is installed and has logged at least one call.</p>
<div class="table-wrap">
<table id="calls-table"><thead><tr>
<th class="sortable" data-sort="timestamp">Time</th>
<th class="sortable" data-sort="direction">Direction</th>
<th class="sortable" data-sort="from">From</th>
<th class="sortable" data-sort="to">To</th>
<th class="sortable" data-sort="duration">Duration</th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Texts</h3></div>
<div class="card-body">
<p class="muted" style="margin-top:0">Internal SIP messages between extensions, and SMS-over-SIP messages arriving on the trunk DID (Anveo-specific, diagnostic until routing is confirmed against real traffic — see PSTN Trunk docs). Metadata only (sender/recipient/time) — message bodies are never logged.</p>
<div class="table-wrap">
<table id="texts-table"><thead><tr>
<th class="sortable" data-sort="timestamp">Time</th>
<th class="sortable" data-sort="type">Type</th>
<th class="sortable" data-sort="direction">Direction</th>
<th class="sortable" data-sort="from">From</th>
<th class="sortable" data-sort="to">To</th>
<th class="sortable" data-sort="status">Status</th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
</div>
<div id="tab-crowdsec" style="display:none"> <div id="tab-crowdsec" style="display:none">
<div class="card"> <div class="card">
<div class="card-head"><h3>Active bans</h3></div> <div class="card-head"><h3>Active bans</h3></div>
@@ -3989,7 +4130,7 @@ var QRCode;
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); } function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
const TABS = ["security", "extensions", "crowdsec"]; const TABS = ["security", "comms", "extensions", "crowdsec"];
document.querySelectorAll(".tab-btn").forEach(btn => { document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
@@ -4051,6 +4192,94 @@ async function loadSecurity() {
renderSecurity(); renderSecurity();
} }
// ── Calls & Texts tab ───────────────────────────────────────────────────────
let lastCalls = [];
let callsSort = { key: null, dir: 1 };
function renderCalls() {
let rows = lastCalls.slice();
if (callsSort.key) {
rows.sort((a, b) => {
const av = (a[callsSort.key] || "").toString().toLowerCase(), bv = (b[callsSort.key] || "").toString().toLowerCase();
if (av < bv) return -1 * callsSort.dir;
if (av > bv) return 1 * callsSort.dir;
return 0;
});
}
document.querySelectorAll("#calls-table th.sortable .arrow").forEach(a => a.remove());
if (callsSort.key) {
const th = document.querySelector(`#calls-table th[data-sort="${callsSort.key}"]`);
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${callsSort.dir === 1 ? "▲" : "▼"}</span>`);
}
const tbody = document.querySelector("#calls-table tbody");
tbody.innerHTML = rows.map(c => `<tr>
<td>${esc(c.timestamp)}</td>
<td>${esc(c.direction)}</td>
<td>${esc(c.from)}</td>
<td>${esc(c.to)}</td>
<td>${esc(c.duration)}s</td>
</tr>`).join("") || `<tr><td colspan=5 class=muted>No calls found.</td></tr>`;
}
document.querySelectorAll("#calls-table th.sortable").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
callsSort.dir = (callsSort.key === key) ? -callsSort.dir : 1;
callsSort.key = key;
renderCalls();
});
});
async function loadCalls() {
const res = await fetch("/api/pstn-calls");
lastCalls = await res.json();
renderCalls();
}
let lastTexts = [];
let textsSort = { key: null, dir: 1 };
function renderTexts() {
let rows = lastTexts.slice();
if (textsSort.key) {
rows.sort((a, b) => {
const av = (a[textsSort.key] || "").toString().toLowerCase(), bv = (b[textsSort.key] || "").toString().toLowerCase();
if (av < bv) return -1 * textsSort.dir;
if (av > bv) return 1 * textsSort.dir;
return 0;
});
}
document.querySelectorAll("#texts-table th.sortable .arrow").forEach(a => a.remove());
if (textsSort.key) {
const th = document.querySelector(`#texts-table th[data-sort="${textsSort.key}"]`);
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${textsSort.dir === 1 ? "▲" : "▼"}</span>`);
}
const tbody = document.querySelector("#texts-table tbody");
tbody.innerHTML = rows.map(t => `<tr>
<td>${esc(t.timestamp)}</td>
<td>${esc(t.type)}</td>
<td>${esc(t.direction)}</td>
<td>${esc(t.from)}</td>
<td>${esc(t.to)}</td>
<td>${esc(t.status)}</td>
</tr>`).join("") || `<tr><td colspan=6 class=muted>No texts found.</td></tr>`;
}
document.querySelectorAll("#texts-table th.sortable").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
textsSort.dir = (textsSort.key === key) ? -textsSort.dir : 1;
textsSort.key = key;
renderTexts();
});
});
async function loadTexts() {
const res = await fetch("/api/comms-texts");
lastTexts = await res.json();
renderTexts();
}
let lastDecisions = []; let lastDecisions = [];
let decSort = { key: null, dir: 1 }; let decSort = { key: null, dir: 1 };
@@ -5096,11 +5325,15 @@ async function removePersonalDid(did) {
} }
loadSecurity(); loadSecurity();
loadCalls();
loadTexts();
loadDecisions(); loadDecisions();
loadAsnExempt(); loadAsnExempt();
loadCrowdsecStatus(); loadCrowdsecStatus();
initExtensionsTab(); initExtensionsTab();
setInterval(loadSecurity, 30000); setInterval(loadSecurity, 30000);
setInterval(loadCalls, 30000);
setInterval(loadTexts, 30000);
setInterval(loadDecisions, 30000); setInterval(loadDecisions, 30000);
</script> </script>
</body></html> </body></html>
@@ -5135,6 +5368,10 @@ class Handler(BaseHTTPRequestHandler):
self._html(html) self._html(html)
elif self.path == "/api/security-events": elif self.path == "/api/security-events":
self._json(parse_security_log()) self._json(parse_security_log())
elif self.path == "/api/pstn-calls":
self._json(parse_pstn_calls())
elif self.path == "/api/comms-texts":
self._json(parse_texts())
elif self.path == "/api/decisions": elif self.path == "/api/decisions":
self._json(get_decisions()) self._json(get_decisions())
elif self.path == "/api/asn-exempt": elif self.path == "/api/asn-exempt":