feat: Add SSH remote execution for HAProxy on remote host
MCP server can now manage HAProxy running on a remote host via SSH. When SSH_HOST env var is set, all file I/O and subprocess commands (podman, acme.sh, openssl) are routed through SSH instead of local exec. - Add ssh_ops.py module with remote_exec, run_command, file I/O helpers - Modify file_ops.py to support remote reads/writes via SSH - Update all tools (domains, certificates, health, configuration) for SSH - Fix domains.py: replace direct fcntl usage with file_lock context manager - Add openssh-client to Docker image for SSH connectivity - Update k8s deployment with SSH env vars and SSH key secret mount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
"""Domain management tools for HAProxy MCP Server."""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import Field
|
||||
@@ -15,8 +13,10 @@ from ..config import (
|
||||
MAX_SLOTS,
|
||||
SUBPROCESS_TIMEOUT,
|
||||
CERTS_DIR,
|
||||
REMOTE_MODE,
|
||||
logger,
|
||||
)
|
||||
from ..ssh_ops import run_command, remote_file_exists
|
||||
from ..exceptions import HaproxyError
|
||||
from ..validation import validate_domain, validate_ip, validate_port_int
|
||||
from ..haproxy_client import haproxy_cmd
|
||||
@@ -115,6 +115,13 @@ def _rollback_domain_addition(
|
||||
logger.error("Failed to rollback map file after HAProxy error")
|
||||
|
||||
|
||||
def _file_exists(path: str) -> bool:
|
||||
"""Check file existence locally or remotely."""
|
||||
if REMOTE_MODE:
|
||||
return remote_file_exists(path)
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def check_certificate_coverage(domain: str) -> tuple[bool, str]:
|
||||
"""Check if a domain is covered by an existing certificate.
|
||||
|
||||
@@ -124,34 +131,35 @@ def check_certificate_coverage(domain: str) -> tuple[bool, str]:
|
||||
Returns:
|
||||
Tuple of (is_covered, certificate_name or message)
|
||||
"""
|
||||
if not os.path.isdir(CERTS_DIR):
|
||||
if REMOTE_MODE:
|
||||
dir_check = run_command(["test", "-d", CERTS_DIR])
|
||||
if dir_check.returncode != 0:
|
||||
return False, "Certificate directory not found"
|
||||
elif not os.path.isdir(CERTS_DIR):
|
||||
return False, "Certificate directory not found"
|
||||
|
||||
# Check for exact match first
|
||||
exact_pem = os.path.join(CERTS_DIR, f"{domain}.pem")
|
||||
if os.path.exists(exact_pem):
|
||||
exact_pem = f"{CERTS_DIR}/{domain}.pem"
|
||||
if _file_exists(exact_pem):
|
||||
return True, domain
|
||||
|
||||
# Check for wildcard coverage (e.g., api.example.com covered by *.example.com)
|
||||
parts = domain.split(".")
|
||||
if len(parts) >= 2:
|
||||
# Try parent domain (example.com for api.example.com)
|
||||
parent_domain = ".".join(parts[1:])
|
||||
parent_pem = os.path.join(CERTS_DIR, f"{parent_domain}.pem")
|
||||
parent_pem = f"{CERTS_DIR}/{parent_domain}.pem"
|
||||
|
||||
if os.path.exists(parent_pem):
|
||||
# Verify the certificate has wildcard SAN
|
||||
if _file_exists(parent_pem):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
result = run_command(
|
||||
["openssl", "x509", "-in", parent_pem, "-noout", "-ext", "subjectAltName"],
|
||||
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT
|
||||
timeout=SUBPROCESS_TIMEOUT,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Check if wildcard covers this domain
|
||||
wildcard = f"*.{parent_domain}"
|
||||
if wildcard in result.stdout:
|
||||
return True, f"{parent_domain} (wildcard)"
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
except (TimeoutError, OSError):
|
||||
pass
|
||||
|
||||
return False, "No matching certificate"
|
||||
@@ -235,96 +243,92 @@ def register_domain_tools(mcp):
|
||||
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"
|
||||
with open(lock_path, 'w') as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
from ..file_ops import file_lock
|
||||
with file_lock(f"{MAP_FILE}.lock"):
|
||||
# Read map contents once for both existence check and pool lookup
|
||||
entries = get_map_contents()
|
||||
|
||||
# Check if domain already exists (using cached entries)
|
||||
for domain_entry, backend in entries:
|
||||
if domain_entry == domain:
|
||||
return f"Error: Domain {domain} already exists (mapped to {backend})"
|
||||
|
||||
# Build used pools and registered domains sets
|
||||
used_pools: set[str] = set()
|
||||
registered_domains: set[str] = set()
|
||||
for entry_domain, backend in entries:
|
||||
if backend.startswith("pool_"):
|
||||
used_pools.add(backend)
|
||||
if not entry_domain.startswith("."):
|
||||
registered_domains.add(entry_domain)
|
||||
|
||||
# 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)
|
||||
|
||||
try:
|
||||
# Read map contents once for both existence check and pool lookup
|
||||
entries = get_map_contents()
|
||||
|
||||
# Check if domain already exists (using cached entries)
|
||||
for domain_entry, backend in entries:
|
||||
if domain_entry == domain:
|
||||
return f"Error: Domain {domain} already exists (mapped to {backend})"
|
||||
|
||||
# Build used pools and registered domains sets
|
||||
used_pools: set[str] = set()
|
||||
registered_domains: set[str] = set()
|
||||
for entry_domain, backend in entries:
|
||||
if backend.startswith("pool_"):
|
||||
used_pools.add(backend)
|
||||
if not entry_domain.startswith("."):
|
||||
registered_domains.add(entry_domain)
|
||||
|
||||
# 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)
|
||||
|
||||
# Save to disk first (atomic write for persistence)
|
||||
entries.append((domain, pool))
|
||||
if not is_subdomain:
|
||||
entries.append((f".{domain}", pool))
|
||||
try:
|
||||
# Save to disk first (atomic write for persistence)
|
||||
entries.append((domain, pool))
|
||||
if not is_subdomain:
|
||||
entries.append((f".{domain}", pool))
|
||||
try:
|
||||
save_map_file(entries)
|
||||
except IOError as e:
|
||||
return f"Error: Failed to save map file: {e}"
|
||||
|
||||
# Update HAProxy maps via Runtime API
|
||||
try:
|
||||
_update_haproxy_maps(domain, pool, is_subdomain)
|
||||
except HaproxyError as e:
|
||||
_rollback_domain_addition(domain, entries)
|
||||
return f"Error: Failed to update HAProxy map: {e}"
|
||||
|
||||
# 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"
|
||||
haproxy_cmd(f"set server {pool}/{server} addr {ip} port {http_port}")
|
||||
haproxy_cmd(f"set server {pool}/{server} state ready")
|
||||
except HaproxyError as e:
|
||||
remove_server_from_config(domain, 1)
|
||||
return f"Domain {domain} added to {pool} but server config failed: {e}"
|
||||
result = f"Domain {domain} added to {pool} with server {ip}:{http_port}"
|
||||
else:
|
||||
result = f"Domain {domain} added to {pool} (no servers configured)"
|
||||
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
|
||||
# Check certificate coverage
|
||||
cert_covered, cert_info = check_certificate_coverage(domain)
|
||||
if cert_covered:
|
||||
result += f"\nSSL: Using certificate {cert_info}"
|
||||
else:
|
||||
result += f"\nSSL: No certificate found. Use haproxy_issue_cert(\"{domain}\") to issue one."
|
||||
|
||||
return result
|
||||
save_map_file(entries)
|
||||
except IOError as e:
|
||||
return f"Error: Failed to save map file: {e}"
|
||||
|
||||
# Update HAProxy maps via Runtime API
|
||||
try:
|
||||
_update_haproxy_maps(domain, pool, is_subdomain)
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
_rollback_domain_addition(domain, entries)
|
||||
return f"Error: Failed to update HAProxy map: {e}"
|
||||
|
||||
# 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"
|
||||
haproxy_cmd(f"set server {pool}/{server} addr {ip} port {http_port}")
|
||||
haproxy_cmd(f"set server {pool}/{server} state ready")
|
||||
except HaproxyError as e:
|
||||
remove_server_from_config(domain, 1)
|
||||
return f"Domain {domain} added to {pool} but server config failed: {e}"
|
||||
result = f"Domain {domain} added to {pool} with server {ip}:{http_port}"
|
||||
else:
|
||||
result = f"Domain {domain} added to {pool} (no servers configured)"
|
||||
|
||||
if is_subdomain:
|
||||
result += f" (subdomain of {parent_domain}, no wildcard)"
|
||||
|
||||
# Check certificate coverage
|
||||
cert_covered, cert_info = check_certificate_coverage(domain)
|
||||
if cert_covered:
|
||||
result += f"\nSSL: Using certificate {cert_info}"
|
||||
else:
|
||||
result += f"\nSSL: No certificate found. Use haproxy_issue_cert(\"{domain}\") to issue one."
|
||||
|
||||
return result
|
||||
|
||||
except HaproxyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
def haproxy_remove_domain(
|
||||
|
||||
Reference in New Issue
Block a user