diff --git a/Apps/caddy-autogen/HOW_TO_VERIFY.md b/Apps/caddy-autogen/HOW_TO_VERIFY.md index d0b5e55..9318aea 100644 --- a/Apps/caddy-autogen/HOW_TO_VERIFY.md +++ b/Apps/caddy-autogen/HOW_TO_VERIFY.md @@ -27,32 +27,22 @@ Konfigurera lokal DNS (AdGuard Home): - `A`/rewrite för `*.home.example.com` -> ZimaOS LAN-IP - `A`/rewrite för `home.example.com` -> ZimaOS LAN-IP -## 3) Positivt test: endpoint skapas korrekt - -Exempel med Frigate (web UI på 5000): - -Sätt env-vars på Frigate-containern: - -```text -LABEL_CADDY_ENABLE=true -LABEL_CADDY_TARGET_PORT=5000 -LABEL_CADDY_HOST=frigate -LABEL_CADDY_SCHEME=http -LABEL_CADDY_PATH=/ -``` - -Verifiera: +## 3) Positivt test: status-UI + endpoint 1. Vänta 15-30 sekunder (default polling). -2. Öppna `https://frigate.home.example.com`. -3. Kontrollera att sidan svarar med giltigt certifikat. -4. Kontrollera att media-portar (8554/8555) inte blivit egna hostnamn/endpoints. +2. Öppna `http://:31820/`. +3. Verifiera i UI: + - `Caddy Apply = OK` + - `Cloudflare Reachability = OK` + - `Cloudflare Token = OK` + - minst en route visas under `Generated Hosts`. +4. Öppna en exponerad host, t.ex. `https://frigate.home.example.com`. Förväntat resultat: -- Endpoint finns för web UI. +- UI laddar och visar aktuell status. +- Endpoint finns för web UI-hosten. - Certifikat utfärdas via Let's Encrypt DNS-01. -- Endast explicit målport routas. ## 4) Negativt test: fail-closed @@ -60,24 +50,26 @@ Scenario A, ingen opt-in: 1. Ta bort `LABEL_CADDY_ENABLE=true` från målcontainern. 2. Vänta en polling-cykel. -3. Kontrollera att endpointen försvinner/slutar routas. +3. Verifiera i status-UI att routen försvinner. -Scenario B, saknad Cloudflare-token: +Scenario B, saknad/ogiltig Cloudflare-token: 1. Sätt `REQUIRE_CLOUDFLARE=true`. 2. Ta bort eller ogiltiggör `CLOUDFLARE_API_TOKEN`. -3. Verifiera att ingen ny extern route publiceras. +3. Verifiera i status-UI att Cloudflare Token inte är OK. +4. Verifiera att ingen ny extern route publiceras. Förväntat resultat: - Appen exponerar inte tjänster oavsiktligt. -- Loggar visar tydligt fel kring token/Cloudflare. +- Loggar visar tydligt token/Cloudflare-relaterat fel. ## 5) Kommandon för snabb verifiering -Byt `` och `` enligt din miljö. +Byt ``, `` och `` enligt din miljö. ```bash +curl -sS http://:31820/status.json | jq . nslookup frigate.home.example.com curl -vk https://frigate.home.example.com/ openssl s_client -connect frigate.home.example.com:443 -servername frigate.home.example.com :31820/status.json | jq .`. - `nslookup`-svar från en LAN-klient. - `curl -vk` output mot endpoint. - `openssl s_client` sammanfattning av cert/issuer. diff --git a/Apps/caddy-autogen/README.md b/Apps/caddy-autogen/README.md index cd495a4..d38831b 100644 --- a/Apps/caddy-autogen/README.md +++ b/Apps/caddy-autogen/README.md @@ -8,6 +8,25 @@ Arkitektur: - Endast explicit opt-in exponeras (`LABEL_CADDY_ENABLE=true`). - Caddy config laddas dynamiskt via Caddy Admin API (`POST /load`). - TLS sker med Let's Encrypt DNS-01 via Cloudflare. +- Ett enkelt status-UI finns på port `31820` (LAN/private ranges only). + +## Status-UI (v1) + +Status-UI visar: + +- senaste config apply-status, +- Cloudflare reachability + token-validitet, +- genererade hosts/routes, +- om Let's Encrypt-cert verkar finnas för varje host. + +URL (default): + +- `http://:31820/` + +Obs: + +- "Hosts" i UI betyder **genererade Caddy-routes**, inte att appen skapar A/CNAME-records i Cloudflare-zonen. +- Certstatus bygger på Caddys lokala certificate storage och är en praktisk v1-signal. ## Säkerhetsmodell @@ -15,6 +34,7 @@ Arkitektur: - Ingen auto-exponering av alla portar. - Endast HTTP(S)-upstreams stöds i v1. - Docker socket exponeras inte direkt till agenten, endast via `socket-proxy` med begränsade endpoint-flaggor. +- Status-UI är LAN-begränsat (`remote_ip private_ranges`). ## Miljövariabler (appnivå) @@ -24,6 +44,9 @@ Arkitektur: - `REQUIRE_CLOUDFLARE` (default `true`) - `ALLOW_INTERNAL_TLS_FALLBACK` (default `false`) - `POLL_SECONDS` (default `15`) +- `STATUS_BIND` (default `0.0.0.0:8089`, intern agent endpoint) +- `STATUS_UI_PORT` (default `31820`) +- `CF_VERIFY_URL` (default Cloudflare token verify endpoint) Cloudflare-token bör vara scoped till minsta möjliga rättigheter: @@ -97,10 +120,12 @@ Kör lokala integrationstester för discovery-agenten: ./Apps/caddy-autogen/tests/run_integration_tests.sh ``` -Testerna mockar Docker API-svar och Caddy `/load`-anrop och verifierar: +Testerna mockar Docker API-svar och Cloudflare verify och verifierar: - opt-in-regler (endast markerade containers exponeras), - säker portselektionslogik (inga media/UDP-portar routas av misstag), -- fail-closed beteende när Cloudflare-token saknas. +- fail-closed beteende när Cloudflare-token saknas, +- status-UI-block i genererad Caddy-konfiguration, +- cert-matchning mot lokal Caddy certificate storage. För verifiering i riktig ZimaOS-miljö, se `HOW_TO_VERIFY.md` i samma mapp. diff --git a/Apps/caddy-autogen/agent/discovery_agent.py b/Apps/caddy-autogen/agent/discovery_agent.py index 086fad5..7637700 100644 --- a/Apps/caddy-autogen/agent/discovery_agent.py +++ b/Apps/caddy-autogen/agent/discovery_agent.py @@ -4,10 +4,12 @@ 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"} @@ -116,7 +118,15 @@ def _build_fqdn(host_hint: str, base_domain: str) -> str: 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): +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: @@ -175,7 +185,32 @@ def _collect_routes(docker_api_url: str, env_prefix: str, denylist: set, base_do return routes -def _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_internal_tls_fallback: bool, wildcard_domain: str, cert_email: str): +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") @@ -189,6 +224,8 @@ def _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_inte 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 {") @@ -237,6 +274,147 @@ def _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_inte 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")) @@ -249,6 +427,25 @@ def main(): 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: @@ -268,12 +465,37 @@ def main(): 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, @@ -284,6 +506,16 @@ def main(): 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, @@ -291,21 +523,53 @@ def main(): 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: - status = _post_caddyfile(caddy_load_url, caddyfile) - _log(f"INFO: applied config (routes={len(routes)}, status={status})") + 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: - _log(f"ERROR: http failure {e.code} {e.reason}") + 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: - _log(f"ERROR: connection failure: {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: - _log(f"ERROR: {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) diff --git a/Apps/caddy-autogen/caddy/Caddyfile b/Apps/caddy-autogen/caddy/Caddyfile index 3213def..9451418 100644 --- a/Apps/caddy-autogen/caddy/Caddyfile +++ b/Apps/caddy-autogen/caddy/Caddyfile @@ -2,6 +2,19 @@ admin {$CADDY_ADMIN} } +:31820 { + @allowed remote_ip private_ranges + handle @allowed { + @status_json path /status.json + handle @status_json { + reverse_proxy discovery-agent:8089 + } + root * /srv/status + file_server + } + respond "forbidden" 403 +} + :80 { respond "caddy-autogen initializing" 200 } diff --git a/Apps/caddy-autogen/caddy/Dockerfile b/Apps/caddy-autogen/caddy/Dockerfile index a837e46..7617ac2 100644 --- a/Apps/caddy-autogen/caddy/Dockerfile +++ b/Apps/caddy-autogen/caddy/Dockerfile @@ -5,3 +5,4 @@ RUN xcaddy build \ FROM caddy:2.10.2-alpine COPY --from=builder /usr/bin/caddy /usr/bin/caddy COPY Caddyfile /etc/caddy/Caddyfile +COPY status/ /srv/status/ diff --git a/Apps/caddy-autogen/caddy/status/index.html b/Apps/caddy-autogen/caddy/status/index.html new file mode 100644 index 0000000..f2f4045 --- /dev/null +++ b/Apps/caddy-autogen/caddy/status/index.html @@ -0,0 +1,186 @@ + + + + + + Caddy AutoGen Status + + + +
+
+

