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
2 changes: 2 additions & 0 deletions configs/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ defaults
timeout connect 5s
timeout client 30s
timeout server 30s
# Slow-loris protection: cap the time allowed to receive a full request.
timeout http-request 5s

frontend fe_http
bind *:80
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""add ddos protection fields to policies

Revision ID: 473d83df7b55
Revises: b7c4d8e9f012
Create Date: 2026-07-21 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "473d83df7b55"
down_revision: str | Sequence[str] | None = "b7c4d8e9f012"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
with op.batch_alter_table("policies") as batch_op:
batch_op.add_column(
sa.Column(
"ddos_protection_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.add_column(
sa.Column(
"rate_limit_requests",
sa.Integer(),
nullable=False,
server_default="100",
)
)
batch_op.add_column(
sa.Column(
"rate_limit_window_seconds",
sa.Integer(),
nullable=False,
server_default="10",
)
)
batch_op.add_column(
sa.Column(
"max_connections_per_ip",
sa.Integer(),
nullable=False,
server_default="20",
)
)
batch_op.create_check_constraint(
"ck_policies_rate_limit_requests",
"rate_limit_requests >= 1",
)
batch_op.create_check_constraint(
"ck_policies_rate_limit_window_seconds",
"rate_limit_window_seconds BETWEEN 1 AND 3600",
)
batch_op.create_check_constraint(
"ck_policies_max_connections_per_ip",
"max_connections_per_ip >= 1",
)


def downgrade() -> None:
"""Downgrade schema."""
with op.batch_alter_table("policies") as batch_op:
batch_op.drop_constraint(
"ck_policies_max_connections_per_ip", type_="check"
)
batch_op.drop_constraint(
"ck_policies_rate_limit_window_seconds", type_="check"
)
batch_op.drop_constraint("ck_policies_rate_limit_requests", type_="check")
batch_op.drop_column("max_connections_per_ip")
batch_op.drop_column("rate_limit_window_seconds")
batch_op.drop_column("rate_limit_requests")
batch_op.drop_column("ddos_protection_enabled")
32 changes: 32 additions & 0 deletions src/backend/app/models/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ class Policy(Base):
"outbound_anomaly_threshold >= 1",
name="ck_policies_outbound_anomaly_threshold",
),
CheckConstraint(
"rate_limit_requests >= 1",
name="ck_policies_rate_limit_requests",
),
CheckConstraint(
"rate_limit_window_seconds BETWEEN 1 AND 3600",
name="ck_policies_rate_limit_window_seconds",
),
CheckConstraint(
"max_connections_per_ip >= 1",
name="ck_policies_max_connections_per_ip",
),
)

id: Mapped[int] = mapped_column(primary_key=True)
Expand Down Expand Up @@ -97,6 +109,26 @@ class Policy(Base):
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

# DDoS protection (HAProxy stick-table rate limiting / connection throttling)
ddos_protection_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
rate_limit_requests: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=100, # max requests per window, per source IP
)
rate_limit_window_seconds: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=10, # rate-limit window length in seconds
)
max_connections_per_ip: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=20, # concurrent connection limit per source IP
)

# Audit — who created the policy
# ForeignKey("users.id") = reference to users table, id column
# nullable=True — can be NULL if the user was deleted (soft delete)
Expand Down
4 changes: 4 additions & 0 deletions src/backend/app/routers/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def create_policy(
inbound_anomaly_threshold=body.inbound_anomaly_threshold,
outbound_anomaly_threshold=body.outbound_anomaly_threshold,
enforcement_mode=body.enforcement_mode,
ddos_protection_enabled=body.ddos_protection_enabled,
rate_limit_requests=body.rate_limit_requests,
rate_limit_window_seconds=body.rate_limit_window_seconds,
max_connections_per_ip=body.max_connections_per_ip,
created_by=current_user.id,
)
except PolicyNameAlreadyExistsError as error:
Expand Down
12 changes: 12 additions & 0 deletions src/backend/app/schemas/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class PolicyCreate(BaseModel):
inbound_anomaly_threshold: int = 5
outbound_anomaly_threshold: int = 4
enforcement_mode: PolicyEnforcementMode = PolicyEnforcementMode.block
ddos_protection_enabled: bool = False
rate_limit_requests: int = Field(default=100, ge=1)
rate_limit_window_seconds: int = Field(default=10, ge=1, le=3600)
max_connections_per_ip: int = Field(default=20, ge=1)

@field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold")
@classmethod
Expand All @@ -42,6 +46,10 @@ class PolicyUpdate(BaseModel):
outbound_anomaly_threshold: int | None = None
enforcement_mode: PolicyEnforcementMode | None = None
is_active: bool | None = None
ddos_protection_enabled: bool | None = None
rate_limit_requests: int | None = Field(default=None, ge=1)
rate_limit_window_seconds: int | None = Field(default=None, ge=1, le=3600)
max_connections_per_ip: int | None = Field(default=None, ge=1)

@field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold")
@classmethod
Expand All @@ -64,6 +72,10 @@ class PolicyResponse(BaseModel):
outbound_anomaly_threshold: int
enforcement_mode: PolicyEnforcementMode
is_active: bool
ddos_protection_enabled: bool
rate_limit_requests: int
rate_limit_window_seconds: int
max_connections_per_ip: int
created_by: int | None
created_at: datetime
updated_at: datetime
Expand Down
30 changes: 28 additions & 2 deletions src/backend/app/services/config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CrsPolicyRenderContext,
CustomRuleRenderContext,
HaproxyBackend,
HaproxyDdos,
HaproxyRenderContext,
HaproxyRoute,
HaproxyServer,
Expand Down Expand Up @@ -52,7 +53,17 @@ def generate(
(vhost for vhost in vhosts if vhost.is_active),
key=lambda vhost: vhost.domain,
)
vhost_contexts = [_to_haproxy_context(vhost) for vhost in active_vhosts]
policies_by_id = {policy.id: policy for policy in policies}

