Adds custom domains.

This commit is contained in:
2026-07-12 17:29:34 -07:00
parent 964226d671
commit 1767a0a304
19 changed files with 753 additions and 114 deletions

View File

@@ -41,6 +41,9 @@ class GatewayRoute:
name: str | None = None # backing program/service
node: str | None = None
public: bool = False # also served under public_domain
# Optional exact public-facing FQDN override (apex allowed). When set, the
# public name is this instead of the derived <address>.<public_domain>.
public_host: str | None = None
@property
def is_host(self) -> bool:
@@ -67,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str, bool]]:
"""Each local deployment's route as ``(name, kind, target, public)``.
) -> list[tuple[str, str, str, bool, str | None]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host)``.
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available.
"""
out: list[tuple[str, str, str, bool]] = []
out: list[tuple[str, str, str, bool, str | None]] = []
if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do.
@@ -87,20 +90,22 @@ def _local_routes(
if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program)
if src is not None:
out.append((name, "static", str(src / dep.root), bool(dep.public)))
pub_host = dep.public_host if dep.public else None
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public)))
pub_host = dep.public_host if dep.public else None
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
return out
# No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all():
if not d.enabled:
continue
if d.static_root:
out.append((name, "static", d.static_root, d.public))
out.append((name, "static", d.static_root, d.public, d.public_host))
elif d.subdomain and (d.port or d.base_url):
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public))
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
return out
@@ -141,8 +146,8 @@ def compute_routes(
# 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, is_public in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public))
for name, kind, target, is_public, pub_host in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
if remote_registries:
routes.extend(_remote_routes(config, registry, remote_registries))
@@ -238,6 +243,34 @@ def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
]
def _public_site_block(host: str, kind: str, target: str) -> list[str]:
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
covered by a wildcard cert, so it gets its own site. Caddy issues that exact
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
Cloudflare token can edit the host's zone."""
if kind == "static":
body = [
f" root * {target}",
" try_files {path} /index.html",
" file_server",
]
elif kind == "remote":
body = [
f" reverse_proxy {target} {{",
" lb_try_duration 1s",
" fail_duration 30s",
" transport http {",
" dial_timeout 2s",
" }",
" }",
]
else:
body = [f" reverse_proxy {target}"]
return [f"{host} {{", *body, "}", ""]
# Castle's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served
# on the :<port> site in off mode (no domain → no subdomains).
@@ -308,14 +341,21 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}")
lines.append("")
# Public domain: public services are also reachable under a separate zone
# (e.g. domain0.org) so LAN clients can access them by their public name.
# Central passes TLS through; Caddy obtains a separate wildcard cert.
# Public exposure: public deployments are also reachable by their public
# name so LAN clients can use it directly. Two shapes:
# - default → <address>.<public_domain>, all served by one wildcard site
# (*.<public_domain>, its own wildcard cert), when a node-wide
# public_domain distinct from the internal zone is configured.
# - override → an exact `public_host` (an apex or a name in another zone),
# which a wildcard can't cover, so each gets a standalone site with its
# own cert (DNS-01 via the global acme_dns).
public_domain = node.public_domain
public_routes = [r for r in routes if r.public]
if public_domain and public_domain != domain and public_routes:
default_pub = [r for r in public_routes if not r.public_host]
custom_pub = [r for r in public_routes if r.public_host]
if public_domain and public_domain != domain and default_pub:
lines.append(f"*.{public_domain} {{")
for r in public_routes:
for r in default_pub:
host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub"
if r.kind == "static":
@@ -326,6 +366,9 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(label, host, r.target)
lines.append("}")
lines.append("")
for r in custom_pub:
if r.public_host: # always true (custom_pub filter); narrows the type
lines += _public_site_block(r.public_host, r.kind, r.target)
# Redirect the bare gateway port to the dashboard subdomain.
lines += [
f":{gw_port} {{",

View File

@@ -1,13 +1,15 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
on deploy it creates one per public service and deletes any that point at this
tunnel but no longer correspond to a public service. It **only ever touches
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
the zone — so a hand-managed A/CNAME in the same zone is safe.
Castle owns the CNAMEs — across every zone the token can see — that point at its
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
work) and deletes any that point at this tunnel but no longer correspond to a
public service. It **only ever touches records whose content is
`<tunnel_id>.cfargotunnel.com`** — never other records in a zone — so a
hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
"Edit zone DNS" template — that single permission both resolves the zone by name
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
"Edit zone DNS" template — that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
@@ -43,65 +45,100 @@ def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
return json.loads(resp.read())
def _zone_for(host: str, zones: list[dict]) -> dict | None:
"""The visible zone whose name is the longest suffix of ``host`` (or None).
Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
subdomain in any accessible zone both resolve, even when zones nest.
"""
matches = [
z
for z in zones
if host == z["name"] or host.endswith("." + z["name"])
]
return max(matches, key=lambda z: len(z["name"])) if matches else None
def reconcile_public_dns(
public_domain: str | None,
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
"""Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
Creates missing CNAMEs (proxied the tunnel) and deletes castle-managed ones
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
touches records pointing elsewhere.
Each desired host is routed to the accessible zone whose name is its longest
suffix (so apex hosts and hosts in different zones are handled), then per zone
castle creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
no longer desired. Never touches records pointing elsewhere. Scanning every
visible zone also cleans up stale CNAMEs after a host moves zones or all public
services are removed.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
no tunnel / no public domain), so the caller can fall back to those hints.
no tunnel), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
if not (token and public_domain and tunnel_id):
if not (token and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
if not zres:
zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
if not zones:
messages.append(
f"Warning: DNS token can't see zone '{public_domain}' — public "
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
f"'Edit zone DNS' template) scoped to {public_domain}."
"Warning: DNS token can't see any zone — public CNAMEs not "
"reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
"DNS' template) on the target zone(s)."
)
return False
zone_id = zres[0]["id"]
# Castle-managed set = existing CNAMEs whose content is our tunnel.
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
return True
desired = set(desired_hosts)
created = sorted(desired - set(managed))
removed = sorted(set(managed) - desired)
for host in created:
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
for host in removed:
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
# Route each desired host to its zone (longest-suffix match). Hosts with no
# accessible zone can't be created — surface them rather than silently drop.
desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
for host in desired_hosts:
z = _zone_for(host, zones)
if z is None:
messages.append(
f"Warning: no accessible Cloudflare zone for public host "
f"'{host}' — its CNAME was not created. The DNS token needs "
f"DNS:Edit on that host's zone."
)
continue
desired_by_zone[z["id"]].add(host)
created: list[str] = []
removed: list[str] = []
# Reconcile every visible zone (not just those with desired hosts) so a
# CNAME orphaned by a host moving zones / going internal is cleaned up.
for z in zones:
zone_id = z["id"]
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
desired = desired_by_zone[zone_id]
for host in sorted(desired - set(managed)):
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
created.append(host)
for host in sorted(set(managed) - desired):
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
removed.append(host)
if created or removed:
parts = []
if created:
parts.append(f"+{len(created)} ({', '.join(created)})")
parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
if removed:
parts.append(f"-{len(removed)} ({', '.join(removed)})")
parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]

View File

@@ -37,21 +37,42 @@ def tunnel_credentials_path(tunnel_id: str) -> Path:
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
def public_fqdn(d: Deployment, node) -> str | None:
"""The public-facing hostname for a deployment, or None if it has none.
A deployment may override its public name with an exact FQDN (``public_host`` —
an apex like ``example.com`` or a name in another zone); otherwise it publishes
at ``<subdomain>.<public_domain>`` using the node-wide default public domain.
None when neither an override nor a default public domain is available.
"""
if d.public_host:
return d.public_host
if node.public_domain and d.subdomain:
return f"{d.subdomain}.{node.public_domain}"
return None
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted."""
return sorted(
(name, d)
for _kind, name, d in registry.all()
if d.public and d.subdomain
(
(name, d)
for _kind, name, d in registry.all()
if d.public and d.subdomain
),
key=lambda nd: nd[0],
)
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)]
"""The public hostnames that need a DNS route.
Each is either a per-deployment ``public_host`` override or the default
``<sub>.<public_domain>``; deployments with neither are skipped.
"""
node = registry.node
hosts = [public_fqdn(d, node) for _, d in public_deployments(registry)]
return [h for h in hosts if h]
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
@@ -62,7 +83,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
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):
# A public deployment needs a tunnel + an internal host to bridge to; the
# node-wide public_domain is only the *default* public name, so it isn't
# required (a deployment may carry its own public_host override instead).
if not (node.tunnel_id and node.gateway_domain):
return None
pubs = public_deployments(registry)
if not pubs:
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
ingress: list[dict] = []
for _name, d in pubs:
public_host = f"{d.subdomain}.{node.public_domain}"
public_host = public_fqdn(d, node)
if not public_host:
# public but no override and no default public domain — nothing to map.
continue
internal_host = f"{d.subdomain}.{node.gateway_domain}"
ingress.append(
{
@@ -84,6 +111,9 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
},
}
)
if not ingress:
# Every public deployment was skipped (no override, no default domain).
return None
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
ingress.append({"service": "http_status:404"})

