From 78456e6a3a2199f6d3bda4256a9dd570745df0f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 03:44:19 +0000 Subject: [PATCH 1/2] Fix ASN-exempt fork never actually disabling the hub-original scenarios "cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum" was failing silently (stderr suppressed, "|| true" swallowed the non-zero exit) because these scenarios are members of the crowdsecurity/asterisk collection, and cscli refuses to remove/disable a collection member without --force. The un-exempted hub originals kept running side-by-side with the ASN-exempt local forks the entire time, independently banning the same traffic with no ASN awareness at all - confirmed live by a ban showing scenario "crowdsecurity/asterisk_bf" (the hub original) rather than "local/asterisk_bf" (the fork) for an ASN that had just been exempted. Fixed in two places: - crowdsec.sh's initial setup now passes --force and surfaces a clear warning (with the manual fix command) if it still fails, instead of silently continuing. - security-dashboard's set-asn-exempt.sh helper now re-asserts the forced disable on every ASN-list save, so an install already affected by this (from before this fix, or where the step failed for any other reason) self-heals the next time the ASN list is edited via the dashboard, without needing to re-run the full CrowdSec installer. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- services/crowdsec.sh | 20 ++++++++++++++++++-- services/security-dashboard.sh | 13 +++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/services/crowdsec.sh b/services/crowdsec.sh index c37eaf0..ddaec1c 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -263,8 +263,24 @@ ASTENUM # Disable the hub originals so they don't double-process the # same events alongside the ASN-exempt forks written above. - sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum 2>/dev/null || true - echo " ✓ Wrote ASN-exempt local forks; disabled the hub originals" + # --force is required: these scenarios came in as part of the + # crowdsecurity/asterisk collection, and cscli refuses to + # remove/disable a collection member without it. Confirmed + # live: without --force this failed silently (stderr + # suppressed, "|| true" swallowed the non-zero exit), leaving + # the un-exempted hub original running side-by-side with the + # ASN-exempt fork the entire time — the fork's exemption + # never actually took effect for anyone, since the original + # scenario kept independently banning the same traffic with + # no ASN awareness at all. + if sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force 2>/dev/null; then + echo " ✓ Wrote ASN-exempt local forks; disabled the hub originals" + else + log_warning "Failed to disable the hub-original asterisk_bf/asterisk_user_enum scenarios —" + log_warning "the ASN exemption below will NOT take effect until this is resolved. Run:" + log_warning " sudo cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force" + log_warning " sudo systemctl restart crowdsec" + fi echo " ℹ Exempted ASNs: $ASN_LIST — SSH/web/geo-allowlist scenarios are unaffected" echo " ℹ Edit /etc/crowdsec/scenarios/local-asterisk_*.yaml to add/remove ASNs later" echo " (then: sudo systemctl restart crowdsec)" diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index aac93f2..eb6767d 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -588,6 +588,19 @@ if [[ "$found" != "1" ]]; then exit 1 fi +# Self-healing: the hub-original crowdsecurity/asterisk_bf / +# asterisk_user_enum scenarios have no ASN awareness at all, so if they're +# still enabled alongside the exempt forks above, they independently ban +# the same traffic regardless of anything just written — the exemption +# above would silently do nothing. crowdsec.sh's original install is +# supposed to disable them (--force, since they're crowdsecurity/asterisk +# collection members), but an install from before that fix shipped (or one +# where that step failed silently) would still have them active. Re-assert +# it on every save rather than trusting it was ever done correctly once — +# confirmed live: an install where this step had silently failed kept +# banning an exempted ASN under the hub-original scenario name. +cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force 2>/dev/null || true + if ! systemctl restart crowdsec; then echo "Wrote ASN list but failed to restart CrowdSec" >&2 exit 2 From 616dcdcfbbe081b02ce5a22f0d6058ea8f6dafd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 04:44:19 +0000 Subject: [PATCH 2/2] Fix Security Log tab loading the entire multi-GB log into memory parse_security_log() did f.readlines() on ASTERISK_LOG before slicing the last 5000 lines - that reads the ENTIRE file into memory first. That log is Asterisk's unrotated console/security output, and this tab polls it every 30 seconds from the browser. Confirmed live: on a 1GB-RAM droplet with a 1.4GB log file, this ballooned the dashboard (explicitly meant to be a lightweight stdlib-only process) to 677MB RSS / 1.8GB peak swap, which left CrowdSec unable to even start (boot timeout) and directly contributed to the droplet becoming unresponsive. Fixed by reading only a bounded ~2MB tail from the end of the file (seek + fixed-size read) instead of the whole thing - memory use is now constant regardless of how large the log grows. Tested against a 180MB synthetic log: memory delta dropped from being proportional to file size to ~7MB, in 0.05s. Also added log rotation (services/asterisk-digital-ocean.sh) for that same file, which had no rotation at all and reached 1.4GB in about 3 days - copytruncate avoids needing to signal the containerized Asterisk process to reopen its log handle. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- services/asterisk-digital-ocean.sh | 25 ++++++++++++++++++++++++ services/security-dashboard.sh | 31 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 9a30bb2..2fd381f 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -270,6 +270,29 @@ _asterisk_do_refresh_vendor_files() { fi } +# ── Shared: log rotation for logs/full (unbounded otherwise) ────────────── +# Confirmed live: with no rotation, this file grew to 1.4GB in about 3 days +# on a busy box (SIP scanning noise is constant on the public internet) — +# a real disk-exhaustion risk on a small droplet, and separately made the +# Security Dashboard balloon to 600+MB RAM/GBs of swap reading it every 30s +# before that was fixed to only read a bounded tail (see +# services/security-dashboard.sh). copytruncate avoids needing to signal +# Asterisk to reopen its log file — it has a long-held file descriptor on +# this path and no reload mechanism this installer can reach from the host. +_asterisk_do_write_logrotate() { + local _ea_dir="$1" + cat > /etc/logrotate.d/asterisk-digital-ocean << LOGROTATE +$_ea_dir/logs/full { + size 100M + rotate 5 + compress + missingok + notifempty + copytruncate +} +LOGROTATE +} + # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh # installs and updates. Must be called with $PWD already at $EA_DIR. @@ -379,6 +402,7 @@ install_asterisk-digital-ocean() { _asterisk_do_refresh_vendor_files _asterisk_do_write_compose + _asterisk_do_write_logrotate "$EA_DIR" log_info "Rebuilding and restarting containers..." if docker compose up -d --build --force-recreate; then @@ -447,6 +471,7 @@ install_asterisk-digital-ocean() { cd "$EA_DIR" || return 1 _asterisk_do_refresh_vendor_files + _asterisk_do_write_logrotate "$EA_DIR" # ── DigitalOcean droplet detection ──────────────────────────────────────── # A droplet's own public IP/ID are readable, unauthenticated, from the diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index eb6767d..d25bef8 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -659,18 +659,43 @@ TIER_RE = re.compile(r"^(internal|restricted|full)$") NUMBER_RE = re.compile(r"^\d{11}$") +SECURITY_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.""" + 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 + 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. + """ 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 + with open(ASTERISK_LOG, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + start = max(0, size - SECURITY_LOG_TAIL_BYTES) + f.seek(start) + data = f.read() except OSError: return [] + text = data.decode("utf-8", errors="replace") + 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: if "SecurityEvent=" not in line: continue