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 { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
import { KindBadge } from "./KindBadge"
interface ServiceCardProps { interface ServiceCardProps {
service: ServiceSummary service: ServiceSummary
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
> >
{service.id} {service.id}
</Link> </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} /> <HealthBadge status={health.status} latency={health.latency_ms} />
) : hasHttp ? ( ) : hasHttp ? (
<HealthBadge status="unknown" /> <HealthBadge status="unknown" />
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
</div> </div>
<div className="flex items-center gap-1.5 mb-2"> <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} /> <StackBadge stack={service.stack} />
</div> </div>

View File

@@ -5,7 +5,7 @@ import { X } from "lucide-react"
import { apiClient } from "@/services/api/client" import { apiClient } from "@/services/api/client"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField } from "./fields" import { Field, TextField, PublicHostRadios } from "./fields"
const SELECT = const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" "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 [health, setHealth] = useState("/health")
const [proxy, setProxy] = useState(true) const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false) 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 [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
...(description ? { description } : {}), ...(description ? { description } : {}),
} }
if (kind === "tool") return { ...base, manager: "path" } 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) // systemd (service or job)
const cfg: Record<string, unknown> = { const cfg: Record<string, unknown> = {
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
} }
} }
if (proxy) cfg.proxy = true 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 return cfg
} }
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
</label> </label>
</Field> </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" && ( {kind === "static" && (
<TextField <>
label="Root" <TextField
value={root} label="Root"
onChange={setRoot} value={root}
width="w-48" onChange={setRoot}
mono width="w-48"
placeholder="dist" 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"> <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 type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" 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" import type { Requirement } from "./fields"
interface Props { interface Props {
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const [reach, setReach] = useState( const [reach, setReach] = useState(
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"), (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: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const { element: requiresEditor, value: requiresValue } = useRequires( 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. // reach needs a port to route through the gateway; without one it's off.
config.reach = port ? reach : "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.proxy
delete config.public delete config.public
@@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/> />
<Field <Field
label="Reach" 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"> <div className="flex items-center gap-1.5">
<select <select
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
disabled={!port} 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" 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="off">off</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option> <option value="internal">internal</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option> <option value="public">public</option>
</select> </select>
{!port && ( {!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span> <span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)} )}
</div> </div>
</Field> </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} {requiresEditor}

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" 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" import type { Requirement } from "./fields"
interface Props { interface Props {
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const [isPublic, setIsPublic] = useState( const [isPublic, setIsPublic] = useState(
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public", ((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( const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>, 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.description = description || null
config.root = root || "dist" config.root = root || "dist"
config.reach = isPublic ? "public" : "internal" 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 delete config.public
config.requires = requiresValue() config.requires = requiresValue()
const env = merged() const env = merged()
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
/> />
<Field <Field
label="Reach" 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"> <div className="flex items-center gap-1.5">
<select <select
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
onChange={(e) => setIsPublic(e.target.value === "public")} 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)]" 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="internal">internal</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option> <option value="public">public</option>
</select> </select>
</div> </div>
</Field> </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} {requiresEditor}
{envEditor} {envEditor}
<FormFooter <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. // A value that is *exactly* a secret ref → fully editable via SecretsEditor.
const SECRET_RE = /^\$\{secret:([^}]+)\}$/ const SECRET_RE = /^\$\{secret:([^}]+)\}$/
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These // 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: except FileNotFoundError:
pass pass
# Which local deployments are public → their public URL (<name>.<public_domain>). # Which local deployments are public → their public URL. A `public_host`
# Scan ALL deployments, not just `config.services` — a public *static* (caddy) # override (apex / another zone) wins; otherwise <name>.<public_domain>.
# is not a service, so filtering to services dropped its public_url (calculator).
public_domain = registry.node.public_domain public_domain = registry.node.public_domain
public_names = {
name def _public_url(r) -> str | None:
for _k, name, dep in (config.all_deployments() if config else []) if not getattr(r, "public", False):
if getattr(dep, "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()} remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [ routes = [
@@ -938,11 +941,7 @@ def get_gateway() -> GatewayInfo:
target=r.target, target=r.target,
name=r.name, name=r.name,
node=r.node or registry.node.hostname, node=r.node or registry.node.hostname,
public_url=( public_url=_public_url(r),
f"https://{r.name}.{public_domain}"
if public_domain and r.name in public_names
else None
),
) )
for r in compute_routes(registry, config, remote or None) 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]: def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments """The Cloudflare CNAMEs (a ``public_host`` override, else
being removed — surfaced so the operator can clean up DNS.""" <subdomain>.<public_domain>) of any public deployments being removed —
surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None) gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None public_domain = getattr(gw, "public_domain", None) if gw else None
if not public_domain:
return []
hosts: list[str] = [] hosts: list[str] = []
for kind, d in deployments: for kind, d in deployments:
spec = config.deployment(kind, d) spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False): if spec is None or not getattr(spec, "public", False):
continue continue
sub = getattr(spec, "subdomain", None) or d override = getattr(spec, "public_host", None)
hosts.append(f"{sub}.{public_domain}") if override:
hosts.append(override)
elif public_domain:
sub = getattr(spec, "subdomain", None) or d
hosts.append(f"{sub}.{public_domain}")
return hosts return hosts

