Adds custom domains.
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} {{",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
110
core/tests/test_dns.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user