Files
haproxy-openmanager/backend/utils/stick_table_import.py
T
taylanbakircioglu dd11fe1be5 fix(import): keep stick-table and stick rules on Config Import (v1.11.3)
A frontend re-defined with
  stick-table type ip size 200k expire 3h store http_req_rate(10s),gpc0_rate(10s),gpt0
kept rendering the old 100k/30s table after a Config Import of the full
frontend section, with no error or warning.

The import parser had no branch for `stick-table` (frontend or backend) or for
backend `stick on|match|store-request|store-response`, so those lines were
dropped silently. The frontend still used track-sc0 / sc_* counters, so the
renderer's v1.7.4 auto-inject added its default table in place of the
operator's one, and the gpc0 rules imported with it referenced counters the
table did not store.

  - Parser: these lines are kept verbatim in the frontend/backend `options`
    field (free-form, already rendered and snapshotted; no schema change)
    and listed as import warnings.
  - Import preview: options are merged per kind, so a stick-only import keeps
    the stored `option` lines and vice versa. The table the generator emits
    itself for Rate Limit / WAF rate_limit (type ip size 100k expire <N>s
    store http_req_rate(10s)) is not stored where the generator will render
    it, so round-trips show no change and later window edits still apply.
  - The preview stripped every `track-sc0 src` / `sc_http_req_rate(0)` line
    as auto-generated. It now strips only the exact generated forms, and
    only on frontends with Rate Limit or an active rate_limit WAF rule;
    operator trackers elsewhere were silently deleted on update.
  - Renderer: a backend emits only its first `stick-table`. A second one is
    fatal in HAProxy and made the agent reject the whole cluster config.
  - Non-blocking advisories: a table without http_req_rate while Rate Limit
    is active (HAProxy accepts it and the limit never fires), a table also
    left in Request Headers, `stick on` without a table, `peers` references.

Configs without stick lines parse and render byte-identical (S0 render diff
empty; 70/70 stick-free test configs parse identically). Generated configs
pass `haproxy -c` on 3.3.10 and 2.6. No schema change, no agent change.

Fixes #72
2026-10-16 14:13:36 +03:00