View File

@@ -41,6 +41,9 @@ class GatewayRoute:
name: str | None = None # backing program/service name: str | None = None # backing program/service
node: str | None = None node: str | None = None
public: bool = False # also served under public_domain 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 @property
def is_host(self) -> bool: def is_host(self) -> bool:
@@ -67,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes( def _local_routes(
config: CastleConfig | None, registry: NodeRegistry config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str, bool]]: ) -> list[tuple[str, str, str, bool, str | None]]:
"""Each local deployment's route as ``(name, kind, target, public)``. """Each local deployment's route as ``(name, kind, target, public, public_host)``.
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or ``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml`` ``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec; (``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available. 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: if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy). # Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do. # jobs/tools/references never do.
@@ -87,20 +90,22 @@ def _local_routes(
if kind == "static" and isinstance(dep, CaddyDeployment): if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program) src = _program_source(config, dep.program)
if src is not None: 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): elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep) expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url): 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 return out
# No config → route from the deployed registry snapshot. # No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all(): for _kind, name, d in registry.all():
if not d.enabled: if not d.enabled:
continue continue
if d.static_root: 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): 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 return out
@@ -141,8 +146,8 @@ def compute_routes(
# built dir; everything else that's exposed reverse-proxies its port/base_url. # built dir; everything else that's exposed reverse-proxies its port/base_url.
# (Static frontends are `runner: static` services now — no separate program # (Static frontends are `runner: static` services now — no separate program
# branch, so routing derives from one place.) # branch, so routing derives from one place.)
for name, kind, target, is_public in _local_routes(config, registry): for name, kind, target, is_public, pub_host in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public)) routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
if remote_registries: if remote_registries:
routes.extend(_remote_routes(config, registry, 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 # 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 # 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). # 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 += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}") lines.append("}")
lines.append("") lines.append("")
# Public domain: public services are also reachable under a separate zone # Public exposure: public deployments are also reachable by their public
# (e.g. domain0.org) so LAN clients can access them by their public name. # name so LAN clients can use it directly. Two shapes:
# Central passes TLS through; Caddy obtains a separate wildcard cert. # - 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_domain = node.public_domain
public_routes = [r for r in routes if r.public] 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} {{") lines.append(f"*.{public_domain} {{")
for r in public_routes: for r in default_pub:
host = f"{r.address}.{public_domain}" host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub" label = f"{r.name or r.address}_pub"
if r.kind == "static": if r.kind == "static":
@@ -326,6 +366,9 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(label, host, r.target) lines += _host_matcher_block(label, host, r.target)
lines.append("}") lines.append("}")
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. # Redirect the bare gateway port to the dashboard subdomain.
lines += [ lines += [
f":{gw_port} {{", f":{gw_port} {{",

View File

@@ -1,13 +1,15 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services. """Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel: Castle owns the CNAMEs — across every zone the token can see — that point at its
on deploy it creates one per public service and deletes any that point at this Cloudflare tunnel: on deploy it creates one per public host (each routed to the
tunnel but no longer correspond to a public service. It **only ever touches accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in work) and deletes any that point at this tunnel but no longer correspond to a
the zone — so a hand-managed A/CNAME in the same zone is safe. 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 Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
"Edit zone DNS" template — that single permission both resolves the zone by name "Edit zone DNS" template — that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at 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 `~/.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. 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()) 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( def reconcile_public_dns(
public_domain: str | None,
tunnel_id: str | None, tunnel_id: str | None,
desired_hosts: list[str], desired_hosts: list[str],
messages: list[str], messages: list[str],
token: str | None = None, token: str | None = None,
) -> bool: ) -> 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 Each desired host is routed to the accessible zone whose name is its longest
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never suffix (so apex hosts and hosts in different zones are handled), then per zone
touches records pointing elsewhere. 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 Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token / 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() token = token or public_dns_token()
if not (token and public_domain and tunnel_id): if not (token and tunnel_id):
return False return False
target = f"{tunnel_id}.cfargotunnel.com" target = f"{tunnel_id}.cfargotunnel.com"
try: try:
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or [] zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
if not zres: if not zones:
messages.append( messages.append(
f"Warning: DNS token can't see zone '{public_domain}' — public " "Warning: DNS token can't see any zone — public CNAMEs not "
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's " "reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
f"'Edit zone DNS' template) scoped to {public_domain}." "DNS' template) on the target zone(s)."
) )
return False return True
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}
desired = set(desired_hosts) # Route each desired host to its zone (longest-suffix match). Hosts with no
created = sorted(desired - set(managed)) # accessible zone can't be created — surface them rather than silently drop.
removed = sorted(set(managed) - desired) desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
for host in created: for host in desired_hosts:
_api( z = _zone_for(host, zones)
token, if z is None:
"POST", messages.append(
f"/zones/{zone_id}/dns_records", f"Warning: no accessible Cloudflare zone for public host "
{"type": "CNAME", "name": host, "content": target, "proxied": True}, f"'{host}' — its CNAME was not created. The DNS token needs "
) f"DNS:Edit on that host's zone."
for host in removed: )
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}") 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: if created or removed:
parts = [] parts = []
if created: if created:
parts.append(f"+{len(created)} ({', '.join(created)})") parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
if removed: 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)}") messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else: 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 return True
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200] 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" 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]]: def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted.""" """The deployed services flagged public (and actually routed), name-sorted."""
return sorted( return sorted(
(name, d) (
for _kind, name, d in registry.all() (name, d)
if d.public and d.subdomain 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]: def public_hostnames(registry: NodeRegistry) -> list[str]:
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``).""" """The public hostnames that need a DNS route.
dom = registry.node.public_domain
if not dom: Each is either a per-deployment ``public_host`` override or the default
return [] ``<sub>.<public_domain>``; deployments with neither are skipped.
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)] """
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: 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. removes any stale config and leaves the tunnel down.
""" """
node = registry.node 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 return None
pubs = public_deployments(registry) pubs = public_deployments(registry)
if not pubs: if not pubs:
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
ingress: list[dict] = [] ingress: list[dict] = []
for _name, d in pubs: 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}" internal_host = f"{d.subdomain}.{node.gateway_domain}"
ingress.append( 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. # Cloudflared requires a terminal catch-all; anything unmapped is refused.
ingress.append({"service": "http_status:404"}) ingress.append({"service": "http_status:404"})

View File

@@ -33,6 +33,28 @@ class Reach(str, Enum):
PUBLIC = "public" 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`) # Launch specs — how systemd starts a process (discriminated union on `launcher`)
# --------------------- # ---------------------
@@ -420,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
expose: ExposeSpec | None = None expose: ExposeSpec | None = None
# How far this process is exposed (off | internal | public). See `Reach`. # How far this process is exposed (off | internal | public). See `Reach`.
reach: Reach = Reach.OFF 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 manage: ManageSpec | None = None
@model_validator(mode="after") @model_validator(mode="after")
@@ -448,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
"reach: public for a raw-TCP service isn't supported yet " "reach: public for a raw-TCP service isn't supported yet "
"(see docs/tcp-exposure.md step 5); use reach: internal" "(see docs/tcp-exposure.md step 5); use reach: internal"
) )
_validate_public_host(self.public_host, self.reach)
return self return self
# Derived, read-only back-compat accessors (not serialized) so existing # 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 # A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel. # `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL 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") @model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment: def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF: if self.reach == Reach.OFF:
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public") raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
_validate_public_host(self.public_host, self.reach)
return self return self
@property @property

View File

@@ -83,6 +83,9 @@ class Deployment:
# Also projected to the public internet via the tunnel at # Also projected to the public internet via the tunnel at
# <subdomain>.<gateway.public_domain>. Requires subdomain. # <subdomain>.<gateway.public_domain>. Requires subdomain.
public: bool = False 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 # Raw-TCP exposure port (postgres, redis, …). Set → reachable at
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route). # <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
tcp_port: int | None = None tcp_port: int | None = None
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"), subdomain=comp_data.get("subdomain"),
public=comp_data.get("public", False), public=comp_data.get("public", False),
public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"), tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"), static_root=comp_data.get("static_root"),
base_url=comp_data.get("base_url"), 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 entry["subdomain"] = comp.subdomain
if comp.public: if comp.public:
entry["public"] = comp.public entry["public"] = comp.public
if comp.public_host:
entry["public_host"] = comp.public_host
if comp.tcp_port is not None: if comp.tcp_port is not None:
entry["tcp_port"] = comp.tcp_port entry["tcp_port"] = comp.tcp_port
if comp.static_root: if comp.static_root:

View File

@@ -41,6 +41,7 @@ def _make_registry(
gateway_tls: str | None = None, gateway_tls: str | None = None,
gateway_domain: str | None = None, gateway_domain: str | None = None,
acme_email: str | None = None, acme_email: str | None = None,
public_domain: str | None = None,
) -> NodeRegistry: ) -> NodeRegistry:
reg = NodeRegistry( reg = NodeRegistry(
node=NodeConfig( node=NodeConfig(
@@ -49,6 +50,7 @@ def _make_registry(
gateway_tls=gateway_tls, gateway_tls=gateway_tls,
gateway_domain=gateway_domain, gateway_domain=gateway_domain,
acme_email=acme_email, acme_email=acme_email,
public_domain=public_domain,
), ),
) )
for name, d in (deployed or {}).items(): 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( 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 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: class TestOffMode:
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api. """No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
Other services are port-only (not routed).""" 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: def test_no_run_is_invalid(self) -> None:
"""A systemd deployment requires a run (launch) spec.""" """A systemd deployment requires a run (launch) spec."""
with pytest.raises(Exception): with pytest.raises(Exception):

View File

@@ -46,6 +46,31 @@ class TestSupabaseStackResolution:
assert "install" in actions and "uninstall" in actions 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: class TestResolution:
def test_stack_only_program_unchanged(self) -> None: def test_stack_only_program_unchanged(self) -> None:
"""A program with a stack and no commands resolves all stack verbs.""" """A program with a stack and no commands resolves all stack verbs."""
@@ -61,7 +86,11 @@ class TestResolution:
p = ProgramSpec.model_validate( p = ProgramSpec.model_validate(
{ {
"source": "/tmp/y", "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) actions = available_actions(p)
@@ -79,15 +108,24 @@ class TestResolution:
def test_hybrid_override_one_verb(self) -> None: def test_hybrid_override_one_verb(self) -> None:
"""A stack program can override a single verb; the rest fall back to stack.""" """A stack program can override a single verb; the rest fall back to stack."""
p = ProgramSpec.model_validate( 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, "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: def test_build_declared_via_buildspec(self) -> None:
"""`build` is declared through BuildSpec.commands, not CommandsSpec.""" """`build` is declared through BuildSpec.commands, not CommandsSpec."""
p = ProgramSpec.model_validate( 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 _declared_commands(p, "build") == [["make"]]
assert "build" in available_actions(p) 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"] hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
if "hostname" in r} if "hostname" in r}
assert hosts == {"guestbook.pub.payne.io"} 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` WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
routes is gone). Caddy proxies WebSocket upgrades transparently. 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 **Gateway routes — one concept, three target kinds.** The gateway maps a public
**address** (always a subdomain host, `<name>.<domain>`) to a **target**: **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 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 ## The part that isn't the tunnel
Reachability is the easy half. Anything public also needs, per service: Reachability is the easy half. Anything public also needs, per service: