"""Caddy configuration generator and manager.""" import os from typing import Optional from app.models.https_config import HTTPSConfig # Shared reverse-proxy snippet used in every Caddy site block _PROXY_SNIPPET = """\ reverse_proxy digiserver-app:5000 { header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-Proto {scheme} transport http { read_timeout 300s write_timeout 300s } } request_body { max_size 2GB } encode gzip header { X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" } log { output file /var/log/caddy/access.log } """ class CaddyConfigGenerator: """Generate Caddyfile configuration based on HTTPSConfig.""" @staticmethod def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str: """Generate a complete Caddyfile. Behaviour: - HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode). - HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert for that domain; HTTP redirects to HTTPS automatically. - HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA (self-signed, trusted within the Docker network). """ if config is None: config = HTTPSConfig.get_config() email = (config.email or "admin@localhost") if config else "admin@localhost" https_enabled = config.https_enabled if config else False domain = (config.domain or "").strip() if config else "" ip_address = (config.ip_address or "").strip() if config else "" global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n""" if https_enabled and domain: # Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically # when a plain hostname (no scheme) is used. caddyfile = global_block caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n" # Also accept requests on the raw IP (HTTP only, no cert needed) if ip_address: caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n" elif https_enabled and ip_address: # No public domain — use Caddy's internal CA (self-signed) caddyfile = global_block caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n" caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n" else: # HTTP-only fallback (first deploy, before HTTPS is configured) caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n" caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n" return caddyfile @staticmethod def write_caddyfile(caddyfile_content: str, path: str = '/etc/caddy/Caddyfile') -> bool: """Write Caddyfile to disk. The default path is /etc/caddy/Caddyfile — the standard location inside the caddy:2-alpine container when a volume is mounted there. """ try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w') as f: f.write(caddyfile_content) return True except Exception as e: print(f"Error writing Caddyfile: {str(e)}") return False @staticmethod def reload_caddy() -> bool: """Push the current Caddyfile to Caddy via its admin API (/load). Caddy applies the new config live without dropping connections. """ try: import urllib.request caddyfile_path = '/etc/caddy/Caddyfile' if not os.path.exists(caddyfile_path): print(f"Caddyfile not found at {caddyfile_path}") return False with open(caddyfile_path, 'rb') as f: caddyfile_bytes = f.read() req = urllib.request.Request( 'http://caddy:2019/load', data=caddyfile_bytes, headers={'Content-Type': 'text/caddyfile'}, method='POST', ) response = urllib.request.urlopen(req, timeout=10) return response.status == 200 except Exception as e: print(f"Caddy reload error: {str(e)}") return False """Generate complete Caddyfile content. Args: config: HTTPSConfig instance or None Returns: Complete Caddyfile content as string """ # Get config from database if not provided if config is None: config = HTTPSConfig.get_config() # Base configuration email = "admin@localhost" if config and config.email: email = config.email base_config = f"""{{ # Global options email {email} # Admin API for configuration management (listen on all interfaces) admin 0.0.0.0:2019 # Uncomment for testing to avoid rate limits # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory }} # Shared reverse proxy configuration (reverse_proxy_config) {{ reverse_proxy digiserver-app:5000 {{ header_up Host {{host}} header_up X-Real-IP {{remote_host}} header_up X-Forwarded-Proto {{scheme}} # Timeouts for large uploads transport http {{ read_timeout 300s write_timeout 300s }} }} # File upload size limit (2GB) request_body {{ max_size 2GB }} # Security headers header {{ X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" }} # Logging log {{ output file /var/log/caddy/access.log }} }} # Localhost (development/local access) http://localhost {{ import reverse_proxy_config }} """ # Add main domain/IP configuration if HTTPS is enabled if config and config.https_enabled and config.domain and config.ip_address: # Internal domain configuration domain_config = f""" # Internal domain (HTTP only - internal use) http://{config.domain} {{ import reverse_proxy_config }} # Handle IP address access http://{config.ip_address} {{ import reverse_proxy_config }} """ base_config += domain_config else: # Default fallback configuration base_config += """ # Internal domain (HTTP only - internal use) http://digiserver.sibiusb.harting.intra { import reverse_proxy_config } # Handle IP address access http://10.76.152.164 { import reverse_proxy_config } """ # Add catch-all for any other HTTP requests base_config += """ # Catch-all for any other HTTP requests http://* { import reverse_proxy_config } """ return base_config @staticmethod def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool: """Write Caddyfile to disk. Args: caddyfile_content: Content to write path: Path to Caddyfile Returns: True if successful, False otherwise """ try: with open(path, 'w') as f: f.write(caddyfile_content) return True except Exception as e: print(f"Error writing Caddyfile: {str(e)}") return False @staticmethod def reload_caddy() -> bool: """Reload Caddy configuration without restart. Note: Caddy monitoring is handled via file watching. After writing the Caddyfile, Caddy should automatically reload. If it doesn't, you may need to restart the Caddy container manually. Returns: True if configuration was written successfully (Caddy will auto-reload) """ try: # Just verify that Caddy is reachable import urllib.request response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2) return response.status == 200 except Exception as e: # Caddy might not be reachable, but Caddyfile was already written # Caddy should reload automatically when it detects file changes print(f"Note: Caddy reload check returned: {str(e)}") return True # Return True anyway since Caddyfile was written