Caddy AutoGen Status

+ +
+
Loading...
+ +
+
+
Caddy Apply
+
-
+
+
+
Cloudflare Reachability
+
-
+
+
+
Cloudflare Token
+
-
+
+
+
Routes
+
-
+
+
+ +
+
Generated Hosts
+ + + + + + + + + + + +
HostUpstreamSchemePathLE Cert
+
+ +
+
Last Error
+
-
+
+
+ + + + diff --git a/Apps/caddy-autogen/config/defaults.yaml b/Apps/caddy-autogen/config/defaults.yaml index f6b1636..e683ec5 100644 --- a/Apps/caddy-autogen/config/defaults.yaml +++ b/Apps/caddy-autogen/config/defaults.yaml @@ -10,3 +10,8 @@ poll_seconds: 15 require_cloudflare: true allow_internal_tls_fallback: false container_name_denylist: "caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy" +status_bind: "0.0.0.0:8089" +status_ui_port: 31820 +status_upstream: "discovery-agent:8089" +cf_verify_url: "https://api.cloudflare.com/client/v4/user/tokens/verify" +caddy_data_dir: "/caddy-data" diff --git a/Apps/caddy-autogen/docker-compose.yaml b/Apps/caddy-autogen/docker-compose.yaml index f5b7b27..00cec68 100644 --- a/Apps/caddy-autogen/docker-compose.yaml +++ b/Apps/caddy-autogen/docker-compose.yaml @@ -19,6 +19,9 @@ services: - target: 443 published: 4431 protocol: tcp + - target: 31820 + published: 31820 + protocol: tcp volumes: - type: bind source: /DATA/AppData/$AppID/caddy/data @@ -45,6 +48,9 @@ services: - container: "443" description: en_us: HTTPS ingress + - container: "31820" + description: + en_us: Local/LAN status UI volumes: - container: /data description: @@ -105,12 +111,21 @@ services: DEFAULT_SCHEME: http DEFAULT_PATH: / DEFAULT_HEALTH_URI: + STATUS_BIND: 0.0.0.0:8089 + STATUS_UI_PORT: 31820 + STATUS_UPSTREAM: discovery-agent:8089 + CF_VERIFY_URL: https://api.cloudflare.com/client/v4/user/tokens/verify + CADDY_DATA_DIR: /caddy-data CONFIG_FILE: /app/config/defaults.yaml volumes: - type: bind source: /DATA/AppData/$AppID/config target: /app/config read_only: true + - type: bind + source: /DATA/AppData/$AppID/caddy/data + target: /caddy-data + read_only: true read_only: true tmpfs: - /tmp diff --git a/Apps/caddy-autogen/tests/integration_tests.py b/Apps/caddy-autogen/tests/integration_tests.py index f35a836..ff36bdb 100644 --- a/Apps/caddy-autogen/tests/integration_tests.py +++ b/Apps/caddy-autogen/tests/integration_tests.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 import importlib.util +import json +import tempfile from pathlib import Path @@ -93,8 +95,13 @@ def test_optin_route_selection(module): allow_internal_tls_fallback=False, wildcard_domain="home.example.test", cert_email="", + status_ui_port=31820, + status_upstream="discovery-agent:8089", ) + assert_true(":31820" in caddyfile, "expected status UI server block") + assert_true("remote_ip private_ranges" in caddyfile, "expected LAN-only restriction") + assert_true("reverse_proxy discovery-agent:8089" in caddyfile, "expected status upstream") assert_true("frigate.home.example.test" in caddyfile, "expected frigate host in caddyfile") assert_true("reverse_proxy http://host.docker.internal:5000" in caddyfile, "expected web port route") assert_true("8554" not in caddyfile and "8555" not in caddyfile, "media ports must not be routed") @@ -122,6 +129,8 @@ def test_fail_closed_and_internal_fallback(module): allow_internal_tls_fallback=False, wildcard_domain="", cert_email="", + status_ui_port=31820, + status_upstream="discovery-agent:8089", ) except RuntimeError as exc: failed = True @@ -135,15 +144,60 @@ def test_fail_closed_and_internal_fallback(module): allow_internal_tls_fallback=True, wildcard_domain="", cert_email="", + status_ui_port=31820, + status_upstream="discovery-agent:8089", ) assert_true("local_certs" in fallback_caddyfile, "expected local_certs in fallback mode") assert_true("tls internal" in fallback_caddyfile, "expected internal tls in fallback mode") +def test_cloudflare_verify_and_cert_discovery(module): + class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_urlopen(req, timeout=0): + _ = req + _ = timeout + return FakeResponse({"success": True}) + + original_urlopen = module.urllib.request.urlopen + module.urllib.request.urlopen = fake_urlopen + try: + status = module._verify_cloudflare_token("https://api.cloudflare.com/client/v4/user/tokens/verify", "token") + finally: + module.urllib.request.urlopen = original_urlopen + + assert_true(status["reachable"] is True, "cloudflare should be reachable in mocked success") + assert_true(status["token_valid"] is True, "token should be valid in mocked success") + + with tempfile.TemporaryDirectory() as td: + cert_dir = Path(td) / "caddy" / "certificates" / "acme-v02.api.letsencrypt.org-directory" / "example.com" + cert_dir.mkdir(parents=True, exist_ok=True) + (cert_dir / "demo.home.example.test.crt").write_text("fake", encoding="utf-8") + (cert_dir / "_.home.example.test.crt").write_text("fake", encoding="utf-8") + + hosts = module._collect_letsencrypt_hosts(td) + assert_true("demo.home.example.test" in hosts, "expected concrete cert host") + assert_true("*.home.example.test" in hosts, "expected wildcard cert host conversion") + assert_true(module._has_matching_le_cert("api.home.example.test", hosts), "wildcard should match") + assert_true(module._has_matching_le_cert("demo.home.example.test", hosts), "exact cert should match") + + def main(): module = load_agent_module() test_optin_route_selection(module) test_fail_closed_and_internal_fallback(module) + test_cloudflare_verify_and_cert_discovery(module) print("Integration tests passed")