security-dashboard: add Calls & Texts tab for PSTN calls and SIP/SMS texts

Surfaces pstn-trunk.sh's existing pstn-trunk-calls.log (already recording
every PSTN call's numbers, just never shown on the dashboard) plus two new
metadata-only logs: sip-messages.log for internal SIP MESSAGE
deliveries/denials (asterisk.sh) and pstn-sms.log for SMS-over-SIP arrivals
(pstn-trunk.sh). No message bodies are ever logged. The dashboard reads all
three via a new /api/pstn-calls and /api/comms-texts pair, sharing a common
bounded tail helper with the Security Log parser.
This commit is contained in:
Claude
2026-08-08 02:32:42 +00:00
parent 048ed4af8e
commit d4ca8277a6
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
# match, which denies by default (same fail-closed behavior as an
# 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() {
local FILE="$1"
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,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny)
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(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()
EOF
}
+9
View File
@@ -379,6 +379,12 @@ _pstn_write_sms_inbound_dialplan() {
; docker exec <container> asterisk -rx "core set verbose 5"
; (send the text)
; 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]
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)})
@@ -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_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,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()
EOF
}
+271 -34
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# 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).
#
# Can also be run standalone on any machine:
@@ -64,7 +65,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
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() {
local APP_DIR="/opt/security-dashboard"
@@ -102,8 +103,9 @@ install_security-dashboard() {
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ SECURITY DASHBOARD │"
echo "│ Asterisk failed-connection log + one Extensions tab (devices, │"
echo "│ ring groups, PSTN tiers, personal DIDs) + CrowdSec bans, │"
echo "│ Asterisk failed-connection log + call/text history + one │"
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 "│ cscli and read Asterisk's files directly. Authelia-protected. │"
echo "└─────────────────────────────────────────────────────────────────┘"
@@ -111,8 +113,8 @@ install_security-dashboard() {
if [ -z "$ASTERISK_EA_DIR" ]; then
log_warning "No Asterisk install detected."
log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab"
log_warning "still works fine."
log_warning "The Security Log, Calls & Texts, and Extensions tabs will just be empty —"
log_warning "CrowdSec's tab still works fine."
fi
if [ "$DRY_RUN" = true ]; then
@@ -208,16 +210,17 @@ install_security-dashboard() {
write_readme "$APP_DIR" << README_MD
# Security Dashboard
Asterisk failed-connection log + CrowdSec ban management, one Authelia-
protected page. Runs natively on the host (systemd service \`security-dashboard\`),
Asterisk failed-connection log + call/text history + CrowdSec ban
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.
## Tabs
Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two are
always there (they only need Asterisk itself, detected once at install time);
CrowdSec checks its own live install state on every page load and hides its
nav button if \`cscli\` isn't found.
Four tabs: **Security Log**, **Calls & Texts**, **Extensions**, **CrowdSec**.
The first three are always there (they only need Asterisk itself, detected
once at install time); CrowdSec checks its own live install state on every
page load and hides its nav button if \`cscli\` isn't found.
Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions*
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
(wrong password, unknown extension, etc.) with timestamp/account/remote IP,
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
always works) and, when the Easy Asterisk container is reachable, its own
device list. Columns: Ext, Name, then Category/Status/Transport if that
@@ -506,6 +523,7 @@ User=$_svc_user
Group=$_svc_user
Environment=DASHBOARD_PORT=$_port
Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full}
Environment=ASTERISK_LOG_DIR=$_log_dir
Environment=ASTERISK_CONFIG_DIR=$_config_dir
Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir
Environment=ASTERISK_EA_CONTAINER=$_ea_container
@@ -873,6 +891,14 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
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", "")
ASN_SCENARIO_FILES = [
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
@@ -925,34 +951,31 @@ def _normalize_nanp_number(token):
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):
"""Tail ASTERISK_LOG and return the most recent SecurityEvent lines,
newest first, as dicts. Missing file / no lines -> empty list, never an
error — this is a convenience view, not load-bearing.
Reads only a bounded byte window from the END of the file, not the whole
thing — this log is Asterisk's unrotated console/security output and can
grow to multiple GB. The previous version did f.readlines() (loads the
ENTIRE file into memory) before slicing the last 5000 lines, and this
tab polls every 30 seconds from the browser. Confirmed live: on a 1GB-RAM
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
def _tail_lines(path, max_lines=5000):
"""Reads only a bounded byte window from the END of a file, not the whole
thing, and returns its lines oldest-first within that window. Shared by
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
these tabs poll every 30 seconds from the browser. Confirmed live: on a
1GB-RAM droplet with a 1.4GB log file, an earlier version that did
f.readlines() (loads the ENTIRE file into memory) 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
~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 []
events = []
try:
with open(ASTERISK_LOG, "rb") as f:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
start = max(0, size - SECURITY_LOG_TAIL_BYTES)
start = max(0, size - LOG_TAIL_BYTES)
f.seek(start)
data = f.read()
except OSError:
@@ -961,8 +984,14 @@ def parse_security_log(limit=200):
lines = text.splitlines()
if start > 0 and lines:
lines = lines[1:] # first line is likely truncated mid-line
lines = lines[-5000:]
for line in lines:
return lines[-max_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:
continue
ts_match = TS_RE.match(line)
@@ -981,6 +1010,84 @@ def parse_security_log(limit=200):
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):
"""Runs a whitelisted sudo command. Always list-form args, never
shell=True — no shell metacharacter interpretation is possible regardless
@@ -3162,6 +3269,7 @@ INDEX_HTML = """<!doctype html>
<h1>Security Dashboard</h1>
<nav>
<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" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button>
</nav>
@@ -3183,6 +3291,39 @@ INDEX_HTML = """<!doctype html>
</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 class="card">
<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])); }
const TABS = ["security", "extensions", "crowdsec"];
const TABS = ["security", "comms", "extensions", "crowdsec"];
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
@@ -4051,6 +4192,94 @@ async function loadSecurity() {
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 decSort = { key: null, dir: 1 };
@@ -5096,11 +5325,15 @@ async function removePersonalDid(did) {
}
loadSecurity();
loadCalls();
loadTexts();
loadDecisions();
loadAsnExempt();
loadCrowdsecStatus();
initExtensionsTab();
setInterval(loadSecurity, 30000);
setInterval(loadCalls, 30000);
setInterval(loadTexts, 30000);
setInterval(loadDecisions, 30000);
</script>
</body></html>
@@ -5135,6 +5368,10 @@ class Handler(BaseHTTPRequestHandler):
self._html(html)
elif self.path == "/api/security-events":
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":
self._json(get_decisions())
elif self.path == "/api/asn-exempt":