From 1767a0a3048226150dcc35b1f11d650aa796713e Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Jul 2026 17:29:34 -0700 Subject: [PATCH] Adds custom domains. --- app/src/components/ServiceCard.tsx | 13 +- .../detail/CreateDeploymentForm.tsx | 65 ++++++++-- app/src/components/detail/ServiceFields.tsx | 44 ++++++- app/src/components/detail/StaticFields.tsx | 30 ++++- app/src/components/detail/fields.tsx | 47 +++++++ castle-api/src/castle_api/routes.py | 25 ++-- cli/src/castle_cli/commands/delete.py | 15 ++- core/src/castle_core/generators/caddyfile.py | 71 ++++++++--- core/src/castle_core/generators/dns.py | 119 ++++++++++++------ core/src/castle_core/generators/tunnel.py | 50 ++++++-- core/src/castle_core/manifest.py | 32 +++++ core/src/castle_core/registry.py | 6 + core/tests/test_caddyfile.py | 57 ++++++++- core/tests/test_dns.py | 110 ++++++++++++++++ core/tests/test_manifest.py | 34 +++++ core/tests/test_resolution.py | 46 ++++++- core/tests/test_tunnel.py | 40 ++++++ docs/registry.md | 20 +++ docs/tunnel-setup.md | 43 +++++++ 19 files changed, 753 insertions(+), 114 deletions(-) create mode 100644 core/tests/test_dns.py diff --git a/app/src/components/ServiceCard.tsx b/app/src/components/ServiceCard.tsx index f1c1a9b..f0ac803 100644 --- a/app/src/components/ServiceCard.tsx +++ b/app/src/components/ServiceCard.tsx @@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks" import { launcherLabel, subdomainUrl } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" import { StackBadge } from "./StackBadge" -import { KindBadge } from "./KindBadge" interface ServiceCardProps { service: ServiceSummary @@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) { > {service.id} - {health ? ( + {/* A static site is served from disk by the gateway, so it's inherently + "up" — show a green pill in the same slot as a service's health, rather + than a separate kind badge, so statics and services read consistently. */} + {service.kind === "static" ? ( + + + static + + ) : health ? ( ) : hasHttp ? ( @@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
- {/* A static (caddy-served) "service" is distinguished from a systemd one. */} - {service.kind === "static" && }
diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index 8d74fb0..f1dad1e 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -5,7 +5,7 @@ import { X } from "lucide-react" import { apiClient } from "@/services/api/client" import { useGateway } from "@/services/api/hooks" import { gatewayHost, publicGatewayHost } from "@/lib/labels" -import { Field, TextField } from "./fields" +import { Field, TextField, PublicHostRadios } from "./fields" const SELECT = "bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" @@ -57,6 +57,9 @@ export function CreateDeploymentForm({ const [health, setHealth] = useState("/health") const [proxy, setProxy] = useState(true) const [isPublic, setIsPublic] = useState(false) + // Optional exact public FQDN (apex / another zone) overriding .. + const [publicHost, setPublicHost] = useState("") + const [publicMode, setPublicMode] = useState<"default" | "custom">("default") const [schedule, setSchedule] = useState("0 2 * * *") const [busy, setBusy] = useState(null) const [error, setError] = useState("") @@ -81,7 +84,14 @@ export function CreateDeploymentForm({ ...(description ? { description } : {}), } if (kind === "tool") return { ...base, manager: "path" } - if (kind === "static") return { ...base, manager: "caddy", root } + if (kind === "static") { + const cfg: Record = { ...base, manager: "caddy", root } + if (isPublic) { + cfg.reach = "public" + if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim() + } + return cfg + } // systemd (service or job) const cfg: Record = { @@ -103,7 +113,10 @@ export function CreateDeploymentForm({ } } if (proxy) cfg.proxy = true - if (proxy && isPublic) cfg.public = true + if (proxy && isPublic) { + cfg.public = true + if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim() + } return cfg } @@ -231,6 +244,17 @@ export function CreateDeploymentForm({ )} + {proxy && isPublic && ( + + ", publicDomain)} + /> + + )} )} @@ -239,14 +263,33 @@ export function CreateDeploymentForm({ )} {kind === "static" && ( - + <> + + ", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}> + + + {isPublic && ( + + ", publicDomain)} + /> + + )} + )}
diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx index 3f3d64b..947c88f 100644 --- a/app/src/components/detail/ServiceFields.tsx +++ b/app/src/components/detail/ServiceFields.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import type { ServiceDetail } from "@/types" import { useGateway } from "@/services/api/hooks" import { gatewayHost, publicGatewayHost } from "@/lib/labels" -import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields" +import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields" import type { Requirement } from "./fields" interface Props { @@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { const [reach, setReach] = useState( (m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"), ) + // Optional exact public FQDN (apex or another zone) that overrides the default + // .. Only applies when reach is public; `publicMode` picks + // default vs a custom host, and `publicHost` holds the custom value. + const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "") + const [publicMode, setPublicMode] = useState<"default" | "custom">( + m.public_host ? "custom" : "default", + ) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record) const { element: requiresEditor, value: requiresValue } = useRequires( @@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { } // reach needs a port to route through the gateway; without one it's off. config.reach = port ? reach : "off" + // public_host only applies to a public service using a custom domain; else + // clear it (send null so the PATCH merge drops any stale override). + config.public_host = + config.reach === "public" && publicMode === "custom" && publicHost.trim() + ? publicHost.trim() + : null } delete config.proxy delete config.public @@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { />
{!port && ( set a port to expose )}
+ {port && ( + + {reach === "off" && ( + localhost:{port} + )} + {reach === "internal" && ( + + {gatewayHost(service.id, domain)} + + )} + {reach === "public" && ( + + )} + + )} )} {requiresEditor} diff --git a/app/src/components/detail/StaticFields.tsx b/app/src/components/detail/StaticFields.tsx index 1d6aba1..64616bf 100644 --- a/app/src/components/detail/StaticFields.tsx +++ b/app/src/components/detail/StaticFields.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import type { DeploymentDetail } from "@/types" import { useGateway } from "@/services/api/hooks" import { gatewayHost, publicGatewayHost } from "@/lib/labels" -import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields" +import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields" import type { Requirement } from "./fields" interface Props { @@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) { const [isPublic, setIsPublic] = useState( ((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public", ) + // Optional exact public FQDN (apex or another zone) overriding .. + const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "") + const [publicMode, setPublicMode] = useState<"default" | "custom">( + m.public_host ? "custom" : "default", + ) const { element: envEditor, merged } = useEnvSecrets( obj(obj(m.defaults).env) as Record, ) @@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) { config.description = description || null config.root = root || "dist" config.reach = isPublic ? "public" : "internal" + // public_host only applies to a public site using a custom domain; else clear + // (null) so the merge drops any stale override. + config.public_host = + isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null delete config.public config.requires = requiresValue() const env = merged() @@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) { />
+ + {isPublic ? ( + + ) : ( + {gatewayHost(dep.id, domain)} + )} + {requiresEditor} {envEditor} .` (shown + * read-only); `custom` reveals a text input for an exact FQDN (apex or another + * zone) that becomes the deployment's `public_host`. */ +export function PublicHostRadios({ + mode, + onModeChange, + value, + onChange, + defaultHost, +}: { + mode: "default" | "custom" + onModeChange: (m: "default" | "custom") => void + value: string + onChange: (v: string) => void + defaultHost: string +}) { + return ( +
+ + + {mode === "custom" && ( +
+ onChange(e.target.value)} + placeholder="example.com" + className={`w-64 ${INPUT} font-mono`} + /> +

+ An exact hostname — an apex (example.com) or a name in another zone. The + Cloudflare token(s) need DNS:Edit on that zone. +

+
+ )} +
+ ) +} + // A value that is *exactly* a secret ref → fully editable via SecretsEditor. const SECRET_RE = /^\$\{secret:([^}]+)\}$/ // A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 7bf89a6..0914fe1 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -920,15 +920,18 @@ def get_gateway() -> GatewayInfo: except FileNotFoundError: pass - # Which local deployments are public → their public URL (.). - # Scan ALL deployments, not just `config.services` — a public *static* (caddy) - # is not a service, so filtering to services dropped its public_url (calculator). + # Which local deployments are public → their public URL. A `public_host` + # override (apex / another zone) wins; otherwise .. public_domain = registry.node.public_domain - public_names = { - name - for _k, name, dep in (config.all_deployments() if config else []) - if getattr(dep, "public", False) - } + + def _public_url(r) -> str | None: + if not getattr(r, "public", False): + return None + if r.public_host: + return f"https://{r.public_host}" + if public_domain: + return f"https://{r.name}.{public_domain}" + return None remote = {h: r.registry for h, r in mesh_state.all_nodes().items()} routes = [ @@ -938,11 +941,7 @@ def get_gateway() -> GatewayInfo: target=r.target, name=r.name, node=r.node or registry.node.hostname, - public_url=( - f"https://{r.name}.{public_domain}" - if public_domain and r.name in public_names - else None - ), + public_url=_public_url(r), ) for r in compute_routes(registry, config, remote or None) ] diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index 67cba85..8050d0f 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -163,17 +163,20 @@ def run_delete(args: argparse.Namespace) -> int: def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]: - """The Cloudflare CNAMEs (.) of any public deployments - being removed — surfaced so the operator can clean up DNS.""" + """The Cloudflare CNAMEs (a ``public_host`` override, else + .) of any public deployments being removed — + surfaced so the operator can clean up DNS.""" gw = getattr(config, "gateway", None) public_domain = getattr(gw, "public_domain", None) if gw else None - if not public_domain: - return [] hosts: list[str] = [] for kind, d in deployments: spec = config.deployment(kind, d) if spec is None or not getattr(spec, "public", False): continue - sub = getattr(spec, "subdomain", None) or d - hosts.append(f"{sub}.{public_domain}") + override = getattr(spec, "public_host", None) + if override: + hosts.append(override) + elif public_domain: + sub = getattr(spec, "subdomain", None) or d + hosts.append(f"{sub}.{public_domain}") return hosts diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 361bb22..1887e08 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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
.. + 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 ``*.`` 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 : 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 →
., all served by one wildcard site + # (*., 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} {{", diff --git a/core/src/castle_core/generators/dns.py b/core/src/castle_core/generators/dns.py index 43e9eaf..64a984a 100644 --- a/core/src/castle_core/generators/dns.py +++ b/core/src/castle_core/generators/dns.py @@ -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 `.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 +`.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 == `.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 == `.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] diff --git a/core/src/castle_core/generators/tunnel.py b/core/src/castle_core/generators/tunnel.py index 093ac60..2056583 100644 --- a/core/src/castle_core/generators/tunnel.py +++ b/core/src/castle_core/generators/tunnel.py @@ -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 ``.`` 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 (``.``).""" - 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 + ``.``; 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"}) diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index f8d653b..9f3cc6b 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 `.`. + # 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 diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 7d9d13c..fe05dc0 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -83,6 +83,9 @@ class Deployment: # Also projected to the public internet via the tunnel at # .. 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 .. + public_host: str | None = None # Raw-TCP exposure port (postgres, redis, …). Set → reachable at # .: 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: diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 731f2f7..e23a995 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -41,6 +41,7 @@ def _make_registry( gateway_tls: str | None = None, gateway_domain: str | None = None, acme_email: str | None = None, + public_domain: str | None = None, ) -> NodeRegistry: reg = NodeRegistry( node=NodeConfig( @@ -49,6 +50,7 @@ def _make_registry( gateway_tls=gateway_tls, gateway_domain=gateway_domain, acme_email=acme_email, + public_domain=public_domain, ), ) for name, d in (deployed or {}).items(): @@ -68,9 +70,14 @@ def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "p ) -def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry: +def _acme( + deployed: dict[str, Deployment], + domain: str | None = "example.com", + public_domain: str | None = None, +) -> NodeRegistry: return _make_registry( - gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed + gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", + public_domain=public_domain, deployed=deployed, ) @@ -133,6 +140,52 @@ class TestAcmeMode: assert "file_server" in cf +class TestPublicExposure: + """Public deployments are served under the public zone; a `public_host` + override (apex / other zone) gets its own standalone site with its own cert.""" + + def _static_pub(self, name: str, public_host: str | None = None) -> Deployment: + return Deployment( + manager="caddy", run_cmd=[], subdomain=name, + static_root=f"/data/repos/{name}/public", public=True, public_host=public_host, + ) + + def test_default_public_uses_wildcard_site(self) -> None: + reg = _acme({"blog": self._static_pub("blog")}, public_domain="pub.example.org") + cf = generate_caddyfile_from_registry(reg) + assert "*.pub.example.org {" in cf + assert "@host_blog_pub host blog.pub.example.org" in cf + + def test_public_host_override_is_standalone_apex_site(self) -> None: + reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")}, + public_domain="pub.example.org") + cf = generate_caddyfile_from_registry(reg) + # An explicit apex site (not under the *.pub wildcard) so Caddy issues its + # own cert via DNS-01; file_server serves the same local dir directly. + assert "payne.io {" in cf + assert "root * /data/repos/payne-io/public" in cf + # The override host must NOT appear as a *.pub.example.org subdomain. + assert "payne-io.pub.example.org" not in cf + + def test_override_and_default_coexist(self) -> None: + reg = _acme( + {"blog": self._static_pub("blog"), + "payne-io": self._static_pub("payne-io", "payne.io")}, + public_domain="pub.example.org", + ) + cf = generate_caddyfile_from_registry(reg) + assert "*.pub.example.org {" in cf # default wildcard block still present + assert "@host_blog_pub host blog.pub.example.org" in cf + assert "payne.io {" in cf # standalone apex site + + def test_public_host_without_default_domain(self) -> None: + # No node-wide public_domain: only the override host gets a site. + reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")}) + cf = generate_caddyfile_from_registry(reg) + assert "payne.io {" in cf + assert "*.None" not in cf + + class TestOffMode: """No domain → HTTP-only control plane on :: dashboard at / + /api → castle-api. Other services are port-only (not routed).""" diff --git a/core/tests/test_dns.py b/core/tests/test_dns.py new file mode 100644 index 0000000..efa8c07 --- /dev/null +++ b/core/tests/test_dns.py @@ -0,0 +1,110 @@ +"""Tests for multi-zone public DNS (Cloudflare CNAME) reconciliation.""" + +from __future__ import annotations + +import castle_core.generators.dns as dns +from castle_core.generators.dns import _zone_for, reconcile_public_dns + +TID = "tid-abc" +TARGET = f"{TID}.cfargotunnel.com" + +ZONES = [ + {"id": "z_payne", "name": "payne.io"}, + {"id": "z_ex", "name": "example.org"}, +] + + +class _FakeCloudflare: + """A minimal fake of the Cloudflare API used by reconcile_public_dns. + + ``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE + calls so tests can assert exactly what castle created/removed. + """ + + def __init__(self, records: dict[str, dict[str, tuple[str, str]]]): + self.records = records + self.created: list[tuple[str, str]] = [] # (zone_id, name) + self.deleted: list[tuple[str, str]] = [] # (zone_id, record_id) + self._n = 0 + + def api(self, token: str, method: str, path: str, body: dict | None = None) -> dict: + if method == "GET" and path.startswith("/zones?"): + return {"result": ZONES} + if method == "GET" and "/dns_records" in path: + zone_id = path.split("/zones/")[1].split("/")[0] + recs = self.records.get(zone_id, {}) + return { + "result": [ + {"id": rid, "name": name, "content": content, "type": "CNAME"} + for name, (rid, content) in recs.items() + ] + } + if method == "POST": + zone_id = path.split("/zones/")[1].split("/")[0] + assert body is not None + self.created.append((zone_id, body["name"])) + return {"result": {"id": f"new{self._n}"}} + if method == "DELETE": + zone_id = path.split("/zones/")[1].split("/")[0] + rid = path.rsplit("/", 1)[1] + self.deleted.append((zone_id, rid)) + return {"result": {"id": rid}} + raise AssertionError(f"unexpected call {method} {path}") + + +def _run(fake: _FakeCloudflare, desired: list[str], monkeypatch) -> list[str]: + monkeypatch.setattr(dns, "_api", fake.api) + messages: list[str] = [] + ok = reconcile_public_dns(TID, desired, messages, token="tok") + assert ok is True + return messages + + +def test_longest_suffix_routes_host_to_its_zone() -> None: + assert _zone_for("payne.io", ZONES)["id"] == "z_payne" # apex + assert _zone_for("api.payne.io", ZONES)["id"] == "z_payne" # subdomain + assert _zone_for("app.example.org", ZONES)["id"] == "z_ex" + assert _zone_for("nope.other.net", ZONES) is None # no visible zone + + +def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None: + fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}}) + _run(fake, ["payne.io", "app.example.org"], monkeypatch) + assert set(fake.created) == {("z_payne", "payne.io"), ("z_ex", "app.example.org")} + assert fake.deleted == [] + + +def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None: + # z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere. + fake = _FakeCloudflare(records={ + "z_payne": { + "payne.io": ("r1", TARGET), # castle-managed, still desired + "old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete + "keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched + }, + "z_ex": {}, + }) + _run(fake, ["payne.io"], monkeypatch) + assert fake.created == [] + assert fake.deleted == [("z_payne", "r2")] # only the stale managed one + + +def test_empty_desired_cleans_all_managed(monkeypatch) -> None: + fake = _FakeCloudflare(records={ + "z_payne": {"payne.io": ("r1", TARGET)}, + "z_ex": {"a.example.org": ("r2", TARGET)}, + }) + _run(fake, [], monkeypatch) + assert set(fake.deleted) == {("z_payne", "r1"), ("z_ex", "r2")} + + +def test_no_token_returns_false(monkeypatch) -> None: + monkeypatch.setattr(dns, "public_dns_token", lambda: None) + assert reconcile_public_dns(TID, ["payne.io"], [], token=None) is False + + +def test_unresolvable_host_warns_but_still_reconciles_others(monkeypatch) -> None: + fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}}) + msgs = _run(fake, ["payne.io", "x.unknown.net"], monkeypatch) + assert fake.created == [("z_payne", "payne.io")] + assert any("unknown.net" in m for m in msgs) diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index 0c4f11e..7b63446 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -176,6 +176,40 @@ class TestSystemdDeployment: } ) + def test_public_host_override_accepted_with_reach_public(self) -> None: + base = dict( + manager="systemd", + run=RunCommand(launcher="command", argv=["x"]), + expose={"http": {"internal": {"port": 9001}}}, + ) + s = SystemdDeployment.model_validate( + {**base, "reach": "public", "public_host": "payne.io"} + ) + assert s.public_host == "payne.io" + + def test_public_host_requires_reach_public(self) -> None: + """public_host without reach: public is a no-op; reject it at load.""" + base = dict( + manager="systemd", + run=RunCommand(launcher="command", argv=["x"]), + expose={"http": {"internal": {"port": 9001}}}, + ) + with pytest.raises(ValueError, match="public_host is only valid"): + SystemdDeployment.model_validate( + {**base, "reach": "internal", "public_host": "payne.io"} + ) + + def test_public_host_must_be_bare_hostname(self) -> None: + base = dict( + manager="systemd", + run=RunCommand(launcher="command", argv=["x"]), + expose={"http": {"internal": {"port": 9001}}}, + reach="public", + ) + for bad in ("https://payne.io", "payne.io/path", "payne.io:443", "payne .io"): + with pytest.raises(ValueError, match="bare hostname"): + SystemdDeployment.model_validate({**base, "public_host": bad}) + def test_no_run_is_invalid(self) -> None: """A systemd deployment requires a run (launch) spec.""" with pytest.raises(Exception): diff --git a/core/tests/test_resolution.py b/core/tests/test_resolution.py index 4bd3bea..4c37f21 100644 --- a/core/tests/test_resolution.py +++ b/core/tests/test_resolution.py @@ -46,6 +46,31 @@ class TestSupabaseStackResolution: assert "install" in actions and "uninstall" in actions +class TestHugoStackResolution: + def test_hugo_provides_only_build_verbs(self) -> None: + """Hugo is build-only: it resolves build/install/uninstall but NOT the + lint/test/type-check/check verbs it has no native tooling for.""" + p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "hugo"}) + actions = available_actions(p) + assert "build" in actions + assert "install" in actions and "uninstall" in actions + for absent in ("lint", "test", "type-check", "check"): + assert absent not in actions + + def test_declared_command_still_overrides_hugo(self) -> None: + """A hugo program can still declare a verb the stack doesn't provide — + declared commands are resolved regardless of the handler's `provides`.""" + p = ProgramSpec.model_validate( + { + "source": "/tmp/x", + "stack": "hugo", + "commands": {"test": [["htmltest"]]}, + } + ) + actions = available_actions(p) + assert "test" in actions and "build" in actions + + class TestResolution: def test_stack_only_program_unchanged(self) -> None: """A program with a stack and no commands resolves all stack verbs.""" @@ -61,7 +86,11 @@ class TestResolution: p = ProgramSpec.model_validate( { "source": "/tmp/y", - "commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]}, + "commands": { + "lint": [["make", "lint"]], + "test": [["make", "test"]], + "run": [["./bin/y"]], + }, } ) actions = available_actions(p) @@ -79,15 +108,24 @@ class TestResolution: def test_hybrid_override_one_verb(self) -> None: """A stack program can override a single verb; the rest fall back to stack.""" p = ProgramSpec.model_validate( - {"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}} + { + "source": "/tmp/z", + "stack": "python-cli", + "commands": {"test": [["pytest", "-x"]]}, + } ) assert _declared_commands(p, "test") == [["pytest", "-x"]] - assert _declared_commands(p, "build") is None # build still comes from the stack + assert ( + _declared_commands(p, "build") is None + ) # build still comes from the stack def test_build_declared_via_buildspec(self) -> None: """`build` is declared through BuildSpec.commands, not CommandsSpec.""" p = ProgramSpec.model_validate( - {"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}} + { + "source": "/tmp/w", + "build": {"commands": [["make"]], "outputs": ["dist/"]}, + } ) assert _declared_commands(p, "build") == [["make"]] assert "build" in available_actions(p) diff --git a/core/tests/test_tunnel.py b/core/tests/test_tunnel.py index d52c216..5f72f4c 100644 --- a/core/tests/test_tunnel.py +++ b/core/tests/test_tunnel.py @@ -80,3 +80,43 @@ def test_public_static_frontend_gets_ingress() -> None: hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"] if "hostname" in r} assert hosts == {"guestbook.pub.payne.io"} + + +def test_public_host_override_used_as_ingress_hostname() -> None: + # An apex `public_host` overrides . for the public name, + # but the origin still bridges to the internal . host. + reg = _registry(deployed={ + "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io", + static_root="/data/repos/payne-io/public", public=True, + public_host="payne.io"), + }) + cfg = yaml.safe_load(generate_tunnel_config(reg)) + rules = {r["hostname"]: r for r in cfg["ingress"] if "hostname" in r} + assert set(rules) == {"payne.io"} + assert rules["payne.io"]["originRequest"]["httpHostHeader"] == "payne-io.civil.payne.io" + assert public_hostnames(reg) == ["payne.io"] + + +def test_public_host_default_and_override_coexist() -> None: + reg = _registry(deployed={ + "app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001, + subdomain="app", public=True), + "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io", + static_root="/d/public", public=True, public_host="payne.io"), + }) + assert set(public_hostnames(reg)) == {"app.pub.payne.io", "payne.io"} + + +def test_public_host_works_without_default_public_domain() -> None: + # A deployment with its own public_host publishes even if the node has no + # default public_domain; the plain public service (no override) is skipped. + reg = _registry(public_domain=None, deployed={ + "app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001, + subdomain="app", public=True), + "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io", + static_root="/d/public", public=True, public_host="payne.io"), + }) + assert public_hostnames(reg) == ["payne.io"] + cfg = yaml.safe_load(generate_tunnel_config(reg)) + hosts = {r["hostname"] for r in cfg["ingress"] if "hostname" in r} + assert hosts == {"payne.io"} diff --git a/docs/registry.md b/docs/registry.md index 4ec3678..e94783e 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -370,6 +370,26 @@ to the backend root, so root-relative asset URLs and `window.location`-derived WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path` routes is gone). Caddy proxies WebSocket upgrades transparently. +#### `public_host` — Publish on a different domain / apex (opt-in) + +`gateway.public_domain` is the **default** public zone. To project a specific +deployment on a *different* domain, or at an **apex** (`payne.io`, which can't be a +`.` subdomain), set an exact `public_host` FQDN on the deployment (only +valid with `reach: public` / `public: true`): + +```yaml +reach: public +public_host: payne.io # exact hostname; overrides . +``` + +The tunnel origin still bridges to the internal `.` host, and +the gateway also serves the custom host **LAN-direct** with its own DNS-01 cert. The +public CNAME is reconciled into whichever accessible Cloudflare zone is the host's +longest suffix, so the `CLOUDFLARE_PUBLIC_DNS_TOKEN` (and the gateway's +`CLOUDFLARE_API_TOKEN`, for the cert) must have `DNS:Edit` on that zone. A +deployment with `public_host` publishes even with no node-wide `public_domain`. Full +prerequisites (tokens + LAN DNS for the apex): @docs/tunnel-setup.md. + **Gateway routes — one concept, three target kinds.** The gateway maps a public **address** (always a subdomain host, `.`) to a **target**: diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md index b39d618..444e463 100644 --- a/docs/tunnel-setup.md +++ b/docs/tunnel-setup.md @@ -129,6 +129,49 @@ Without the token, `castle apply` instead prints the exact command to run per ho cloudflared tunnel route dns .pub.payne.io ``` +### Publishing on a different domain (`public_host`) + +By default a public deployment is projected at `.` — +`public_domain` is the *default* public zone. To publish a specific deployment on a +**different** domain, or at an **apex** (e.g. `payne.io`, which can't be expressed +as `.`), set an exact `public_host` on the deployment: + +```yaml +# deployments/statics/payne-io.yaml +manager: caddy +program: payne-io +root: public +reach: public +public_host: payne.io # exact FQDN — overrides . +``` + +`public_host` is an exact hostname (no scheme/port/path) and only applies with +`reach: public`. When set: + +- **Tunnel ingress** maps that hostname to the tunnel; the origin still bridges to + the deployment's *internal* host (`.`), so Caddy routes it + and the internal wildcard cert validates — same as the default path. +- **Public DNS** reconcile routes the CNAME into whichever accessible Cloudflare + zone is the longest suffix of the host (so `payne.io` lands in zone `payne.io`, + `x.other.org` in `other.org`). It reconciles across **every** zone the + `CLOUDFLARE_PUBLIC_DNS_TOKEN` can see, so the token must have `DNS:Edit` on each + target zone. Cloudflare flattens the apex CNAME automatically. +- **LAN-direct HTTPS.** The gateway also serves the custom host directly as its own + Caddy site, obtaining that host's cert via DNS-01 (the global `acme_dns`). This + requires the gateway's `CLOUDFLARE_API_TOKEN` to have `DNS:Edit` on the host's + zone too, and LAN DNS to resolve the host to this node — for an apex add e.g. + `address=/payne.io/` on the LAN resolver (the `*.` + wildcard doesn't cover a foreign apex). + +A deployment with `public_host` publishes even if no node-wide `public_domain` is +configured. Prerequisites in one place, for `payne-io` → `https://payne.io`: + +| need | why | +|------|-----| +| `CLOUDFLARE_PUBLIC_DNS_TOKEN` has `DNS:Edit` on `payne.io` | the proxied apex CNAME → tunnel | +| `CLOUDFLARE_API_TOKEN` (gateway) has `DNS:Edit` on `payne.io` | the LAN-direct apex cert (DNS-01) | +| LAN DNS `address=/payne.io/` | LAN browsers resolve the apex to the gateway | + ## The part that isn't the tunnel Reachability is the easy half. Anything public also needs, per service: