#!/usr/bin/env python3 import hashlib import json import os import re import sys import time import urllib.error import urllib.parse import urllib.request 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 _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_internal_tls_fallback: bool, wildcard_domain: str, cert_email: 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("") 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 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() 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 = "" _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: 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, ) 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, ) digest = hashlib.sha256(caddyfile.encode("utf-8")).hexdigest() if digest != last_digest: status = _post_caddyfile(caddy_load_url, caddyfile) _log(f"INFO: applied config (routes={len(routes)}, status={status})") last_digest = digest else: _log(f"INFO: no config changes (routes={len(routes)})") except urllib.error.HTTPError as e: _log(f"ERROR: http failure {e.code} {e.reason}") except urllib.error.URLError as e: _log(f"ERROR: connection failure: {e}") except Exception as e: _log(f"ERROR: {e}") time.sleep(poll_seconds) if __name__ == "__main__": try: main() except KeyboardInterrupt: _log("INFO: shutdown requested") sys.exit(0)