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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} {{",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
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:
|
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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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**:
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user