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:
kaffa
2026-02-07 00:22:39 +09:00
parent 4a411202d3
commit da533f407a
6 changed files with 53 additions and 14 deletions

View File

@@ -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"

View File

@@ -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.

View File

@@ -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).

View File

@@ -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):

View File

@@ -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):

View File

@@ -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.