220 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Issue #72 — Config Import policy for stick-table / stick rules and the rate-limit
lines the config generator injects.
Pure functions (no DB) so the import endpoint stays thin and the policy is unit
testable. "Platform regenerates" means the generator will itself emit the
rate-limit table/tracker/deny lines for a frontend: its `rate_limit` field is set
or an active `rate_limit` WAF rule applies to it (see services/haproxy_config.py).
"""
import re
from typing import Iterable, List, Optional, Tuple
# The only stick-table forms the generator emits (services/haproxy_config.py:
# frontend rate_limit, stick-table auto-inject, WAF rate_limit rule).
PLATFORM_STICK_TABLE_RE = re.compile(
r'^stick-table\s+type\s+ip\s+size\s+100k\s+expire\s+(\d+)s\s+store\s+http_req_rate\(10s\)$'
)
AUTO_INJECT_EXPIRE_SECONDS = 30
# Exact forms of the rate-limit request lines the generator emits.
_PLATFORM_RATE_LIMIT_HEADER_RES = (
re.compile(r'^http-request\s+track-sc0\s+src$'),
re.compile(r'^http-request\s+deny\s+if\s+\{\s*sc_http_req_rate\(0\)\s+gt\s+\d+\s*\}$'),
re.compile(
r'^http-request\s+capture\s+req\.hdr\(User-Agent\)\s+len\s+64\s+if\s+'
r'\{\s*sc_http_req_rate\(0\)\s+gt\s+\d+\s*\}$'
),
)
_STICK_RULE_RE = re.compile(r'^stick\s+(on|match|store-request|store-response)\s')
_TABLE_ARG_RE = re.compile(r'(?:^|\s)table\s+\S+')
_PEERS_ARG_RE = re.compile(r'(?:^|\s)peers\s+(\S+)')
def _lines(text: Optional[str]) -> List[str]:
if not text:
return []
return [l.strip() for l in text.split('\n') if l.strip()]
def is_stick_line(line: str) -> bool:
s = line.strip()
return s.startswith('stick-table') or s.startswith('stick ')
def is_stick_table_line(line: str) -> bool:
return line.strip().startswith('stick-table')
def uses_stick_counter(line: str) -> bool:
"""Same test the renderer uses to decide a frontend needs a stick-table
(services/haproxy_config.py `_emit_fe`)."""
s = line.strip()
return "track-sc" in s or ("sc_" in s and "_rate(" in s)
def is_platform_rate_limit_header(line: str) -> bool:
s = line.strip()
return any(r.match(s) for r in _PLATFORM_RATE_LIMIT_HEADER_RES)
def is_always_auto_header(line: str) -> bool:
"""ACME and WAF lines are generator-only: stripped regardless of context."""
s = line.strip()
if "is_acme_challenge" in s:
return True
if s.startswith("http-request") and " waf_" in s:
return True
return False
def strip_auto_headers(headers: Optional[str], platform_regenerates: bool) -> Optional[str]:
"""Drop generator-owned lines from a `request_headers` value.
Rate-limit lines are dropped only when the generator will emit them again for
this frontend AND they match its exact form; otherwise they are the
operator's own (e.g. a tracker feeding gpc0 rules) and are kept.
"""
if not headers:
return None
kept = []
for line in headers.split("\n"):
if is_always_auto_header(line):
continue
if platform_regenerates and is_platform_rate_limit_header(line):
continue
kept.append(line)
return "\n".join(kept) if kept else None
def drop_platform_stick_tables(
options: Optional[str],
platform_regenerates: bool,
frontend_uses_counter: bool,
) -> Tuple[Optional[str], List[str]]:
"""Remove imported stick-table lines the generator will reproduce itself.
Keeping them would freeze them in `options` (emitted first, so they win the
dedup) and hide later rate_limit / WAF window changes. A generator-shaped
line is dropped when rate_limit/WAF will emit a table, or when it is the
30s auto-inject default and the frontend uses a counter (auto-inject then
renders the identical line). Anything else is the operator's table.
"""
if not options:
return options, []
kept, dropped = [], []
for line in options.split("\n"):
m = PLATFORM_STICK_TABLE_RE.match(line.strip())
if m and (
platform_regenerates
or (int(m.group(1)) == AUTO_INJECT_EXPIRE_SECONDS and frontend_uses_counter)
):
dropped.append(line.strip())
continue
kept.append(line)
return ("\n".join(kept) if kept else None), dropped
def merge_options(parsed: Optional[str], existing: Optional[str]) -> Optional[str]:
"""Merge an imported `options` value into the stored one per directive type.
Bulk import only overwrote `options` when the import carried `option` lines;
with stick lines now landing in `options` too, a stick-only import must not
wipe hand-entered `option` lines (and vice versa). Each group is replaced
only when the import carries that group. Returns `existing` unchanged when
the merged lines are the same set, so re-imports don't flag a change.
"""
parsed_lines = _lines(parsed)
existing_lines = _lines(existing)
parsed_other = [l for l in parsed_lines if not is_stick_line(l)]
parsed_stick = [l for l in parsed_lines if is_stick_line(l)]
existing_other = [l for l in existing_lines if not is_stick_line(l)]
existing_stick = [l for l in existing_lines if is_stick_line(l)]
final = (parsed_other or existing_other) + (parsed_stick or existing_stick)
if not final:
return None
if sorted(final) == sorted(existing_lines):
return existing
return "\n".join(final)
def frontend_uses_counter(fields: Iterable) -> bool:
"""True if any non-stick line of the given frontend fields uses a counter."""
for value in fields:
if not value:
continue
items = value if isinstance(value, (list, tuple)) else str(value).split("\n")
for line in items:
if isinstance(line, str) and not is_stick_line(line) and uses_stick_counter(line):
return True
return False
def stick_advisories(
kind: str,
name: str,
final_options: Optional[str],
imported_options: Optional[str],
final_other_fields: dict,
platform_regenerates: bool,
) -> List[str]:
"""Non-blocking import-preview warnings for stick directives.
kind: 'Frontend' or 'Backend'. final_other_fields: {label: final value} of
the other free-form fields (what they hold after this import is applied).
"""
warnings: List[str] = []
final_lines = _lines(final_options)
tables = [l for l in final_lines if is_stick_table_line(l)]
imported_tables = [l for l in _lines(imported_options) if is_stick_table_line(l)]
if kind == "Frontend" and platform_regenerates:
for t in tables:
if not PLATFORM_STICK_TABLE_RE.match(t) and "http_req_rate" not in t:
warnings.append(
f"⚠️ Frontend '{name}': the imported stick-table does not store "
f"http_req_rate, but this frontend's Rate Limit / rate_limit WAF rule "
f"checks sc_http_req_rate(0) against it. HAProxy accepts this and the "
f"rate limit silently never triggers. Add http_req_rate(10s) to the "
f"table's 'store' list to keep it working."
)
if imported_tables:
for label, value in final_other_fields.items():
if any(is_stick_table_line(l) for l in _lines(value)):
effect = (
"only the first stick-table is rendered (Options), so the one in "
f"{label} is ignored"
)
warnings.append(
f"⚠️ {kind} '{name}': a stick-table is also stored in {label}; "
f"{effect}. Remove it from {label}."
)
if kind == "Backend":
has_own_table = bool(tables) or any(
is_stick_table_line(l) for v in final_other_fields.values() for l in _lines(v)
)
for l in final_lines:
if _STICK_RULE_RE.match(l) and not _TABLE_ARG_RE.search(l) and not has_own_table:
warnings.append(
f"⚠️ Backend '{name}': '{l}' needs a stick-table in this backend or a "
f"'table <name>' argument. HAProxy rejects it at apply "
f"(\"unable to find stick-table\"); the previous config keeps running."
)
for t in tables:
m = _PEERS_ARG_RE.search(t)
if m:
warnings.append(
f"ℹ️ {kind} '{name}': stick-table references peers section "
f"'{m.group(1)}'. HAProxy OpenManager does not manage peers sections: define "
f"it in each node's haproxy.cfg BEFORE the 'defaults' section (the agent "
f"keeps everything above the first defaults/listen/frontend/backend and "
f"replaces the rest)."
)
return warnings