Files
zima-apps/Apps/caddy-autogen/agent/discovery_agent.py
T
2026-03-18 16:19:01 +01:00

319 lines
11 KiB
Python

#!/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)