feat(backend): add config-only automatic IP banning for DDoS policies#279
Conversation
Extends per-policy DDoS protection with automatic IP banning: repeated rate-limit/connection-limit violations increment a per-source abuse counter in a dedicated stick-table, and the source is denied once it crosses ban_threshold until it stays idle for ban_duration_seconds. - Policy gains auto_ban_enabled, ban_threshold, ban_duration_seconds (only takes effect when ddos_protection_enabled is also true). - HaproxyDdos carries the ban stick-table name and thresholds; the template emits track-sc1 + sc-inc-gpc0 + a gpc0-based deny per vhost, exempting health checks and ACME challenges like the rate/conn rules. - Policy form gains an auto-ban toggle nested under DDoS protection. No live ban-list read/unban yet — that remains a separate follow-up.
There was a problem hiding this comment.
Pull request overview
Adds “config-only” automatic IP banning as an extension of the existing per-policy DDoS protection, spanning backend persistence/validation, HAProxy config generation, and frontend policy editing/types/tests.
Changes:
- Added auto-ban fields to
Policy(DB + Pydantic schemas + service/router wiring) and surfaced them through the frontend policy form/types. - Extended HAProxy render context + template + generator to emit a per-vhost ban stick-table (
gpc0) and related request rules when enabled. - Added unit/integration/frontend tests covering round-tripping policy fields and validating rendered/generated HAProxy configs.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx | Updates vhost detail mock data to include new policy auto-ban fields. |
| src/frontend/src/pages/policies/PolicyDetailPage.test.tsx | Updates policy detail mock data to include new auto-ban fields. |
| src/frontend/src/pages/policies/PoliciesPage.test.tsx | Updates policy list mock data to include new auto-ban fields. |
| src/frontend/src/features/vhosts/types.ts | Extends assigned-policy TypeScript type with auto-ban fields. |
| src/frontend/src/features/policies/types.ts | Extends policy/request TypeScript types to include auto-ban fields. |
| src/frontend/src/features/policies/PolicyFormModal.tsx | Adds UI controls and submit wiring for auto-ban settings (nested under DDoS). |
| src/frontend/src/features/policies/PolicyFormModal.test.tsx | Adds UI/submit tests for the new auto-ban toggle and numeric inputs. |
| src/backend/tests/unit/test_policy_service.py | Adds unit tests asserting auto-ban fields persist/update via PolicyService. |
| src/backend/tests/unit/test_config_renderer_haproxy.py | Adds render-context validation and template assertions for auto-ban blocks + haproxy validation. |
| src/backend/tests/unit/test_config_generator.py | Adds generator assertions + haproxy validation for generated configs with auto-ban. |
| src/backend/tests/integration/test_policies_router.py | Adds integration tests for create/patch and validation of auto-ban fields. |
| src/backend/app/templates/haproxy.cfg.j2 | Adds HAProxy template blocks for ban stick-table tracking/increment/deny. |
| src/backend/app/services/policy_service.py | Adds allowlisted fields + create parameters for auto-ban persistence. |
| src/backend/app/services/config_renderer.py | Extends HaproxyDdos with auto-ban fields and validation; enforces unique ban table names. |
| src/backend/app/services/config_generator.py | Wires policy auto-ban fields into the HAProxy render context and table naming. |
| src/backend/app/schemas/policy.py | Adds Pydantic fields/constraints for create/update/response schemas. |
| src/backend/app/routers/policies.py | Passes auto-ban fields from request body into policy creation. |
| src/backend/app/models/policy.py | Adds DB columns + check constraints for auto-ban fields on Policy. |
| src/backend/alembic/versions/ea9292cbacec_add_auto_ban_fields_to_policies.py | Adds Alembic migration for new columns + check constraints. |
| http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } | ||
| {% if route.ddos.auto_ban_enabled %} | ||
| # Automatic IP banning: repeated rate/conn violations trip a ban that | ||
| # lifts after ban_duration_seconds of inactivity (track-sc1 refreshes | ||
| # the entry's expiry on every request, so a persistent attacker stays | ||
| # banned). sc-inc-gpc0 must run before the matching deny rule. | ||
| http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } | ||
| http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } | ||
| {% endif %} |
There was a problem hiding this comment.
Confirmed — real bug. Reordered so track-sc1, the already-banned check, and both sc-inc-gpc0 rules run before the rate/conn deny rules in both fe_http and fe_https, so the abuse counter increments and the ban table's expiry refreshes on every tracked request as intended. (2335c78)
| http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } | ||
| {% if route.ddos.auto_ban_enabled %} | ||
| http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } | ||
| http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } | ||
| {% endif %} |
There was a problem hiding this comment.
Fixed the same way — track-sc1/sc-inc-gpc0 now run before the rate/conn deny rules in fe_https too. (2335c78)
http-request deny stops rule evaluation, so the rate/conn deny rules running before sc-inc-gpc0 meant the abuse counter never incremented and auto-ban could never trigger. Move track-sc1, the already-banned check, and both sc-inc-gpc0 rules ahead of the rate/conn deny rules in both fe_http and fe_https.
Summary
Extends the per-policy DDoS protection introduced in #274 with automatic IP banning, entirely inside the generated HAProxy config (no live socket management yet).
Policygainsauto_ban_enabled,ban_threshold,ban_duration_seconds— only takes effect whenddos_protection_enabledis also true.st_ban_vhost_Nstick-table (separate from the rate-limit table) tracks a per-source abuse counter viagpc0.sc-inc-gpc0); once it crossesban_thresholdthe source is denied (429) until it stays idle forban_duration_seconds.Out of scope
Closes #275
Related to #176
Test plan
uv run pytest --cov=app(564 passed)uv run mypy app/uv run ruff check app/pnpm run type-checkpnpm run lintpnpm test(115 passed)haproxy -cagainst rendered configs with auto-ban enabled (renderer + generator tests)uv run alembic upgrade headapplies cleanly