View File

@@ -33,6 +33,28 @@ class Reach(str, Enum):
PUBLIC = "public"
def _validate_public_host(host: str | None, reach: Reach) -> None:
"""Validate an optional ``public_host`` override on an exposable deployment.
A ``public_host`` only makes sense for a publicly-projected deployment, and
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
tunnel ingress ``hostname`` and a Caddy site address verbatim.
"""
if host is None:
return
if reach != Reach.PUBLIC:
raise ValueError(
f"public_host is only valid with reach: public (got reach: {reach.value})"
)
bad = any(c.isspace() for c in host) or any(
tok in host for tok in ("://", "/", ":")
)
if not host or host != host.strip(".") or bad:
raise ValueError(
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
)
# ---------------------
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
# ---------------------
@@ -420,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
expose: ExposeSpec | None = None
# How far this process is exposed (off | internal | public). See `Reach`.
reach: Reach = Reach.OFF
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
public_host: str | None = None
manage: ManageSpec | None = None
@model_validator(mode="after")
@@ -448,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
"reach: public for a raw-TCP service isn't supported yet "
"(see docs/tcp-exposure.md step 5); use reach: internal"
)
_validate_public_host(self.public_host, self.reach)
return self
# Derived, read-only back-compat accessors (not serialized) so existing
@@ -488,11 +516,15 @@ class CaddyDeployment(DeploymentBase):
# A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
# with `reach: public`; see SystemdDeployment.public_host.
public_host: str | None = None
@model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF:
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
_validate_public_host(self.public_host, self.reach)
return self
@property

View File

@@ -83,6 +83,9 @@ class Deployment:
# Also projected to the public internet via the tunnel at
# <subdomain>.<gateway.public_domain>. Requires subdomain.
public: bool = False
# Optional public hostname override (exact FQDN, apex allowed). When set it is
# the public-facing name instead of the derived <subdomain>.<public_domain>.
public_host: str | None = None
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
tcp_port: int | None = None
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"),
public=comp_data.get("public", False),
public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"),
base_url=comp_data.get("base_url"),
@@ -263,6 +267,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["subdomain"] = comp.subdomain
if comp.public:
entry["public"] = comp.public
if comp.public_host:
entry["public_host"] = comp.public_host
if comp.tcp_port is not None:
entry["tcp_port"] = comp.tcp_port
if comp.static_root: