Skip to content

Commit a9b768d

Browse files
authored
feat(backend): read and unban auto-ban stick-table entries via HAProxy Runtime API (#282)
* feat(backend): read and unban auto-ban stick-table entries via HAProxy Runtime API Adds an admin-level stats socket to the generated haproxy.cfg (the existing socket is operator-level, which show table but not clear table) and a BanListService that lists tracked/banned source IPs across every auto-ban-enabled vhost and clears an IP from all of its ban tables on unban, exposed via GET/DELETE /security/banned-ips. * fix(backend): address ban-list review feedback on socket perms and error handling - Fix admin stats socket to mode 666: HAProxy runs as root while the backend drops to an unprivileged user via gosu, so a root-owned mode 660 socket was unreadable/unwritable by the backend, breaking every list/unban call. Applied to the template, static bootstrap config, and release seed config for parity. - Classify HAProxy Runtime API error replies (unknown table, permission denied, ...) inside _send_runtime_command, since HAProxy reports those as plain text rather than a socket-level failure; previously an unban could report a table as cleared even when HAProxy rejected the command. - Make list_banned raise RuntimeApiError when every ban table read fails, mirroring unban, so a fully unreachable Runtime API surfaces as a 502 instead of an empty list indistinguishable from "nothing tracked". - Document the new HAPROXY_STATS_SOCKET_PATH / HAPROXY_STATS_TIMEOUT_SECONDS settings in .env.example.
1 parent bcd3f1d commit a9b768d

12 files changed

Lines changed: 812 additions & 0 deletions

File tree

configs/haproxy/haproxy.cfg

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ global
77
maxconn 2000
88
# Operator level avoids exposing administrative Runtime API commands.
99
stats socket /tmp/haproxy.sock mode 660 level operator
10+
# Admin level, reachable only from the backend over the shared runtime
11+
# volume (not exposed to the host). Needed for `clear table` (unban) —
12+
# see #276 — which the operator-level socket above deliberately cannot
13+
# perform. mode 666 matches the master socket (docker-compose command,
14+
# `-S ...master.sock,mode,666,...`): HAProxy runs as root while the
15+
# backend drops to an unprivileged user via gosu, so a root-owned 660
16+
# socket would not be readable/writable by the backend.
17+
stats socket /var/run/haproxy/admin.sock mode 666 level admin
1018

1119
defaults
1220
mode http

release/haproxy/haproxy.cfg

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ global
77
maxconn 2000
88
# Operator level avoids exposing administrative Runtime API commands.
99
stats socket /tmp/haproxy.sock mode 660 level operator
10+
# Admin level, reachable only from the backend over the shared runtime
11+
# volume (not exposed to the host). Needed for `clear table` (unban) —
12+
# see #276 — which the operator-level socket above deliberately cannot
13+
# perform. mode 666 matches the master socket (docker-compose command,
14+
# `-S ...master.sock,mode,666,...`): HAProxy runs as root while the
15+
# backend drops to an unprivileged user via gosu, so a root-owned 660
16+
# socket would not be readable/writable by the backend.
17+
stats socket /var/run/haproxy/admin.sock mode 666 level admin
1018

1119
defaults
1220
mode http

src/backend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ DEBUG=false
4242
# HAPROXY_VALIDATION_TIMEOUT_SECONDS=10
4343
# HAPROXY_MASTER_SOCKET_PATH=/var/run/haproxy/master.sock
4444
# HAPROXY_RELOAD_TIMEOUT_SECONDS=10
45+
# HAPROXY_STATS_SOCKET_PATH=/var/run/haproxy/admin.sock
46+
# HAPROXY_STATS_TIMEOUT_SECONDS=10
4547

4648
# Optional: used by scripts/seed_admin.py
4749
# ADMIN_EMAIL=admin@example.com

src/backend/app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ class Settings(EnvFileSettings):
7676
haproxy_validation_timeout_seconds: int = 10
7777
haproxy_master_socket_path: str = "/var/run/haproxy/master.sock"
7878
haproxy_reload_timeout_seconds: int = 10
79+
haproxy_stats_socket_path: str = "/var/run/haproxy/admin.sock"
80+
haproxy_stats_timeout_seconds: int = 10
7981
log_retention_days: int = 30
8082

8183
@field_validator("database_url")
@@ -87,6 +89,7 @@ def database_url_must_not_be_empty(cls, value: str) -> str:
8789
"runtime_generated_config_root",
8890
"haproxy_validation_binary",
8991
"haproxy_master_socket_path",
92+
"haproxy_stats_socket_path",
9093
)
9194
@classmethod
9295
def runtime_paths_must_not_be_empty(cls, value: str) -> str:
@@ -97,6 +100,7 @@ def runtime_paths_must_not_be_empty(cls, value: str) -> str:
97100
@field_validator(
98101
"haproxy_validation_timeout_seconds",
99102
"haproxy_reload_timeout_seconds",
103+
"haproxy_stats_timeout_seconds",
100104
)
101105
@classmethod
102106
def timeout_settings_must_be_positive(cls, value: int) -> int:

