Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions configs/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ global
maxconn 2000
# Operator level avoids exposing administrative Runtime API commands.
stats socket /tmp/haproxy.sock mode 660 level operator
# Admin level, reachable only from the backend over the shared runtime
# volume (not exposed to the host). Needed for `clear table` (unban) —
# see #276 — which the operator-level socket above deliberately cannot
# perform. mode 666 matches the master socket (docker-compose command,
# `-S ...master.sock,mode,666,...`): HAProxy runs as root while the
# backend drops to an unprivileged user via gosu, so a root-owned 660
# socket would not be readable/writable by the backend.
stats socket /var/run/haproxy/admin.sock mode 666 level admin

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

defaults
mode http
Expand Down
2 changes: 2 additions & 0 deletions src/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ DEBUG=false
# HAPROXY_VALIDATION_TIMEOUT_SECONDS=10
# HAPROXY_MASTER_SOCKET_PATH=/var/run/haproxy/master.sock
# HAPROXY_RELOAD_TIMEOUT_SECONDS=10
# HAPROXY_STATS_SOCKET_PATH=/var/run/haproxy/admin.sock
# HAPROXY_STATS_TIMEOUT_SECONDS=10

# Optional: used by scripts/seed_admin.py
# ADMIN_EMAIL=admin@example.com
Expand Down
4 changes: 4 additions & 0 deletions src/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class Settings(EnvFileSettings):
haproxy_validation_timeout_seconds: int = 10
haproxy_master_socket_path: str = "/var/run/haproxy/master.sock"
haproxy_reload_timeout_seconds: int = 10
haproxy_stats_socket_path: str = "/var/run/haproxy/admin.sock"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] New settings haproxy_stats_socket_path and haproxy_stats_timeout_seconds are not documented in src/backend/.env.example next to the existing HAPROXY_MASTER_SOCKET_PATH / reload timeout comments. Operators overriding the path would not discover the knobs from the env template.

Suggestion: Add commented examples for HAPROXY_STATS_SOCKET_PATH and HAPROXY_STATS_TIMEOUT_SECONDS in .env.example.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 01e1849: added HAPROXY_STATS_SOCKET_PATH and HAPROXY_STATS_TIMEOUT_SECONDS to .env.example.

haproxy_stats_timeout_seconds: int = 10
log_retention_days: int = 30

@field_validator("database_url")
Expand All @@ -87,6 +89,7 @@ def database_url_must_not_be_empty(cls, value: str) -> str:
"runtime_generated_config_root",
"haproxy_validation_binary",
"haproxy_master_socket_path",
"haproxy_stats_socket_path",
)
@classmethod
def runtime_paths_must_not_be_empty(cls, value: str) -> str:
Expand All @@ -97,6 +100,7 @@ def runtime_paths_must_not_be_empty(cls, value: str) -> str:
@field_validator(
"haproxy_validation_timeout_seconds",
"haproxy_reload_timeout_seconds",
"haproxy_stats_timeout_seconds",
)
@classmethod
def timeout_settings_must_be_positive(cls, value: int) -> int:
Expand Down
2 changes: 2 additions & 0 deletions src/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
rule_exclusions,
rule_overrides,
runtime_status,
security,
vhosts,
)
from app.services.config_apply import seed_runtime_config
Expand Down Expand Up @@ -143,6 +144,7 @@ def _seed_runtime_config() -> None:
app.include_router(rule_exclusions.router)
app.include_router(rule_overrides.router)
app.include_router(runtime_status.router)
app.include_router(security.router)
app.include_router(vhosts.router)


Expand Down
55 changes: 55 additions & 0 deletions src/backend/app/routers/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Security API router — auto-ban list read/unban via HAProxy Runtime API."""

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.database import get_db
from app.dependencies import require_admin
from app.models.user import User
from app.schemas.security import BannedIpListResponse, UnbanResponse
from app.services.ban_list_service import (
BanListService,
InvalidIpError,
RuntimeApiError,
)

router = APIRouter(prefix="/security", tags=["security"])


@router.get("/banned-ips", response_model=BannedIpListResponse)
def list_banned_ips(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> BannedIpListResponse:
"""Returns tracked/banned source IPs across auto-ban-enabled vhosts (admin only)."""
service = BanListService(db)
try:
items = service.list_banned()
except RuntimeApiError as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to reach HAProxy Runtime API",
) from error
return BannedIpListResponse(items=items, total=len(items))


@router.delete("/banned-ips/{ip}", response_model=UnbanResponse)
def unban_ip(
ip: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> UnbanResponse:
"""Clears a source IP from every active ban stick-table (admin only)."""
service = BanListService(db)
try:
return service.unban(ip)
except InvalidIpError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(error),
) from error
except RuntimeApiError as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to reach HAProxy Runtime API",
) from error
29 changes: 29 additions & 0 deletions src/backend/app/schemas/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Pydantic schemas for the ban-list / unban security endpoints."""

