Checkpoint: Cloudflare tunnel + public toggle, and static-runner frontends
Two features built this session, committed together as a checkpoint before the deployment-model refactor (they share files, so kept as one rollback point): - Public exposure via Cloudflare tunnel: a `public` toggle on services (requires proxy), gateway.public_domain/tunnel_id config, a cloudflared ingress generator (generators/tunnel.py) that maps <name>.<public_domain> to the gateway's internal host, deploy wiring, and docs/tunnel-setup.md. - Frontends as `static`-runner services: a RunStatic runner served by Caddy (file_server), no systemd unit (like `remote`); the Caddyfile generator now derives static routes from services instead of a behavior==frontend branch.
This commit is contained in:
@@ -118,6 +118,12 @@ class GatewayConfig:
|
||||
domain: str | None = None
|
||||
acme_email: str | None = None
|
||||
acme_dns_provider: str = "cloudflare"
|
||||
# Public exposure via the Cloudflare tunnel (optional). `public_domain` is the
|
||||
# separate zone public services are published at (e.g. "pub.payne.io" →
|
||||
# <service>.pub.payne.io), kept distinct from `domain` so internal subdomain
|
||||
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
|
||||
public_domain: str | None = None
|
||||
tunnel_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -265,6 +271,8 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
domain=gateway_data.get("domain"),
|
||||
acme_email=gateway_data.get("acme_email"),
|
||||
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
|
||||
public_domain=gateway_data.get("public_domain"),
|
||||
tunnel_id=gateway_data.get("tunnel_id"),
|
||||
)
|
||||
|
||||
# repo: field points to the git repo for repo-relative sources
|
||||
|
||||
@@ -28,6 +28,10 @@ from castle_core.generators.caddyfile import (
|
||||
generate_caddyfile_from_registry,
|
||||
service_proxy_targets,
|
||||
)
|
||||
from castle_core.generators.tunnel import (
|
||||
generate_tunnel_config,
|
||||
public_hostnames,
|
||||
)
|
||||
from castle_core.generators.systemd import (
|
||||
SECRET_ENV_DIR,
|
||||
generate_timer,
|
||||
@@ -82,6 +86,8 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
gateway_domain=config.gateway.domain,
|
||||
acme_email=config.gateway.acme_email,
|
||||
acme_dns_provider=config.gateway.acme_dns_provider,
|
||||
public_domain=config.gateway.public_domain,
|
||||
tunnel_id=config.gateway.tunnel_id,
|
||||
)
|
||||
|
||||
# Load existing registry to preserve entries not being redeployed,
|
||||
@@ -134,6 +140,9 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
caddyfile_path.write_text(caddyfile_content)
|
||||
result.messages.append(f"Caddyfile written: {caddyfile_path}")
|
||||
|
||||
# Generate the cloudflared tunnel ingress from the registry's public services.
|
||||
_write_tunnel_config(registry, result.messages)
|
||||
|
||||
# acme mode needs a domain + a DNS-provider token; warn (don't fail) if the
|
||||
# prerequisites the operator must set up by hand are missing.
|
||||
if (config.gateway.tls or "").lower() == "acme":
|
||||
@@ -184,6 +193,46 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
_TUNNEL_NAME = "tunnel" # the cloudflared service is castle-tunnel
|
||||
|
||||
|
||||
def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
|
||||
"""Write the cloudflared ingress config from the registry's public services.
|
||||
|
||||
No public services (or no tunnel configured) → remove any stale config and
|
||||
leave the tunnel down. Otherwise write it, list the hostnames that still need a
|
||||
DNS route, and restart the tunnel service if it's running so it takes effect.
|
||||
"""
|
||||
config_path = SPECS_DIR / "cloudflared.yml"
|
||||
content = generate_tunnel_config(registry)
|
||||
if content is None:
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
messages.append("No public services — removed cloudflared config.")
|
||||
return
|
||||
|
||||
config_path.write_text(content)
|
||||
hosts = public_hostnames(registry)
|
||||
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
|
||||
# DNS is not automatic: each public host needs a CNAME → the tunnel. Surface the
|
||||
# exact commands rather than silently assuming they're routed.
|
||||
tid = registry.node.tunnel_id
|
||||
for h in hosts:
|
||||
messages.append(f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})")
|
||||
|
||||
tunnel_unit = unit_name(_TUNNEL_NAME)
|
||||
active = subprocess.run(
|
||||
["systemctl", "--user", "is-active", tunnel_unit], capture_output=True, text=True
|
||||
)
|
||||
if active.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "--user", "restart", tunnel_unit], check=False)
|
||||
messages.append("Tunnel reloaded.")
|
||||
else:
|
||||
messages.append(
|
||||
"Tunnel not running — enable it with 'castle service enable tunnel'."
|
||||
)
|
||||
|
||||
|
||||
def _reload_gateway(messages: list[str]) -> None:
|
||||
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
||||
gw_unit = unit_name(_GATEWAY_NAME)
|
||||
@@ -287,14 +336,19 @@ def _build_deployed_service(
|
||||
# /data/castle/protonmail). Falls back to the service name.
|
||||
config_key = svc.program or name
|
||||
|
||||
managed = run.runner != "remote"
|
||||
# `remote` and `static` are caddy-managed (or unmanaged) — no local process, no
|
||||
# systemd unit. The gateway is their runtime.
|
||||
managed = run.runner not in ("remote", "static")
|
||||
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||
managed = False
|
||||
|
||||
# Routing comes from the shared deriver, so the registry written here and the
|
||||
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is the
|
||||
# checkbox; the subdomain is the service name.
|
||||
# checkbox; the subdomain is the service name. A `static` service is inherently
|
||||
# served (that's its whole purpose), so it's always exposed.
|
||||
expose, port, base_url = service_proxy_targets(name, svc)
|
||||
if run.runner == "static":
|
||||
expose = True
|
||||
health_path = None
|
||||
if svc.expose and svc.expose.http:
|
||||
health_path = svc.expose.http.health_path
|
||||
@@ -329,6 +383,11 @@ def _build_deployed_service(
|
||||
)
|
||||
stop_cmd = _build_stop_cmd(name, run, source_dir)
|
||||
|
||||
# A static service serves <program-source>/<run.root> via the gateway.
|
||||
static_root = None
|
||||
if run.runner == "static" and source_dir is not None:
|
||||
static_root = str(source_dir / run.root) # type: ignore[union-attr]
|
||||
|
||||
# Resolve stack from referenced program
|
||||
stack = None
|
||||
if svc.program and svc.program in config.programs:
|
||||
@@ -346,6 +405,8 @@ def _build_deployed_service(
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
subdomain=(name if expose else None),
|
||||
public=bool(getattr(svc, "public", False) and expose),
|
||||
static_root=static_root,
|
||||
base_url=base_url,
|
||||
managed=managed,
|
||||
)
|
||||
@@ -549,6 +610,9 @@ def _build_run_cmd(
|
||||
return cmd
|
||||
case "remote":
|
||||
return []
|
||||
case "static":
|
||||
# No process — the gateway file_servers this service's root.
|
||||
return []
|
||||
case _:
|
||||
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
|
||||
|
||||
|
||||
@@ -66,24 +66,46 @@ def service_proxy_targets(name: str, svc: ServiceSpec) -> ProxyTargets:
|
||||
return expose, port, base_url
|
||||
|
||||
|
||||
def _local_exposures(
|
||||
def _local_routes(
|
||||
config: CastleConfig | None, registry: NodeRegistry
|
||||
) -> list[tuple[str, ProxyTargets]]:
|
||||
"""Each local service's (expose?, port, base_url), name-sorted.
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Each local service's route as ``(name, kind, target)``, name-sorted.
|
||||
|
||||
Prefers ``castle.yaml`` (``config.services``) as the source of truth so a
|
||||
regenerated Caddyfile always reflects the current spec; falls back to the
|
||||
deployed registry snapshot when config isn't available.
|
||||
``kind`` is ``static`` (file-serve a built dir) or ``proxy`` (reverse-proxy a
|
||||
port/base_url). Prefers ``castle.yaml`` (``config.services``) as the source of
|
||||
truth so a regenerated Caddyfile always reflects the current spec; falls back to
|
||||
the deployed registry snapshot when config isn't available.
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
services = getattr(config, "services", None)
|
||||
if services is not None:
|
||||
return sorted(
|
||||
(name, service_proxy_targets(name, svc)) for name, svc in services.items()
|
||||
)
|
||||
return sorted(
|
||||
(name, (d.subdomain is not None, d.port, d.base_url))
|
||||
for name, d in registry.deployed.items()
|
||||
)
|
||||
for name, svc in sorted(services.items()):
|
||||
if svc.run.runner == "static":
|
||||
src = _program_source(config, svc.program)
|
||||
if src is not None:
|
||||
out.append((name, "static", str(src / svc.run.root)))
|
||||
continue
|
||||
expose, port, base_url = service_proxy_targets(name, svc)
|
||||
if expose and (port or base_url):
|
||||
out.append((name, "proxy", base_url or f"localhost:{port}"))
|
||||
return out
|
||||
# No config → route from the deployed registry snapshot.
|
||||
for name, d in sorted(registry.deployed.items()):
|
||||
if d.static_root:
|
||||
out.append((name, "static", d.static_root))
|
||||
elif d.subdomain and (d.port or d.base_url):
|
||||
out.append((name, "proxy", d.base_url or f"localhost:{d.port}"))
|
||||
return out
|
||||
|
||||
|
||||
def _program_source(config: CastleConfig | None, program: str | None):
|
||||
"""Absolute source dir of a referenced program (already resolved), or None."""
|
||||
if config is None or not program:
|
||||
return None
|
||||
prog = getattr(config, "programs", {}).get(program)
|
||||
if prog and prog.source:
|
||||
return Path(prog.source)
|
||||
return None
|
||||
|
||||
|
||||
def compute_routes(
|
||||
@@ -107,24 +129,12 @@ def compute_routes(
|
||||
node = registry.node.hostname
|
||||
routes: list[GatewayRoute] = []
|
||||
|
||||
# Exposed services → a subdomain reverse-proxy (address = service name).
|
||||
for name, (expose, port, base_url) in _local_exposures(config, registry):
|
||||
if expose and (port or base_url):
|
||||
target = base_url or f"localhost:{port}"
|
||||
routes.append(GatewayRoute(name, "proxy", target, name, node))
|
||||
|
||||
# Static frontends — a behavior=frontend program with build outputs and no
|
||||
# service of its own; served in place from <source>/<dist> at <name>.<domain>.
|
||||
if config is not None:
|
||||
for name, prog in sorted(config.programs.items()):
|
||||
if prog.behavior != "frontend" or not prog.source:
|
||||
continue
|
||||
if not (prog.build and prog.build.outputs):
|
||||
continue
|
||||
if name in config.services: # self-serving frontend → already a proxy route
|
||||
continue
|
||||
serve_dir = str(Path(prog.source) / prog.build.outputs[0])
|
||||
routes.append(GatewayRoute(name, "static", serve_dir, name, node))
|
||||
# Every route comes from a service. `static`-runner services file-serve their
|
||||
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
||||
# (Static frontends are `runner: static` services now — no separate program
|
||||
# branch, so routing derives from one place.)
|
||||
for name, kind, target in _local_routes(config, registry):
|
||||
routes.append(GatewayRoute(name, kind, target, name, node))
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
95
core/src/castle_core/generators/tunnel.py
Normal file
95
core/src/castle_core/generators/tunnel.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Cloudflare tunnel (cloudflared) ingress config generation.
|
||||
|
||||
Projects ``public: true`` services onto the public internet at
|
||||
``<subdomain>.<public_domain>``, bridging each to the gateway's existing internal
|
||||
host site so the tunnel reuses all of Caddy's routing and TLS. The public zone and
|
||||
the internal zone are deliberately different (``pub.payne.io`` vs
|
||||
``civil.payne.io``) so internal subdomain names never appear in public DNS; the
|
||||
ingress rewrites the Host header and TLS SNI to the *internal* name so Caddy routes
|
||||
the request correctly and its wildcard cert validates.
|
||||
|
||||
The generated config is locally-managed (a ``config.yml`` with an explicit ingress
|
||||
list), not a remotely-managed token tunnel — so the public surface is exactly the
|
||||
set of ``public: true`` services, generated here, not something edited in a
|
||||
dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.config import SECRETS_DIR
|
||||
from castle_core.registry import Deployment, NodeRegistry
|
||||
|
||||
# Cloudflared tunnel credentials (from `cloudflared tunnel create`) live here as a
|
||||
# secret, one JSON per tunnel id. The generated config references this path.
|
||||
TUNNEL_CREDENTIALS_DIR = SECRETS_DIR / "cloudflared"
|
||||
|
||||
# In acme mode Caddy serves each host site on :443 with a publicly-valid cert, so
|
||||
# the tunnel origin is the gateway on 443 (not the :<gateway_port> redirect site).
|
||||
_GATEWAY_ORIGIN = "https://localhost:443"
|
||||
|
||||
|
||||
def tunnel_credentials_path(tunnel_id: str) -> Path:
|
||||
"""Path to a tunnel's credentials JSON (kept in the secret store)."""
|
||||
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
|
||||
|
||||
|
||||
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
|
||||
"""The deployed services flagged public (and actually routed), name-sorted."""
|
||||
return sorted(
|
||||
(name, d)
|
||||
for name, d in registry.deployed.items()
|
||||
if d.public and d.subdomain
|
||||
)
|
||||
|
||||
|
||||
def public_hostnames(registry: NodeRegistry) -> list[str]:
|
||||
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``)."""
|
||||
dom = registry.node.public_domain
|
||||
if not dom:
|
||||
return []
|
||||
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
|
||||
|
||||
|
||||
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
"""Render the cloudflared ``config.yml``, or None if there's nothing to serve.
|
||||
|
||||
Returns None when the tunnel isn't configured (no ``tunnel_id`` /
|
||||
``public_domain`` / ``gateway_domain``) or no service is public — deploy then
|
||||
removes any stale config and leaves the tunnel down.
|
||||
"""
|
||||
node = registry.node
|
||||
if not (node.tunnel_id and node.public_domain and node.gateway_domain):
|
||||
return None
|
||||
pubs = public_deployments(registry)
|
||||
if not pubs:
|
||||
return None
|
||||
|
||||
ingress: list[dict] = []
|
||||
for _name, d in pubs:
|
||||
public_host = f"{d.subdomain}.{node.public_domain}"
|
||||
internal_host = f"{d.subdomain}.{node.gateway_domain}"
|
||||
ingress.append(
|
||||
{
|
||||
"hostname": public_host,
|
||||
"service": _GATEWAY_ORIGIN,
|
||||
# Bridge the public name to the internal host: Caddy routes by this
|
||||
# Host and its wildcard cert is issued for this SNI.
|
||||
"originRequest": {
|
||||
"originServerName": internal_host,
|
||||
"httpHostHeader": internal_host,
|
||||
},
|
||||
}
|
||||
)
|
||||
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
|
||||
ingress.append({"service": "http_status:404"})
|
||||
|
||||
config = {
|
||||
"tunnel": node.tunnel_id,
|
||||
"credentials-file": str(tunnel_credentials_path(node.tunnel_id)),
|
||||
"ingress": ingress,
|
||||
}
|
||||
return yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||
@@ -76,8 +76,23 @@ class RunRemote(RunBase):
|
||||
health_url: str | None = None
|
||||
|
||||
|
||||
class RunStatic(RunBase):
|
||||
"""A static site served by the gateway (Caddy ``file_server``), no process.
|
||||
|
||||
Like ``remote``, this is a service with no local process and no systemd unit —
|
||||
the gateway *is* its runtime. ``root`` is the built directory to serve, resolved
|
||||
relative to the referenced program's source (e.g. ``dist`` or ``public``).
|
||||
Building that directory is the program's concern; this only serves it.
|
||||
"""
|
||||
|
||||
runner: Literal["static"]
|
||||
root: str = "dist" # served dir, relative to the program source
|
||||
|
||||
|
||||
RunSpec = Annotated[
|
||||
Union[RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote],
|
||||
Union[
|
||||
RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote, RunStatic
|
||||
],
|
||||
Field(discriminator="runner"),
|
||||
]
|
||||
|
||||
@@ -255,6 +270,10 @@ class ServiceSpec(BaseModel):
|
||||
# Expose this service at <service-name>.<gateway.domain> through the gateway
|
||||
# (the subdomain is the service name). False → reachable only at its host:port.
|
||||
proxy: bool = False
|
||||
# Also expose this service to the public internet via the Cloudflare tunnel, at
|
||||
# <service-name>.<gateway.public_domain>. Default False — public is opt-in and
|
||||
# explicit. Requires proxy (the tunnel projects an already-routed subdomain).
|
||||
public: bool = False
|
||||
manage: ManageSpec | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
|
||||
@@ -263,6 +282,8 @@ class ServiceSpec(BaseModel):
|
||||
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||
if self.run.runner == "remote":
|
||||
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||
if self.public and not self.proxy:
|
||||
raise ValueError("public requires proxy (a service must be routed to be exposed).")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ class NodeConfig:
|
||||
gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains
|
||||
acme_email: str | None = None
|
||||
acme_dns_provider: str = "cloudflare"
|
||||
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
|
||||
public_domain: str | None = None
|
||||
tunnel_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.hostname:
|
||||
@@ -55,6 +58,12 @@ class Deployment:
|
||||
# Exposed at <subdomain>.<gateway.domain> (the subdomain is the service name),
|
||||
# or None when the service is reachable only at its host:port.
|
||||
subdomain: str | None = None
|
||||
# Also projected to the public internet via the tunnel at
|
||||
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
||||
public: bool = False
|
||||
# For `static` runner services: the absolute dir the gateway file_servers.
|
||||
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
|
||||
static_root: str | None = None
|
||||
base_url: str | None = None
|
||||
schedule: str | None = None
|
||||
managed: bool = False
|
||||
@@ -94,6 +103,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
gateway_domain=node_data.get("gateway_domain"),
|
||||
acme_email=node_data.get("acme_email"),
|
||||
acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"),
|
||||
public_domain=node_data.get("public_domain"),
|
||||
tunnel_id=node_data.get("tunnel_id"),
|
||||
)
|
||||
|
||||
deployed: dict[str, Deployment] = {}
|
||||
@@ -123,6 +134,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
subdomain=comp_data.get("subdomain"),
|
||||
public=comp_data.get("public", False),
|
||||
static_root=comp_data.get("static_root"),
|
||||
base_url=comp_data.get("base_url"),
|
||||
schedule=comp_data.get("schedule"),
|
||||
managed=comp_data.get("managed", False),
|
||||
@@ -157,6 +170,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
data["node"]["acme_email"] = registry.node.acme_email
|
||||
if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare":
|
||||
data["node"]["acme_dns_provider"] = registry.node.acme_dns_provider
|
||||
if registry.node.public_domain:
|
||||
data["node"]["public_domain"] = registry.node.public_domain
|
||||
if registry.node.tunnel_id:
|
||||
data["node"]["tunnel_id"] = registry.node.tunnel_id
|
||||
|
||||
for name, comp in registry.deployed.items():
|
||||
entry: dict = {
|
||||
@@ -180,6 +197,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["health_path"] = comp.health_path
|
||||
if comp.subdomain:
|
||||
entry["subdomain"] = comp.subdomain
|
||||
if comp.public:
|
||||
entry["public"] = comp.public
|
||||
if comp.static_root:
|
||||
entry["static_root"] = comp.static_root
|
||||
if comp.base_url:
|
||||
entry["base_url"] = comp.base_url
|
||||
if comp.schedule:
|
||||
|
||||
@@ -16,6 +16,7 @@ from castle_core.manifest import (
|
||||
HttpInternal,
|
||||
ProgramSpec,
|
||||
RunPython,
|
||||
RunStatic,
|
||||
ServiceSpec,
|
||||
)
|
||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
@@ -105,17 +106,16 @@ class TestAcmeMode:
|
||||
def test_static_frontend_is_a_file_server_subdomain(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Static frontends come from config.programs, so hand the generator a config.
|
||||
# A static frontend is a `runner: static` service serving <source>/<root>.
|
||||
import castle_core.config as config_mod
|
||||
|
||||
cfg = _config(
|
||||
services={},
|
||||
programs={
|
||||
"castle": ProgramSpec(
|
||||
behavior="frontend", source="/data/repos/castle/app",
|
||||
build=BuildSpec(outputs=["dist"]),
|
||||
services={
|
||||
"castle": ServiceSpec(
|
||||
program="castle", run=RunStatic(runner="static", root="dist")
|
||||
)
|
||||
},
|
||||
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
|
||||
)
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
|
||||
cf = generate_caddyfile_from_registry(_acme({}))
|
||||
|
||||
70
core/tests/test_tunnel.py
Normal file
70
core/tests/test_tunnel.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for cloudflared tunnel ingress generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.generators.tunnel import (
|
||||
generate_tunnel_config,
|
||||
public_hostnames,
|
||||
)
|
||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
|
||||
|
||||
def _registry(
|
||||
*,
|
||||
tunnel_id: str | None = "tid-123",
|
||||
public_domain: str | None = "pub.payne.io",
|
||||
gateway_domain: str | None = "civil.payne.io",
|
||||
deployed: dict[str, Deployment] | None = None,
|
||||
) -> NodeRegistry:
|
||||
return NodeRegistry(
|
||||
node=NodeConfig(
|
||||
hostname="civil",
|
||||
gateway_tls="acme",
|
||||
gateway_domain=gateway_domain,
|
||||
public_domain=public_domain,
|
||||
tunnel_id=tunnel_id,
|
||||
),
|
||||
deployed=deployed
|
||||
or {
|
||||
"app": Deployment(runner="python", run_cmd=["x"], port=9001,
|
||||
subdomain="app", public=True),
|
||||
"private": Deployment(runner="python", run_cmd=["y"], port=9002,
|
||||
subdomain="private", public=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_public_service_maps_public_zone_to_internal_host() -> None:
|
||||
cfg = yaml.safe_load(generate_tunnel_config(_registry()))
|
||||
assert cfg["tunnel"] == "tid-123"
|
||||
rules = {r.get("hostname"): r for r in cfg["ingress"] if "hostname" in r}
|
||||
# only the public service is mapped
|
||||
assert set(rules) == {"app.pub.payne.io"}
|
||||
r = rules["app.pub.payne.io"]
|
||||
assert r["service"] == "https://localhost:443"
|
||||
# public zone → internal host (Host + SNI rewritten so Caddy routes + cert validates)
|
||||
assert r["originRequest"]["httpHostHeader"] == "app.civil.payne.io"
|
||||
assert r["originRequest"]["originServerName"] == "app.civil.payne.io"
|
||||
|
||||
|
||||
def test_terminal_catch_all_present() -> None:
|
||||
cfg = yaml.safe_load(generate_tunnel_config(_registry()))
|
||||
assert cfg["ingress"][-1] == {"service": "http_status:404"}
|
||||
|
||||
|
||||
def test_private_service_not_in_public_dns() -> None:
|
||||
assert public_hostnames(_registry()) == ["app.pub.payne.io"]
|
||||
|
||||
|
||||
def test_none_when_no_public_services() -> None:
|
||||
only_private = {
|
||||
"x": Deployment(runner="python", run_cmd=["x"], subdomain="x", public=False)
|
||||
}
|
||||
assert generate_tunnel_config(_registry(deployed=only_private)) is None
|
||||
|
||||
|
||||
def test_none_when_tunnel_unconfigured() -> None:
|
||||
assert generate_tunnel_config(_registry(tunnel_id=None)) is None
|
||||
assert generate_tunnel_config(_registry(public_domain=None)) is None
|
||||
Reference in New Issue
Block a user