#!/usr/bin/env python3 import json import os import subprocess import sys import time import requests HAPROXY_API_BASE = "http://:admin@127.0.0.1:5555/v3" CERT_DIR = "/home/fourlights/.acme.sh" ACME_SCRIPT = "/usr/local/bin/acme.sh" class PodmanHAProxyACMESync: def __init__(self): self.ssl_services = set() self.session = requests.Session() self.session.headers.update({"Content-Type": "application/json"}) def get_next_index(self, path): response = self.session.get( f"{HAPROXY_API_BASE}/services/haproxy/configuration/{path}" ) return len(response.json()) if response.status_code == 200 else None def get_dataplaneapi_version(self): response = self.session.get( f"{HAPROXY_API_BASE}/services/haproxy/configuration/version" ) return response.json() if response.status_code == 200 else None def get_container_labels(self, container_id): try: result = subprocess.run( ["podman", "inspect", container_id], capture_output=True, text=True ) if result.returncode == 0: data = json.loads(result.stdout) return data[0]["Config"]["Labels"] or {} except Exception as e: print(f"Error getting labels for {container_id}: {e}") return {} # ===== PHASE 1: Helper Methods (Check/Query Operations) ===== def check_acl_exists(self, frontend, acl_name, criterion, value): """ Check if an ACL exists in the specified frontend by matching name, criterion, and value. Returns: (exists: bool, index: int | None, current_config: dict | None) """ try: response = self.session.get( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls" ) if response.status_code == 200: acls = response.json() for idx, acl in enumerate(acls): if (acl.get("acl_name") == acl_name and acl.get("criterion") == criterion and acl.get("value") == value): return (True, idx, acl) return (False, None, None) except Exception as e: print(f"[UPSERT-ERROR] Error checking ACL existence in frontend {frontend}: {e}") return (False, None, None) def check_backend_rule_exists(self, frontend, rule_name, cond_test): """ Check if a backend switching rule exists in the specified frontend by matching name and condition test. Returns: (exists: bool, index: int | None, current_config: dict | None) """ try: response = self.session.get( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules" ) if response.status_code == 200: rules = response.json() for idx, rule in enumerate(rules): if (rule.get("name") == rule_name and rule.get("cond_test") == cond_test): return (True, idx, rule) return (False, None, None) except Exception as e: print(f"[UPSERT-ERROR] Error checking backend rule existence in frontend {frontend}: {e}") return (False, None, None) def check_http_request_rule_exists(self, frontend, cond_test, redirect_type, redirect_value): """ Check if an http_request_rule (redirect rule) exists by matching condition test and redirect parameters. Returns: (exists: bool, index: int | None, current_config: dict | None) """ try: response = self.session.get( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules" ) if response.status_code == 200: rules = response.json() for idx, rule in enumerate(rules): if (rule.get("cond_test") == cond_test and rule.get("type") == "redirect"): redirect_cfg = rule.get("redirect_rule", {}) if (redirect_cfg.get("type") == redirect_type and redirect_cfg.get("value") == redirect_value): return (True, idx, rule) return (False, None, None) except Exception as e: print(f"[UPSERT-ERROR] Error checking http_request_rule existence in frontend {frontend}: {e}") return (False, None, None) # ===== PHASE 2: Upsert Operations ===== def upsert_acl(self, frontend, acl_name, criterion, value): """ Create or update an ACL in the specified frontend. Returns: (success: bool, operation_type: str) - operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR" """ try: exists, idx, current_config = self.check_acl_exists(frontend, acl_name, criterion, value) if exists and current_config: # ACL exists with same config - skip print(f"[UPSERT-SKIP] ACL {acl_name} already exists in frontend {frontend}, skipping") return (True, "SKIP") elif exists and idx is not None: # ACL exists but with different config - delete and recreate try: self.session.delete( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls/{idx}?version={self.get_dataplaneapi_version()}" ) except Exception as e: print(f"[UPSERT-ERROR] Failed to delete old ACL {acl_name}: {e}") return (False, "ERROR") # Create new ACL acl_data = { "acl_name": acl_name, "criterion": criterion, "value": value, } response = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls/{self.get_next_index(f'frontends/{frontend}/acls')}?version={self.get_dataplaneapi_version()}", json=acl_data, ) if response.status_code in [200, 201]: op_type = "UPDATE" if (exists and idx is not None) else "CREATE" print(f"[UPSERT-{op_type}] ACL {acl_name} {op_type.lower()}d in frontend {frontend}") return (True, op_type) else: print(f"[UPSERT-ERROR] Failed to create ACL {acl_name}: {response.status_code} - {response.text}") return (False, "ERROR") except Exception as e: print(f"[UPSERT-ERROR] Exception upserting ACL {acl_name}: {e}") return (False, "ERROR") def upsert_backend_rule(self, frontend, rule_name, cond_test): """ Create or update a backend switching rule in the specified frontend. Returns: (success: bool, operation_type: str) - operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR" """ try: exists, idx, current_config = self.check_backend_rule_exists(frontend, rule_name, cond_test) if exists and current_config: # Rule exists with same config - skip print(f"[UPSERT-SKIP] Backend rule {rule_name} already exists in frontend {frontend}, skipping") return (True, "SKIP") elif exists and idx is not None: # Rule exists but with different config - delete and recreate try: self.session.delete( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules/{idx}?version={self.get_dataplaneapi_version()}" ) except Exception as e: print(f"[UPSERT-ERROR] Failed to delete old backend rule {rule_name}: {e}") return (False, "ERROR") # Create new backend rule rule_data = { "name": rule_name, "cond": "if", "cond_test": cond_test, } response = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules/{self.get_next_index(f'frontends/{frontend}/backend_switching_rules')}?version={self.get_dataplaneapi_version()}", json=rule_data, ) if response.status_code in [200, 201]: op_type = "UPDATE" if (exists and idx is not None) else "CREATE" print(f"[UPSERT-{op_type}] Backend rule {rule_name} {op_type.lower()}d in frontend {frontend}") return (True, op_type) else: print(f"[UPSERT-ERROR] Failed to create backend rule {rule_name}: {response.status_code} - {response.text}") return (False, "ERROR") except Exception as e: print(f"[UPSERT-ERROR] Exception upserting backend rule {rule_name}: {e}") return (False, "ERROR") def upsert_http_request_rule(self, frontend, cond_test, redirect_rule_data): """ Create or update an http_request_rule (redirect rule) in the specified frontend. redirect_rule_data should contain: {"type": "redirect", "redirect_rule": {...}, "cond": "if", "cond_test": ...} Returns: (success: bool, operation_type: str) - operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR" """ try: redirect_type = redirect_rule_data.get("redirect_rule", {}).get("type") redirect_value = redirect_rule_data.get("redirect_rule", {}).get("value") exists, idx, current_config = self.check_http_request_rule_exists( frontend, cond_test, redirect_type, redirect_value ) if exists and current_config: # Rule exists with same config - skip print(f"[UPSERT-SKIP] Redirect rule for {cond_test} already exists in frontend {frontend}, skipping") return (True, "SKIP") elif exists and idx is not None: # Rule exists but with different config - delete and recreate try: self.session.delete( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules/{idx}?version={self.get_dataplaneapi_version()}" ) except Exception as e: print(f"[UPSERT-ERROR] Failed to delete old redirect rule for {cond_test}: {e}") return (False, "ERROR") # Create new http_request_rule response = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules/{self.get_next_index(f'frontends/{frontend}/http_request_rules')}?version={self.get_dataplaneapi_version()}", json=redirect_rule_data, ) if response.status_code in [200, 201]: op_type = "UPDATE" if (exists and idx is not None) else "CREATE" print(f"[UPSERT-{op_type}] Redirect rule for {cond_test} {op_type.lower()}d in frontend {frontend}") return (True, op_type) else: print(f"[UPSERT-ERROR] Failed to create redirect rule for {cond_test}: {response.status_code} - {response.text}") return (False, "ERROR") except Exception as e: print(f"[UPSERT-ERROR] Exception upserting redirect rule for {cond_test}: {e}") return (False, "ERROR") def request_certificate(self, domain): print(f"[CERT-REQUEST] About to request certificate for {domain}") sys.stdout.flush() try: cmd = [ ACME_SCRIPT, "--issue", "-d", domain, "--standalone", "--httpport", "8888", "--server", "letsencrypt", "--listen-v4", "--debug", "2", ] # Log the command being executed print(f"[CERT-REQUEST] Executing: {' '.join(cmd)}") sys.stdout.flush() result = subprocess.run(cmd, capture_output=True, text=True) # Log both stdout and stderr for complete debugging if result.stdout: print(f"[CERT-STDOUT] {result.stdout}") sys.stdout.flush() if result.stderr: print(f"[CERT-STDERR] {result.stderr}") sys.stderr.flush() if result.returncode == 0: print(f"[CERT-SUCCESS] Certificate obtained for {domain}") sys.stdout.flush() self.install_certificate(domain) return True else: print(f"[CERT-FAILED] Failed to obtain certificate for {domain}") print(f"[CERT-FAILED] Return code: {result.returncode}") sys.stdout.flush() return False except Exception as e: print(f"[CERT-ERROR] Error requesting certificate: {e}") sys.stdout.flush() return False def install_certificate(self, domain): cert_file = f"{CERT_DIR}/{domain}.pem" try: acme_cert_dir = f"/home/fourlights/.acme.sh/{domain}_ecc" with open(cert_file, "w") as outfile: with open(f"{acme_cert_dir}/fullchain.cer") as cert: outfile.write(cert.read()) with open(f"{acme_cert_dir}/{domain}.key") as key: outfile.write(key.read()) try: with open(f"{acme_cert_dir}/ca.cer") as ca: outfile.write(ca.read()) except FileNotFoundError: pass os.chmod(cert_file, 0o600) print(f"Certificate installed at {cert_file}") self.update_haproxy_ssl_bind(domain) except Exception as e: print(f"Error installing certificate for {domain}: {e}") def update_haproxy_ssl_bind(self, domain): print(f"Updating ssl bind for {domain}") try: ssl_bind_data = { "address": "*", "port": 443, "ssl": True, "ssl_certificate": f"{CERT_DIR}/{domain}.pem", } response = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/binds?version={self.get_dataplaneapi_version()}", json=ssl_bind_data, ) print(response.json()) if response.status_code in [200, 201]: print(f"Updated HAProxy SSL bind for {domain}") except Exception as e: print(f"Error updating HAProxy SSL bind: {e}") def setup_certificate_renewal(self, domain): renewal_script = f"/etc/cron.d/acme-{domain.replace('.', '-')}" cron_content = f"""0 0 * * * root {ACME_SCRIPT} --renew -d {domain} --post-hook "systemctl reload haproxy" >/dev/null 2>&1 """ with open(renewal_script, "w") as f: f.write(cron_content) print(f"Setup automatic renewal for {domain}") def update_haproxy_backend(self, service_name, host, port, action="add"): backend_name = f"backend_{service_name}" server_name = f"{service_name}_server" if action == "add": backend_data = { "name": backend_name, "mode": "http", "balance": {"algorithm": "roundrobin"}, } backends = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends?version={self.get_dataplaneapi_version()}", json=backend_data, ) print(backends.json()) server_data = { "name": server_name, "address": host, "port": int(port), "check": "enabled", } tweak = self.session.post( f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers?version={self.get_dataplaneapi_version()}", json=server_data, ) print(tweak.json()) elif action == "remove": self.session.delete( f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers/{server_name}?version={self.get_dataplaneapi_version()}" ) def update_haproxy_frontend_rule( self, service_name, domain, ssl_enabled=False, action="add" ): """ Update HAProxy frontend rules (ACLs, backend switching rules, and redirect rules). Uses upsert behavior to prevent duplicate rules on multiple syncs. """ if action == "add": if ssl_enabled and domain and domain not in self.ssl_services: print(f"Setting up SSL for {domain}") if self.request_certificate(domain): self.setup_certificate_renewal(domain) self.ssl_services.add(domain) # Upsert ACL in HTTP frontend (main) self.upsert_acl( "main", f"is_{service_name}", "hdr(host)", domain ) # Upsert ACL in HTTPS frontend if SSL enabled if ssl_enabled: self.upsert_acl( "https_main", f"is_{service_name}", "hdr(host)", domain ) # Upsert backend switching rule in HTTP frontend self.upsert_backend_rule( "main", f"backend_{service_name}", f"is_{service_name}" ) # Upsert backend switching rule in HTTPS frontend if SSL enabled if ssl_enabled: self.upsert_backend_rule( "https_main", f"backend_{service_name}", f"is_{service_name}" ) # Upsert redirect rule (HTTP to HTTPS) redirect_rule = { "type": "redirect", "redirect_rule": {"type": "scheme", "value": "https", "code": 301}, "cond": "if", "cond_test": f"is_{service_name}", } self.upsert_http_request_rule( "main", f"is_{service_name}", redirect_rule ) def process_container_event(self, event): # DIAGNOSTIC: Log raw event structure print( f"[EVENT-DEBUG] Received event - Type: {event.get('Type', 'MISSING')}, Action: {event.get('Action', 'MISSING')}" ) sys.stdout.flush() # DIAGNOSTIC: Check for Actor key if "Actor" not in event: print( f"[EVENT-SKIP] Skipping event without 'Actor' key - Full event: {json.dumps(event)}" ) sys.stdout.flush() return # DIAGNOSTIC: Check for ID in Actor if "ID" not in event["Actor"]: print( f"[EVENT-SKIP] Skipping event without 'Actor.ID' - Actor content: {json.dumps(event['Actor'])}" ) sys.stdout.flush() return container_id = event["Actor"]["ID"][:12] action = event["Action"] print( f"[EVENT-PROCESS] Processing '{action}' event for container {container_id}" ) sys.stdout.flush() labels = self.get_container_labels(container_id) # Dictionary to store discovered services services = {} # First, check for namespaced labels (haproxy.{service_name}.enable) for label_key, label_value in labels.items(): if ( label_key.startswith("haproxy.") and label_key.endswith(".enable") and label_value.lower() == "true" ): # Extract service name from label key parts = label_key.split(".") if len(parts) == 3: # haproxy.{service_name}.enable service_name = parts[1] # Extract properties for this service namespace service_config = { "service_name": service_name, "host": labels.get(f"haproxy.{service_name}.host", "127.0.0.1"), "port": labels.get(f"haproxy.{service_name}.port", "8080"), "domain": labels.get(f"haproxy.{service_name}.domain", None), "ssl_enabled": labels.get( f"haproxy.{service_name}.tls", "false" ).lower() == "true", } services[service_name] = service_config # Backward compatibility: If no namespaced labels found, check for flat labels if ( not services and "haproxy.enable" in labels and labels["haproxy.enable"].lower() == "true" ): service_name = labels.get("haproxy.service", container_id) services[service_name] = { "service_name": service_name, "host": labels.get("haproxy.host", "127.0.0.1"), "port": labels.get("haproxy.port", "8080"), "domain": labels.get("haproxy.domain", None), "ssl_enabled": labels.get("haproxy.tls", "false").lower() == "true", } # Process each discovered service for service_name, config in services.items(): if action in ["start", "restart"]: print( f"Adding service {config['service_name']} to HAProxy (SSL: {config['ssl_enabled']}, Domain: {config['domain']})" ) sys.stdout.flush() self.update_haproxy_backend( config["service_name"], config["host"], config["port"], "add" ) if config["domain"]: self.update_haproxy_frontend_rule( config["service_name"], config["domain"], config["ssl_enabled"], "add", ) elif action in ["stop", "remove", "died"]: print(f"Removing service {config['service_name']} from HAProxy") sys.stdout.flush() self.update_haproxy_backend( config["service_name"], config["host"], config["port"], "remove" ) def watch_events(self): print("Starting Podman-HAProxy-ACME sync...") # Track last sync time last_full_sync = 0 SYNC_INTERVAL = 60 # Re-scan all containers every 60 seconds def do_full_sync(): """Perform a full sync of all running containers""" print("Performing full container sync...") try: result = subprocess.run( ["podman", "ps", "--format", "json"], capture_output=True, text=True ) if result.returncode == 0: containers = json.loads(result.stdout) for container in containers: event = { "Type": "container", "Action": "start", "Actor": {"ID": container.get("Id", "")}, } self.process_container_event(event) print(f"Synced {len(containers)} containers") except Exception as e: print(f"Error during full sync: {e}") # Initial sync do_full_sync() last_full_sync = time.time() print("Watching for container events...") cmd = ["podman", "events", "--format", "json"] process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) # Use select/poll for non-blocking read so we can do periodic syncs import select while True: # Check if it's time for periodic sync if time.time() - last_full_sync >= SYNC_INTERVAL: do_full_sync() last_full_sync = time.time() # Check for events with timeout ready, _, _ = select.select([process.stdout], [], [], 5) if ready: line = process.stdout.readline() if line: try: event = json.loads(line.strip()) if event["Type"] == "container": self.process_container_event(event) except json.JSONDecodeError as e: print( f"[EVENT-ERROR] JSON decode error: {e} - Line: {line[:100]}" ) sys.stdout.flush() except KeyError as e: print( f"[EVENT-ERROR] Missing key {e} in event: {json.dumps(event)}" ) sys.stdout.flush() except Exception as e: print(f"[EVENT-ERROR] Error processing event: {e}") print(f"[EVENT-ERROR] Event structure: {json.dumps(event)}") sys.stdout.flush() if __name__ == "__main__": os.makedirs(CERT_DIR, exist_ok=True) sync = PodmanHAProxyACMESync() sync.watch_events()