diff --git a/services/asterisk.sh b/services/asterisk.sh index 019ea2b..d68a866 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -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 } diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index dff3b18..78a2fc3 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -379,6 +379,12 @@ _pstn_write_sms_inbound_dialplan() { ; docker exec 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 } diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 0834055..9cb099e 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -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 = """

Security Dashboard

@@ -3183,6 +3291,39 @@ INDEX_HTML = """ +