diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index beec66a..a928b02 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -118,6 +118,14 @@ install_security-dashboard() { 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) @@ -182,108 +190,14 @@ SDSVC log_warning "Failed to start — check: systemctl status security-dashboard" fi - # ── Caddy + Authelia ────────────────────────────────────────────────────── - # This is deliberately more insistent about Authelia than most services — - # it can delete active CrowdSec bans, so an unauthenticated exposure here - # is a real security hole, not just an inconvenience. - echo "" - if command -v docker &>/dev/null && docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then - 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)." - else - 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 - - if [ -z "$EXTRA_BLOCK" ]; then - log_error "Proceeding WITHOUT Authelia 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." - SD_DOMAIN="" - fi - fi - - if [ -n "$SD_DOMAIN" ]; then - 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} { -${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 - - # Port 8092 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 - fi - fi - else - log_info "Caddy not running — dashboard stays on http://localhost:$DASHBOARD_PORT until you set it up." - 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 @@ -296,9 +210,17 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. - **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, and a way to add/remove ASNs from the ASN-exempt Asterisk - brute-force scenarios (see \`services/crowdsec.sh\`'s "Exempt specific carrier - ASNs" option) without SSHing in. + 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 @@ -310,16 +232,23 @@ sudo journalctl -u security-dashboard -f ## Security notes - Runs as a dedicated, unprivileged system user (\`secdash\`), not root. -- Sudo access is scoped to exactly four commands via +- 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 only to label ASN exemptions with a carrier name from past alerts), + 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.** Don't run it without Authelia - (or equivalent) in front of it. +- **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 "" @@ -344,6 +273,7 @@ _secdash_write_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 @@ -352,6 +282,185 @@ SUDOERS || { 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() { @@ -383,6 +492,7 @@ 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): @@ -513,9 +623,13 @@ def get_asn_exempt(known_names=None): def set_asn_exempt(asn_list): - clean = [a.strip() for a in asn_list if ASN_RE.match(a.strip())] - if not clean: - return False, "No valid (numeric) ASNs provided" + # 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: @@ -532,9 +646,71 @@ def set_asn_exempt(asn_list): 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 @@ -590,7 +766,7 @@ INDEX_HTML = """ -
+
ASNCarrier
@@ -662,9 +838,15 @@ async function loadAsnExempt() { const data = await res.json(); const asns = data.asns || []; document.getElementById("asn-input").value = asns.map(a => a.asn).join(", "); - document.getElementById("asn-list").textContent = asns.length - ? "Currently exempt: " + asns.map(a => a.asn + (a.name ? " (" + a.name + ")" : "")).join(", ") - : "No ASNs currently exempted."; + const tbody = document.querySelector("#asn-table tbody"); + tbody.innerHTML = asns.map(a => ` + ${esc(a.asn)} + ${esc(a.name) || '(unknown)'} + + + + + `).join("") || "No ASNs currently exempted."; } document.getElementById("asn-save").addEventListener("click", async () => { @@ -673,8 +855,31 @@ document.getElementById("asn-save").addEventListener("click", async () => { const res = await fetch("/api/asn-exempt", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asns: asns})}); const data = await res.json(); document.getElementById("msg").textContent = data.message || (data.ok ? "Saved" : "Failed"); + loadAsnExempt(); }); +async function unexemptAsn(asn) { + if (!confirm("Remove ASN " + asn + " from the exemption list? Future Asterisk auth failures from it will be evaluated normally again (no immediate ban of past offenders).")) return; + const current = (document.getElementById("asn-input").value || "").split(",").map(s => s.trim()).filter(s => s && s !== asn); + const res = await fetch("/api/asn-exempt", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asns: current})}); + const data = await res.json(); + document.getElementById("msg").textContent = data.message || (data.ok ? "Saved" : "Failed"); + loadAsnExempt(); +} + +async function banAsn(asn) { + if (!confirm("Remove ASN " + asn + " from the exemption list AND immediately ban (24h) every IP CrowdSec has ever recorded for it? Use this for an accidental whitelist.")) return; + const res = await fetch("/api/asn-exempt/ban", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asn: asn})}); + const data = await res.json(); + const parts = [data.unexempt_message || (data.ok ? "Unwhitelisted" : "Unwhitelist failed")]; + if (data.banned_ips && data.banned_ips.length) parts.push("Banned: " + data.banned_ips.join(", ")); + if (data.failed_ips && data.failed_ips.length) parts.push("Failed to ban: " + data.failed_ips.join(", ")); + if (!data.banned_ips || !data.banned_ips.length) parts.push("No previously-recorded IPs found for this ASN to ban."); + document.getElementById("msg").textContent = parts.join(" — "); + loadAsnExempt(); + loadDecisions(); +} + const adminUrl = "__ASTERISK_ADMIN_URL__"; if (adminUrl) { const link = document.getElementById("admin-link"); @@ -740,6 +945,8 @@ class Handler(BaseHTTPRequestHandler): 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)