# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview HAProxy Dynamic Load Balancer Management Suite - a system for runtime management of HAProxy without restarts. Provides MCP interface for dynamic domain/server management. Frontend supports HTTP/HTTPS/HTTP3, backends use HTTP only (SSL termination at HAProxy). **Key Feature:** Zero-reload domain management using HAProxy map files and pool backends. ## Architecture ``` ┌─ K8s Pod (MCP Server) ──────────┐ SSH/SCP ┌─ HAProxy Host ──────────────────┐ │ │ ───────────→ │ │ │ /app/data/haproxy_mcp.db │ Runtime API │ HAProxy (host network) │ │ (SQLite - local working copy) │ ───────────→ │ TCP 80/443 + UDP 443 (HTTP/3) │ │ │ :9999 │ Runtime API: localhost:9999 │ │ MCP Server :8000 │ │ │ │ Transport: streamable-http │ SCP sync │ /opt/haproxy/conf/ │ │ │ ←──────────→ │ haproxy_mcp.db (persistent) │ │ │ map sync │ domains.map │ │ │ ───────────→ │ wildcards.map │ └──────────────────────────────────┘ └─────────────────────────────────┘ ``` ### Data Flow - **SQLite DB** (`haproxy_mcp.db`): Single source of truth for domains, servers, certificates - **Map files** (`domains.map`, `wildcards.map`): Generated from SQLite, HAProxy reads directly - **Runtime API**: Real-time HAProxy configuration (map entries, server addresses) ### Domain Routing Flow ``` Request → HAProxy Frontend ↓ domains.map lookup (Host header) ↓ pool_N backend (N = 1-100) ↓ Server (from SQLite DB, auto-restored on startup) ``` ## Services ### HAProxy (Podman Quadlet) ```bash systemctl start haproxy # Start systemctl stop haproxy # Stop systemctl restart haproxy # Restart systemctl status haproxy # Status ``` - Quadlet config: `/etc/containers/systemd/haproxy.container` - Network mode: `host` (direct access to host services) ### MCP Server (systemd) ```bash systemctl start haproxy-mcp # Start systemctl stop haproxy-mcp # Stop systemctl restart haproxy-mcp # Restart journalctl -u haproxy-mcp -f # Logs ``` - Transport: `streamable-http` - Internal: `http://localhost:8000/mcp` - External: `https://mcp.inouter.com/mcp` (via HAProxy) - **Auto-restore:** Servers restored from SQLite DB on startup ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `MCP_HOST` | `0.0.0.0` | MCP server bind host | | `MCP_PORT` | `8000` | MCP server port | | `HAPROXY_HOST` | `localhost` | HAProxy Runtime API host | | `HAPROXY_PORT` | `9999` | HAProxy Runtime API port | | `HAPROXY_STATE_FILE` | `/opt/haproxy/data/servers.state` | State file path | | `HAPROXY_MAP_FILE` | `/opt/haproxy/conf/domains.map` | Map file path | | `HAPROXY_DB_FILE` | `/opt/haproxy/conf/haproxy_mcp.db` | Local SQLite DB path | | `HAPROXY_REMOTE_DB_FILE` | `/opt/haproxy/conf/haproxy_mcp.db` | Remote SQLite DB path (SCP target) | | `HAPROXY_SERVERS_FILE` | `/opt/haproxy/conf/servers.json` | Legacy servers config (migration only) | | `HAPROXY_CERTS_FILE` | `/opt/haproxy/conf/certificates.json` | Legacy certs config (migration only) | | `HAPROXY_CUSTOM_TLDS` | `it.com` | Custom multi-part TLDs (comma-separated) for subdomain detection | | `HAPROXY_POOL_COUNT` | `100` | Number of pool backends | | `HAPROXY_MAX_SLOTS` | `10` | Max servers per pool | | `HAPROXY_CONTAINER` | `haproxy` | Podman container name | | `LOG_LEVEL` | `INFO` | Logging level (DEBUG/INFO/WARNING/ERROR) | ## Zero-Reload Domain Management ### How It Works 1. **SQLite DB** (`haproxy_mcp.db`): Single source of truth for all persistent data 2. **domains.map**: Generated from SQLite, maps domain → pool backend 3. **Pool backends**: 100 pre-configured backends (`pool_1` to `pool_100`) 4. **Runtime API**: Add/remove map entries and configure servers without reload ### Adding a Domain ```bash haproxy_add_domain("example.com", "10.0.0.1") # Creates: pool_N_1 → 10.0.0.1:80 haproxy_add_domain("api.example.com", "10.0.0.1", 8080) # Creates: pool_N_1 → 10.0.0.1:8080 ``` This will: 1. Find available pool (e.g., `pool_5`) via SQLite query 2. Save to SQLite DB (domain, backend, server) 3. Sync `domains.map` from DB (for HAProxy file-based lookup) 4. Update HAProxy runtime map: `add map ... example.com pool_5` 5. Configure server in pool via Runtime API (HTTP only) 6. Upload DB to remote host via SCP (persistence) **No HAProxy reload required!** ### Pool Sharing Multiple domains can share the same pool/backend servers using `share_with`: ```bash # First domain gets its own pool haproxy_add_domain("example.com", "10.0.0.1") # Creates: example.com → pool_5 # Additional domains share the same pool haproxy_add_domain("www.example.com", share_with="example.com") haproxy_add_domain("api.example.com", share_with="example.com") # Both use: pool_5 (same backend servers) ``` **Benefits:** - Saves pool slots (100 pools can serve unlimited domains) - Multiple domains → same backend servers - Shared domains stored as `shares_with` in SQLite domains table **Behavior:** - Shared domains cannot specify `ip` (use existing servers) - Removing a shared domain keeps servers intact - Removing the last domain using a pool clears the servers ### Backend Configuration - Backends always use HTTP (port 80 or custom) - SSL/TLS termination happens at HAProxy frontend - Each pool has 10 server slots (pool_N_1 to pool_N_10) - **IPv6 supported**: Both IPv4 and IPv6 addresses accepted ### Bulk Server Operations ```bash # Add multiple servers at once haproxy_add_servers("api.example.com", '[ {"slot": 1, "ip": "10.0.0.1", "http_port": 80}, {"slot": 2, "ip": "10.0.0.2", "http_port": 80}, {"slot": 3, "ip": "2001:db8::1", "http_port": 80} ]') ``` ### Files | File | Purpose | |------|---------| | `haproxy_mcp.db` | SQLite DB — single source of truth (domains, servers, certs) | | `domains.map` | Domain → Pool mapping (generated from DB, HAProxy reads directly) | | `wildcards.map` | Wildcard domain mapping (generated from DB) | | `haproxy.cfg` | Pool backend definitions (static) | | `servers.json` | Legacy — used only for initial migration to SQLite | | `certificates.json` | Legacy — used only for initial migration to SQLite | ## SSL/TLS Certificates ### Current Setup | Item | Value | |------|-------| | ACME Client | acme.sh (Google Trust Services CA) | | Cert Directory | `/opt/haproxy/certs/` (auto-loaded by HAProxy) | | acme.sh Home | `~/.acme.sh/` | ### How It Works 1. HAProxy binds with `crt /etc/haproxy/certs/` (directory, not file) 2. All `.pem` files in directory are auto-loaded 3. SNI selects correct certificate based on domain 4. acme.sh deploy hook auto-creates combined PEM on issue/renewal ### Adding New Certificate ```bash # Issue certificate (Google Trust Services) ~/.acme.sh/acme.sh --issue \ --dns dns_cf \ -d "newdomain.com" -d "*.newdomain.com" \ --reloadcmd "cat ~/.acme.sh/newdomain.com_ecc/fullchain.cer ~/.acme.sh/newdomain.com_ecc/newdomain.com.key > /opt/haproxy/certs/newdomain.com.pem && podman exec haproxy kill -USR2 1" ``` ### Certificate Commands ```bash # List certificates ~/.acme.sh/acme.sh --list # Renew all ~/.acme.sh/acme.sh --cron # Force renew specific domain ~/.acme.sh/acme.sh --renew -d "domain.com" --force # Check loaded certs in HAProxy ls -la /opt/haproxy/certs/ ``` ### Cloudflare Credentials - File: `~/.secrets/cloudflare.ini` - Format: `dns_cloudflare_api_token = TOKEN` - Also export: `export CF_Token="your_token"` ## HTTP/3 (QUIC) Frontend supports all protocols: ``` TCP 443: HTTP/1.1, HTTP/2 UDP 443: HTTP/3 (QUIC) ``` Clients receive `alt-svc: h3=":443"; ma=86400` header for HTTP/3 upgrade. ## MCP Security HAProxy proxies MCP with Bearer Token authentication (configured in frontend ACL). ### Access Rules | Source | Token Required | |--------|----------------| | Tailscale (100.64.0.0/10) | No | | External (CF Worker, etc.) | Yes | ### Token Location - File: `/opt/haproxy/conf/mcp-token.env` ### Connection Examples **Tailscale (no auth):** ```bash claude mcp add --transport http haproxy http://100.108.39.107:8000/mcp ``` **External (with auth):** ```bash curl -X POST https://mcp.inouter.com/mcp \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":0}' ``` ## Health Check ### System Health (`haproxy_health`) Returns overall system status: ```json { "status": "healthy", "components": { "mcp": {"status": "ok"}, "haproxy": {"status": "ok", "version": "3.3.2", "uptime_sec": 3600}, "config_files": {"status": "ok", "files": {"map_file": "ok", "db_file": "ok"}}, "container": {"status": "ok", "state": "running"} } } ``` ### Domain Health (`haproxy_domain_health`) Returns backend server status for a specific domain: ```json { "domain": "api.example.com", "backend": "pool_3", "status": "healthy", "servers": [ {"name": "pool_3_1", "addr": "10.0.0.1:80", "status": "UP", "check_status": "L4OK"} ], "healthy_count": 1, "total_count": 1 } ``` **Status values:** `healthy` (all UP), `degraded` (partial UP), `down` (all DOWN), `no_servers` ## MCP Tools (30 total) ### Domain Management | Tool | Description | |------|-------------| | `haproxy_list_domains` | List domains (use `include_wildcards=true` for wildcards) | | `haproxy_add_domain` | Add domain to pool (no reload), supports `share_with` for pool sharing | | `haproxy_remove_domain` | Remove domain from pool (no reload) | | `haproxy_cleanup_wildcards` | Remove unnecessary wildcard entries for subdomain domains | ### Server Management | Tool | Description | |------|-------------| | `haproxy_list_servers` | List servers for a domain | | `haproxy_add_server` | Add server to slot (1-10 or 0=auto), auto-saved | | `haproxy_add_servers` | Bulk add servers (JSON array), auto-saved | | `haproxy_remove_server` | Remove server from slot, auto-saved | | `haproxy_set_server_state` | Set state: ready/drain/maint | | `haproxy_set_server_weight` | Set weight (0-256) | | `haproxy_set_domain_state` | Set all servers of domain to ready/drain/maint | | `haproxy_wait_drain` | Wait for connections to drain (timeout configurable) | ### Health Check | Tool | Description | |------|-------------| | `haproxy_health` | System health (MCP, HAProxy, config files) | | `haproxy_domain_health` | Domain-specific health with server status | | `haproxy_get_server_health` | Get UP/DOWN/MAINT status for all servers | ### Monitoring | Tool | Description | |------|-------------| | `haproxy_stats` | HAProxy statistics | | `haproxy_backends` | List backends | | `haproxy_list_frontends` | List frontends with status | | `haproxy_get_connections` | Active connections per server | ### Configuration | Tool | Description | |------|-------------| | `haproxy_reload` | Validate, reload config, auto-restore servers | | `haproxy_check_config` | Validate config syntax | | `haproxy_save_state` | Save server state to disk (legacy) | | `haproxy_restore_state` | Restore state from disk (legacy) | ### Certificate Management (Zero-Downtime via Runtime API) | Tool | Description | |------|-------------| | `haproxy_list_certs` | List all certificates with expiry info | | `haproxy_cert_info` | Get detailed certificate info (expiry, issuer, SANs) | | `haproxy_issue_cert` | Issue new certificate via acme.sh + Cloudflare DNS | | `haproxy_renew_cert` | Renew specific certificate (force option available) | | `haproxy_renew_all_certs` | Renew all certificates due for renewal | | `haproxy_delete_cert` | Delete certificate from acme.sh and HAProxy | | `haproxy_load_cert` | Load/reload certificate into HAProxy (manual trigger) | ## Key Conventions ### Pool-Based Routing - Domains map to pools: `example.com` → `pool_N` - 100 pools available: `pool_1` to `pool_100` - Each pool has 10 server slots ### Server Naming Pool backends use simple naming: `pool_N_1` to `pool_N_10` **Examples:** ``` example.com → pool_5 └─ pool_5_1 (slot 1) └─ pool_5_2 (slot 2) └─ ... up to pool_5_10 api.example.com → pool_6 └─ pool_6_1 (slot 1) ``` ### Persistence (SQLite) - **SQLite DB** (`haproxy_mcp.db`): All domains, servers, certificates stored in single file - **Remote sync**: DB uploaded to HAProxy host via SCP after every write - **Startup restore**: DB downloaded from remote on pod start, servers restored via Runtime API - **Migration**: First startup auto-migrates from legacy JSON/map files (one-time, idempotent) - **WAL mode**: Write-Ahead Logging for concurrent access, checkpointed before SCP upload ### Safety Features - **Atomic file writes**: Temp file + rename prevents corruption (map files) - **SQLite transactions**: All DB writes are atomic - **DB-first pattern**: Config saved to SQLite before HAProxy update, rollback on failure - **Command validation**: HAProxy responses checked for errors - **Input validation**: Domain format, IP (v4/v6), port range, slot limits - **Bulk limits**: Max 10 servers per bulk add, 10KB JSON size limit ### Performance Optimization - **Command batching**: Multiple HAProxy commands sent in single TCP connection - Server config (addr + state): 1 connection instead of 2 - Startup restore: All servers restored in 1 connection (was 2×N for N servers) - Example: 7 servers restored = 1 connection (was 14 connections) ### Zero-Downtime Certificate Management - **Runtime API**: Certificates loaded/updated without HAProxy reload - `new ssl cert` → `set ssl cert` → `commit ssl cert` - No connection drops during certificate changes - **Persistence**: Certificate domains stored in SQLite `certificates` table - **Auto-restore**: Certificates reloaded into HAProxy on MCP startup ## HAProxy Runtime API ```bash # Show domain mappings echo "show map /usr/local/etc/haproxy/domains.map" | nc localhost 9999 # Add domain mapping (runtime only) echo "add map /usr/local/etc/haproxy/domains.map example.com pool_5" | nc localhost 9999 # Show server state echo "show servers state" | nc localhost 9999 # Show backends echo "show backend" | nc localhost 9999 # Set server address echo "set server pool_1/pool_1_1 addr 10.0.0.1 port 80" | nc localhost 9999 # Enable server echo "set server pool_1/pool_1_1 state ready" | nc localhost 9999 ``` ## Directory Structure ``` /opt/haproxy/ ├── haproxy_mcp/ # MCP server package (streamable-http) │ ├── server.py # Main entry point │ ├── config.py # Configuration and constants │ ├── db.py # SQLite database (single source of truth) │ ├── ssh_ops.py # SSH/SCP remote execution │ ├── exceptions.py # Exception classes │ ├── validation.py # Input validation │ ├── haproxy_client.py # HAProxy Runtime API client │ ├── file_ops.py # File I/O (delegates to db.py) │ ├── utils.py # Parsing utilities │ └── tools/ # MCP tools (29 total) │ ├── domains.py # Domain management (3 tools) │ ├── servers.py # Server management (7 tools) │ ├── health.py # Health checks (3 tools) │ ├── monitoring.py # Monitoring (4 tools) │ ├── configuration.py # Config management (4 tools) │ └── certificates.py # Certificate management (7 tools) ├── conf/ │ ├── haproxy.cfg # Main HAProxy config (100 pool backends) │ ├── haproxy_mcp.db # SQLite DB (single source of truth) │ ├── domains.map # Domain → Pool mapping (generated from DB) │ ├── wildcards.map # Wildcard domain mapping (generated from DB) │ ├── servers.json # Legacy (migration source only) │ ├── certificates.json # Legacy (migration source only) │ └── mcp-token.env # Bearer token for MCP auth ├── certs/ # SSL/TLS certificates (HAProxy PEM format) ├── data/ # Legacy state files ├── scripts/ # Utility scripts └── run/ # Runtime socket files ~/.acme.sh/ ├── *_ecc/ # Certificate directories (one per domain) └── acme.sh # ACME client script /etc/containers/systemd/ └── haproxy.container # Podman Quadlet config ``` ## Configuration Files | File | Purpose | |------|---------| | `/opt/haproxy/conf/haproxy.cfg` | HAProxy main config | | `/opt/haproxy/conf/haproxy_mcp.db` | SQLite DB (single source of truth) | | `/opt/haproxy/conf/domains.map` | Domain routing map (generated from DB) | | `/opt/haproxy/conf/wildcards.map` | Wildcard domain map (generated from DB) | | `/opt/haproxy/certs/*.pem` | SSL certificates (auto-loaded) | | `/etc/containers/systemd/haproxy.container` | Podman Quadlet | | `/etc/systemd/system/haproxy-mcp.service` | MCP service | | `/opt/haproxy/conf/mcp-token.env` | MCP auth token | | `~/.acme.sh/` | acme.sh certificates and config | | `~/.secrets/cloudflare.ini` | Cloudflare API token | ## Startup Sequence ``` 1. systemd starts haproxy.service ↓ 2. HAProxy loads haproxy.cfg (100 pool backends) ↓ 3. HAProxy loads domains.map (domain → pool mapping) ↓ 4. systemd starts haproxy-mcp.service (or K8s pod starts) ↓ 5. init_db(): Download SQLite DB from remote host via SCP (first time: migrate from legacy JSON/map files → create DB → upload) ↓ 6. MCP server restores servers from DB via Runtime API ↓ 7. MCP server loads certificates from DB via Runtime API (zero-downtime) ↓ 8. Ready to serve traffic ``` ### Write Flow (runtime) ``` MCP tool call (add domain/server/cert) ↓ Write to local SQLite DB ↓ Sync map files to remote (domains only) ↓ Update HAProxy via Runtime API ↓ Upload DB to remote host via SCP (persistence) ```