diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 185c071..4b828aa 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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" → + # .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 diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index aea7d53..408ec98 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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 / 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] diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 8dd7524..8985c8d 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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 / at .. - 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 diff --git a/core/src/castle_core/generators/tunnel.py b/core/src/castle_core/generators/tunnel.py new file mode 100644 index 0000000..7603c5c --- /dev/null +++ b/core/src/castle_core/generators/tunnel.py @@ -0,0 +1,95 @@ +"""Cloudflare tunnel (cloudflared) ingress config generation. + +Projects ``public: true`` services onto the public internet at +``.``, 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 : 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 (``.``).""" + 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) diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 71c39f0..878bcb8 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 . 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 + # .. 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 diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 54742ea..87c3989 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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 .. + 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 . (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 + # .. 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: diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 644b338..d3fa569 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -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 /. 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({})) diff --git a/core/tests/test_tunnel.py b/core/tests/test_tunnel.py new file mode 100644 index 0000000..b9f91ca --- /dev/null +++ b/core/tests/test_tunnel.py @@ -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 diff --git a/docs/registry.md b/docs/registry.md index cc64a51..c9cc5fd 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -279,6 +279,21 @@ service is reachable only at its own `host:port`. proxy: true # expose at . ``` +### `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 **`.`** (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 . +``` + 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 to the backend root, so root-relative asset URLs and `window.location`-derived diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md new file mode 100644 index 0000000..2f3f5b7 --- /dev/null +++ b/docs/tunnel-setup.md @@ -0,0 +1,121 @@ +# Public exposure via Cloudflare Tunnel + +Castle is LAN-only by default: services with `proxy: true` are reachable at +`.` (e.g. `.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 `.`, 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 + (`.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= +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: +``` + +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/.yaml`: + +```yaml +proxy: true # required — the service must be routed +public: true # also expose at .pub.payne.io via the tunnel +``` + +Then deploy and route DNS (a CNAME `.pub.payne.io → .cfargotunnel.com`, +created once per public host — `castle deploy` prints the exact command): + +```bash +castle deploy +cloudflared tunnel route dns .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.