Add caddy-autogen app, tests, and agent policy updates

This commit is contained in:
Joachim Friberg
2026-03-18 16:19:01 +01:00
commit 9ea8ca421b
19 changed files with 1273 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Caddy AutoGen
Caddy AutoGen bygger Caddy-routes "on the fly" från körande containers i ZimaOS/CasaOS.
Arkitektur:
- `discovery-agent` läser Docker metadata via `socket-proxy`.
- 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.
## Säkerhetsmodell
- Fail-closed default: om `REQUIRE_CLOUDFLARE=true` och token saknas laddas ingen ny extern config.
- 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.
## Miljövariabler (appnivå)
- `BASE_DOMAIN` (krävs), t.ex. `home.example.com`
- `CLOUDFLARE_API_TOKEN` (krävs i fail-closed läge)
- `WILDCARD_DOMAIN` (valfri), t.ex. `home.example.com`
- `REQUIRE_CLOUDFLARE` (default `true`)
- `ALLOW_INTERNAL_TLS_FALLBACK` (default `false`)
- `POLL_SECONDS` (default `15`)
Cloudflare-token bör vara scoped till minsta möjliga rättigheter:
- `Zone.Zone:Read`
- `Zone.DNS:Edit`
## Miljövariabler på målcontainers (opt-in)
Sätt dessa på varje container som ska exponeras:
- `LABEL_CADDY_ENABLE=true`
- `LABEL_CADDY_TARGET_PORT=<container_tcp_port>`
Valfria:
- `LABEL_CADDY_HOST=<subdomain-or-fqdn>`
- `LABEL_CADDY_SCHEME=http|https`
- `LABEL_CADDY_PATH=/`
- `LABEL_CADDY_HEALTH_URI=/health`
### Exempel (Frigate)
För Frigate med portar `5000`, `8554`, `8555`:
- Sätt `LABEL_CADDY_ENABLE=true`
- Sätt `LABEL_CADDY_TARGET_PORT=5000`
Då skapas endpoint bara för web UI-porten (`5000`), inte för RTSP/WebRTC-portarna.
Konkreta env-vars att sätta på Frigate-containern:
```text
LABEL_CADDY_ENABLE=true
LABEL_CADDY_TARGET_PORT=5000
LABEL_CADDY_HOST=frigate
LABEL_CADDY_SCHEME=http
LABEL_CADDY_PATH=/
```
## Lokal DNS för split-horizon
Rekommenderad stack: **AdGuard Home**.
- Skapa lokala records/rewrite för `*.BASE_DOMAIN` och `BASE_DOMAIN` mot ZimaOS-serverns LAN-IP.
- Behåll samma publika zon i Cloudflare för DNS-01-validering.
Alternativ som fungerar bra i ZimaOS:
- Technitium DNS (mer avancerad DNS-kontroll)
- Pi-hole (+ ev. Unbound)
## Säkerhetsavvikelser
Följande högriskdetaljer är medvetna designval:
- Använder Docker API via `socket-proxy` (inte direkt socket i discovery-agent).
- Caddy måste exponera 80/443 för ingress.
Riskreduktion:
- socket-proxy endpoint-whitelist (`CONTAINERS`, `EVENTS`, `INFO`, `NETWORKS`, `PING`, `VERSION`)
- `POST=0`
- `no-new-privileges`
- read-only där möjligt
## Integrationstester
Kör lokala integrationstester för discovery-agenten:
```bash
./Apps/caddy-autogen/tests/run_integration_tests.sh
```
Testerna mockar Docker API-svar och Caddy `/load`-anrop 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.
+5
View File
@@ -0,0 +1,5 @@
FROM python:3.12.8-alpine
WORKDIR /app
COPY discovery_agent.py /app/discovery_agent.py
USER 65532:65532
CMD ["python3", "/app/discovery_agent.py"]
+318
View File
@@ -0,0 +1,318 @@
#!/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)
+7
View File
@@ -0,0 +1,7 @@
{
admin {$CADDY_ADMIN}
}
:80 {
respond "caddy-autogen initializing" 200
}
+7
View File
@@ -0,0 +1,7 @@
FROM caddy:2.10.2-builder-alpine AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2.10.2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
+12
View File
@@ -0,0 +1,12 @@
# Caddy AutoGen defaults
base_domain: ""
wildcard_domain: ""
cert_email: ""
env_prefix: "LABEL_CADDY_"
default_scheme: "http"
default_path: "/"
default_health_uri: ""
poll_seconds: 15
require_cloudflare: true
allow_internal_tls_fallback: false
container_name_denylist: "caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy"
+162
View File
@@ -0,0 +1,162 @@
name: caddy-autogen
services:
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
container_name: caddy-autogen
restart: unless-stopped
environment:
TZ: ${TZ}
CADDY_ADMIN: ${CADDY_ADMIN:-0.0.0.0:2019}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
ports:
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/$AppID/caddy/data
target: /data
- type: bind
source: /DATA/AppData/$AppID/caddy/config
target: /config
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
x-casaos:
envs:
- container: CADDY_ADMIN
description:
en_us: Caddy admin endpoint bind address
- container: CLOUDFLARE_API_TOKEN
description:
en_us: Cloudflare API token (Zone Read + DNS Edit)
ports:
- container: "80"
description:
en_us: HTTP ingress
- container: "443"
description:
en_us: HTTPS ingress
volumes:
- container: /data
description:
en_us: Caddy runtime data and certificates
- container: /config
description:
en_us: Caddy configuration state
socket-proxy:
image: lscr.io/linuxserver/socket-proxy:version-24.02.26
container_name: caddy-autogen-socket-proxy
restart: unless-stopped
environment:
TZ: ${TZ}
CONTAINERS: 1
EVENTS: 1
INFO: 1
NETWORKS: 1
PING: 1
POST: 0
VERSION: 1
read_only: true
tmpfs:
- /run
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
discovery-agent:
build:
context: ./agent
dockerfile: Dockerfile
container_name: caddy-autogen-discovery
restart: unless-stopped
depends_on:
- caddy
- socket-proxy
environment:
TZ: ${TZ}
DOCKER_API_URL: ${DOCKER_API_URL:-http://socket-proxy:2375}
CADDY_LOAD_URL: ${CADDY_LOAD_URL:-http://caddy:2019/load}
BASE_DOMAIN: ${BASE_DOMAIN}
WILDCARD_DOMAIN: ${WILDCARD_DOMAIN:-}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CERT_EMAIL: ${CERT_EMAIL:-}
REQUIRE_CLOUDFLARE: ${REQUIRE_CLOUDFLARE:-true}
ALLOW_INTERNAL_TLS_FALLBACK: ${ALLOW_INTERNAL_TLS_FALLBACK:-false}
ENV_PREFIX: ${ENV_PREFIX:-LABEL_CADDY_}
POLL_SECONDS: ${POLL_SECONDS:-15}
CONTAINER_NAME_DENYLIST: ${CONTAINER_NAME_DENYLIST:-caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy}
DEFAULT_SCHEME: ${DEFAULT_SCHEME:-http}
DEFAULT_PATH: ${DEFAULT_PATH:-/}
DEFAULT_HEALTH_URI: ${DEFAULT_HEALTH_URI:-}
CONFIG_FILE: ${CONFIG_FILE:-/app/config/defaults.yaml}
volumes:
- type: bind
source: /DATA/AppData/$AppID/config
target: /app/config
read_only: true
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
x-casaos:
envs:
- container: BASE_DOMAIN
description:
en_us: Base domain used for endpoints, e.g. home.example.com
- container: WILDCARD_DOMAIN
description:
en_us: Optional wildcard certificate domain, e.g. home.example.com
- container: REQUIRE_CLOUDFLARE
description:
en_us: Fail closed when Cloudflare token is missing
- container: ALLOW_INTERNAL_TLS_FALLBACK
description:
en_us: Enable internal Caddy certificates when Cloudflare is unavailable
- container: POLL_SECONDS
description:
en_us: Docker state reconciliation interval
volumes:
- container: /app/config
description:
en_us: Discovery defaults configuration (read-only)
x-casaos:
architectures:
- amd64
- arm64
- arm
main: caddy
category: Network
author: Zima Apps Team
developer: Zima Apps Team
icon: https://caddyserver.com/resources/images/caddy-circle-lock.svg
tagline:
en_us: Auto-generate Caddy endpoints from running containers
description:
en_us: >-
Discovers ZimaOS containers through Docker API and generates Caddy routes on the fly.
Uses explicit env-based opt-in (LABEL_CADDY_*) with fail-closed defaults, Cloudflare DNS-01
certificates, and local split-horizon DNS compatibility.
title:
en_us: Caddy AutoGen
index: /
port_map: ${HTTPS_PORT:-443}
scheme: https
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
import importlib.util
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
AGENT_PATH = ROOT_DIR / "agent" / "discovery_agent.py"
def load_agent_module():
spec = importlib.util.spec_from_file_location("discovery_agent", AGENT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def assert_true(condition, message):
if not condition:
raise AssertionError(message)
def test_optin_route_selection(module):
containers = [
{"Id": "frigate123", "Names": ["/frigate"], "Image": "ghcr.io/blakeblackshear/frigate:stable"},
{"Id": "hidden123", "Names": ["/hidden-service"], "Image": "nginx:1.27"},
]
inspect_map = {
"frigate123": {
"Config": {
"Env": [
"LABEL_CADDY_ENABLE=true",
"LABEL_CADDY_TARGET_PORT=5000",
"LABEL_CADDY_SCHEME=http",
]
},
"NetworkSettings": {
"Ports": {
"5000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "5000"}],
"8554/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8554"}],
"8555/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8555"}],
"8555/udp": [{"HostIp": "0.0.0.0", "HostPort": "8555"}],
}
},
},
"hidden123": {
"Config": {
"Env": [
"LABEL_CADDY_ENABLE=false",
"LABEL_CADDY_TARGET_PORT=8080",
]
},
"NetworkSettings": {
"Ports": {
"8080/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}],
}
},
},
}
def fake_get_json(url: str):
if url.endswith("/containers/json?all=0"):
return containers
if "/containers/" in url and url.endswith("/json"):
container_id = url.rsplit("/containers/", 1)[1].rsplit("/json", 1)[0]
return inspect_map[container_id]
raise AssertionError(f"unexpected URL in test: {url}")
original = module._get_json
module._get_json = fake_get_json
try:
routes = module._collect_routes(
docker_api_url="http://docker.test",
env_prefix="LABEL_CADDY_",
denylist={"caddy-autogen", "caddy-autogen-discovery", "caddy-autogen-socket-proxy"},
base_domain="home.example.test",
default_scheme="http",
default_path="/",
default_health_uri="",
)
finally:
module._get_json = original
assert_true(len(routes) == 1, f"expected 1 route, got {len(routes)}")
route = routes[0]
assert_true(route["fqdn"] == "frigate.home.example.test", f"unexpected fqdn: {route['fqdn']}")
assert_true(route["upstream"] == "host.docker.internal:5000", f"unexpected upstream: {route['upstream']}")
caddyfile = module._generate_caddyfile(
routes=routes,
token="dummy-token",
require_cloudflare=True,
allow_internal_tls_fallback=False,
wildcard_domain="home.example.test",
cert_email="",
)
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")
assert_true("hidden-service.home.example.test" not in caddyfile, "non opt-in container must stay hidden")
def test_fail_closed_and_internal_fallback(module):
routes = [
{
"name": "demo",
"fqdn": "demo.home.example.test",
"scheme": "http",
"upstream": "host.docker.internal:5000",
"path": "/",
"health_uri": "",
}
]
failed = False
try:
module._generate_caddyfile(
routes=routes,
token="",
require_cloudflare=True,
allow_internal_tls_fallback=False,
wildcard_domain="",
cert_email="",
)
except RuntimeError as exc:
failed = True
assert_true("CLOUDFLARE_API_TOKEN" in str(exc), "expected explicit token error")
assert_true(failed, "expected fail-closed RuntimeError without Cloudflare token")
fallback_caddyfile = module._generate_caddyfile(
routes=routes,
token="",
require_cloudflare=False,
allow_internal_tls_fallback=True,
wildcard_domain="",
cert_email="",
)
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 main():
module = load_agent_module()
test_optin_route_selection(module)
test_fail_closed_and_internal_fallback(module)
print("Integration tests passed")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PYTHONDONTWRITEBYTECODE=1 python3 "$ROOT_DIR/tests/integration_tests.py"