feat: Add CrowdSec logging, rate limiting, and fix MCP parameter defaults
- Add real client IP detection (CF-Connecting-IP / src fallback) to both frontends - Add per-IP rate limiting (429) using real IP for Cloudflare compatibility - Add CrowdSec syslog forwarding with custom log format - Add httplog option for detailed HTTP logging - Fix Python-level defaults on MCP tool parameters to match Field(default=X) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ global
|
|||||||
defaults
|
defaults
|
||||||
log global
|
log global
|
||||||
mode http
|
mode http
|
||||||
|
option httplog
|
||||||
option dontlognull
|
option dontlognull
|
||||||
option http-keep-alive
|
option http-keep-alive
|
||||||
option forwardfor
|
option forwardfor
|
||||||
@@ -53,10 +54,27 @@ frontend stats
|
|||||||
# HTTP Frontend - forward to backend (same as HTTPS)
|
# HTTP Frontend - forward to backend (same as HTTPS)
|
||||||
frontend http_front
|
frontend http_front
|
||||||
bind *:80
|
bind *:80
|
||||||
# ACME challenge for certbot (unused - using DNS-01)
|
|
||||||
# acl is_acme path_beg /.well-known/acme-challenge/
|
# -- Shared security config (keep in sync with http_front/https_front) --
|
||||||
# use_backend acme_backend if is_acme
|
|
||||||
# http-request redirect scheme https unless is_acme
|
# Send HTTP logs to CrowdSec
|
||||||
|
log 10.253.100.240:514 local0
|
||||||
|
|
||||||
|
# Set real client IP (CF-Connecting-IP if via Cloudflare, otherwise direct client IP)
|
||||||
|
http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if { req.hdr(CF-Connecting-IP) -m found }
|
||||||
|
http-request set-var(txn.real_ip) src unless { var(txn.real_ip) -m found }
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
log-format "%[var(txn.real_ip)]:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq \"%r\""
|
||||||
|
|
||||||
|
# Per-IP concurrent connection limit (slowloris protection)
|
||||||
|
# Note: http_front and https_front have separate stick-tables, so the same
|
||||||
|
# IP is counted independently in each frontend (HTTP vs HTTPS).
|
||||||
|
stick-table type ip size 200k expire 5m store conn_cur
|
||||||
|
acl is_internal src 127.0.0.0/8 100.64.0.0/10
|
||||||
|
http-request track-sc0 hdr_ip(X-Real-IP) if !is_internal
|
||||||
|
http-request deny deny_status 429 if !is_internal { sc_conn_cur(0) gt 500 }
|
||||||
|
|
||||||
|
# -- End shared security config --
|
||||||
|
|
||||||
# 2-stage map-based routing for performance:
|
# 2-stage map-based routing for performance:
|
||||||
# Stage 1: Exact match with map_str (O(log n) - fast, uses ebtree)
|
# Stage 1: Exact match with map_str (O(log n) - fast, uses ebtree)
|
||||||
@@ -72,6 +90,27 @@ frontend https_front
|
|||||||
bind quic4@:443 ssl crt /etc/haproxy/certs/ alpn h3
|
bind quic4@:443 ssl crt /etc/haproxy/certs/ alpn h3
|
||||||
http-response set-header alt-svc "h3=\":443\"; ma=86400"
|
http-response set-header alt-svc "h3=\":443\"; ma=86400"
|
||||||
|
|
||||||
|
# -- Shared security config (keep in sync with http_front/https_front) --
|
||||||
|
|
||||||
|
# Send HTTP logs to CrowdSec
|
||||||
|
log 10.253.100.240:514 local0
|
||||||
|
|
||||||
|
# Set real client IP (CF-Connecting-IP if via Cloudflare, otherwise direct client IP)
|
||||||
|
http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if { req.hdr(CF-Connecting-IP) -m found }
|
||||||
|
http-request set-var(txn.real_ip) src unless { var(txn.real_ip) -m found }
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
log-format "%[var(txn.real_ip)]:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq \"%r\""
|
||||||
|
|
||||||
|
# Per-IP concurrent connection limit (slowloris protection)
|
||||||
|
# Note: http_front and https_front have separate stick-tables, so the same
|
||||||
|
# IP is counted independently in each frontend (HTTP vs HTTPS).
|
||||||
|
stick-table type ip size 200k expire 5m store conn_cur
|
||||||
|
acl is_internal src 127.0.0.0/8 100.64.0.0/10
|
||||||
|
http-request track-sc0 hdr_ip(X-Real-IP) if !is_internal
|
||||||
|
http-request deny deny_status 429 if !is_internal { sc_conn_cur(0) gt 500 }
|
||||||
|
|
||||||
|
# -- End shared security config --
|
||||||
|
|
||||||
# MCP authentication (Bearer Token or Tailscale)
|
# MCP authentication (Bearer Token or Tailscale)
|
||||||
acl is_mcp hdr(host) -i mcp.inouter.com
|
acl is_mcp hdr(host) -i mcp.inouter.com
|
||||||
acl valid_token req.hdr(Authorization) -m str "Bearer dcb7963ab3ef705f6b780818f78942a100efa3b55e3d2f99c4560b65da64c426"
|
acl valid_token req.hdr(Authorization) -m str "Bearer dcb7963ab3ef705f6b780818f78942a100efa3b55e3d2f99c4560b65da64c426"
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ def register_certificate_tools(mcp):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_issue_cert(
|
def haproxy_issue_cert(
|
||||||
domain: Annotated[str, Field(description="Primary domain (e.g., example.com)")],
|
domain: Annotated[str, Field(description="Primary domain (e.g., example.com)")],
|
||||||
wildcard: Annotated[bool, Field(default=True, description="Include wildcard (*.example.com). Default: true")]
|
wildcard: Annotated[bool, Field(default=True, description="Include wildcard (*.example.com). Default: true")] = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Issue a new SSL/TLS certificate using acme.sh with Cloudflare DNS.
|
"""Issue a new SSL/TLS certificate using acme.sh with Cloudflare DNS.
|
||||||
|
|
||||||
@@ -528,7 +528,7 @@ def register_certificate_tools(mcp):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_renew_cert(
|
def haproxy_renew_cert(
|
||||||
domain: Annotated[str, Field(description="Domain name to renew (e.g., example.com)")],
|
domain: Annotated[str, Field(description="Domain name to renew (e.g., example.com)")],
|
||||||
force: Annotated[bool, Field(default=False, description="Force renewal even if not due. Default: false")]
|
force: Annotated[bool, Field(default=False, description="Force renewal even if not due. Default: false")] = False
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Renew an existing certificate.
|
"""Renew an existing certificate.
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def register_domain_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_list_domains(
|
def haproxy_list_domains(
|
||||||
include_wildcards: Annotated[bool, Field(default=False, description="Include wildcard entries (.example.com). Default: False")]
|
include_wildcards: Annotated[bool, Field(default=False, description="Include wildcard entries (.example.com). Default: False")] = False
|
||||||
) -> str:
|
) -> str:
|
||||||
"""List all configured domains with their backend servers."""
|
"""List all configured domains with their backend servers."""
|
||||||
try:
|
try:
|
||||||
@@ -206,9 +206,9 @@ def register_domain_tools(mcp):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_add_domain(
|
def haproxy_add_domain(
|
||||||
domain: Annotated[str, Field(description="Domain name to add (e.g., api.example.com, example.com)")],
|
domain: Annotated[str, Field(description="Domain name to add (e.g., api.example.com, example.com)")],
|
||||||
ip: Annotated[str, Field(default="", description="Optional: Initial server IP. If provided, adds server to slot 1")],
|
ip: Annotated[str, Field(default="", description="Optional: Initial server IP. If provided, adds server to slot 1")] = "",
|
||||||
http_port: Annotated[int, Field(default=80, description="HTTP port for backend server (default: 80)")],
|
http_port: Annotated[int, Field(default=80, description="HTTP port for backend server (default: 80)")] = 80,
|
||||||
share_with: Annotated[str, Field(default="", description="Optional: Existing domain to share pool with. New domain uses same backend servers.")]
|
share_with: Annotated[str, Field(default="", description="Optional: Existing domain to share pool with. New domain uses same backend servers.")] = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add a new domain to HAProxy (no reload required).
|
"""Add a new domain to HAProxy (no reload required).
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ def register_health_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_get_server_health(
|
def haproxy_get_server_health(
|
||||||
backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")]
|
backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")] = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get health status of all servers (low-level view). For domain-specific, use haproxy_domain_health."""
|
"""Get health status of all servers (low-level view). For domain-specific, use haproxy_domain_health."""
|
||||||
if backend and not validate_backend_name(backend):
|
if backend and not validate_backend_name(backend):
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def register_monitoring_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_get_connections(
|
def haproxy_get_connections(
|
||||||
backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")]
|
backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")] = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get active connection counts per server for monitoring traffic distribution."""
|
"""Get active connection counts per server for monitoring traffic distribution."""
|
||||||
if backend and not validate_backend_name(backend):
|
if backend and not validate_backend_name(backend):
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ def register_server_tools(mcp):
|
|||||||
domain: Annotated[str, Field(description="Domain name to add server to (e.g., api.example.com)")],
|
domain: Annotated[str, Field(description="Domain name to add server to (e.g., api.example.com)")],
|
||||||
slot: Annotated[int, Field(description="Server slot number 1-10, or 0 for auto-select next available slot")],
|
slot: Annotated[int, Field(description="Server slot number 1-10, or 0 for auto-select next available slot")],
|
||||||
ip: Annotated[str, Field(description="Server IP address (IPv4 like 10.0.0.1 or IPv6 like 2001:db8::1)")],
|
ip: Annotated[str, Field(description="Server IP address (IPv4 like 10.0.0.1 or IPv6 like 2001:db8::1)")],
|
||||||
http_port: Annotated[int, Field(default=80, description="HTTP port for backend connection (default: 80)")]
|
http_port: Annotated[int, Field(default=80, description="HTTP port for backend connection (default: 80)")] = 80
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add a server to a domain's backend pool for load balancing.
|
"""Add a server to a domain's backend pool for load balancing.
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ def register_server_tools(mcp):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def haproxy_wait_drain(
|
def haproxy_wait_drain(
|
||||||
domain: Annotated[str, Field(description="Domain name to wait for (e.g., api.example.com)")],
|
domain: Annotated[str, Field(description="Domain name to wait for (e.g., api.example.com)")],
|
||||||
timeout: Annotated[int, Field(default=30, description="Maximum seconds to wait (default: 30, max: 300)")]
|
timeout: Annotated[int, Field(default=30, description="Maximum seconds to wait (default: 30, max: 300)")] = 30
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Wait for all active connections to drain from a domain's servers.
|
"""Wait for all active connections to drain from a domain's servers.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user