#!/bin/bash # services/security-dashboard.sh — Security dashboard: Asterisk failed-connection # log + 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: # sudo bash security-dashboard.sh # (Docker must already be installed when run standalone — Caddy fronts this, # even though the dashboard itself runs natively on the host, not in Docker) # # Why native, not Docker: it needs to run `cscli` (a host binary — CrowdSec is # a system service, not a container, see services/crowdsec.sh) and read # Asterisk's security log directly off disk. Running natively avoids bridging # the container/host boundary entirely — no LAPI credentials to expose to a # containerized frontend, no Docker socket mount. Same reasoning as why # CrowdSec itself is a system service in this repo, not a docker-compose one. # ── Standalone bootstrap ────────────────────────────────────────────────────── if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # shellcheck source=../lib/common.sh source "$_COMMON" else log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } fi ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" register_service() { :; } _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + CrowdSec bans (Authelia-protected)" 8092 install_security-dashboard() { local APP_DIR="/opt/security-dashboard" local DASHBOARD_PORT=8092 local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs" local SVC_USER="secdash" local ASTERISK_ADMIN_URL="" if [ -f "$DOCKER_DIR/asterisk-digital-ocean/.env" ]; then local _ea_domain _ea_domain="$(grep -E '^DOMAIN_NAME=' "$DOCKER_DIR/asterisk-digital-ocean/.env" | cut -d= -f2-)" [ -n "$_ea_domain" ] && ASTERISK_ADMIN_URL="https://${_ea_domain}" fi echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ SECURITY DASHBOARD │" echo "│ Asterisk failed-connection log + CrowdSec decisions, one page. │" echo "│ Runs natively on the host (not Docker) so it can call cscli and │" echo "│ read Asterisk's security log directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" if [ ! -d "$ASTERISK_LOG_DIR" ]; then log_warning "No asterisk-digital-ocean install detected at $ASTERISK_LOG_DIR." log_warning "The Security Log tab will just be empty — CrowdSec's tab still works fine." fi if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create system user $SVC_USER" echo "[DRY-RUN] Would write $APP_DIR/app.py" echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi if [ -f "$APP_DIR/app.py" ]; then local MODE="" prompt_reinstall_mode MODE 2>/dev/null || { # prompt_reinstall_mode isn't defined in the standalone stub — fall # back to a plain yes/no when run outside the full repo. local _r="" prompt_yn " Security dashboard already exists at $APP_DIR — reconfigure? (y/n):" "n" _r [ "$_r" = "y" ] || [ "$_r" = "Y" ] && MODE="fresh" || MODE="cancel" } case "$MODE" in update) log_info "Refreshing app code + sudoers rule (no config/domain changes)..." _secdash_write_app "$APP_DIR" _secdash_write_sudoers "$SVC_USER" systemctl restart security-dashboard 2>/dev/null \ && log_success "security-dashboard restarted" \ || log_warning "Restart failed — check: systemctl status security-dashboard" echo "" local _reconf="" prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf if [[ "$_reconf" =~ ^[Yy]$ ]]; then _secdash_remove_caddy_block "$DASHBOARD_PORT" _secdash_configure_caddy "$DASHBOARD_PORT" fi return 0 ;; cancel) log_info "Leaving the existing install as-is." return 0 ;; fresh) ;; esac fi # ── System user (no login, no home directory needed) ──────────────────── if ! id "$SVC_USER" &>/dev/null; then useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER" log_success "Created system user $SVC_USER" fi # Read access to the Asterisk security log without running as root or the # actual user — add secdash to the group that owns the log files instead. if [ -d "$ASTERISK_LOG_DIR" ]; then local _log_group _log_group="$(stat -c '%G' "$ASTERISK_LOG_DIR" 2>/dev/null || echo "$ACTUAL_USER")" usermod -aG "$_log_group" "$SVC_USER" 2>/dev/null || true chmod 750 "$ASTERISK_LOG_DIR" 2>/dev/null || true fi mkdir -p "$APP_DIR" _secdash_write_app "$APP_DIR" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" _secdash_write_sudoers "$SVC_USER" # ── systemd unit ────────────────────────────────────────────────────────── cat > /etc/systemd/system/security-dashboard.service << SDSVC [Unit] Description=Security dashboard (Asterisk security log + CrowdSec decisions) After=network.target [Service] Type=simple User=$SVC_USER Group=$SVC_USER Environment=DASHBOARD_PORT=$DASHBOARD_PORT Environment=ASTERISK_LOG=$ASTERISK_LOG_DIR/full Environment=ASTERISK_ADMIN_URL=$ASTERISK_ADMIN_URL ExecStart=/usr/bin/python3 $APP_DIR/app.py Restart=on-failure RestartSec=3 NoNewPrivileges=false ProtectSystem=strict ReadOnlyPaths=$ASTERISK_LOG_DIR ReadWritePaths=/etc/crowdsec/scenarios [Install] WantedBy=multi-user.target SDSVC systemctl daemon-reload systemctl enable security-dashboard >/dev/null 2>&1 if systemctl restart security-dashboard; then log_success "security-dashboard started on port $DASHBOARD_PORT (all interfaces — UFW scopes actual access)" else log_warning "Failed to start — check: systemctl status security-dashboard" fi # ── Caddy + Authelia (+ optional independent Basic Auth) ──────────────── # This is deliberately more insistent about auth than most services — it # can delete active CrowdSec bans, so an unauthenticated exposure here is # a real security hole, not just an inconvenience. Factored into # _secdash_configure_caddy so "update" mode can also offer to reconfigure # it later (e.g. to add Basic Auth to an already-deployed dashboard) # without duplicating this logic — see that function for the rest. _secdash_configure_caddy "$DASHBOARD_PORT" 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\`), not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. ## Tabs - **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures (wrong password, unknown extension, etc.) with timestamp/account/remote IP. - **CrowdSec** — current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns, and management of the ASN-exempt Asterisk brute-force scenarios (see \`services/crowdsec.sh\`'s "Exempt specific carrier ASNs" option) without SSHing in: - **Currently-exempt ASNs** are listed with carrier name (resolved from current bans, falling back to alert history for ASNs with no active ban right now) regardless of when they were added. - **Unwhitelist** removes an ASN from the exemption list — future Asterisk auth failures from it are evaluated normally again. - **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP CrowdSec has ever recorded for that ASN, for accidental-whitelist cases where you don't want to wait for it to misbehave again. - Link to the Asterisk web admin itself (doesn't embed it, just links out). ## Manage \`\`\` sudo systemctl status security-dashboard sudo systemctl restart security-dashboard sudo journalctl -u security-dashboard -f \`\`\` ## Security notes - Runs as a dedicated, unprivileged system user (\`secdash\`), not root. - Sudo access is scoped to exactly five commands via \`/etc/sudoers.d/security-dashboard\`: \`cscli decisions delete --id \`, \`cscli decisions list -o json\`, \`cscli alerts list -o json\` (read-only, used to label ASN exemptions with a carrier name from past alerts and to find known offending IPs for the "Ban" action), \`cscli decisions add --ip --duration --type ban --reason \` (used only by "Ban"), and \`systemctl restart crowdsec\`. Nothing else. - Listens on all interfaces (Caddy reaches it via \`host.docker.internal\`, a Docker bridge IP — a loopback-only bind refuses that). Access is scoped by UFW instead, allowed only from Caddy's internal network, not the internet. - **This page can delete active security bans.** It's protected by Authelia (or a remote instance) by default, and the installer offers a second, independent HTTP Basic Auth layer in front of that — a request must pass Basic Auth *and* Authelia before it ever reaches the app, so an Authelia bug or misconfiguration alone isn't enough to expose this page. Re-run the installer ("update" mode → reconfigure Caddy protection) to add, rotate, or remove that Basic Auth layer later. README_MD echo "" echo " Local access: http://localhost:$DASHBOARD_PORT" echo " README: $APP_DIR/README.md" echo "" } # Scoped sudo — only the exact commands the app needs, nothing else. Numeric- # only glob on the decision ID; Python subprocess calls always pass args as a # list (no shell=True anywhere), so there's no shell-metachar injection # surface even before sudoers' own pattern match kicks in — the server-side # ID validation (must be all-digits) happens before this is ever reached, # this is defense in depth, not the only check. Separate function, called # from both "update" and fresh-install, so adding a new permission later # (like alerts list, added after ASN-exempt entries with no currently-active # ban had no carrier name to show) reaches existing installs on their next # update instead of silently only applying to new ones. _secdash_write_sudoers() { local _svc_user="$1" cat > /etc/sudoers.d/security-dashboard << SUDOERS $_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete --id [0-9]* $_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions list -o json $_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli alerts list -o json $_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions add --ip * --duration * --type ban --reason * $_svc_user ALL=(root) NOPASSWD: /usr/bin/systemctl restart crowdsec SUDOERS chmod 440 /etc/sudoers.d/security-dashboard visudo -c -f /etc/sudoers.d/security-dashboard >/dev/null 2>&1 \ && log_success "Sudoers rule installed and validated" \ || { log_error "Sudoers rule failed validation — removing it (dashboard's CrowdSec tab won't work until fixed)"; rm -f /etc/sudoers.d/security-dashboard; } } # Caddy + Authelia (+ optional independent Basic Auth) for the dashboard. # Separate function so "update" mode can call _secdash_remove_caddy_block + # this to reconfigure an already-deployed dashboard (e.g. to add Basic Auth # retroactively) using the exact same code path as a fresh install, instead # of hand-patching a live Caddyfile block in place. _secdash_configure_caddy() { local DASHBOARD_PORT="$1" echo "" if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then log_info "Caddy not running — dashboard stays on http://localhost:$DASHBOARD_PORT until you set it up." return 0 fi local _default_domain="" if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then _default_domain="security.${SITE_DOMAIN}" fi local SD_DOMAIN="" prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN if [ -z "$SD_DOMAIN" ]; then log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)." return 0 fi local EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then EXTRA_BLOCK=" import authelia" log_info "Local Authelia detected — protecting with it." else log_warning "No local Authelia found. This dashboard can delete active security" log_warning "bans — strongly recommend protecting it before exposing it publicly." local _use_remote="" prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote if [[ "$_use_remote" =~ ^[Yy]$ ]]; then local _remote_authelia="" prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia if [ -n "$_remote_authelia" ]; then # See services/asterisk-digital-ocean.sh for why # X-Forwarded-Host must be a literal domain here, not # the {host} placeholder — confirmed live that the # placeholder still evaluates to the upstream # Authelia's own hostname for a scheme-qualified # remote upstream, not the original site's. EXTRA_BLOCK=" forward_auth ${_remote_authelia} { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Name Remote-Email header_up X-Forwarded-Method {method} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host ${SD_DOMAIN} header_up X-Forwarded-Uri {uri} }" fi fi fi # ── Independent Basic Auth layer (defense-in-depth on top of Authelia) ── # Authelia already gates this page, but it's still one piece of software # this dashboard trusts completely — this repo already hit one real # Authelia forward_auth header bypass (see services/authelia.sh's # header_up X-Forwarded-Host fix). This dashboard can delete active # security bans, so it's worth a second, genuinely independent gate that # doesn't depend on Authelia (or its session store, or its config) at # all. basicauth is written before EXTRA_BLOCK below, so a request must # clear it before ever reaching Authelia's forward_auth call. local BASICAUTH_BLOCK="" local _use_basicauth="" prompt_yn " Add an independent Basic Auth login in front of Authelia, as a second, separate layer? (y/n):" "y" _use_basicauth if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then local BA_USER="" BA_PASS="" BA_HASH="" prompt_text " Basic Auth username [admin]:" "admin" BA_USER BA_PASS="$(generate_password 20)" log_info "Generating Basic Auth password hash (via the running Caddy container)..." BA_HASH="$(docker exec caddy caddy hash-password --plaintext "$BA_PASS" 2>/dev/null)" if [ -z "$BA_HASH" ]; then log_warning "Could not generate the Basic Auth hash — skipping this layer. Authelia alone will protect the dashboard." else BASICAUTH_BLOCK=" basicauth { ${BA_USER} ${BA_HASH} } " log_success "Basic Auth username: ${BA_USER}" log_success "Basic Auth password: ${BA_PASS}" log_warning "Save that password now — only the bcrypt hash is written to the Caddyfile, it is not stored anywhere in plaintext." fi fi if [ -z "$EXTRA_BLOCK" ] && [ -z "$BASICAUTH_BLOCK" ]; then log_error "Proceeding WITHOUT any auth protection — anyone who finds this domain" log_error "can view and delete active security bans. Strongly reconsider." local _confirm_unsafe="" prompt_yn " Really continue without auth protection? (y/n):" "n" _confirm_unsafe if [[ ! "$_confirm_unsafe" =~ ^[Yy]$ ]]; then log_info "Skipping Caddy setup. Re-run this installer once Authelia is available." return 0 fi fi local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" if [ -f "$CADDY_FILE" ] && ! grep -q "^${SD_DOMAIN} {" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDYBLOCK # Security Dashboard ${SD_DOMAIN} { ${BASICAUTH_BLOCK}${EXTRA_BLOCK} reverse_proxy host.docker.internal:${DASHBOARD_PORT} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" } log { output file /var/log/caddy/${SD_DOMAIN}.log format json } } CADDYBLOCK docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true docker compose -f "$DOCKER_DIR/caddy/docker-compose.yml" restart caddy 2>/dev/null \ && log_success "Caddy restarted — dashboard at https://${SD_DOMAIN}" \ || log_warning "Restart Caddy manually: cd $DOCKER_DIR/caddy && docker compose restart" elif [ -f "$CADDY_FILE" ]; then log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone." fi # This port never needs to be open to the internet — only Caddy (local, # via host.docker.internal) ever needs to reach it. if command -v ufw &>/dev/null; then ufw delete allow "${DASHBOARD_PORT}/tcp" 2>/dev/null || true if declare -f ufw_allow_from_caddy_net >/dev/null 2>&1; then ufw_allow_from_caddy_net "${DASHBOARD_PORT}" fi fi } # Removes the dashboard's existing Caddyfile site block (found via its # unique reverse_proxy line, walking backward to the nearest " {" # open and forward to the matching unindented "}" close) so # _secdash_configure_caddy can regenerate it fresh on "update" mode's # reconfigure path, rather than trying to surgically patch a live Caddyfile # in place — a whole-block delete-and-regenerate is much harder to get # subtly wrong than in-place editing of a file this security-critical. _secdash_remove_caddy_block() { local port="$1" local caddy_file="$DOCKER_DIR/caddy/Caddyfile" [ -f "$caddy_file" ] || return 0 local marker=" reverse_proxy host.docker.internal:${port}" local marker_line domain_line end_line marker_line="$(grep -nF "$marker" "$caddy_file" | head -1 | cut -d: -f1)" if [ -z "$marker_line" ]; then return 0 # nothing deployed yet — fine, the fresh flow will just append fi domain_line="$(head -n "$marker_line" "$caddy_file" | grep -nE '^[^[:space:]#].* \{$' | tail -1 | cut -d: -f1)" if [ -z "$domain_line" ]; then log_warning "Could not find the start of the existing dashboard Caddy block — leaving it as-is." return 1 fi # Pull in the "# Security Dashboard" comment line right above it too, if present if [ "$domain_line" -gt 1 ] && sed -n "$((domain_line - 1))p" "$caddy_file" | grep -qx '# Security Dashboard'; then domain_line=$((domain_line - 1)) fi end_line="$(tail -n "+$marker_line" "$caddy_file" | grep -nx '}' | head -1 | cut -d: -f1)" if [ -z "$end_line" ]; then log_warning "Could not find the end of the existing dashboard Caddy block — leaving it as-is." return 1 fi end_line=$((marker_line + end_line - 1)) sed -i "${domain_line},${end_line}d" "$caddy_file" log_info "Removed the existing dashboard Caddy block (regenerating it fresh)." } # Writes the Python app. Separate function so "update" mode (refresh code, # keep config) and fresh installs share one copy instead of drifting apart. _secdash_write_app() { local _app_dir="$1" mkdir -p "$_app_dir" cat > "$_app_dir/app.py" << 'PYAPP' #!/usr/bin/env python3 """Security dashboard: Asterisk failed-connection log + CrowdSec decisions. Stdlib only, deliberately — this runs on a small droplet alongside Asterisk, Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead. """ import json import os import re import subprocess from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "") ASN_SCENARIO_FILES = [ "/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml", ] TS_RE = re.compile(r"^\[([^\]]+)\]") KV_RE = re.compile(r'(\w+)="([^"]*)"') ASN_FILTER_RE = re.compile(r"ASNNumber in \[([^\]]*)\]\)") ID_RE = re.compile(r"^\d+$") ASN_RE = re.compile(r"^\d+$") IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") 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.""" if not ASTERISK_LOG or not os.path.isfile(ASTERISK_LOG): return [] events = [] try: with open(ASTERISK_LOG, "r", errors="replace") as f: lines = f.readlines()[-5000:] # cap how much we ever scan except OSError: return [] for line in lines: if "SecurityEvent=" not in line: continue ts_match = TS_RE.match(line) fields = dict(KV_RE.findall(line)) if not fields.get("SecurityEvent"): continue events.append({ "timestamp": ts_match.group(1) if ts_match else "", "event": fields.get("SecurityEvent", ""), "severity": fields.get("Severity", ""), "account": fields.get("AccountID", ""), "remote": fields.get("RemoteAddress", ""), "reason": fields.get("SecurityEvent", ""), }) events.reverse() return events[:limit] def run_sudo(args, timeout=15): """Runs a whitelisted sudo command. Always list-form args, never shell=True — no shell metacharacter interpretation is possible regardless of what's in the arguments, on top of the sudoers-side restriction.""" try: result = subprocess.run( ["sudo"] + args, capture_output=True, text=True, timeout=timeout ) return result.returncode == 0, result.stdout, result.stderr except (subprocess.TimeoutExpired, OSError) as e: return False, "", str(e) def get_decisions(): ok, out, err = run_sudo(["/usr/bin/cscli", "decisions", "list", "-o", "json"]) if not ok or not out.strip(): return [] try: data = json.loads(out) except json.JSONDecodeError: return [] decisions = [] for alert in data or []: # AS number/name and country live on the parent alert's "source" # object, not on the individual decision — confirmed against real # output (source.as_number, source.as_name, source.cn) rather than # guessed, after getting evt.Enriched.ASNNumber's type wrong earlier # tonight for the same underlying data. source = alert.get("source") or {} for d in alert.get("decisions") or []: decisions.append({ "id": d.get("id"), "value": d.get("value"), "scenario": d.get("scenario"), "duration": d.get("duration"), "origin": d.get("origin"), "as_number": source.get("as_number", ""), "as_name": source.get("as_name", ""), "country": source.get("cn", ""), }) return decisions def delete_decision(decision_id): if not ID_RE.match(str(decision_id)): return False, "Invalid decision ID" ok, out, err = run_sudo(["/usr/bin/cscli", "decisions", "delete", "--id", str(decision_id)]) return ok, (err or out or ("deleted" if ok else "failed")) def get_alert_history_names(): """ASN -> as_name map built from historical alerts (cscli alerts list, unlike decisions list, includes expired/resolved ones). A successfully exempted ASN (e.g. T-Mobile once its bans stop firing) has no *active* decision left to source a name from — this is the fallback that still finds one, from the alert that was raised before the exemption took effect.""" ok, out, err = run_sudo(["/usr/bin/cscli", "alerts", "list", "-o", "json"]) if not ok or not out.strip(): return {} try: data = json.loads(out) except json.JSONDecodeError: return {} names = {} for alert in data or []: source = alert.get("source") or {} asn = source.get("as_number") name = source.get("as_name") if asn and name: names.setdefault(str(asn), name) return names def get_asn_exempt(known_names=None): """known_names: optional {asn: as_name} lookup, built from current decisions, to label already-exempt ASNs that aren't actively generating bans right now (and so wouldn't otherwise have a name available).""" known_names = known_names or {} asns = set() for path in ASN_SCENARIO_FILES: try: with open(path) as f: content = f.read() except OSError: continue m = ASN_FILTER_RE.search(content) if m: for tok in m.group(1).split(","): tok = tok.strip().strip("'").strip('"') if tok: asns.add(tok) ordered = sorted(asns, key=lambda x: int(x) if x.isdigit() else 0) return [{"asn": a, "name": known_names.get(a, "")} for a in ordered] def set_asn_exempt(asn_list): # Empty is valid and means "no ASNs exempted" — ASNNumber in [] is valid # expr-language and always evaluates false, so the exclusion filter # !(... in []) is always true and every Asterisk auth failure is # evaluated normally again. Needed so removing the last remaining # exempt ASN (the "unwhitelist" action) can actually reach zero instead # of being stuck refusing an empty save. clean = sorted(set(a.strip() for a in asn_list if ASN_RE.match(a.strip()))) expr = ", ".join("'%s'" % a for a in clean) for path in ASN_SCENARIO_FILES: try: with open(path) as f: content = f.read() except OSError: continue new_content = ASN_FILTER_RE.sub("ASNNumber in [%s])" % expr, content) try: with open(path, "w") as f: f.write(new_content) except OSError as e: return False, "Failed writing %s: %s" % (path, e) ok, out, err = run_sudo(["/usr/bin/systemctl", "restart", "crowdsec"]) if not ok: return False, "Wrote ASN list but failed to restart CrowdSec: %s" % (err or out) if not clean: return True, "Cleared — no ASNs exempted, all Asterisk traffic is evaluated normally again." return True, "Updated: %s" % ", ".join(clean) def get_asn_source_ips(asn): """Every source IP CrowdSec has ever recorded for a given ASN, from alert history (includes expired/resolved alerts) — used so "ban" can act on previously-seen offenders immediately, not just future ones.""" ok, out, err = run_sudo(["/usr/bin/cscli", "alerts", "list", "-o", "json"]) if not ok or not out.strip(): return [] try: data = json.loads(out) except json.JSONDecodeError: return [] ips = set() for alert in data or []: source = alert.get("source") or {} if str(source.get("as_number", "")) == str(asn): ip = source.get("ip") if ip and IP_RE.match(ip): ips.add(ip) return sorted(ips) def ban_ip(ip, reason, duration="24h"): if not IP_RE.match(ip): return False, "Invalid IP" ok, out, err = run_sudo([ "/usr/bin/cscli", "decisions", "add", "--ip", ip, "--duration", duration, "--type", "ban", "--reason", reason, ]) return ok, (err or out or ("banned" if ok else "failed")) def ban_asn(asn): """For an accidental whitelist: drop the ASN from the exempt list (so future traffic from it is evaluated normally again) and immediately ban every IP CrowdSec has on record for it, so the response isn't limited to "wait for it to misbehave again.""" asn = str(asn).strip() if not ASN_RE.match(asn): return {"ok": False, "message": "Invalid ASN"} current = [d["asn"] for d in get_asn_exempt()] if asn in current: remaining = [a for a in current if a != asn] unexempt_ok, unexempt_message = set_asn_exempt(remaining) else: unexempt_ok, unexempt_message = True, "ASN was not currently exempt" banned, failed = [], [] for ip in get_asn_source_ips(asn): ok, _msg = ban_ip(ip, "manual: AS%s exemption removed, known offender re-banned" % asn) (banned if ok else failed).append(ip) return { "ok": unexempt_ok, "unexempt_message": unexempt_message, "banned_ips": banned, "failed_ips": failed, } INDEX_HTML = """ Security Dashboard

