Files
haproxy-manager-base/haproxy_tarpit_config.txt
T
shadowdaoandClaude Opus 5 b6a62e7f9f 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>
2026-08-22 10:48:49 -07:00

187 lines
8.5 KiB
Plaintext

# =============================================================================
# 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
chroot /var/lib/haproxy
stats socket /var/run/haproxy.sock mode 600 level admin
stats timeout 30s
user haproxy
group haproxy
# SSL/TLS settings if using HTTPS
ssl-default-bind-ciphers ECDHE+AESGCM:ECDHE+CHACHA20:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
timeout tarpit 60s # Maximum tarpit time
option httplog
option dontlognull
option log-health-checks
retries 3
# Error files (optional - customize as needed)
errorfile 400 /usr/local/etc/haproxy/errors/400.http
errorfile 403 /usr/local/etc/haproxy/errors/403.http
errorfile 408 /usr/local/etc/haproxy/errors/408.http
errorfile 500 /usr/local/etc/haproxy/errors/500.http
errorfile 502 /usr/local/etc/haproxy/errors/502.http
errorfile 503 /usr/local/etc/haproxy/errors/503.http
errorfile 504 /usr/local/etc/haproxy/errors/504.http
frontend web_frontend
bind *:80
bind *:443 ssl crt /etc/ssl/certs/haproxy.pem
# Stick table for tracking attacks with escalating timeouts
# gpc0 = total scan attempts
# gpc1 = escalation level (0=none, 1=level1, 2=level2, 3=level3)
# gpc2 = total tarpit/block actions taken
stick-table type ip size 200k expire 2h store gpc0,gpc1,gpc2,http_err_rate(30s),http_err_rate(300s),http_err_rate(3600s)
# Whitelist trusted networks and monitoring systems
acl trusted_networks src 127.0.0.1 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
acl monitoring_systems src -f /etc/haproxy/monitoring_ips.txt
acl health_check path_beg /health /ping /status /.well-known/
# Allow trusted traffic to bypass all protection
http-request allow if trusted_networks or monitoring_systems or health_check
# Define threat levels based on scan attempts and rates
acl low_threat sc_get_gpc0(0) ge 3 sc_get_gpc0(0) lt 10
acl medium_threat sc_get_gpc0(0) ge 10 sc_get_gpc0(0) lt 25
acl high_threat sc_get_gpc0(0) ge 25 sc_get_gpc0(0) lt 50
acl critical_threat sc_get_gpc0(0) ge 50
# Rate-based detection (burst attacks)
acl burst_attack sc_http_err_rate(0,30s) gt 8 # >8 errors in 30 seconds
acl sustained_attack sc_http_err_rate(0,300s) gt 3 # >3 errors/min for 5 minutes
acl persistent_attack sc_http_err_rate(0,3600s) gt 1 # >1 error/min for 1 hour
# Escalation levels (tracks how many times we've escalated this IP)
acl escalation_level_0 sc_get_gpc1(0) eq 0
acl escalation_level_1 sc_get_gpc1(0) eq 1
acl escalation_level_2 sc_get_gpc1(0) eq 2
acl escalation_level_3 sc_get_gpc1(0) ge 3
# ESCALATING TARPIT RULES
# Level 1: Short tarpit (2-5 seconds) for first offense
http-request tarpit timeout 2s if low_threat escalation_level_0
http-request tarpit timeout 3s if medium_threat escalation_level_0
http-request tarpit timeout 5s if burst_attack escalation_level_0
# Level 2: Medium tarpit (8-15 seconds) for second offense
http-request tarpit timeout 8s if low_threat escalation_level_1
http-request tarpit timeout 12s if medium_threat escalation_level_1
http-request tarpit timeout 15s if high_threat escalation_level_1
http-request tarpit timeout 10s if sustained_attack escalation_level_1
# Level 3: Long tarpit (20-45 seconds) for repeat offenders
http-request tarpit timeout 20s if low_threat escalation_level_2
http-request tarpit timeout 30s if medium_threat escalation_level_2
http-request tarpit timeout 45s if high_threat escalation_level_2
http-request tarpit timeout 25s if persistent_attack escalation_level_2
# Level 4: Maximum tarpit (60 seconds) for persistent attackers
http-request tarpit timeout 60s if escalation_level_3
# Complete block for critical threats regardless of escalation level
http-request deny deny_status 429 if critical_threat
# Increment escalation level when we apply tarpit/block
http-request sc-inc-gpc1(0) if low_threat or medium_threat or high_threat or burst_attack or sustained_attack or persistent_attack
http-request sc-inc-gpc2(0) if low_threat or medium_threat or high_threat or critical_threat or burst_attack or sustained_attack or persistent_attack
# Capture useful headers for logging
capture request header User-Agent len 150
capture request header X-Forwarded-For len 30
capture request header Referer len 100
# Enhanced logging format with protection metrics
log-format "%ci:%cp [%t] %ft %b/%s %Tq/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS \"%r\" \"%[capture.req.hdr(0)]\" gpc0:%[sc_get_gpc0(0)] gpc1:%[sc_get_gpc1(0)] gpc2:%[sc_get_gpc2(0)] err_rate_30s:%[sc_http_err_rate(0,30s)]"
# Redirect HTTP to HTTPS (optional)
redirect scheme https if !{ ssl_fc }
default_backend web_servers
backend web_servers
balance roundrobin
option httpchk GET /health HTTP/1.1\r\nHost:\ localhost
# Define scanning attempt patterns
acl is_404_error status 404
acl is_403_error status 403
acl is_401_error status 401
acl is_400_error status 400
acl is_scan_attempt status 400 401 403 404
# Additional suspicious patterns (optional)
acl suspicious_path path_reg -i \.(php|asp|aspx|jsp|cgi)$
acl suspicious_path path_reg -i /(wp-admin|phpmyadmin|admin|login|xmlrpc)
acl suspicious_path path_reg -i \.(env|git|svn|backup|bak|old)
# Track scan attempts in the frontend stick table
http-response sc-inc-gpc0(0) if is_scan_attempt
# Optional: Track suspicious paths even if they return 200
# http-response sc-inc-gpc0(0) if suspicious_path
# Optional: Different weights for different error types
# http-response sc-add-gpc0(0) 2 if is_403_error # 403s count as 2 points
# http-response sc-add-gpc0(0) 3 if is_401_error # 401s count as 3 points
# Server definitions - adjust as needed
server web1 backend-server-1:80 check maxconn 200 weight 100
server web2 backend-server-2:80 check maxconn 200 weight 100
# server web3 backend-server-3:80 check maxconn 200 weight 100
# Optional: Stats page for monitoring
frontend stats
bind *:8404
# Restrict access to stats page
acl allowed_ips src 127.0.0.1 192.168.0.0/16 10.0.0.0/8
http-request allow if allowed_ips
http-request deny
stats enable
stats uri /stats
stats refresh 30s
stats show-legends
stats show-node