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
|
domain: str | None = None
|
||||||
acme_email: str | None = None
|
acme_email: str | None = None
|
||||||
acme_dns_provider: str = "cloudflare"
|
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
|
@dataclass
|
||||||
@@ -265,6 +271,8 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
domain=gateway_data.get("domain"),
|
domain=gateway_data.get("domain"),
|
||||||
acme_email=gateway_data.get("acme_email"),
|
acme_email=gateway_data.get("acme_email"),
|
||||||
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
|
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
|
# 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,
|
generate_caddyfile_from_registry,
|
||||||
service_proxy_targets,
|
service_proxy_targets,
|
||||||
)
|
)
|
||||||
|
from castle_core.generators.tunnel import (
|
||||||
|
generate_tunnel_config,
|
||||||
|
public_hostnames,
|
||||||
|
)
|
||||||
from castle_core.generators.systemd import (
|
from castle_core.generators.systemd import (
|
||||||
SECRET_ENV_DIR,
|
SECRET_ENV_DIR,
|
||||||
generate_timer,
|
generate_timer,
|
||||||
@@ -82,6 +86,8 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
|||||||
gateway_domain=config.gateway.domain,
|
gateway_domain=config.gateway.domain,
|
||||||
acme_email=config.gateway.acme_email,
|
acme_email=config.gateway.acme_email,
|
||||||
acme_dns_provider=config.gateway.acme_dns_provider,
|
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,
|
# 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)
|
caddyfile_path.write_text(caddyfile_content)
|
||||||
result.messages.append(f"Caddyfile written: {caddyfile_path}")
|
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
|
# 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.
|
# prerequisites the operator must set up by hand are missing.
|
||||||
if (config.gateway.tls or "").lower() == "acme":
|
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:
|
def _reload_gateway(messages: list[str]) -> None:
|
||||||
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
||||||
gw_unit = unit_name(_GATEWAY_NAME)
|
gw_unit = unit_name(_GATEWAY_NAME)
|
||||||
@@ -287,14 +336,19 @@ def _build_deployed_service(
|
|||||||
# /data/castle/protonmail). Falls back to the service name.
|
# /data/castle/protonmail). Falls back to the service name.
|
||||||
config_key = svc.program or 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:
|
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||||
managed = False
|
managed = False
|
||||||
|
|
||||||
# Routing comes from the shared deriver, so the registry written here and the
|
# Routing comes from the shared deriver, so the registry written here and the
|
||||||
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is 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)
|
expose, port, base_url = service_proxy_targets(name, svc)
|
||||||
|
if run.runner == "static":
|
||||||
|
expose = True
|
||||||
health_path = None
|
health_path = None
|
||||||
if svc.expose and svc.expose.http:
|
if svc.expose and svc.expose.http:
|
||||||
health_path = svc.expose.http.health_path
|
health_path = svc.expose.http.health_path
|
||||||
@@ -329,6 +383,11 @@ def _build_deployed_service(
|
|||||||
)
|
)
|
||||||
stop_cmd = _build_stop_cmd(name, run, source_dir)
|
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
|
# Resolve stack from referenced program
|
||||||
stack = None
|
stack = None
|
||||||
if svc.program and svc.program in config.programs:
|
if svc.program and svc.program in config.programs:
|
||||||
@@ -346,6 +405,8 @@ def _build_deployed_service(
|
|||||||
port=port,
|
port=port,
|
||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
subdomain=(name if expose else None),
|
subdomain=(name if expose else None),
|
||||||
|
public=bool(getattr(svc, "public", False) and expose),
|
||||||
|
static_root=static_root,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
managed=managed,
|
managed=managed,
|
||||||
)
|
)
|
||||||
@@ -549,6 +610,9 @@ def _build_run_cmd(
|
|||||||
return cmd
|
return cmd
|
||||||
case "remote":
|
case "remote":
|
||||||
return []
|
return []
|
||||||
|
case "static":
|
||||||
|
# No process — the gateway file_servers this service's root.
|
||||||
|
return []
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
|
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
|
return expose, port, base_url
|
||||||
|
|
||||||
|
|
||||||
def _local_exposures(
|
def _local_routes(
|
||||||
config: CastleConfig | None, registry: NodeRegistry
|
config: CastleConfig | None, registry: NodeRegistry
|
||||||
) -> list[tuple[str, ProxyTargets]]:
|
) -> list[tuple[str, str, str]]:
|
||||||
"""Each local service's (expose?, port, base_url), name-sorted.
|
"""Each local service's route as ``(name, kind, target)``, name-sorted.
|
||||||
|
|
||||||
Prefers ``castle.yaml`` (``config.services``) as the source of truth so a
|
``kind`` is ``static`` (file-serve a built dir) or ``proxy`` (reverse-proxy a
|
||||||
regenerated Caddyfile always reflects the current spec; falls back to the
|
port/base_url). Prefers ``castle.yaml`` (``config.services``) as the source of
|
||||||
deployed registry snapshot when config isn't available.
|
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)
|
services = getattr(config, "services", None)
|
||||||
if services is not None:
|
if services is not None:
|
||||||
return sorted(
|
for name, svc in sorted(services.items()):
|
||||||
(name, service_proxy_targets(name, svc)) for name, svc in services.items()
|
if svc.run.runner == "static":
|
||||||
)
|
src = _program_source(config, svc.program)
|
||||||
return sorted(
|
if src is not None:
|
||||||
(name, (d.subdomain is not None, d.port, d.base_url))
|
out.append((name, "static", str(src / svc.run.root)))
|
||||||
for name, d in registry.deployed.items()
|
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(
|
def compute_routes(
|
||||||
@@ -107,24 +129,12 @@ def compute_routes(
|
|||||||
node = registry.node.hostname
|
node = registry.node.hostname
|
||||||
routes: list[GatewayRoute] = []
|
routes: list[GatewayRoute] = []
|
||||||
|
|
||||||
# Exposed services → a subdomain reverse-proxy (address = service name).
|
# Every route comes from a service. `static`-runner services file-serve their
|
||||||
for name, (expose, port, base_url) in _local_exposures(config, registry):
|
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
||||||
if expose and (port or base_url):
|
# (Static frontends are `runner: static` services now — no separate program
|
||||||
target = base_url or f"localhost:{port}"
|
# branch, so routing derives from one place.)
|
||||||
routes.append(GatewayRoute(name, "proxy", target, name, node))
|
for name, kind, target in _local_routes(config, registry):
|
||||||
|
routes.append(GatewayRoute(name, kind, 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))
|
|
||||||
|
|
||||||
return routes
|
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
|
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[
|
RunSpec = Annotated[
|
||||||
Union[RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote],
|
Union[
|
||||||
|
RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote, RunStatic
|
||||||
|
],
|
||||||
Field(discriminator="runner"),
|
Field(discriminator="runner"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -255,6 +270,10 @@ class ServiceSpec(BaseModel):
|
|||||||
# Expose this service at <service-name>.<gateway.domain> through the gateway
|
# Expose this service at <service-name>.<gateway.domain> through the gateway
|
||||||
# (the subdomain is the service name). False → reachable only at its host:port.
|
# (the subdomain is the service name). False → reachable only at its host:port.
|
||||||
proxy: bool = False
|
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
|
manage: ManageSpec | None = None
|
||||||
defaults: DefaultsSpec | 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.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||||
if self.run.runner == "remote":
|
if self.run.runner == "remote":
|
||||||
raise ValueError("manage.systemd cannot be enabled for 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
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class NodeConfig:
|
|||||||
gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains
|
gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains
|
||||||
acme_email: str | None = None
|
acme_email: str | None = None
|
||||||
acme_dns_provider: str = "cloudflare"
|
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:
|
def __post_init__(self) -> None:
|
||||||
if not self.hostname:
|
if not self.hostname:
|
||||||
@@ -55,6 +58,12 @@ class Deployment:
|
|||||||
# Exposed at <subdomain>.<gateway.domain> (the subdomain is the service name),
|
# Exposed at <subdomain>.<gateway.domain> (the subdomain is the service name),
|
||||||
# or None when the service is reachable only at its host:port.
|
# or None when the service is reachable only at its host:port.
|
||||||
subdomain: str | None = None
|
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
|
base_url: str | None = None
|
||||||
schedule: str | None = None
|
schedule: str | None = None
|
||||||
managed: bool = False
|
managed: bool = False
|
||||||
@@ -94,6 +103,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
gateway_domain=node_data.get("gateway_domain"),
|
gateway_domain=node_data.get("gateway_domain"),
|
||||||
acme_email=node_data.get("acme_email"),
|
acme_email=node_data.get("acme_email"),
|
||||||
acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"),
|
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] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
@@ -123,6 +134,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
port=comp_data.get("port"),
|
port=comp_data.get("port"),
|
||||||
health_path=comp_data.get("health_path"),
|
health_path=comp_data.get("health_path"),
|
||||||
subdomain=comp_data.get("subdomain"),
|
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"),
|
base_url=comp_data.get("base_url"),
|
||||||
schedule=comp_data.get("schedule"),
|
schedule=comp_data.get("schedule"),
|
||||||
managed=comp_data.get("managed", False),
|
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
|
data["node"]["acme_email"] = registry.node.acme_email
|
||||||
if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare":
|
if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare":
|
||||||
data["node"]["acme_dns_provider"] = registry.node.acme_dns_provider
|
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():
|
for name, comp in registry.deployed.items():
|
||||||
entry: dict = {
|
entry: dict = {
|
||||||
@@ -180,6 +197,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|||||||
entry["health_path"] = comp.health_path
|
entry["health_path"] = comp.health_path
|
||||||
if comp.subdomain:
|
if comp.subdomain:
|
||||||
entry["subdomain"] = 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:
|
if comp.base_url:
|
||||||
entry["base_url"] = comp.base_url
|
entry["base_url"] = comp.base_url
|
||||||
if comp.schedule:
|
if comp.schedule:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from castle_core.manifest import (
|
|||||||
HttpInternal,
|
HttpInternal,
|
||||||
ProgramSpec,
|
ProgramSpec,
|
||||||
RunPython,
|
RunPython,
|
||||||
|
RunStatic,
|
||||||
ServiceSpec,
|
ServiceSpec,
|
||||||
)
|
)
|
||||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
@@ -105,17 +106,16 @@ class TestAcmeMode:
|
|||||||
def test_static_frontend_is_a_file_server_subdomain(
|
def test_static_frontend_is_a_file_server_subdomain(
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> 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
|
import castle_core.config as config_mod
|
||||||
|
|
||||||
cfg = _config(
|
cfg = _config(
|
||||||
services={},
|
services={
|
||||||
programs={
|
"castle": ServiceSpec(
|
||||||
"castle": ProgramSpec(
|
program="castle", run=RunStatic(runner="static", root="dist")
|
||||||
behavior="frontend", source="/data/repos/castle/app",
|
|
||||||
build=BuildSpec(outputs=["dist"]),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
|
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
|
||||||
cf = generate_caddyfile_from_registry(_acme({}))
|
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
|
||||||
@@ -279,6 +279,21 @@ service is reachable only at its own `host:port`.
|
|||||||
proxy: true # expose at <service-name>.<gateway.domain>
|
proxy: true # expose at <service-name>.<gateway.domain>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `public` — Also expose to the public internet (opt-in)
|
||||||
|
|
||||||
|
`public: true` additionally projects a proxied service to the public internet via a
|
||||||
|
Cloudflare tunnel, at **`<service-name>.<gateway.public_domain>`** (a separate zone,
|
||||||
|
so internal subdomain names stay out of public DNS). Defaults to `false` — public is
|
||||||
|
explicit — and **requires `proxy: true`**. `castle deploy` generates the cloudflared
|
||||||
|
ingress from the set of public services. Needs `gateway.public_domain` +
|
||||||
|
`gateway.tunnel_id` set and the `castle-tunnel` service running; see
|
||||||
|
@docs/tunnel-setup.md for the one-time setup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
proxy: true
|
||||||
|
public: true # also reachable at <service-name>.<gateway.public_domain>
|
||||||
|
```
|
||||||
|
|
||||||
The subdomain is always the service name — there's nothing to customize (rename the
|
The subdomain is always the service name — there's nothing to customize (rename the
|
||||||
service to change it). There are **no path-prefix routes**: a whole subdomain maps
|
service to change it). There are **no path-prefix routes**: a whole subdomain maps
|
||||||
to the backend root, so root-relative asset URLs and `window.location`-derived
|
to the backend root, so root-relative asset URLs and `window.location`-derived
|
||||||
|
|||||||
121
docs/tunnel-setup.md
Normal file
121
docs/tunnel-setup.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Public exposure via Cloudflare Tunnel
|
||||||
|
|
||||||
|
Castle is LAN-only by default: services with `proxy: true` are reachable at
|
||||||
|
`<name>.<gateway.domain>` (e.g. `<name>.civil.payne.io`) through split DNS, and
|
||||||
|
nothing is on the public internet. This document sets up a **per-service public
|
||||||
|
toggle** — flip `public: true` on a service and it's also reachable from the public
|
||||||
|
internet at `<name>.<gateway.public_domain>`, via a Cloudflare Tunnel.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **Default private.** `public` defaults to `false`; a service is public only when
|
||||||
|
you say so, and `public: true` requires `proxy: true` (it projects an
|
||||||
|
already-routed subdomain).
|
||||||
|
- **Separate public zone.** Public services publish at a *different* zone
|
||||||
|
(`<name>.pub.payne.io`), so internal subdomain names never appear in public DNS.
|
||||||
|
- **The tunnel bridges public → internal.** `cloudflared` dials **outbound** to
|
||||||
|
Cloudflare (no inbound holes, no public IP needed) and forwards each public
|
||||||
|
hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the
|
||||||
|
*internal* name so Caddy routes it and its wildcard cert validates. The public
|
||||||
|
surface is exactly the set of `public: true` services — `castle deploy` generates
|
||||||
|
the ingress from the registry.
|
||||||
|
- **One kill switch.** Stop `castle-tunnel` → instantly nothing is public.
|
||||||
|
|
||||||
|
```
|
||||||
|
foo.pub.payne.io ──(Cloudflare edge, public cert)──▶ cloudflared (outbound)
|
||||||
|
└─▶ https://localhost:443, Host/SNI = foo.civil.payne.io ──▶ Caddy ──▶ service
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-time setup (owner steps — needs Cloudflare login)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install cloudflared (Debian/Ubuntu) — it's NOT in the default repos, so add
|
||||||
|
# Cloudflare's apt repo first:
|
||||||
|
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||||
|
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y cloudflared
|
||||||
|
|
||||||
|
# 2. Authenticate and create the tunnel (opens a browser to pick the zone)
|
||||||
|
cloudflared tunnel login
|
||||||
|
cloudflared tunnel create castle # prints a tunnel UUID + writes creds JSON
|
||||||
|
|
||||||
|
# 3. Move the credentials into Castle's secret store (path the generator expects)
|
||||||
|
mkdir -p ~/.castle/secrets/cloudflared
|
||||||
|
TID=<the-uuid-from-step-2>
|
||||||
|
mv ~/.cloudflared/$TID.json ~/.castle/secrets/cloudflared/$TID.json
|
||||||
|
chmod 600 ~/.castle/secrets/cloudflared/$TID.json
|
||||||
|
|
||||||
|
# 4. Tell Castle the public zone + tunnel id (in ~/.castle/castle.yaml)
|
||||||
|
# gateway:
|
||||||
|
# ...
|
||||||
|
# public_domain: pub.payne.io
|
||||||
|
# tunnel_id: <the-uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create the tunnel service at `~/.castle/services/castle-tunnel.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: Cloudflare tunnel — public exposure for public:true services
|
||||||
|
run:
|
||||||
|
runner: command
|
||||||
|
argv:
|
||||||
|
- cloudflared
|
||||||
|
- tunnel
|
||||||
|
- --no-autoupdate
|
||||||
|
- --config
|
||||||
|
- /home/payne/.castle/artifacts/specs/cloudflared.yml
|
||||||
|
- run
|
||||||
|
manage:
|
||||||
|
systemd: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bring it online:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle deploy # writes cloudflared.yml from public services
|
||||||
|
castle service enable castle-tunnel # start the tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the toggle
|
||||||
|
|
||||||
|
Mark a service public in its `services/<name>.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
proxy: true # required — the service must be routed
|
||||||
|
public: true # also expose at <name>.pub.payne.io via the tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
Then deploy and route DNS (a CNAME `<name>.pub.payne.io → <tunnel>.cfargotunnel.com`,
|
||||||
|
created once per public host — `castle deploy` prints the exact command):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
castle deploy
|
||||||
|
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
|
||||||
|
```
|
||||||
|
|
||||||
|
`castle deploy` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the
|
||||||
|
current set of public services and restarts `castle-tunnel`. Flip `public` back to
|
||||||
|
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the
|
||||||
|
ingress immediately.
|
||||||
|
|
||||||
|
## The part that isn't the tunnel
|
||||||
|
|
||||||
|
Reachability is the easy half. Anything public also needs, per service:
|
||||||
|
|
||||||
|
- **Auth.** "Public" rarely means "no auth." Put login in front — Cloudflare Access
|
||||||
|
at the edge, an auth proxy, or the app's own (Supabase GoTrue). A public app whose
|
||||||
|
privacy matters must actually enforce it (auth-gated shell + signed storage URLs,
|
||||||
|
not just row-level security).
|
||||||
|
- **Hardening.** Rate-limiting / WAF (free on Cloudflare), and never expose admin or
|
||||||
|
substrate-Studio surfaces.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Requires `gateway.tls: acme` (the tunnel forwards to the gateway's real-cert
|
||||||
|
`:443` host sites). On an `off`/`internal` gateway the origin bridge doesn't apply.
|
||||||
|
- Cloudflare terminates TLS at the edge (it can see plaintext). For a
|
||||||
|
no-third-party-in-path variant, the same `public: true` model can drive a
|
||||||
|
self-hosted VPS + WireGuard edge instead — the toggle and generator stay; only the
|
||||||
|
transport changes.
|
||||||
Reference in New Issue
Block a user