From da533f407a7debf4c6ee2f37182867711be61301 Mon Sep 17 00:00:00 2001 From: kaffa Date: Sat, 7 Feb 2026 00:22:39 +0900 Subject: [PATCH] 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 --- conf/haproxy.cfg | 47 ++++++++++++++++++++++++++++--- haproxy_mcp/tools/certificates.py | 4 +-- haproxy_mcp/tools/domains.py | 8 +++--- haproxy_mcp/tools/health.py | 2 +- haproxy_mcp/tools/monitoring.py | 2 +- haproxy_mcp/tools/servers.py | 4 +-- 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/conf/haproxy.cfg b/conf/haproxy.cfg index 4ef4d00..69b4af1 100644 --- a/conf/haproxy.cfg +++ b/conf/haproxy.cfg @@ -31,6 +31,7 @@ global defaults log global mode http + option httplog option dontlognull option http-keep-alive option forwardfor @@ -53,10 +54,27 @@ frontend stats # HTTP Frontend - forward to backend (same as HTTPS) frontend http_front bind *:80 - # ACME challenge for certbot (unused - using DNS-01) - # acl is_acme path_beg /.well-known/acme-challenge/ - # use_backend acme_backend if is_acme - # http-request redirect scheme https unless is_acme + + # -- 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 -- # 2-stage map-based routing for performance: # 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 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) acl is_mcp hdr(host) -i mcp.inouter.com acl valid_token req.hdr(Authorization) -m str "Bearer dcb7963ab3ef705f6b780818f78942a100efa3b55e3d2f99c4560b65da64c426" diff --git a/haproxy_mcp/tools/certificates.py b/haproxy_mcp/tools/certificates.py index b827a5c..4ad170b 100644 --- a/haproxy_mcp/tools/certificates.py +++ b/haproxy_mcp/tools/certificates.py @@ -515,7 +515,7 @@ def register_certificate_tools(mcp): @mcp.tool() def haproxy_issue_cert( 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: """Issue a new SSL/TLS certificate using acme.sh with Cloudflare DNS. @@ -528,7 +528,7 @@ def register_certificate_tools(mcp): @mcp.tool() def haproxy_renew_cert( 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: """Renew an existing certificate. diff --git a/haproxy_mcp/tools/domains.py b/haproxy_mcp/tools/domains.py index 5d5d71d..c473b59 100644 --- a/haproxy_mcp/tools/domains.py +++ b/haproxy_mcp/tools/domains.py @@ -162,7 +162,7 @@ def register_domain_tools(mcp): @mcp.tool() 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: """List all configured domains with their backend servers.""" try: @@ -206,9 +206,9 @@ def register_domain_tools(mcp): @mcp.tool() def haproxy_add_domain( 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")], - http_port: Annotated[int, Field(default=80, description="HTTP port for backend server (default: 80)")], - share_with: Annotated[str, Field(default="", description="Optional: Existing domain to share pool with. New domain uses same backend servers.")] + 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)")] = 80, + share_with: Annotated[str, Field(default="", description="Optional: Existing domain to share pool with. New domain uses same backend servers.")] = "" ) -> str: """Add a new domain to HAProxy (no reload required). diff --git a/haproxy_mcp/tools/health.py b/haproxy_mcp/tools/health.py index 3f1cbe1..d904c47 100644 --- a/haproxy_mcp/tools/health.py +++ b/haproxy_mcp/tools/health.py @@ -188,7 +188,7 @@ def register_health_tools(mcp): @mcp.tool() 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: """Get health status of all servers (low-level view). For domain-specific, use haproxy_domain_health.""" if backend and not validate_backend_name(backend): diff --git a/haproxy_mcp/tools/monitoring.py b/haproxy_mcp/tools/monitoring.py index 2667e63..9a3c29c 100644 --- a/haproxy_mcp/tools/monitoring.py +++ b/haproxy_mcp/tools/monitoring.py @@ -75,7 +75,7 @@ def register_monitoring_tools(mcp): @mcp.tool() 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: """Get active connection counts per server for monitoring traffic distribution.""" if backend and not validate_backend_name(backend): diff --git a/haproxy_mcp/tools/servers.py b/haproxy_mcp/tools/servers.py index 1dabc45..3ee27a7 100644 --- a/haproxy_mcp/tools/servers.py +++ b/haproxy_mcp/tools/servers.py @@ -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)")], 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)")], - 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: """Add a server to a domain's backend pool for load balancing. @@ -509,7 +509,7 @@ def register_server_tools(mcp): @mcp.tool() def haproxy_wait_drain( 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: """Wait for all active connections to drain from a domain's servers.