Security Dashboard

Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.

TimeEventAccountRemoteSeverity
""" class Handler(BaseHTTPRequestHandler): def _json(self, obj, status=200): body = json.dumps(obj).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _html(self, html, status=200): body = html.encode() self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/" or self.path == "": html = INDEX_HTML.replace("__ASTERISK_ADMIN_URL__", ASTERISK_ADMIN_URL) self._html(html) elif self.path == "/api/security-events": self._json(parse_security_log()) elif self.path == "/api/decisions": self._json(get_decisions()) elif self.path == "/api/asn-exempt": decisions = get_decisions() known_names = {d["as_number"]: d["as_name"] for d in decisions if d.get("as_number")} for asn, name in get_alert_history_names().items(): known_names.setdefault(asn, name) self._json({"asns": get_asn_exempt(known_names)}) else: self._json({"error": "not found"}, 404) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" try: payload = json.loads(raw or b"{}") except json.JSONDecodeError: payload = {} if self.path == "/api/decisions/delete": ok, message = delete_decision(payload.get("id", "")) self._json({"ok": ok, "message": message}) elif self.path == "/api/asn-exempt": ok, message = set_asn_exempt(payload.get("asns", [])) self._json({"ok": ok, "message": message}) elif self.path == "/api/asn-exempt/ban": self._json(ban_asn(payload.get("asn", ""))) else: self._json({"error": "not found"}, 404) def log_message(self, fmt, *args): pass # systemd journal captures stdout/stderr already; keep it quiet def main(): ThreadingHTTPServer.allow_reuse_address = True # 0.0.0.0, not 127.0.0.1: Caddy runs in a container and reaches this via # host.docker.internal (a Docker bridge gateway IP, not localhost) — a # loopback-only bind refuses that connection outright. Confirmed live: # "dial tcp 172.17.0.1:8092: connect: connection refused" even though # curl from the host itself worked fine on 127.0.0.1. Access is scoped by # UFW (see install_security-dashboard), not by which interface this binds # to — same pattern every other host-network service in this repo uses. with ThreadingHTTPServer(("0.0.0.0", PORT), Handler) as httpd: print(f"Security dashboard running on 0.0.0.0:{PORT}") httpd.serve_forever() if __name__ == "__main__": main() PYAPP } [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_security-dashboard