Add LAN-only status UI for caddy-autogen
This commit is contained in:
@@ -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
|
||||||
- `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
|
## 3) Positivt test: status-UI + endpoint
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. Vänta 15-30 sekunder (default polling).
|
1. Vänta 15-30 sekunder (default polling).
|
||||||
2. Öppna `https://frigate.home.example.com`.
|
2. Öppna `http://<zima-lan-ip>:31820/`.
|
||||||
3. Kontrollera att sidan svarar med giltigt certifikat.
|
3. Verifiera i UI:
|
||||||
4. Kontrollera att media-portar (8554/8555) inte blivit egna hostnamn/endpoints.
|
- `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:
|
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.
|
- Certifikat utfärdas via Let's Encrypt DNS-01.
|
||||||
- Endast explicit målport routas.
|
|
||||||
|
|
||||||
## 4) Negativt test: fail-closed
|
## 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.
|
1. Ta bort `LABEL_CADDY_ENABLE=true` från målcontainern.
|
||||||
2. Vänta en polling-cykel.
|
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`.
|
1. Sätt `REQUIRE_CLOUDFLARE=true`.
|
||||||
2. Ta bort eller ogiltiggör `CLOUDFLARE_API_TOKEN`.
|
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:
|
Förväntat resultat:
|
||||||
|
|
||||||
- Appen exponerar inte tjänster oavsiktligt.
|
- 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
|
## 5) Kommandon för snabb verifiering
|
||||||
|
|
||||||
Byt `<domain>` och `<host>` enligt din miljö.
|
Byt `<domain>`, `<host>` och `<zima-lan-ip>` enligt din miljö.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -sS http://<zima-lan-ip>:31820/status.json | jq .
|
||||||
nslookup frigate.home.example.com
|
nslookup frigate.home.example.com
|
||||||
curl -vk https://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 </dev/null
|
openssl s_client -connect frigate.home.example.com:443 -servername frigate.home.example.com </dev/null
|
||||||
@@ -106,6 +98,7 @@ Samla detta först. Det kortar felsökningstiden avsevärt.
|
|||||||
- Målcontainerns publicerade portar.
|
- Målcontainerns publicerade portar.
|
||||||
|
|
||||||
3. Nät/TLS-bevis:
|
3. Nät/TLS-bevis:
|
||||||
|
- `curl -sS http://<zima-lan-ip>:31820/status.json | jq .`.
|
||||||
- `nslookup`-svar från en LAN-klient.
|
- `nslookup`-svar från en LAN-klient.
|
||||||
- `curl -vk` output mot endpoint.
|
- `curl -vk` output mot endpoint.
|
||||||
- `openssl s_client` sammanfattning av cert/issuer.
|
- `openssl s_client` sammanfattning av cert/issuer.
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ Arkitektur:
|
|||||||
- Endast explicit opt-in exponeras (`LABEL_CADDY_ENABLE=true`).
|
- Endast explicit opt-in exponeras (`LABEL_CADDY_ENABLE=true`).
|
||||||
- Caddy config laddas dynamiskt via Caddy Admin API (`POST /load`).
|
- Caddy config laddas dynamiskt via Caddy Admin API (`POST /load`).
|
||||||
- TLS sker med Let's Encrypt DNS-01 via Cloudflare.
|
- 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://<zima-lan-ip>: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
|
## Säkerhetsmodell
|
||||||
|
|
||||||
@@ -15,6 +34,7 @@ Arkitektur:
|
|||||||
- Ingen auto-exponering av alla portar.
|
- Ingen auto-exponering av alla portar.
|
||||||
- Endast HTTP(S)-upstreams stöds i v1.
|
- Endast HTTP(S)-upstreams stöds i v1.
|
||||||
- Docker socket exponeras inte direkt till agenten, endast via `socket-proxy` med begränsade endpoint-flaggor.
|
- 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å)
|
## Miljövariabler (appnivå)
|
||||||
|
|
||||||
@@ -24,6 +44,9 @@ Arkitektur:
|
|||||||
- `REQUIRE_CLOUDFLARE` (default `true`)
|
- `REQUIRE_CLOUDFLARE` (default `true`)
|
||||||
- `ALLOW_INTERNAL_TLS_FALLBACK` (default `false`)
|
- `ALLOW_INTERNAL_TLS_FALLBACK` (default `false`)
|
||||||
- `POLL_SECONDS` (default `15`)
|
- `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:
|
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
|
./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),
|
- opt-in-regler (endast markerade containers exponeras),
|
||||||
- säker portselektionslogik (inga media/UDP-portar routas av misstag),
|
- 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.
|
För verifiering i riktig ZimaOS-miljö, se `HOW_TO_VERIFY.md` i samma mapp.
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
@@ -116,7 +118,15 @@ def _build_fqdn(host_hint: str, base_domain: str) -> str:
|
|||||||
return fqdn
|
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 = []
|
routes = []
|
||||||
containers = _get_json(f"{docker_api_url}/containers/json?all=0")
|
containers = _get_json(f"{docker_api_url}/containers/json?all=0")
|
||||||
for c in containers:
|
for c in containers:
|
||||||
@@ -175,7 +185,32 @@ def _collect_routes(docker_api_url: str, env_prefix: str, denylist: set, base_do
|
|||||||
return routes
|
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:
|
if require_cloudflare and not token:
|
||||||
raise RuntimeError("CLOUDFLARE_API_TOKEN is required in fail-closed mode")
|
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("}")
|
||||||
out.append("")
|
out.append("")
|
||||||
|
|
||||||
|
_append_status_site(out, status_ui_port=status_ui_port, status_upstream=status_upstream)
|
||||||
|
|
||||||
if wildcard_domain and token:
|
if wildcard_domain and token:
|
||||||
out.append(f"{wildcard_domain}, *.{wildcard_domain} {{")
|
out.append(f"{wildcard_domain}, *.{wildcard_domain} {{")
|
||||||
out.append(" tls {")
|
out.append(" tls {")
|
||||||
@@ -237,6 +274,147 @@ def _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_inte
|
|||||||
return "\n".join(out)
|
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():
|
def main():
|
||||||
defaults = _read_simple_yaml(os.getenv("CONFIG_FILE", "/app/config/defaults.yaml"))
|
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_scheme = str(_cfg("DEFAULT_SCHEME", defaults, "default_scheme", "http")).strip().lower()
|
||||||
default_path = str(_cfg("DEFAULT_PATH", defaults, "default_path", "/")).strip() or "/"
|
default_path = str(_cfg("DEFAULT_PATH", defaults, "default_path", "/")).strip() or "/"
|
||||||
default_health_uri = str(_cfg("DEFAULT_HEALTH_URI", defaults, "default_health_uri", "")).strip()
|
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)
|
poll_seconds_raw = _cfg("POLL_SECONDS", defaults, "poll_seconds", 15)
|
||||||
try:
|
try:
|
||||||
@@ -268,12 +465,37 @@ def main():
|
|||||||
token = os.getenv("CLOUDFLARE_API_TOKEN", "").strip()
|
token = os.getenv("CLOUDFLARE_API_TOKEN", "").strip()
|
||||||
last_digest = ""
|
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(
|
_log(
|
||||||
"INFO: starting caddy-autogen discovery-agent "
|
"INFO: starting caddy-autogen discovery-agent "
|
||||||
f"(docker_api_url={docker_api_url}, caddy_load_url={caddy_load_url}, poll_seconds={poll_seconds})"
|
f"(docker_api_url={docker_api_url}, caddy_load_url={caddy_load_url}, poll_seconds={poll_seconds})"
|
||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
tick_ts = int(time.time())
|
||||||
try:
|
try:
|
||||||
routes = _collect_routes(
|
routes = _collect_routes(
|
||||||
docker_api_url=docker_api_url,
|
docker_api_url=docker_api_url,
|
||||||
@@ -284,6 +506,16 @@ def main():
|
|||||||
default_path=default_path,
|
default_path=default_path,
|
||||||
default_health_uri=default_health_uri,
|
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(
|
caddyfile = _generate_caddyfile(
|
||||||
routes=routes,
|
routes=routes,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -291,21 +523,53 @@ def main():
|
|||||||
allow_internal_tls_fallback=allow_internal_tls_fallback,
|
allow_internal_tls_fallback=allow_internal_tls_fallback,
|
||||||
wildcard_domain=wildcard_domain,
|
wildcard_domain=wildcard_domain,
|
||||||
cert_email=cert_email,
|
cert_email=cert_email,
|
||||||
|
status_ui_port=status_ui_port,
|
||||||
|
status_upstream=status_upstream,
|
||||||
)
|
)
|
||||||
|
|
||||||
digest = hashlib.sha256(caddyfile.encode("utf-8")).hexdigest()
|
digest = hashlib.sha256(caddyfile.encode("utf-8")).hexdigest()
|
||||||
|
apply_ok = True
|
||||||
|
apply_status = 0
|
||||||
|
last_error = ""
|
||||||
if digest != last_digest:
|
if digest != last_digest:
|
||||||
status = _post_caddyfile(caddy_load_url, caddyfile)
|
apply_status = _post_caddyfile(caddy_load_url, caddyfile)
|
||||||
_log(f"INFO: applied config (routes={len(routes)}, status={status})")
|
_log(f"INFO: applied config (routes={len(routes)}, status={apply_status})")
|
||||||
last_digest = digest
|
last_digest = digest
|
||||||
else:
|
else:
|
||||||
_log(f"INFO: no config changes (routes={len(routes)})")
|
_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:
|
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:
|
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:
|
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)
|
time.sleep(poll_seconds)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
admin {$CADDY_ADMIN}
|
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 {
|
:80 {
|
||||||
respond "caddy-autogen initializing" 200
|
respond "caddy-autogen initializing" 200
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ RUN xcaddy build \
|
|||||||
FROM caddy:2.10.2-alpine
|
FROM caddy:2.10.2-alpine
|
||||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY status/ /srv/status/
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Caddy AutoGen Status</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--panel: #111827;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--ok: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--bad: #ef4444;
|
||||||
|
--text: #e5e7eb;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Iosevka", "Menlo", "Consolas", monospace;
|
||||||
|
background: radial-gradient(circle at top, #1e293b, #0b1020 60%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 8px; }
|
||||||
|
.subtitle { color: var(--muted); margin-bottom: 16px; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: color-mix(in oklab, var(--panel), black 8%);
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em; }
|
||||||
|
.value { font-size: 20px; margin-top: 6px; }
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.bad { color: var(--bad); }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.raw {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.topline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #2563eb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="topline">
|
||||||
|
<h1>Caddy AutoGen Status</h1>
|
||||||
|
<button id="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="subtitle" id="updated">Loading...</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Caddy Apply</div>
|
||||||
|
<div class="value" id="apply-status">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Cloudflare Reachability</div>
|
||||||
|
<div class="value" id="cf-reachable">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Cloudflare Token</div>
|
||||||
|
<div class="value" id="cf-token">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Routes</div>
|
||||||
|
<div class="value" id="route-count">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 12px;">
|
||||||
|
<div class="label">Generated Hosts</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Upstream</th>
|
||||||
|
<th>Scheme</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>LE Cert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="routes-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Last Error</div>
|
||||||
|
<div id="last-error" class="raw">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function stateText(ok) {
|
||||||
|
return ok ? ["ok", "OK"] : ["bad", "FAIL"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEl(id, text, cls) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("ok", "warn", "bad");
|
||||||
|
if (cls) el.classList.add(cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTs(ts) {
|
||||||
|
if (!ts) return "-";
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
const res = await fetch('/status.json', { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error('status fetch failed: ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const applyPair = stateText(Boolean(data.last_apply_ok));
|
||||||
|
setEl('apply-status', applyPair[1] + (data.last_apply_http_status ? ` (${data.last_apply_http_status})` : ''), applyPair[0]);
|
||||||
|
|
||||||
|
const reachablePair = stateText(Boolean(data.cloudflare && data.cloudflare.reachable));
|
||||||
|
setEl('cf-reachable', reachablePair[1], reachablePair[0]);
|
||||||
|
|
||||||
|
const tokenPair = stateText(Boolean(data.cloudflare && data.cloudflare.token_valid));
|
||||||
|
setEl('cf-token', tokenPair[1], tokenPair[0]);
|
||||||
|
|
||||||
|
setEl('route-count', String((data.routes || []).length));
|
||||||
|
setEl('updated', `Last tick: ${fmtTs(data.last_tick_ts)} | Cloudflare check: ${fmtTs(data.cloudflare && data.cloudflare.last_check_ts)}`);
|
||||||
|
|
||||||
|
const certByHost = new Map((data.certs || []).map((c) => [c.fqdn, c.letsencrypt_present]));
|
||||||
|
const rows = (data.routes || []).map((r) => {
|
||||||
|
const cert = certByHost.get(r.fqdn) ? 'yes' : 'no';
|
||||||
|
return `<tr><td>${r.fqdn}</td><td>${r.upstream}</td><td>${r.scheme}</td><td>${r.path}</td><td>${cert}</td></tr>`;
|
||||||
|
});
|
||||||
|
document.getElementById('routes-body').innerHTML = rows.join('') || '<tr><td colspan="5">No routes</td></tr>';
|
||||||
|
|
||||||
|
const err = data.last_error || data.cloudflare?.error || '';
|
||||||
|
document.getElementById('last-error').textContent = err || '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err) {
|
||||||
|
setEl('updated', 'Error: ' + err.message, 'bad');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refresh').addEventListener('click', refresh);
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,3 +10,8 @@ poll_seconds: 15
|
|||||||
require_cloudflare: true
|
require_cloudflare: true
|
||||||
allow_internal_tls_fallback: false
|
allow_internal_tls_fallback: false
|
||||||
container_name_denylist: "caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy"
|
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"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ services:
|
|||||||
- target: 443
|
- target: 443
|
||||||
published: 4431
|
published: 4431
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
- target: 31820
|
||||||
|
published: 31820
|
||||||
|
protocol: tcp
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/$AppID/caddy/data
|
source: /DATA/AppData/$AppID/caddy/data
|
||||||
@@ -45,6 +48,9 @@ services:
|
|||||||
- container: "443"
|
- container: "443"
|
||||||
description:
|
description:
|
||||||
en_us: HTTPS ingress
|
en_us: HTTPS ingress
|
||||||
|
- container: "31820"
|
||||||
|
description:
|
||||||
|
en_us: Local/LAN status UI
|
||||||
volumes:
|
volumes:
|
||||||
- container: /data
|
- container: /data
|
||||||
description:
|
description:
|
||||||
@@ -105,12 +111,21 @@ services:
|
|||||||
DEFAULT_SCHEME: http
|
DEFAULT_SCHEME: http
|
||||||
DEFAULT_PATH: /
|
DEFAULT_PATH: /
|
||||||
DEFAULT_HEALTH_URI:
|
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
|
CONFIG_FILE: /app/config/defaults.yaml
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/$AppID/config
|
source: /DATA/AppData/$AppID/config
|
||||||
target: /app/config
|
target: /app/config
|
||||||
read_only: true
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: /DATA/AppData/$AppID/caddy/data
|
||||||
|
target: /caddy-data
|
||||||
|
read_only: true
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -93,8 +95,13 @@ def test_optin_route_selection(module):
|
|||||||
allow_internal_tls_fallback=False,
|
allow_internal_tls_fallback=False,
|
||||||
wildcard_domain="home.example.test",
|
wildcard_domain="home.example.test",
|
||||||
cert_email="",
|
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("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("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")
|
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,
|
allow_internal_tls_fallback=False,
|
||||||
wildcard_domain="",
|
wildcard_domain="",
|
||||||
cert_email="",
|
cert_email="",
|
||||||
|
status_ui_port=31820,
|
||||||
|
status_upstream="discovery-agent:8089",
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
failed = True
|
failed = True
|
||||||
@@ -135,15 +144,60 @@ def test_fail_closed_and_internal_fallback(module):
|
|||||||
allow_internal_tls_fallback=True,
|
allow_internal_tls_fallback=True,
|
||||||
wildcard_domain="",
|
wildcard_domain="",
|
||||||
cert_email="",
|
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("local_certs" in fallback_caddyfile, "expected local_certs in fallback mode")
|
||||||
assert_true("tls internal" in fallback_caddyfile, "expected internal tls 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():
|
def main():
|
||||||
module = load_agent_module()
|
module = load_agent_module()
|
||||||
test_optin_route_selection(module)
|
test_optin_route_selection(module)
|
||||||
test_fail_closed_and_internal_fallback(module)
|
test_fail_closed_and_internal_fallback(module)
|
||||||
|
test_cloudflare_verify_and_cert_discovery(module)
|
||||||
print("Integration tests passed")
|
print("Integration tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user