#cloud-config users: - name: fourlights sudo: ALL=(ALL) NOPASSWD:ALL groups: users,admin,sudo shell: /bin/bash lock_passwd: false ssh_authorized_keys: - ${ssh_public_key} packages: - podman - haproxy - python3 - python3-requests - curl - wget - jq - socat - nmap package_update: true package_upgrade: true write_files: - path: /etc/sudoers.d/fourlights-haproxy permissions: '0440' content: | fourlights ALL=(root) NOPASSWD: /bin/systemctl reload haproxy fourlights ALL=(root) NOPASSWD: /bin/systemctl restart haproxy fourlights ALL=(root) NOPASSWD: /bin/systemctl stop haproxy fourlights ALL=(root) NOPASSWD: /bin/systemctl start haproxy fourlights ALL=(root) NOPASSWD: /bin/chown -R haproxy\:haproxy /etc/ssl/haproxy/* fourlights ALL=(root) NOPASSWD: /bin/chmod 600 /etc/ssl/haproxy/* # HAProxy main configuration - path: /etc/haproxy/haproxy.cfg content: | global daemon stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners stats timeout 30s user haproxy group haproxy log stdout local0 info defaults mode http timeout connect 5000ms timeout client 50000ms timeout server 50000ms option httplog log global # Stats interface frontend stats bind *:8404 http-request use-service prometheus-exporter if { path /metrics } stats enable stats uri /stats stats refresh 10s # HTTP Frontend frontend main bind *:80 # ACL to detect ACME challenge requests acl is_acme_challenge path_beg /.well-known/acme-challenge/ # Route ACME challenges to the acme_challenge backend use_backend acme_challenge if is_acme_challenge default_backend no_match # HTTPS Frontend frontend https_main bind *:443 default_backend no_match # ACME Challenge Backend backend acme_challenge mode http server acme_server 127.0.0.1:8888 # Default backend backend no_match http-request return status 404 content-type text/plain string "No matching service found" - path: /etc/dataplaneapi/dataplaneapi.yml content: | dataplaneapi: host: 0.0.0.0 port: 5555 user: - insecure: true password: admin username: admin haproxy: config_file: /etc/haproxy/haproxy.cfg haproxy_bin: /usr/sbin/haproxy reload: reload_cmd: systemctl reload haproxy restart_cmd: systemctl restart haproxy stats_socket: /var/run/haproxy/admin.sock - path: /usr/local/bin/podman-haproxy-acme-sync-wrapper.sh permissions: '0755' content: | #!/bin/bash set -e MAX_WAIT=60 ELAPSED=0 # Wait for HAProxy echo "Checking HAProxy status..." while ! systemctl is-active --quiet haproxy; do echo "Waiting for HAProxy to start..." sleep 2 ELAPSED=$($ELAPSED + 2) [ $ELAPSED -ge $MAX_WAIT ] && { echo "ERROR: HAProxy timeout"; exit 1; } done echo "HAProxy is active" # Reset and wait for Data Plane API to actually respond ELAPSED=0 echo "Checking Data Plane API readiness..." while true; do HTTP_CODE=$(curl -s -w "%%{http_code}" -o /dev/null \ --connect-timeout 5 \ --max-time 10 \ -u :admin \ http://localhost:5555/v3/services/haproxy/configuration/version 2>/dev/null || echo "000") [ "$HTTP_CODE" = "200" ] && { echo "Data Plane API ready"; break; } echo "Waiting for Data Plane API... (HTTP $HTTP_CODE)" sleep 2 ELAPSED=$((ELAPSED + 2)) if [ $ELAPSED -ge $MAX_WAIT ]; then echo "ERROR: Data Plane API not ready within $MAX_WAITs (HTTP $HTTP_CODE)" journalctl -u dataplaneapi -n 50 --no-pager exit 1 fi done sleep 2 exec /usr/local/bin/podman-haproxy-acme-sync.py # Podman HAProxy ACME Sync Script - path: /usr/local/bin/podman-haproxy-acme-sync.py permissions: '0755' content: | #!/usr/bin/env python3 import json import subprocess import requests import time import os import sys 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 {} 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'): 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) acl_data = { "acl_name": f"is_{service_name}", "criterion": "hdr(host)", "value": domain, } self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/acls/{self.get_next_index('frontends/main/acls')}?version={self.get_dataplaneapi_version()}", json=acl_data) if ssl_enabled: self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/acls/{self.get_next_index('frontends/https_main/acls')}?version={self.get_dataplaneapi_version()}", json=acl_data) rule_data = { "name": f"backend_{service_name}", "cond": "if", "cond_test": f"is_{service_name}", } self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/backend_switching_rules/{self.get_next_index('frontends/main/backend_switching_rules')}?version={self.get_dataplaneapi_version()}", json=rule_data) if ssl_enabled: self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/backend_switching_rules/{self.get_next_index('frontends/https_main/backend_switching_rules')}?version={self.get_dataplaneapi_version()}", json=rule_data) redirect_rule = { "type": "redirect", "redirect_rule": { "type": "scheme", "value": "https", "code": 301 }, "cond": "if", "cond_test": f"is_{service_name}", } self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/http_request_rules/{self.get_next_index('frontends/main/http_request_rules')}?version={self.get_dataplaneapi_version()}", json=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() runcmd: # Create necessary directories - mkdir -p /var/run/haproxy /etc/ssl/haproxy /etc/containers/systemd /etc/haproxy/dataplane /etc/dataplaneapi - chown haproxy:haproxy /var/run/haproxy # Install Data Plane API - cd /tmp && curl -LO https://github.com/haproxytech/dataplaneapi/releases/download/v3.2.4/dataplaneapi_3.2.4_linux_amd64.deb - env DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" /tmp/dataplaneapi_3.2.4_linux_amd64.deb - rm /tmp/dataplaneapi_3.2.4_linux_amd64.deb - mkdir -p /home/fourlights/.config/containers/systemd - mkdir -p /home/fourlights/.config/systemd/user - | cat > /home/fourlights/.config/systemd/user/podman-haproxy-acme-sync.service << 'EOF' [Unit] Description=Podman HAProxy ACME Sync Service After=network.target [Service] Type=simple Environment="XDG_RUNTIME_DIR=/run/user/1000" Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" ExecStart=/usr/local/bin/podman-haproxy-acme-sync-wrapper.sh StandardOutput=journal StandardError=journal Restart=always RestartSec=10 [Install] WantedBy=default.target EOF - chown -R fourlights:fourlights /home/fourlights # Install ACME.sh - su - fourlights -c 'curl https://get.acme.sh | sh -s email=${acme_email}' - ln -sf /home/fourlights/.acme.sh/acme.sh /usr/local/bin/acme.sh # Setup data directory and mount volume - mkdir -p /opt/storage/data - mkfs.ext4 -F /dev/sdb - mount /dev/sdb /opt/storage/data - echo '/dev/sdb /opt/storage/data ext4 defaults 0 2' >> /etc/fstab - chown -R fourlights:fourlights /opt/storage/data # Enable Podman for user services - loginctl enable-linger fourlights - su - fourlights -c 'podman login ghcr.io -u ${ghcr_username} -p ${ghcr_token}' # Enable and start services - systemctl daemon-reload - systemctl enable --now haproxy - systemctl enable --now dataplaneapi - su - fourlights -c 'systemctl --user daemon-reload' - su - fourlights -c 'systemctl --user enable --now podman-haproxy-acme-sync' final_message: "Server setup complete with HAProxy, Podman, and ACME sync configured"