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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 & 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 => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[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":
|
||||||
|
|||||||
Reference in New Issue
Block a user