diff --git a/CLAUDE.md b/CLAUDE.md index 5a4b220..2af82f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,31 @@ This will: **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` reference in `servers.json` + +**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 @@ -247,7 +272,7 @@ Returns backend server status for a specific domain: | Tool | Description | |------|-------------| | `haproxy_list_domains` | List domains (use `include_wildcards=true` for wildcards) | -| `haproxy_add_domain` | Add domain to available pool (no reload), supports IPv6 | +| `haproxy_add_domain` | Add domain to pool (no reload), supports `share_with` for pool sharing | | `haproxy_remove_domain` | Remove domain from pool (no reload) | ### Server Management diff --git a/conf/wildcards.map b/conf/wildcards.map index 6370793..30c3680 100644 --- a/conf/wildcards.map +++ b/conf/wildcards.map @@ -3,7 +3,8 @@ # Uses map_dom for suffix matching .actions.it.com pool_3 -.anvil.it.com pool_4 +.anvil.it.com pool_9 .bench.inouter.com pool_5 .mcp.inouter.com pool_2 .nas.inouter.com pool_1 +.ssh.inouter.com pool_11 diff --git a/haproxy_mcp/file_ops.py b/haproxy_mcp/file_ops.py index 13259c4..ba06697 100644 --- a/haproxy_mcp/file_ops.py +++ b/haproxy_mcp/file_ops.py @@ -332,6 +332,63 @@ def remove_domain_from_config(domain: str) -> None: save_servers_config(config) +def get_shared_domain(domain: str) -> Optional[str]: + """Get the parent domain that this domain shares a pool with. + + Args: + domain: Domain name to check + + Returns: + Parent domain name if sharing, None otherwise + """ + config = load_servers_config() + domain_config = config.get(domain, {}) + return domain_config.get("_shares") + + +def add_shared_domain_to_config(domain: str, shares_with: str) -> None: + """Add a domain that shares a pool with another domain. + + Args: + domain: New domain name + shares_with: Existing domain to share pool with + """ + with file_lock(f"{SERVERS_FILE}.lock"): + config = load_servers_config() + config[domain] = {"_shares": shares_with} + save_servers_config(config) + + +def get_domains_sharing_pool(pool: str) -> list[str]: + """Get all domains that use a specific pool. + + Args: + pool: Pool name (e.g., 'pool_5') + + Returns: + List of domain names using this pool + """ + domains = [] + for domain, backend in get_map_contents(): + if backend == pool and not domain.startswith("."): + domains.append(domain) + return domains + + +def is_shared_domain(domain: str) -> bool: + """Check if a domain is sharing another domain's pool. + + Args: + domain: Domain name to check + + Returns: + True if domain has _shares reference, False otherwise + """ + config = load_servers_config() + domain_config = config.get(domain, {}) + return "_shares" in domain_config + + # Certificate configuration functions def load_certs_config() -> list[str]: diff --git a/haproxy_mcp/tools/domains.py b/haproxy_mcp/tools/domains.py index 276a46a..5d5d71d 100644 --- a/haproxy_mcp/tools/domains.py +++ b/haproxy_mcp/tools/domains.py @@ -28,6 +28,9 @@ from ..file_ops import ( add_server_to_config, remove_server_from_config, remove_domain_from_config, + add_shared_domain_to_config, + get_domains_sharing_pool, + is_shared_domain, ) from ..utils import parse_servers_state, disable_server_slot @@ -204,13 +207,18 @@ def register_domain_tools(mcp): 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)")] + 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.")] ) -> str: """Add a new domain to HAProxy (no reload required). Creates domain→pool mapping. Use haproxy_add_server to add more servers later. + Pool sharing: Use share_with to reuse an existing domain's pool. This saves pool + slots when multiple domains point to the same backend servers. + Example: haproxy_add_domain("api.example.com", ip="10.0.0.1", http_port=8080) + Example: haproxy_add_domain("www.example.com", share_with="example.com") """ # Validate inputs if domain.startswith("."): @@ -221,6 +229,10 @@ def register_domain_tools(mcp): return "Error: Invalid IP address format" if not validate_port_int(http_port): return "Error: Port must be between 1 and 65535" + if share_with and not validate_domain(share_with): + return "Error: Invalid share_with domain format" + if share_with and ip: + return "Error: Cannot specify both ip and share_with (shared domains use existing servers)" # Use file locking for the entire pool allocation operation lock_path = f"{MAP_FILE}.lock" @@ -244,10 +256,19 @@ def register_domain_tools(mcp): if not entry_domain.startswith("."): registered_domains.add(entry_domain) - # Find available pool - pool = _find_available_pool(entries, used_pools) - if not pool: - return f"Error: All {POOL_COUNT} pool backends are in use" + # Handle share_with: reuse existing domain's pool + if share_with: + share_backend = get_domain_backend(share_with) + if not share_backend: + return f"Error: Domain {share_with} not found" + if not share_backend.startswith("pool_"): + return f"Error: Cannot share with legacy backend {share_backend}" + pool = share_backend + else: + # Find available pool + pool = _find_available_pool(entries, used_pools) + if not pool: + return f"Error: All {POOL_COUNT} pool backends are in use" # Check if this is a subdomain of an existing domain is_subdomain, parent_domain = _check_subdomain(domain, registered_domains) @@ -269,8 +290,13 @@ def register_domain_tools(mcp): _rollback_domain_addition(domain, entries) return f"Error: Failed to update HAProxy map: {e}" - # If IP provided, add server to slot 1 - if ip: + # Handle server configuration based on mode + if share_with: + # Save shared domain reference + add_shared_domain_to_config(domain, share_with) + result = f"Domain {domain} added, sharing pool {pool} with {share_with}" + elif ip: + # Add server to slot 1 add_server_to_config(domain, 1, ip, http_port) try: server = f"{pool}_1" @@ -318,6 +344,13 @@ def register_domain_tools(mcp): return f"Error: Cannot remove legacy domain {domain} (uses static backend {backend})" try: + # Check if this domain is sharing another domain's pool + domain_is_shared = is_shared_domain(domain) + + # Check if other domains are sharing this pool + domains_using_pool = get_domains_sharing_pool(backend) + other_domains = [d for d in domains_using_pool if d != domain] + # Save to disk first (atomic write for persistence) entries = get_map_contents() new_entries = [(d, b) for d, b in entries if d != domain and d != f".{domain}"] @@ -334,6 +367,14 @@ def register_domain_tools(mcp): except HaproxyError as e: logger.warning("Failed to remove wildcard entry for %s: %s", domain, e) + # Only clear servers if no other domains are using this pool + if other_domains: + return f"Domain {domain} removed from {backend} (pool still used by: {', '.join(other_domains)})" + + # If this domain was sharing another domain's pool, don't clear servers + if domain_is_shared: + return f"Domain {domain} removed from shared pool {backend}" + # Disable all servers in the pool (reset to 0.0.0.0:0) for slot in range(1, MAX_SLOTS + 1): server = f"{backend}_{slot}"