src/backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
rule_exclusions,
3333
rule_overrides,
3434
runtime_status,
35+
security,
3536
vhosts,
3637
)
3738
from app.services.config_apply import seed_runtime_config
@@ -143,6 +144,7 @@ def _seed_runtime_config() -> None:
143144
app.include_router(rule_exclusions.router)
144145
app.include_router(rule_overrides.router)
145146
app.include_router(runtime_status.router)
147+
app.include_router(security.router)
146148
app.include_router(vhosts.router)
147149

148150

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Security API router — auto-ban list read/unban via HAProxy Runtime API."""
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.orm import Session
5+
6+
from app.database import get_db
7+
from app.dependencies import require_admin
8+
from app.models.user import User
9+
from app.schemas.security import BannedIpListResponse, UnbanResponse
10+
from app.services.ban_list_service import (
11+
BanListService,
12+
InvalidIpError,
13+
RuntimeApiError,
14+
)
15+
16+
router = APIRouter(prefix="/security", tags=["security"])
17+
18+
19+
@router.get("/banned-ips", response_model=BannedIpListResponse)
20+
def list_banned_ips(
21+
db: Session = Depends(get_db),
22+
_: User = Depends(require_admin),
23+
) -> BannedIpListResponse:
24+
"""Returns tracked/banned source IPs across auto-ban-enabled vhosts (admin only)."""
25+
service = BanListService(db)
26+
try:
27+
items = service.list_banned()
28+
except RuntimeApiError as error:
29+
raise HTTPException(
30+
status_code=status.HTTP_502_BAD_GATEWAY,
31+
detail="Failed to reach HAProxy Runtime API",
32+
) from error
33+
return BannedIpListResponse(items=items, total=len(items))
34+
35+
36+
@router.delete("/banned-ips/{ip}", response_model=UnbanResponse)
37+
def unban_ip(
38+
ip: str,
39+
db: Session = Depends(get_db),
40+
_: User = Depends(require_admin),
41+
) -> UnbanResponse:
42+
"""Clears a source IP from every active ban stick-table (admin only)."""
43+
service = BanListService(db)
44+
try:
45+
return service.unban(ip)
46+
except InvalidIpError as error:
47+
raise HTTPException(
48+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
49+
detail=str(error),
50+
) from error
51+
except RuntimeApiError as error:
52+
raise HTTPException(
53+
status_code=status.HTTP_502_BAD_GATEWAY,
54+
detail="Failed to reach HAProxy Runtime API",
55+
) from error
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Pydantic schemas for the ban-list / unban security endpoints."""
2+
3+
from pydantic import BaseModel
4+
5+
6+
class BannedIpResponse(BaseModel):
7+
"""One tracked entry in a vhost's auto-ban stick-table."""
8+
9+
ip: str
10+
vhost_id: int
11+
domain: str
12+
gpc0: int
13+
ban_threshold: int
14+
banned: bool
15+
expires_in_seconds: int
16+
17+
18+
class BannedIpListResponse(BaseModel):
19+
"""Response body returned by GET /security/banned-ips."""
20+
21+
items: list[BannedIpResponse]
22+
total: int
23+
24+
25+
class UnbanResponse(BaseModel):
26+
"""Response body returned by DELETE /security/banned-ips/{ip}."""
27+
28+
ip: str
29+
cleared: int
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Ban-list service — reads/clears auto-ban entries via HAProxy Runtime API.
2+
3+
Talks to the admin-level stats socket (`settings.haproxy_stats_socket_path`)
4+
emitted in the generated `haproxy.cfg` (see #275, #276). Each vhost with
5+
DDoS protection and auto-ban enabled owns a stick-table named
6+
`st_ban_vhost_<id>` (see `config_generator.py::_to_haproxy_context`); this
7+
module enumerates those tables' contents and can clear entries from them.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import ipaddress
13+
import logging
14+
import re
15+
import socket
16+
from dataclasses import dataclass
17+
18+
from sqlalchemy.orm import Session, selectinload
19+
20+
from app.config import settings
21+
from app.models.vhost import VHost
22+
from app.schemas.security import BannedIpResponse, UnbanResponse
23+
24+
logger = logging.getLogger(__name__)
25+
26+
_TABLE_ENTRY_RE = re.compile(
27+
r"key=(?P<key>\S+).*?exp=(?P<exp>\d+).*?gpc0=(?P<gpc0>\d+)"
28+
)
29+
30+
# HAProxy's Runtime API reports failures (unknown table, permission denied,
31+
# unsupported command, ...) as plain text in the command's own response
32+
# rather than as a socket-level error, so a successful `recv()` does not
33+
# mean the command succeeded. Anchored to the start of a line, mirroring
34+
# `config_apply._RELOAD_ERROR_RE`, so a `key=...` data line or the
35+
# `# table: ...` header can never false-positive.
36+
_RUNTIME_API_ERROR_RE = re.compile(
37+
r"^\s*(unknown|no such|can't find|permission denied|invalid|error)\b",
38+
re.IGNORECASE | re.MULTILINE,
39+
)
40+
41+
42+
class BanListError(Exception):
43+
"""Base class for ban-list domain errors."""
44+
45+
46+
class InvalidIpError(BanListError):
47+
"""Raised when a supplied IP address string cannot be parsed."""
48+
49+
def __init__(self, ip: str) -> None:
50+
self.ip = ip
51+
super().__init__(f"'{ip}' is not a valid IP address")
52+
53+
54+
class RuntimeApiError(BanListError):
55+
"""Raised when the HAProxy Runtime API socket cannot be reached at all."""
56+
57+
58+
@dataclass(frozen=True)
59+
class BanTableEntry:
60+
"""One parsed row from a `show table` response."""
61+
62+
ip: str
63+
gpc0: int
64+
expires_in_seconds: int
65+
66+
67+
def _send_runtime_command(command: str) -> str:
68+
"""Send one Runtime API command over the admin stats socket and return output.
69+
70+
Raises `RuntimeApiError` both when the socket itself is unreachable and
71+
when HAProxy accepts the connection but replies with an error message
72+
(e.g. an unknown table) — the caller cannot otherwise tell a successful
73+
`clear`/`show` from one HAProxy silently rejected.
74+
"""
75+
socket_path = settings.haproxy_stats_socket_path
76+
try:
77+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
78+
client.settimeout(settings.haproxy_stats_timeout_seconds)
79+
client.connect(socket_path)
80+
client.sendall(command.encode("utf-8") + b"\n")
81+
client.shutdown(socket.SHUT_WR)
82+
output_chunks: list[bytes] = []
83+
while True:
84+
chunk = client.recv(4096)
85+
if not chunk:
86+
break
87+
output_chunks.append(chunk)
88+
except OSError as error:
89+
raise RuntimeApiError(str(error)) from error
90+
91+
output = b"".join(output_chunks).decode("utf-8", errors="replace")
92+
if _RUNTIME_API_ERROR_RE.search(output):
93+
raise RuntimeApiError(output.strip() or "HAProxy Runtime API returned an error")
94+
95+
return output
96+
97+
98+
def _parse_show_table(raw: str) -> list[BanTableEntry]:
99+
"""Parse `show table <name>` output into structured entries.
100+
101+
Example line:
102+
0x...: key=192.0.2.7 use=0 exp=540000 gpc0=15
103+
`exp` is milliseconds remaining; converted to whole seconds (rounded up
104+
so a live entry never reports 0 remaining).
105+
"""
106+
entries: list[BanTableEntry] = []
107+
for line in raw.splitlines():
108+
match = _TABLE_ENTRY_RE.search(line)
109+
if match is None:
110+
continue
111+
exp_ms = int(match.group("exp"))
112+
entries.append(
113+
BanTableEntry(
114+
ip=match.group("key"),
115+
gpc0=int(match.group("gpc0")),
116+
expires_in_seconds=(exp_ms + 999) // 1000,
117+
)
118+
)
119+
return entries
120+
121+
122+
class BanListService:
123+
"""Reads and clears auto-ban stick-table entries via the Runtime API."""
124+
125+
def __init__(self, db: Session) -> None:
126+
self.db = db
127+
128+
def list_banned(self) -> list[BannedIpResponse]:
129+
"""Return every tracked/banned entry across all auto-ban-enabled vhosts.
130+
131+
Tolerates individual table failures (one dead table must not blank
132+
the whole list) but raises `RuntimeApiError` if every attempted
133+
table failed, so a fully unreachable Runtime API surfaces as an
134+
error instead of an empty list indistinguishable from "nothing
135+
tracked" — mirroring `unban`'s all-tables-failed handling below.
136+
"""
137+
tables = self._active_ban_tables()
138+
results: list[BannedIpResponse] = []
139+
failures = 0
140+
for vhost, table_name, ban_threshold in tables:
141+
try:
142+
raw = _send_runtime_command(f"show table {table_name}")
143+
entries = _parse_show_table(raw)
144+
except RuntimeApiError:
145+
logger.exception(
146+
"ban-list failed to read table %s for vhost %s",
147+
table_name,
148+
vhost.id,
149+
)
150+
failures += 1
151+
continue
152+
153+
for entry in entries:
154+
results.append(
155+
BannedIpResponse(
156+
ip=entry.ip,
157+
vhost_id=vhost.id,
158+
domain=vhost.domain,
159+
gpc0=entry.gpc0,
160+
ban_threshold=ban_threshold,
161+
banned=entry.gpc0 > ban_threshold,
162+
expires_in_seconds=entry.expires_in_seconds,
163+
)
164+
)
165+
166+
if tables and failures == len(tables):
167+
raise RuntimeApiError("Failed to reach HAProxy Runtime API")
168+
169+
return results
170+
171+
def unban(self, ip: str) -> UnbanResponse:
172+
"""Clear an IP from every active ban table; return the number cleared.
173+
174+
`cleared` counts tables where HAProxy confirmed the `clear table`
175+
command (including a no-op when the key was already absent) — not
176+
merely tables where the socket write succeeded, since
177+
`_send_runtime_command` raises on an HAProxy-reported error too.
178+
Tolerates individual table failures (one dead table must not block
179+
clearing the others) but raises `RuntimeApiError` if every attempted
180+
table failed, so a fully unreachable Runtime API surfaces as an
181+
error instead of a silent no-op `cleared=0`.
182+
"""
183+
try:
184+
ipaddress.ip_address(ip)
185+
except ValueError as error:
186+
raise InvalidIpError(ip) from error
187+
188+
tables = self._active_ban_tables()
189+
cleared = 0
190+
failures = 0
191+
for _vhost, table_name, _threshold in tables:
192+
try:
193+
_send_runtime_command(f"clear table {table_name} key {ip}")
194+
cleared += 1
195+
except RuntimeApiError:
196+
logger.exception(
197+
"ban-list failed to clear key %s in table %s", ip, table_name
198+
)
199+
failures += 1
200+
201+
if tables and failures == len(tables):
202+
raise RuntimeApiError("Failed to reach HAProxy Runtime API")
203+
204+
return UnbanResponse(ip=ip, cleared=cleared)
205+
206+
def _active_ban_tables(self) -> list[tuple[VHost, str, int]]:
207+
vhosts = (
208+
self.db.query(VHost)
209+
.options(selectinload(VHost.policy))
210+
.order_by(VHost.id.asc())
211+
.all()
212+
)
213+
return [
214+
(vhost, f"st_ban_vhost_{vhost.id}", vhost.policy.ban_threshold)
215+
for vhost in vhosts
216+
if vhost.is_active
217+
and vhost.policy is not None
218+
and vhost.policy.ddos_protection_enabled
219+
and vhost.policy.auto_ban_enabled
220+
]

0 commit comments

Comments
 (0)