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:
+267
-59
@@ -1872,74 +1872,282 @@ def sync_blocked_ips():
|
||||
log_operation('sync_blocked_ips', False, str(e))
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HAProxy runtime API (stick tables)
|
||||
#
|
||||
# WHY THIS SECTION IS SO DEFENSIVE
|
||||
# --------------------------------
|
||||
# The previous /api/security/stats read `gpc0` and `gpc1` out of `show table
|
||||
# web` and reported them as "scan count" / "offense count" / "blocked". No
|
||||
# stick table in this repo has EVER stored a general-purpose counter — see
|
||||
# STICK_TABLE_FIELD_CONTRACT below and the `store` clauses in
|
||||
# templates/hap_listener.tpl and templates/hap_security_tables.tpl. Three
|
||||
# separate silences let that survive:
|
||||
#
|
||||
# 1. `int(parts[3])` on a positional split raised ValueError on `exp=368842`
|
||||
# and the loop just `continue`d, so every row was skipped and the endpoint
|
||||
# always answered `active_threats: 0` with an empty list. An operator
|
||||
# reading that saw "no threats" and could not tell it apart from "the
|
||||
# parser is broken".
|
||||
# 2. The command was sent WITHOUT a worker prefix. /tmp/haproxy-cli is the
|
||||
# MASTER socket; `show table web` there is answered with "Unknown command:
|
||||
# 'show', but maybe one of the following ones is a better match: ..." --
|
||||
# and socat still exits 0, so `result.returncode != 0` never fired. The
|
||||
# reported `total_tracked_ips` was literally the number of lines in that
|
||||
# help text minus one (8), while the real table held 388 entries.
|
||||
# 3. The shell consumers defaulted every missing field to 0 (`${gpc0:-0}`),
|
||||
# so a field that does not exist rendered as a confident zero.
|
||||
#
|
||||
# Rules for anything added here, all three aimed at the same failure mode:
|
||||
# * Read the CONTRACT, not positions. Stick-table output is `name=value` /
|
||||
# `name(window)=value` pairs whose order and presence follow the template's
|
||||
# `store` clause. Positional indexing silently reads the wrong column the
|
||||
# moment that clause changes.
|
||||
# * NEVER default a missing field to a number. A field the table does not
|
||||
# store must surface as an error naming the field, not as 0.
|
||||
# * NEVER trust socat's exit status. HAProxy reports command errors in the
|
||||
# response BODY and the socket still closes cleanly. Use haproxy_cli().
|
||||
#
|
||||
# scripts/test-stick-table-contract.py holds STICK_TABLE_FIELD_CONTRACT, the
|
||||
# rendered templates and the shell consumers to each other, and fails if any
|
||||
# one of them drifts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# What each stick table ACTUALLY stores, per its `store` clause. The single
|
||||
# source of truth for every consumer in this repo. Keep in sync with the
|
||||
# templates -- the contract test enforces that, in both directions.
|
||||
STICK_TABLE_FIELD_CONTRACT = {
|
||||
'web': ('conn_cur', 'conn_rate', 'http_req_rate', 'http_err_rate'),
|
||||
'wp_bruteforce': ('http_req_rate',),
|
||||
'xmlrpc_bruteforce': ('http_req_rate',),
|
||||
}
|
||||
|
||||
# Metadata every stick-table entry carries regardless of the `store` clause.
|
||||
STICK_TABLE_ENTRY_META = ('key', 'use', 'exp', 'shard')
|
||||
|
||||
# HAProxy answers a rejected runtime command in the response body and the
|
||||
# socket still closes 0. These are the prefixes it uses.
|
||||
_HAPROXY_CLI_ERROR_MARKERS = (
|
||||
'Unknown command',
|
||||
'No such table',
|
||||
'Permission denied',
|
||||
"Can't find the specified process",
|
||||
'unknown process',
|
||||
'Missing ',
|
||||
)
|
||||
|
||||
_STICK_TABLE_TOKEN_RE = re.compile(r'^([a-z_][a-z0-9_]*)(?:\(([^)]*)\))?=(.*)$')
|
||||
_STICK_TABLE_HEADER_RE = re.compile(
|
||||
r'^#\s*table:\s*(?P<name>[^,]+),\s*type:\s*(?P<type>[^,]+),\s*'
|
||||
r'size:\s*(?P<size>\d+),\s*used:\s*(?P<used>\d+)')
|
||||
|
||||
|
||||
class HaproxyCliError(RuntimeError):
|
||||
"""A runtime-API command was rejected, timed out, or answered nothing.
|
||||
|
||||
Exists so a rejected command cannot be mistaken for an empty result. That
|
||||
distinction is the whole point of this module's stick-table code.
|
||||
"""
|
||||
|
||||
|
||||
def _haproxy_socket_path():
|
||||
return HAPROXY_SOCKET_PATH if os.path.exists(HAPROXY_SOCKET_PATH) else '/tmp/haproxy-cli'
|
||||
|
||||
|
||||
def _cli_response_is_error(text):
|
||||
if text is None:
|
||||
return True
|
||||
head = text.lstrip()
|
||||
return any(head.startswith(marker) for marker in _HAPROXY_CLI_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _cli_send(command, socket_path, timeout):
|
||||
proc = subprocess.run(
|
||||
['socat', 'stdio', socket_path],
|
||||
input=command + '\n', capture_output=True, text=True, timeout=timeout)
|
||||
if proc.returncode != 0:
|
||||
raise HaproxyCliError(
|
||||
'socat failed talking to %s (exit %d): %s'
|
||||
% (socket_path, proc.returncode, (proc.stderr or '').strip()))
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def haproxy_cli(command, worker=False, timeout=None):
|
||||
"""Send one runtime-API command and return its response, or raise.
|
||||
|
||||
`worker=True` marks a command that only the WORKER answers (show table,
|
||||
show map, add map, ...). On this deployment the socket is HAProxy's MASTER
|
||||
CLI, where those need an `@1` prefix; on a plain stats socket they must NOT
|
||||
have one. Rather than guessing from configuration that can change under us,
|
||||
try the prefixed form and fall back -- and raise if BOTH are rejected,
|
||||
instead of returning HAProxy's help text as if it were data.
|
||||
"""
|
||||
socket_path = _haproxy_socket_path()
|
||||
timeout = timeout if timeout is not None else DEFAULT_SUBPROCESS_TIMEOUT
|
||||
attempts = (['@1 ' + command, command] if worker else [command])
|
||||
failures = []
|
||||
for attempt in attempts:
|
||||
try:
|
||||
out = _cli_send(attempt, socket_path, timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HaproxyCliError('timed out after %ss running %r on %s'
|
||||
% (timeout, attempt, socket_path))
|
||||
if out.strip() and not _cli_response_is_error(out):
|
||||
return out
|
||||
failures.append('%r -> %r' % (attempt, out.strip()[:200] or '<empty response>'))
|
||||
raise HaproxyCliError(
|
||||
'HAProxy rejected %r on %s (socat exited 0 -- the rejection is in the '
|
||||
'response body, which is exactly why this is checked): %s'
|
||||
% (command, socket_path, '; '.join(failures)))
|
||||
|
||||
|
||||
def parse_stick_table_entry(line):
|
||||
"""{field: {'value': str, 'window_ms': int|None}} for one `show table` row.
|
||||
|
||||
Parses `name=value` / `name(window)=value` pairs by NAME. The leading
|
||||
`0x...:` allocation pointer is skipped -- reading it as the key is how the
|
||||
old code came to report memory addresses as IP addresses.
|
||||
"""
|
||||
fields = {}
|
||||
for token in line.split():
|
||||
m = _STICK_TABLE_TOKEN_RE.match(token)
|
||||
if not m:
|
||||
continue # the 0x...: pointer, or anything else unnamed
|
||||
name, window, value = m.group(1), m.group(2), m.group(3)
|
||||
fields[name] = {
|
||||
'value': value,
|
||||
'window_ms': int(window) if window and window.isdigit() else None,
|
||||
}
|
||||
return fields
|
||||
|
||||
|
||||
def read_stick_table(table):
|
||||
"""(header dict, [(raw line, parsed fields)]) for a stick table.
|
||||
|
||||
Raises HaproxyCliError if the response is not a stick-table dump, or if any
|
||||
row is missing a field the contract says the table stores. A field the
|
||||
table does not carry is an ERROR here, never a zero.
|
||||
"""
|
||||
expected = STICK_TABLE_FIELD_CONTRACT.get(table)
|
||||
if expected is None:
|
||||
raise HaproxyCliError(
|
||||
'no field contract for stick table %r; add it to '
|
||||
'STICK_TABLE_FIELD_CONTRACT (and to the templates) first' % table)
|
||||
|
||||
raw = haproxy_cli('show table %s' % table, worker=True)
|
||||
lines = raw.strip().split('\n')
|
||||
header = _STICK_TABLE_HEADER_RE.match(lines[0]) if lines else None
|
||||
if not header:
|
||||
raise HaproxyCliError(
|
||||
'response to `show table %s` is not a stick-table dump; first line '
|
||||
'was %r' % (table, lines[0][:200] if lines else ''))
|
||||
|
||||
entries = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip() or line.lstrip().startswith('#'):
|
||||
continue
|
||||
fields = parse_stick_table_entry(line)
|
||||
if 'key' not in fields:
|
||||
raise HaproxyCliError(
|
||||
'stick-table row for %r has no key= field: %r' % (table, line[:200]))
|
||||
missing = [f for f in expected if f not in fields]
|
||||
if missing:
|
||||
raise HaproxyCliError(
|
||||
'stick table %r no longer stores %s -- STICK_TABLE_FIELD_CONTRACT '
|
||||
'and the `store` clause in templates/hap_listener.tpl have drifted '
|
||||
'apart. Present: %s. Offending row: %r'
|
||||
% (table, ', '.join(missing),
|
||||
', '.join(sorted(fields)), line[:200]))
|
||||
entries.append((line, fields))
|
||||
|
||||
return {
|
||||
'name': header.group('name'),
|
||||
'type': header.group('type'),
|
||||
'size': int(header.group('size')),
|
||||
'used': int(header.group('used')),
|
||||
}, entries
|
||||
|
||||
|
||||
@app.route('/api/security/stats', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_security_stats():
|
||||
"""Get current security statistics from HAProxy stick table"""
|
||||
"""Per-source connection and request rates from the `web` stick table.
|
||||
|
||||
Reports ONLY what the table stores: conn_cur, conn_rate, http_req_rate and
|
||||
http_err_rate, each with the window HAProxy is actually counting over. It
|
||||
deliberately does NOT classify a "threat level" or report a "blocked" flag:
|
||||
the thresholds live in templates/hap_listener.tpl and a copy here would be
|
||||
a second source of truth free to drift, which is the class of bug this
|
||||
endpoint used to be. Sources at or over a limit are visible from the rates
|
||||
themselves, and the enforcement that actually happened -- 429s, tarpits
|
||||
(termination state PT), WAF denials -- is in the edge access log on the
|
||||
HOST at /var/log/haproxy.log, which records per-request outcomes the stick
|
||||
table never held.
|
||||
|
||||
Query params:
|
||||
limit max sources returned (default 50)
|
||||
min_req_rate only sources at or above this http_req_rate (default 1,
|
||||
i.e. sources with current activity; pass 0 for all)
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(HAPROXY_SOCKET_PATH):
|
||||
socket_path = HAPROXY_SOCKET_PATH
|
||||
else:
|
||||
socket_path = '/tmp/haproxy-cli'
|
||||
limit = max(1, min(int(request.args.get('limit', 50)), 1000))
|
||||
min_req_rate = max(0, int(request.args.get('min_req_rate', 1)))
|
||||
except ValueError:
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'limit and min_req_rate must be integers'}), 400
|
||||
|
||||
# Get stick table data
|
||||
cmd = f'echo "show table web" | socat stdio {socket_path}'
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get stick table data'}), 500
|
||||
|
||||
# Parse stick table output
|
||||
lines = result.stdout.strip().split('\n')
|
||||
threats = []
|
||||
|
||||
for line in lines[1:]: # Skip header
|
||||
parts = line.split()
|
||||
if len(parts) >= 8:
|
||||
ip = parts[0]
|
||||
try:
|
||||
gpc0 = int(parts[3]) if len(parts) > 3 else 0
|
||||
gpc1 = int(parts[4]) if len(parts) > 4 else 0
|
||||
req_rate = int(parts[5]) if len(parts) > 5 else 0
|
||||
err_rate = int(parts[6]) if len(parts) > 6 else 0
|
||||
conn_rate = int(parts[7]) if len(parts) > 7 else 0
|
||||
|
||||
# Only include IPs with significant activity
|
||||
if gpc0 > 0 or gpc1 > 0 or req_rate > 30 or err_rate > 5 or conn_rate > 10:
|
||||
threat_level = 'low'
|
||||
if gpc1 > 2:
|
||||
threat_level = 'critical'
|
||||
elif gpc0 > 0 or err_rate > 10:
|
||||
threat_level = 'high'
|
||||
elif req_rate > 40 or conn_rate > 15:
|
||||
threat_level = 'medium'
|
||||
|
||||
threats.append({
|
||||
'ip': ip,
|
||||
'blocked': gpc0 > 0,
|
||||
'repeat_offender': gpc1 > 2,
|
||||
'offense_count': gpc1,
|
||||
'request_rate': req_rate,
|
||||
'error_rate': err_rate,
|
||||
'connection_rate': conn_rate,
|
||||
'threat_level': threat_level
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Sort by threat level
|
||||
threats.sort(key=lambda x: (x['offense_count'], x['error_rate'], x['request_rate']), reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total_tracked_ips': len(lines) - 1,
|
||||
'active_threats': len(threats),
|
||||
'threats': threats[:50] # Limit to top 50
|
||||
})
|
||||
try:
|
||||
header, entries = read_stick_table('web')
|
||||
except HaproxyCliError as e:
|
||||
log_operation('get_security_stats', False, str(e))
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 502
|
||||
except Exception as e:
|
||||
log_operation('get_security_stats', False, str(e))
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
counters = STICK_TABLE_FIELD_CONTRACT['web']
|
||||
windows = {}
|
||||
sources = []
|
||||
active = 0
|
||||
for line, fields in entries:
|
||||
row = {'ip': fields['key']['value']}
|
||||
for name in counters:
|
||||
# A non-numeric counter means HAProxy's output format changed under
|
||||
# us. Say so; do not coerce it to 0 and report it as a measurement.
|
||||
try:
|
||||
row[name] = int(fields[name]['value'])
|
||||
except ValueError:
|
||||
msg = ('stick table web reported a non-numeric %s=%r; the '
|
||||
'`show table` output format has changed. Row: %r'
|
||||
% (name, fields[name]['value'], line[:200]))
|
||||
log_operation('get_security_stats', False, msg)
|
||||
return jsonify({'status': 'error', 'message': msg}), 502
|
||||
if fields[name]['window_ms'] is not None:
|
||||
windows.setdefault(name, fields[name]['window_ms'])
|
||||
if any(row[name] > 0 for name in counters):
|
||||
active += 1
|
||||
if row['http_req_rate'] >= min_req_rate:
|
||||
sources.append(row)
|
||||
|
||||
sources.sort(key=lambda r: (r['http_req_rate'], r['http_err_rate'],
|
||||
r['conn_rate'], r['conn_cur']), reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'table': header['name'],
|
||||
'table_size': header['size'],
|
||||
'total_tracked_ips': header['used'],
|
||||
'sources_with_activity': active,
|
||||
'counters': list(counters),
|
||||
'counter_windows_ms': windows,
|
||||
'returned': len(sources[:limit]),
|
||||
'min_req_rate': min_req_rate,
|
||||
'sources': sources[:limit],
|
||||
'note': ('Current per-source rates only. The stick table stores no '
|
||||
'history and no counter of past blocks; enforcement events '
|
||||
'are in the edge access log on the host at /var/log/haproxy.log.'),
|
||||
})
|
||||
|
||||
@app.route('/api/security/temporary-block', methods=['POST'])
|
||||
@require_api_key
|
||||
def temporary_block():
|
||||
|
||||
Reference in New Issue
Block a user