Add LAN-only status UI for caddy-autogen

This commit is contained in:
Joachim Friberg
2026-03-23 12:47:30 +01:00
parent 5b15a0aedd
commit 2346d5a096
9 changed files with 590 additions and 34 deletions
+18 -25
View File
@@ -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.
+27 -2
View File
@@ -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.
+271 -7
View File
@@ -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)
+13
View File
@@ -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
} }
+1
View File
@@ -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/
+186
View File
@@ -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>
+5
View File
@@ -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"
+15
View File
@@ -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")