#!/usr/bin/env python3 import hashlib import json import os import re import sys import threading import time import urllib.error import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer TRUE_VALUES = {"1", "true", "yes", "on"} HOST_RE = re.compile(r"^[a-z0-9.-]+$") def _to_bool(value: str, default: bool = False) -> bool: if value is None: return default return value.strip().lower() in TRUE_VALUES def _read_simple_yaml(path: str) -> dict: data = {} try: with open(path, "r", encoding="utf-8") as f: for line in f: stripped = line.strip() if not stripped or stripped.startswith("#"): continue if ":" not in stripped: continue key, raw_val = stripped.split(":", 1) key = key.strip() val = raw_val.strip() if val.startswith('"') and val.endswith('"'): val = val[1:-1] elif val.lower() in {"true", "false"}: val = val.lower() == "true" else: try: val = int(val) except ValueError: pass data[key] = val except FileNotFoundError: pass return data def _cfg(name: str, defaults: dict, key: str, fallback=""): val = os.getenv(name) if val not in (None, ""): return val return defaults.get(key, fallback) def _log(msg: str): print(msg, flush=True) def _get_json(url: str): req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode("utf-8")) def _post_caddyfile(url: str, caddyfile: str): req = urllib.request.Request( url, data=caddyfile.encode("utf-8"), method="POST", headers={"Content-Type": "text/caddyfile"}, ) with urllib.request.urlopen(req, timeout=15) as resp: return resp.status def _env_map(inspect_obj: dict) -> dict: out = {} env_items = (inspect_obj.get("Config") or {}).get("Env") or [] for item in env_items: if "=" in item: key, value = item.split("=", 1) out[key] = value return out def _published_tcp_ports(inspect_obj: dict) -> dict: ports = {} net_settings = (inspect_obj.get("NetworkSettings") or {}).get("Ports") or {} for container_port, mappings in net_settings.items(): if not container_port.endswith("/tcp"): continue container_num = container_port.split("/", 1)[0] if not mappings: continue host_port = mappings[0].get("HostPort") if host_port: ports[container_num] = host_port return ports def _normalize_name(raw_name: str) -> str: cleaned = raw_name.strip().lstrip("/").lower() cleaned = re.sub(r"[^a-z0-9-]", "-", cleaned) cleaned = re.sub(r"-+", "-", cleaned).strip("-") return cleaned or "app" def _build_fqdn(host_hint: str, base_domain: str) -> str: if "." in host_hint: fqdn = host_hint.lower() else: fqdn = f"{host_hint.lower()}.{base_domain.lower()}" if base_domain else "" return fqdn def _collect_routes( docker_api_url: str, env_prefix: str, denylist: set, base_domain: str, default_scheme: str, default_path: str, default_health_uri: str, ): routes = [] containers = _get_json(f"{docker_api_url}/containers/json?all=0") for c in containers: cid = c.get("Id") names = c.get("Names") or [] primary_name = _normalize_name(names[0] if names else c.get("Image", "container")) if primary_name in denylist: continue inspect_obj = _get_json(f"{docker_api_url}/containers/{cid}/json") envs = _env_map(inspect_obj) enabled = _to_bool(envs.get(f"{env_prefix}ENABLE", "false")) if not enabled: continue target_port = envs.get(f"{env_prefix}TARGET_PORT", "").strip() if not target_port.isdigit(): _log(f"WARN: skip {primary_name}: invalid TARGET_PORT='{target_port}'") continue port_map = _published_tcp_ports(inspect_obj) host_port = port_map.get(target_port) if not host_port: _log(f"WARN: skip {primary_name}: target port {target_port} is not published as TCP") continue host_hint = envs.get(f"{env_prefix}HOST", "").strip() or primary_name fqdn = _build_fqdn(host_hint, base_domain) if not fqdn or not HOST_RE.match(fqdn): _log(f"WARN: skip {primary_name}: invalid fqdn '{fqdn}'") continue scheme = (envs.get(f"{env_prefix}SCHEME", "") or default_scheme).strip().lower() if scheme not in {"http", "https"}: _log(f"WARN: skip {primary_name}: invalid scheme '{scheme}'") continue path = (envs.get(f"{env_prefix}PATH", "") or default_path).strip() or "/" if not path.startswith("/"): path = "/" + path health_uri = (envs.get(f"{env_prefix}HEALTH_URI", "") or default_health_uri).strip() routes.append( { "name": primary_name, "fqdn": fqdn, "scheme": scheme, "upstream": f"host.docker.internal:{host_port}", "path": path, "health_uri": health_uri, } ) routes.sort(key=lambda r: r["fqdn"]) return routes def _append_status_site(out: list[str], status_ui_port: int, status_upstream: str) -> None: out.append(f":{status_ui_port} {{") out.append(" @allowed remote_ip private_ranges") out.append(" handle @allowed {") out.append(" @status_json path /status.json") out.append(" handle @status_json {") out.append(f" reverse_proxy {status_upstream}") out.append(" }") out.append(" root * /srv/status") out.append(" file_server") out.append(" }") out.append(" respond \"forbidden\" 403") out.append("}") out.append("") def _generate_caddyfile( routes, token: str, require_cloudflare: bool, allow_internal_tls_fallback: bool, wildcard_domain: str, cert_email: str, status_ui_port: int, status_upstream: str, ): if require_cloudflare and not token: raise RuntimeError("CLOUDFLARE_API_TOKEN is required in fail-closed mode") out = ["{"] if cert_email: out.append(f" email {cert_email}") if token: out.append(" acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}") elif allow_internal_tls_fallback: out.append(" local_certs") out.append("}") out.append("") _append_status_site(out, status_ui_port=status_ui_port, status_upstream=status_upstream) if wildcard_domain and token: out.append(f"{wildcard_domain}, *.{wildcard_domain} {{") out.append(" tls {") out.append(" dns cloudflare {env.CLOUDFLARE_API_TOKEN}") out.append(" }") out.append(" respond \"wildcard certificate anchor\" 204") out.append("}") out.append("") if not routes: out.append(":80 {") out.append(" respond \"no eligible containers for caddy-autogen\" 200") out.append("}") return "\n".join(out) + "\n" for route in routes: out.append(f"{route['fqdn']} {{") if token: out.append(" tls {") out.append(" dns cloudflare {env.CLOUDFLARE_API_TOKEN}") out.append(" }") elif allow_internal_tls_fallback: out.append(" tls internal") if route["path"] != "/": out.append(" @allowed path " + route["path"] + "*") out.append(" handle @allowed {") if route["health_uri"]: out.append(f" reverse_proxy {route['scheme']}://{route['upstream']} {{") out.append(f" health_uri {route['health_uri']}") out.append(" }") else: out.append(f" reverse_proxy {route['scheme']}://{route['upstream']}") out.append(" }") out.append(" respond \"not found\" 404") else: if route["health_uri"]: out.append(f" reverse_proxy {route['scheme']}://{route['upstream']} {{") out.append(f" health_uri {route['health_uri']}") out.append(" }") else: out.append(f" reverse_proxy {route['scheme']}://{route['upstream']}") out.append("}") out.append("") return "\n".join(out) def _parse_bind_addr(value: str, default_host: str = "0.0.0.0", default_port: int = 8089) -> tuple[str, int]: raw = str(value or "").strip() if not raw: return default_host, default_port if ":" not in raw: raise ValueError(f"invalid bind address '{raw}', expected host:port") host, port_raw = raw.rsplit(":", 1) if not host: host = default_host try: port = int(port_raw) except ValueError as exc: raise ValueError(f"invalid port in bind address '{raw}'") from exc if port < 1 or port > 65535: raise ValueError(f"port out of range in bind address '{raw}'") return host, port def _verify_cloudflare_token(verify_url: str, token: str) -> dict: now = int(time.time()) if not token: return { "reachable": False, "token_valid": False, "last_check_ts": now, "error": "CLOUDFLARE_API_TOKEN is missing", } req = urllib.request.Request( verify_url, headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, ) try: with urllib.request.urlopen(req, timeout=10) as resp: payload = json.loads(resp.read().decode("utf-8")) return { "reachable": True, "token_valid": bool(payload.get("success", False)), "last_check_ts": now, "error": "", } except urllib.error.HTTPError as exc: return { "reachable": True, "token_valid": False, "last_check_ts": now, "error": f"HTTP {exc.code}", } except urllib.error.URLError as exc: return { "reachable": False, "token_valid": False, "last_check_ts": now, "error": f"connection failure: {exc}", } except Exception as exc: return { "reachable": False, "token_valid": False, "last_check_ts": now, "error": str(exc), } def _collect_letsencrypt_hosts(caddy_data_dir: str) -> set[str]: results: set[str] = set() cert_root = os.path.join(caddy_data_dir, "caddy", "certificates") if not os.path.isdir(cert_root): return results for root, _dirs, files in os.walk(cert_root): if "letsencrypt" not in root.lower(): continue for filename in files: if not filename.endswith(".crt"): continue host = filename[:-4].lower() if host.startswith("_."): host = "*." + host[2:] if host and host != "*" and (host.startswith("*.") or HOST_RE.match(host)): results.add(host) return results def _has_matching_le_cert(route_fqdn: str, cert_hosts: set[str]) -> bool: if route_fqdn in cert_hosts: return True for cert_host in cert_hosts: if not cert_host.startswith("*."): continue suffix = cert_host[2:] if route_fqdn == suffix or route_fqdn.endswith("." + suffix): return True return False def _build_status_payload(state: dict) -> bytes: body = json.dumps(state, separators=(",", ":"), sort_keys=True).encode("utf-8") return body def _start_status_server(bind_addr: str, snapshot: dict, lock: threading.Lock): host, port = _parse_bind_addr(bind_addr) class _Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path not in {"/status.json", "/healthz"}: self.send_response(404) self.end_headers() return if self.path == "/healthz": self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(b"ok") return with lock: payload = _build_status_payload(snapshot) self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, fmt, *args): return server = ThreadingHTTPServer((host, port), _Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() _log(f"INFO: status endpoint listening on {host}:{port}") return server, thread def main(): defaults = _read_simple_yaml(os.getenv("CONFIG_FILE", "/app/config/defaults.yaml")) docker_api_url = _cfg("DOCKER_API_URL", defaults, "docker_api_url", "http://socket-proxy:2375") caddy_load_url = _cfg("CADDY_LOAD_URL", defaults, "caddy_load_url", "http://caddy:2019/load") base_domain = str(_cfg("BASE_DOMAIN", defaults, "base_domain", "")).strip() wildcard_domain = str(_cfg("WILDCARD_DOMAIN", defaults, "wildcard_domain", "")).strip() cert_email = str(_cfg("CERT_EMAIL", defaults, "cert_email", "")).strip() env_prefix = str(_cfg("ENV_PREFIX", defaults, "env_prefix", "LABEL_CADDY_")).strip() default_scheme = str(_cfg("DEFAULT_SCHEME", defaults, "default_scheme", "http")).strip().lower() default_path = str(_cfg("DEFAULT_PATH", defaults, "default_path", "/")).strip() or "/" default_health_uri = str(_cfg("DEFAULT_HEALTH_URI", defaults, "default_health_uri", "")).strip() status_bind = str(_cfg("STATUS_BIND", defaults, "status_bind", "0.0.0.0:8089")).strip() status_ui_port_raw = _cfg("STATUS_UI_PORT", defaults, "status_ui_port", 31820) status_upstream = str(_cfg("STATUS_UPSTREAM", defaults, "status_upstream", "discovery-agent:8089")).strip() cf_verify_url = str( _cfg( "CF_VERIFY_URL", defaults, "cf_verify_url", "https://api.cloudflare.com/client/v4/user/tokens/verify", ) ).strip() caddy_data_dir = str(_cfg("CADDY_DATA_DIR", defaults, "caddy_data_dir", "/caddy-data")).strip() try: status_ui_port = int(status_ui_port_raw) if status_ui_port < 1 or status_ui_port > 65535: raise ValueError except (TypeError, ValueError): status_ui_port = 31820 poll_seconds_raw = _cfg("POLL_SECONDS", defaults, "poll_seconds", 15) try: poll_seconds = max(5, int(poll_seconds_raw)) except (TypeError, ValueError): poll_seconds = 15 denylist_raw = str(_cfg("CONTAINER_NAME_DENYLIST", defaults, "container_name_denylist", "")) denylist = {item.strip().lower() for item in denylist_raw.split(",") if item.strip()} require_cloudflare = _to_bool(str(_cfg("REQUIRE_CLOUDFLARE", defaults, "require_cloudflare", "true")), True) allow_internal_tls_fallback = _to_bool( str(_cfg("ALLOW_INTERNAL_TLS_FALLBACK", defaults, "allow_internal_tls_fallback", "false")), False, ) token = os.getenv("CLOUDFLARE_API_TOKEN", "").strip() last_digest = "" snapshot_lock = threading.Lock() snapshot = { "app": { "name": "caddy-autogen", "status_ui_port": status_ui_port, "status_upstream": status_upstream, "require_cloudflare": require_cloudflare, "allow_internal_tls_fallback": allow_internal_tls_fallback, }, "last_tick_ts": 0, "last_apply_ok": False, "last_apply_http_status": 0, "last_error": "not started", "routes": [], "cloudflare": { "reachable": False, "token_valid": False, "last_check_ts": 0, "error": "not checked", }, "certs": [], } _start_status_server(status_bind, snapshot, snapshot_lock) _log( "INFO: starting caddy-autogen discovery-agent " f"(docker_api_url={docker_api_url}, caddy_load_url={caddy_load_url}, poll_seconds={poll_seconds})" ) while True: tick_ts = int(time.time()) try: routes = _collect_routes( docker_api_url=docker_api_url, env_prefix=env_prefix, denylist=denylist, base_domain=base_domain, default_scheme=default_scheme, default_path=default_path, default_health_uri=default_health_uri, ) cloudflare_status = _verify_cloudflare_token(cf_verify_url, token) cert_hosts = _collect_letsencrypt_hosts(caddy_data_dir) cert_rows = [ { "fqdn": route["fqdn"], "letsencrypt_present": _has_matching_le_cert(route["fqdn"], cert_hosts), } for route in routes ] caddyfile = _generate_caddyfile( routes=routes, token=token, require_cloudflare=require_cloudflare, allow_internal_tls_fallback=allow_internal_tls_fallback, wildcard_domain=wildcard_domain, cert_email=cert_email, status_ui_port=status_ui_port, status_upstream=status_upstream, ) digest = hashlib.sha256(caddyfile.encode("utf-8")).hexdigest() apply_ok = True apply_status = 0 last_error = "" if digest != last_digest: apply_status = _post_caddyfile(caddy_load_url, caddyfile) _log(f"INFO: applied config (routes={len(routes)}, status={apply_status})") last_digest = digest else: _log(f"INFO: no config changes (routes={len(routes)})") with snapshot_lock: snapshot["last_tick_ts"] = tick_ts snapshot["last_apply_ok"] = apply_ok snapshot["last_apply_http_status"] = apply_status snapshot["last_error"] = last_error snapshot["routes"] = routes snapshot["cloudflare"] = cloudflare_status snapshot["certs"] = cert_rows except urllib.error.HTTPError as e: err = f"http failure {e.code} {e.reason}" _log(f"ERROR: {err}") with snapshot_lock: snapshot["last_tick_ts"] = tick_ts snapshot["last_apply_ok"] = False snapshot["last_apply_http_status"] = 0 snapshot["last_error"] = err except urllib.error.URLError as e: err = f"connection failure: {e}" _log(f"ERROR: {err}") with snapshot_lock: snapshot["last_tick_ts"] = tick_ts snapshot["last_apply_ok"] = False snapshot["last_apply_http_status"] = 0 snapshot["last_error"] = err except Exception as e: err = str(e) _log(f"ERROR: {err}") with snapshot_lock: snapshot["last_tick_ts"] = tick_ts snapshot["last_apply_ok"] = False snapshot["last_apply_http_status"] = 0 snapshot["last_error"] = err time.sleep(poll_seconds) if __name__ == "__main__": try: main() except KeyboardInterrupt: _log("INFO: shutdown requested") sys.exit(0)