from pydantic import BaseModel


class BannedIpResponse(BaseModel):
"""One tracked entry in a vhost's auto-ban stick-table."""

ip: str
vhost_id: int
domain: str
gpc0: int
ban_threshold: int
banned: bool
expires_in_seconds: int


class BannedIpListResponse(BaseModel):
"""Response body returned by GET /security/banned-ips."""

items: list[BannedIpResponse]
total: int


class UnbanResponse(BaseModel):
"""Response body returned by DELETE /security/banned-ips/{ip}."""

ip: str
cleared: int
220 changes: 220 additions & 0 deletions src/backend/app/services/ban_list_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Ban-list service — reads/clears auto-ban entries via HAProxy Runtime API.

Talks to the admin-level stats socket (`settings.haproxy_stats_socket_path`)
emitted in the generated `haproxy.cfg` (see #275, #276). Each vhost with
DDoS protection and auto-ban enabled owns a stick-table named
`st_ban_vhost_<id>` (see `config_generator.py::_to_haproxy_context`); this
module enumerates those tables' contents and can clear entries from them.
"""

from __future__ import annotations

import ipaddress
import logging
import re
import socket
from dataclasses import dataclass

from sqlalchemy.orm import Session, selectinload

from app.config import settings
from app.models.vhost import VHost
from app.schemas.security import BannedIpResponse, UnbanResponse

logger = logging.getLogger(__name__)

_TABLE_ENTRY_RE = re.compile(
r"key=(?P<key>\S+).*?exp=(?P<exp>\d+).*?gpc0=(?P<gpc0>\d+)"
)

# HAProxy's Runtime API reports failures (unknown table, permission denied,
# unsupported command, ...) as plain text in the command's own response
# rather than as a socket-level error, so a successful `recv()` does not
# mean the command succeeded. Anchored to the start of a line, mirroring
# `config_apply._RELOAD_ERROR_RE`, so a `key=...` data line or the
# `# table: ...` header can never false-positive.
_RUNTIME_API_ERROR_RE = re.compile(
r"^\s*(unknown|no such|can't find|permission denied|invalid|error)\b",
re.IGNORECASE | re.MULTILINE,
)


class BanListError(Exception):
"""Base class for ban-list domain errors."""


class InvalidIpError(BanListError):
"""Raised when a supplied IP address string cannot be parsed."""

def __init__(self, ip: str) -> None:
self.ip = ip
super().__init__(f"'{ip}' is not a valid IP address")


class RuntimeApiError(BanListError):
"""Raised when the HAProxy Runtime API socket cannot be reached at all."""


@dataclass(frozen=True)
class BanTableEntry:
"""One parsed row from a `show table` response."""

ip: str
gpc0: int
expires_in_seconds: int


def _send_runtime_command(command: str) -> str:
"""Send one Runtime API command over the admin stats socket and return output.

Raises `RuntimeApiError` both when the socket itself is unreachable and
when HAProxy accepts the connection but replies with an error message
(e.g. an unknown table) — the caller cannot otherwise tell a successful
`clear`/`show` from one HAProxy silently rejected.
"""
socket_path = settings.haproxy_stats_socket_path
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(settings.haproxy_stats_timeout_seconds)
client.connect(socket_path)
client.sendall(command.encode("utf-8") + b"\n")
client.shutdown(socket.SHUT_WR)
output_chunks: list[bytes] = []
while True:
chunk = client.recv(4096)
if not chunk:
break
output_chunks.append(chunk)
except OSError as error:
raise RuntimeApiError(str(error)) from error

output = b"".join(output_chunks).decode("utf-8", errors="replace")
if _RUNTIME_API_ERROR_RE.search(output):
raise RuntimeApiError(output.strip() or "HAProxy Runtime API returned an error")

return output


def _parse_show_table(raw: str) -> list[BanTableEntry]:
"""Parse `show table <name>` output into structured entries.

Example line:
0x...: key=192.0.2.7 use=0 exp=540000 gpc0=15
`exp` is milliseconds remaining; converted to whole seconds (rounded up
so a live entry never reports 0 remaining).
"""
entries: list[BanTableEntry] = []
for line in raw.splitlines():
match = _TABLE_ENTRY_RE.search(line)
if match is None:
continue
exp_ms = int(match.group("exp"))
entries.append(
BanTableEntry(
ip=match.group("key"),
gpc0=int(match.group("gpc0")),
expires_in_seconds=(exp_ms + 999) // 1000,
)
)
return entries


class BanListService:
"""Reads and clears auto-ban stick-table entries via the Runtime API."""

def __init__(self, db: Session) -> None:
self.db = db

def list_banned(self) -> list[BannedIpResponse]:
"""Return every tracked/banned entry across all auto-ban-enabled vhosts.

Tolerates individual table failures (one dead table must not blank
the whole list) but raises `RuntimeApiError` if every attempted
table failed, so a fully unreachable Runtime API surfaces as an
error instead of an empty list indistinguishable from "nothing
tracked" — mirroring `unban`'s all-tables-failed handling below.
"""
tables = self._active_ban_tables()
results: list[BannedIpResponse] = []
failures = 0
for vhost, table_name, ban_threshold in tables:
try:
raw = _send_runtime_command(f"show table {table_name}")
entries = _parse_show_table(raw)
except RuntimeApiError:
logger.exception(
"ban-list failed to read table %s for vhost %s",
table_name,
vhost.id,
)
failures += 1
continue

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] list_banned catches every RuntimeApiError and continues. If the Runtime API is fully down (or every table read fails), the method returns [] and the router responds 200 with total: 0. That is indistinguishable from “no IPs tracked”, while unban carefully raises when failures == len(tables) so a total outage surfaces as 502. Operators can wrongly conclude nothing is banned when HAProxy is unreachable.

Suggestion: Mirror unban: track failures; if there was at least one active ban table and every show table failed, raise RuntimeApiError. In security.py list_banned_ips, catch RuntimeApiError and map it to 502 the same way unban_ip does. Keep per-table skip for partial failures only.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 01e1849: list_banned now tracks per-table failures and raises RuntimeApiError when every table read fails (mirroring unban), and the router maps that to 502.


for entry in entries:
results.append(
BannedIpResponse(
ip=entry.ip,
vhost_id=vhost.id,
domain=vhost.domain,
gpc0=entry.gpc0,
ban_threshold=ban_threshold,
banned=entry.gpc0 > ban_threshold,
expires_in_seconds=entry.expires_in_seconds,
)
)

if tables and failures == len(tables):
raise RuntimeApiError("Failed to reach HAProxy Runtime API")

return results

def unban(self, ip: str) -> UnbanResponse:
"""Clear an IP from every active ban table; return the number cleared.

`cleared` counts tables where HAProxy confirmed the `clear table`
command (including a no-op when the key was already absent) — not
merely tables where the socket write succeeded, since
`_send_runtime_command` raises on an HAProxy-reported error too.
Tolerates individual table failures (one dead table must not block
clearing the others) but raises `RuntimeApiError` if every attempted
table failed, so a fully unreachable Runtime API surfaces as an
error instead of a silent no-op `cleared=0`.
"""
try:
ipaddress.ip_address(ip)
except ValueError as error:
raise InvalidIpError(ip) from error

tables = self._active_ban_tables()
cleared = 0
failures = 0
for _vhost, table_name, _threshold in tables:
try:
_send_runtime_command(f"clear table {table_name} key {ip}")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] unban increments cleared whenever the socket I/O succeeds, without inspecting HAProxy’s reply. HAProxy can return a textual error (e.g. unknown table when DB policy still has auto-ban but config was not applied / table was never created) without raising OSError. Those attempts still count as cleared, so cleared can overstate success. config_apply._reload_haproxy already classifies command output for errors; this path does not.

Suggestion: After _send_runtime_command, treat non-empty error-looking replies (or known “Unknown table” / “Permission denied” strings) as failures for that table, consistent with reload output handling. Document that cleared means “tables successfully cleared” (including no-op when the key was absent) vs “socket accepted the write”.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 01e1849: _send_runtime_command now classifies HAProxy's textual error replies (unknown table, permission denied, etc.) and raises RuntimeApiError, so those attempts are no longer counted in cleared. Added regex-level tests mirroring _RELOAD_ERROR_RE's test style.

cleared += 1
except RuntimeApiError:
logger.exception(
"ban-list failed to clear key %s in table %s", ip, table_name
)
failures += 1

if tables and failures == len(tables):
raise RuntimeApiError("Failed to reach HAProxy Runtime API")

return UnbanResponse(ip=ip, cleared=cleared)

def _active_ban_tables(self) -> list[tuple[VHost, str, int]]:
vhosts = (
self.db.query(VHost)
.options(selectinload(VHost.policy))
.order_by(VHost.id.asc())
.all()
)
return [
(vhost, f"st_ban_vhost_{vhost.id}", vhost.policy.ban_threshold)
for vhost in vhosts
if vhost.is_active
and vhost.policy is not None
and vhost.policy.ddos_protection_enabled
and vhost.policy.auto_ban_enabled
]
Loading
Loading