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

@@ -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}
</Link>
{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" ? (
<span className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full bg-green-800/50 text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
static
</span>
) : health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
) : hasHttp ? (
<HealthBadge status="unknown" />
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
</div>
<div className="flex items-center gap-1.5 mb-2">
{/* A static (caddy-served) "service" is distinguished from a systemd one. */}
{service.kind === "static" && <KindBadge kind="static" />}
<StackBadge stack={service.stack} />
</div>

View File

@@ -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 <name>.<public_domain>.
const [publicHost, setPublicHost] = useState("")
const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(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<string, unknown> = { ...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<string, unknown> = {
@@ -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({
</label>
</Field>
)}
{proxy && isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</>
)}
@@ -239,14 +263,33 @@ export function CreateDeploymentForm({
)}
{kind === "static" && (
<TextField
label="Root"
value={root}
onChange={setRoot}
width="w-48"
mono
placeholder="dist"
/>
<>
<TextField
label="Root"
value={root}
onChange={setRoot}
width="w-48"
mono
placeholder="dist"
/>
<Field label="Public" hint={`A static site is always served at ${gatewayHost(name || "<name>", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
</label>
</Field>
{isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</>
)}
<div className="flex justify-end gap-2 pt-2">

View File

@@ -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
// <name>.<public_domain>. 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<string, string>)
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) {
/>
<Field
label="Reach"
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
>
<div className="flex items-center gap-1.5">
<select
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
disabled={!port}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
>
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
<option value="off">off</option>
<option value="internal">internal</option>
<option value="public">public</option>
</select>
{!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)}
</div>
</Field>
{port && (
<Field label="Address" hint="Where this service is reachable.">
{reach === "off" && (
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
)}
{reach === "internal" && (
<span className="font-mono text-xs text-[var(--muted)]">
{gatewayHost(service.id, domain)}
</span>
)}
{reach === "public" && (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(service.id, publicDomain)}
/>
)}
</Field>
)}
</>
)}
{requiresEditor}

View File

@@ -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 <name>.<public_domain>.
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<string, string>,
)
@@ -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) {
/>
<Field
label="Reach"
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`}
hint="How far this static site is served. internal: via the gateway. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
>
<div className="flex items-center gap-1.5">
<select
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
onChange={(e) => setIsPublic(e.target.value === "public")}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
<option value="internal">internal</option>
<option value="public">public</option>
</select>
</div>
</Field>
<Field label="Address" hint="Where this site is reachable.">
{isPublic ? (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(dep.id, publicDomain)}
/>
) : (
<span className="font-mono text-xs text-[var(--muted)]">{gatewayHost(dep.id, domain)}</span>
)}
</Field>
{requiresEditor}
{envEditor}
<FormFooter

View File

@@ -55,6 +55,53 @@ export function TextField({
)
}
/** The public-address sub-control shown when a deployment's reach is `public`:
* a default/custom choice. `default` uses `<name>.<public_domain>` (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 (
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
<span className="font-mono text-xs text-[var(--muted)]">
{defaultHost} <span className="opacity-60">· default</span>
</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
<span className="text-sm">custom domain</span>
</label>
{mode === "custom" && (
<div className="ml-6 space-y-1">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="example.com"
className={`w-64 ${INPUT} font-mono`}
/>
<p className="text-xs text-[var(--muted)] leading-snug">
An exact hostname an apex (example.com) or a name in another zone. The
Cloudflare token(s) need DNS:Edit on that zone.
</p>
</div>
)}
</div>
)
}
// 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

View File

@@ -920,15 +920,18 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError:
pass
# Which local deployments are public → their public URL (<name>.<public_domain>).
# 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 <name>.<public_domain>.
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)
]

View File

@@ -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 (<subdomain>.<public_domain>) of any public deployments
being removed — surfaced so the operator can clean up DNS."""
"""The Cloudflare CNAMEs (a ``public_host`` override, else
<subdomain>.<public_domain>) 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

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:

View File

@@ -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 :<port>: dashboard at / + /api → castle-api.
Other services are port-only (not routed)."""

110
core/tests/test_dns.py Normal file
View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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 <sub>.<public_domain> for the public name,
# but the origin still bridges to the internal <sub>.<gateway_domain> 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"}

View File

@@ -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
`<name>.<zone>` 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 <name>.<public_domain>
```
The tunnel origin still bridges to the internal `<name>.<gateway.domain>` 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, `<name>.<domain>`) to a **target**:

View File

@@ -129,6 +129,49 @@ Without the token, `castle apply` instead prints the exact command to run per ho
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
```
### Publishing on a different domain (`public_host`)
By default a public deployment is projected at `<name>.<gateway.public_domain>`
`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 `<name>.<zone>`), 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 <name>.<public_domain>
```
`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 (`<name>.<gateway.domain>`), 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/<node-ip>` on the LAN resolver (the `*.<gateway.domain>`
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/<node-ip>` | 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: