fix(security-stats): stop reporting counters the stick tables never stored

/api/security/stats and scripts/show-tarpit-ips.sh reported "Scan Count",
"offense count" and BLOCKED/TARPITTED status parsed from gpc0/gpc1. No stick
table in this repo has ever stored a general-purpose counter -- the `web` table
stores conn_cur, conn_rate(10s), http_req_rate(10s), http_err_rate(30s), and
the two brute-force tables store http_req_rate(60s). Every one of those figures
was fabricated, and an operator was making decisions on them.

Three independent silences kept it alive:

  * `int(parts[3])` on a positional split hit `exp=368842`, raised ValueError,
    and the loop `continue`d -- so the endpoint always answered
    `active_threats: 0` with an empty list. Live on whp01 it also reported
    parts[0], the `0x...:` allocation pointer, as the source IP.
  * The command was sent to /tmp/haproxy-cli WITHOUT the `@1` worker prefix.
    That is the MASTER CLI socket, which answers "Unknown command: 'show' ..."
    -- and socat still exits 0, so the `returncode != 0` guard never fired.
    `total_tracked_ips` was the line count of that help text (8) while the real
    table held 388 entries.
  * The shell consumers wrote `gpc0=${gpc0:-0}`, rendering a field that does
    not exist as a confident zero.

Report what the tables actually store, rather than adding gpc counters to make
the old semantics real. Adding them would mean editing hap_listener.tpl -- the
one change here with a silent-total-outage failure mode -- to rebuild
enforcement history that the edge access log (shipped 2026.08.8, on the host at
/var/log/haproxy.log) already records per request, with status codes,
termination states and request references the stick table could never hold.

  * haproxy_manager.py: STICK_TABLE_FIELD_CONTRACT names what each table
    stores. haproxy_cli() sends worker commands with `@1`, falls back to the
    bare form for a plain stats socket, and inspects the RESPONSE BODY because
    socat's exit status is worthless here. parse_stick_table_entry() reads
    name=value / name(window_ms)=value pairs by NAME, never by position.
    read_stick_table() RAISES -- naming the field -- when a row is missing a
    contract field, instead of defaulting it to 0.
  * /api/security/stats returns the four real counters with their windows, the
    true `used:` count, and no invented threat_level/blocked/offense_count.
    Fewer numbers, all of them real.
  * scripts/show-edge-ip-rates.sh replaces the fabricated report; the four
    expected fields are declared once as EXPECTED_FIELDS and drive the parser.
    show-tarpit-ips.sh becomes a shim that explains why its numbers are gone
    and points at where tarpit events actually live.
  * monitor-attacks.sh loses fourteen fabricated "threat" categories and a
    composite threat score, all permanently zero; its access-log section now
    says the log is on the host instead of silently printing nothing.
  * haproxy_tarpit_config.txt -- the never-shipped design sketch these counters
    were copied from -- gets a NOT IMPLEMENTED banner.
  * scripts/test-stick-table-contract.py (offline, 21 tests) holds the
    templates' `store` clauses, STICK_TABLE_FIELD_CONTRACT and every consumer
    to each other, and asserts each loud-failure path against the real captured
    responses. Template and consumers can no longer drift apart quietly.

No template is touched, so haproxy.cfg is unchanged.

Verified on whp01: total_tracked_ips now tracks `used:` exactly (511 vs the
table's 511, was 8 vs 388), and per-IP values match `show table web key <ip>`
field for field. haproxy PIDs unmoved, `haproxy -c` warnings unchanged, five
customer sites HTTP 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 10:48:49 -07:00
co-authored by Claude Opus 5
parent b2f835a88c
commit b6a62e7f9f
8 changed files with 1273 additions and 296 deletions
+34
View File
@@ -1,3 +1,37 @@
# =============================================================================
# NOT IMPLEMENTED. THIS FILE IS A DESIGN SKETCH THAT WAS NEVER SHIPPED.
# =============================================================================
#
# Nothing here is deployed, has ever been deployed, or is rendered into
# haproxy.cfg. The real edge config is generated from templates/*.tpl. Compare:
#
# THIS FILE proposes: store gpc0,gpc1,gpc2,http_err_rate(30s),...
# plus sc-inc-gpc0/1/2 scan-escalation rules
# templates/hap_listener.tpl ACTUALLY has:
# store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(30s)
#
# There is no gpc0, no gpc1, no gpc2, and no scan-escalation state anywhere on
# the edge, and never has been.
#
# WHY THE BANNER. This file was mistaken for the shipped config. Two consumers
# -- /api/security/stats in haproxy_manager.py and scripts/show-tarpit-ips.sh --
# were written to parse gpc0/gpc1 out of `show table web`, defaulting missing
# fields to 0. The result was a "Scan Count" column and BLOCKED/TARPITTED
# statuses that were pure fabrication, presented to an operator as fact, for
# the entire life of both tools. Fixed 2026-08-22; scripts/test-stick-table-
# contract.py now fails if any consumer's field expectations and the templates'
# `store` clauses ever drift apart again.
#
# IF YOU WANT TO REVIVE ANY OF THIS: the counters must be added to a template
# `store` clause and to STICK_TABLE_FIELD_CONTRACT in haproxy_manager.py first.
# Adding them to a consumer alone produces confident zeros, not data. Note also
# that sc0/sc1/sc2 are all in use and HAProxy's tune.stick-counters defaults to
# 3, and that since 2026.08.8 the edge has real per-request access logging on
# the host at /var/log/haproxy.log -- which records what was actually denied,
# tarpitted and rate-limited, with request references. That log is a better
# source for most of what this sketch was reaching for.
# =============================================================================
global
daemon
log stdout local0 info