"""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, http_fallback: bool = True, http_port: int = 80, https_port: int = 443) -> str: """Generate a complete Caddyfile. Design goals ------------ * **One HTTP endpoint** (port 80) that always answers, whatever the Host header is — so ``http://`` and ``http://`` both work. * **HTTPS on port 443** for the same names when it is enabled. * If HTTPS is disabled or never configured, port 80 simply serves the app — there is no separate "HTTP mode" to configure. Behaviour by configuration -------------------------- * HTTPS off, or no address configured → plain HTTP on ``:http_port``. * HTTPS on → the app is served on port 80 for every configured name and on port 443 over TLS. Whether port 80 *serves* or *redirects* to HTTPS is controlled by ``http_fallback``. Which certificate each name gets -------------------------------- * ``domain`` (when set) → Caddy obtains a certificate automatically (Let's Encrypt/ACME). Only valid for a **publicly resolvable** name. * ``ip_address`` / ``hostname`` → ``tls internal`` (Caddy's local CA). This needs no public DNS and no ACME, which is the right choice for an intranet name such as ``digiserver.sibiusb.harting.intra``. Args: config: HTTPSConfig instance, or None to load from the database. http_fallback: When True, port 80 keeps *serving* the app alongside HTTPS. This is the resilient default: clients that cannot trust the internal CA (e.g. a Kivy player with ``verify_ssl: true``) are still able to connect. When False, port 80 issues a 301 redirect to HTTPS instead. http_port: Port Caddy listens on for plain HTTP (default 80). https_port: Port used to build redirect targets when ``http_fallback`` is False (default 443). Returns: The complete Caddyfile as a string. """ if config is None: config = HTTPSConfig.get_config() email = (config.email or "admin@localhost") if config else "admin@localhost" https_enabled = bool(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 "" hostname = (config.hostname or "").strip() if config else "" # Every name the server should answer to, in priority order, without # duplicates. The IP comes first because it always resolves. names: list[str] = [] for candidate in (ip_address, hostname, domain): if candidate and candidate not in names: names.append(candidate) global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n" # ── TLS with no SNI ──────────────────────────────────────────────── # Browsers do NOT send SNI when the URL is an IP address (an IP is not # a valid SNI hostname). Without a fallback Caddy would identify such a # connection by the container's own internal IP, match no certificate # and abort the handshake with: # "no certificate available for ''" # `default_sni` makes a SNI-less ClientHello resolve to a name we do # serve, so https:// works in the browser. if https_enabled and not domain and ip_address: global_block += f" default_sni {ip_address}\n" global_block += "}\n\n" # ── Plain HTTP only: HTTPS disabled, or no address to certify ─────── if not (https_enabled and names): return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n" caddyfile = global_block # ── Port 80: catch-all so ANY Host header is answered ────────────── # Without this, a request for an unexpected name (e.g. a bare IP when # only a hostname is configured) would hit no site block and fail. caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n" # ── Port 80: explicit per-name blocks ────────────────────────────── for name in names: if http_fallback: caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n" else: # Redirect to the port the host actually publishes. https_url = (f"https://{name}" if https_port == 443 else f"https://{name}:{https_port}") caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n" # ── Port 443: TLS listeners ──────────────────────────────────────── for name in names: if domain and name == domain: # Public name → let Caddy obtain a real certificate. caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n" else: # IP or intranet name → Caddy's internal CA. caddyfile += (f"https://{name} {{\n tls internal\n" f"{_PROXY_SNIPPET}}}\n\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)}")