def _policy_for_vhost(vhost: VHost) -> Policy | None:
if vhost.policy_id is None:
return None
return policies_by_id.get(vhost.policy_id)

vhost_contexts = [
_to_haproxy_context(vhost, _policy_for_vhost(vhost))
for vhost in active_vhosts
]

active_policy, active_overrides, active_exclusions, active_custom_rules = (
_pick_active_policy(
Expand Down Expand Up @@ -193,7 +204,9 @@ def _add_effective_policy_id(
effective_policy_ids.add(policy.id)


def _to_haproxy_context(vhost: VHost) -> HaproxyRenderContext:
def _to_haproxy_context(
vhost: VHost, policy: Policy | None
) -> HaproxyRenderContext:
if vhost.id is None:
raise ValueError(f"Active vhost {vhost.domain!r} has no persisted id")
# Use the database id as the naming suffix so that domain names that only
Expand All @@ -218,12 +231,25 @@ def _to_haproxy_context(vhost: VHost) -> HaproxyRenderContext:
),
"/",
)
ddos = (
HaproxyDdos(
enabled=True,
stick_table_name=f"st_ddos_{suffix}",
rate_limit_requests=policy.rate_limit_requests,
rate_limit_window_seconds=policy.rate_limit_window_seconds,
max_connections_per_ip=policy.max_connections_per_ip,
)
if policy is not None and policy.ddos_protection_enabled
else None
)

return HaproxyRenderContext(
routes=(
HaproxyRoute(
vhost_acl_name=f"host_{suffix}",
vhost_hosts=(vhost.domain,),
ssl_provider=vhost.ssl_provider if vhost.ssl_enabled else "none",
ddos=ddos,
backend=HaproxyBackend(
name=f"be_{suffix}",
health_check_path=health_check_path,
Expand Down
40 changes: 40 additions & 0 deletions src/backend/app/services/config_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,37 @@ def __post_init__(self) -> None:
)


@dataclass(frozen=True)
class HaproxyDdos:
"""Per-vhost DDoS protection settings (rate limiting + connection throttling).

``stick_table_name`` must be a valid HAProxy identifier; this class only
validates its format. Deriving it from the vhost's ACL suffix and
enforcing uniqueness across routes is the caller's responsibility (see
:func:`app.services.config_generator._to_haproxy_context` and the
uniqueness check in :class:`HaproxyRenderContext`).
"""

enabled: bool
stick_table_name: str
rate_limit_requests: int
rate_limit_window_seconds: int
max_connections_per_ip: int

def __post_init__(self) -> None:
_validate_haproxy_identifier(
self.stick_table_name, "HaproxyDdos.stick_table_name"
)
if self.rate_limit_requests < 1:
raise ValueError("HaproxyDdos.rate_limit_requests must be positive")
if not 1 <= self.rate_limit_window_seconds <= 3600:
raise ValueError(
"HaproxyDdos.rate_limit_window_seconds must be between 1 and 3600"
)
if self.max_connections_per_ip < 1:
raise ValueError("HaproxyDdos.max_connections_per_ip must be positive")


@dataclass(frozen=True)
class HaproxyRoute:
"""Prepared HAProxy render input with no database behavior.
Expand All @@ -153,6 +184,7 @@ class HaproxyRoute:
vhost_hosts: tuple[str, ...]
ssl_provider: str
backend: HaproxyBackend
ddos: HaproxyDdos | None = None

def __post_init__(self) -> None:
_validate_haproxy_identifier(
Expand Down Expand Up @@ -189,6 +221,14 @@ def __post_init__(self) -> None:
),
"HaproxyRenderContext.routes.backend.server_name",
)
_ensure_unique(
(
route.ddos.stick_table_name
for route in self.routes
if route.ddos is not None
),
"HaproxyRenderContext.routes.ddos.stick_table_name",
)


@dataclass(frozen=True)
Expand Down
16 changes: 16 additions & 0 deletions src/backend/app/services/policy_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"outbound_anomaly_threshold",
"enforcement_mode",
"is_active",
"ddos_protection_enabled",
"rate_limit_requests",
"rate_limit_window_seconds",
"max_connections_per_ip",
}

# description is intentionally included: it is nullable and may be set to None.
Expand All @@ -25,6 +29,10 @@
"outbound_anomaly_threshold",
"enforcement_mode",
"is_active",
"ddos_protection_enabled",
"rate_limit_requests",
"rate_limit_window_seconds",
"max_connections_per_ip",
}


Expand Down Expand Up @@ -85,6 +93,10 @@ def create_policy(
outbound_anomaly_threshold: int,
enforcement_mode: PolicyEnforcementMode,
created_by: int | None,
ddos_protection_enabled: bool = False,
rate_limit_requests: int = 100,
rate_limit_window_seconds: int = 10,
max_connections_per_ip: int = 20,
) -> Policy:
"""Create and persist a new policy."""
policy = Policy(
Expand All @@ -94,6 +106,10 @@ def create_policy(
inbound_anomaly_threshold=inbound_anomaly_threshold,
outbound_anomaly_threshold=outbound_anomaly_threshold,
enforcement_mode=enforcement_mode,
ddos_protection_enabled=ddos_protection_enabled,
rate_limit_requests=rate_limit_requests,
rate_limit_window_seconds=rate_limit_window_seconds,
max_connections_per_ip=max_connections_per_ip,
is_active=True,
created_by=created_by,
)
Expand Down
Loading
Loading