feat: raw-TCP exposure with castle-managed TLS + reach model
Expose raw-TCP services (postgres) by name at <name>.<domain>:<port> and
cut the gateway's ACME wildcard cert onto them so they present a trusted
cert. Replaces the proxy/public booleans with a single `reach` enum
(off|internal|public); legacy input still parses via derived accessors.
Core:
- expose.tcp{port,tls} + TlsSpec(material: pair|combined|off, reload)
- tls.py: materialize cert files from Caddy's wildcard, reconcile on
renewal; `castle tls` CLI; optional cert_obtained events-exec hook
(gateway.cert_hook, gated so a plugin-less Caddy still parses)
- apply waits (bounded) for the wildcard to issue before materializing so
a fresh-node TLS service starts with its cert in place, then scopes
materialization to the deployments being applied
- reach: internal|public requires an expose block (no silent no-op);
public raw-TCP guarded until tunnel support lands
- chain.pem (${tls_ca}) is the issuer chain (leaf stripped), a real CA
bundle distinct from cert.pem
- one shared ${...} resolver (resolve_placeholders) for env and container
run fields; run.env now expands like volumes/args; $$ escapes a literal
- validate the generated Caddyfile before reloading the gateway so an
invalid config never degrades routing
Docs: docs/tcp-exposure.md. Tests cover reach/expose validation,
placeholder expansion + escape, issuer-chain material, TLS materialize.
This commit is contained in:
@@ -42,6 +42,14 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
const run = obj(m.run)
|
||||
const internal = obj(obj(obj(m.expose).http).internal)
|
||||
const httpExpose = obj(obj(m.expose).http)
|
||||
// A raw-TCP service (postgres, redis, …) exposes `expose.tcp`, not `expose.http`.
|
||||
// It's reachable at <name>.<domain>:<port> via DNS (no gateway HTTP route), so the
|
||||
// HTTP port/health/reach controls below don't apply — show its exposure read-only
|
||||
// and never rebuild `expose` on save (that would nuke expose.tcp). Edit TCP/TLS in
|
||||
// deployments/<name>.yaml for now.
|
||||
const tcp = obj(obj(m.expose).tcp)
|
||||
const tcpTls = obj(tcp.tls)
|
||||
const isTcp = tcp.port != null
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -58,8 +66,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
)
|
||||
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
||||
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
||||
// Exposed at <service-name>.<gateway.domain> when proxy is true.
|
||||
const [expose, setExpose] = useState(m.proxy === true)
|
||||
// How far the service reaches: off | internal | public. Falls back to the
|
||||
// legacy proxy/public booleans for any deployment not yet re-saved.
|
||||
const [reach, setReach] = useState(
|
||||
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
|
||||
)
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
|
||||
@@ -74,19 +85,24 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
// Rebuild the run block for the chosen launcher, preserving other fields.
|
||||
config.run = applyLauncher(obj(config.run), launcher, runProgram)
|
||||
|
||||
if (port) {
|
||||
config.expose = {
|
||||
http: {
|
||||
internal: { port: parseInt(port, 10) },
|
||||
...(health ? { health_path: health } : {}),
|
||||
},
|
||||
// For a TCP service, leave expose.tcp + reach exactly as-is (they're already
|
||||
// in the cloned config) — only the HTTP path rebuilds expose from the form.
|
||||
if (!isTcp) {
|
||||
if (port) {
|
||||
config.expose = {
|
||||
http: {
|
||||
internal: { port: parseInt(port, 10) },
|
||||
...(health ? { health_path: health } : {}),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
delete config.expose
|
||||
}
|
||||
} else {
|
||||
delete config.expose
|
||||
// reach needs a port to route through the gateway; without one it's off.
|
||||
config.reach = port ? reach : "off"
|
||||
}
|
||||
|
||||
if (expose) config.proxy = true
|
||||
else delete config.proxy
|
||||
delete config.proxy
|
||||
delete config.public
|
||||
|
||||
const env = merged()
|
||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||
@@ -127,39 +143,77 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
onChange={setPort}
|
||||
width="w-32"
|
||||
mono
|
||||
placeholder="9001"
|
||||
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
||||
/>
|
||||
<TextField
|
||||
label="Health path"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="/health"
|
||||
hint="HTTP path castle polls to report up/down."
|
||||
/>
|
||||
<Field
|
||||
label="Expose"
|
||||
hint="Route this service through the gateway at <service-name>.<gateway.domain>. Unchecked: reachable only at its own host:port."
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={expose}
|
||||
onChange={(e) => setExpose(e.target.checked)}
|
||||
{isTcp ? (
|
||||
<Field
|
||||
label="Exposure"
|
||||
hint="A raw-TCP service — reachable by name + port via DNS, not the HTTP gateway. Edit its port/TLS in deployments/<name>.yaml for now."
|
||||
>
|
||||
<div className="font-mono text-xs text-[var(--muted)] space-y-1">
|
||||
<div>
|
||||
<span className="text-[var(--fg)]">tcp</span> · port{" "}
|
||||
<span className="text-[var(--fg)]">{String(tcp.port)}</span>
|
||||
{tcpTls.material && tcpTls.material !== "off" ? (
|
||||
<>
|
||||
{" "}
|
||||
· tls <span className="text-[var(--fg)]">{String(tcpTls.material)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
reach{" "}
|
||||
<span className="text-[var(--fg)]">{String(m.reach ?? "internal")}</span> —{" "}
|
||||
{service.id}.<gateway.domain>:{String(tcp.port)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
onChange={setPort}
|
||||
width="w-32"
|
||||
mono
|
||||
placeholder="9001"
|
||||
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
||||
/>
|
||||
<span className="font-mono text-[var(--muted)]">
|
||||
{expose ? `${service.id}.<gateway.domain>` : "off (host:port only)"}
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
<TextField
|
||||
label="Health path"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="/health"
|
||||
hint="HTTP path castle polls to report up/down."
|
||||
/>
|
||||
<Field
|
||||
label="Reach"
|
||||
hint="How far this service is exposed. off: host:port only. internal: <service-name>.<gateway.domain> via the gateway. public: also to the internet via the Cloudflare tunnel."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={reach}
|
||||
onChange={(e) => setReach(e.target.value)}
|
||||
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">off</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
<span className="font-mono text-[var(--muted)] text-xs">
|
||||
{!port
|
||||
? "set a port to expose"
|
||||
: reach === "off"
|
||||
? "host:port only"
|
||||
: reach === "public"
|
||||
? `${service.id}.<gateway.public_domain>`
|
||||
: `${service.id}.<gateway.domain>`}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
saving={saving}
|
||||
|
||||
@@ -19,7 +19,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||
const [root, setRoot] = useState((m.root as string) ?? "dist")
|
||||
const [isPublic, setIsPublic] = useState(m.public === true)
|
||||
// Static sites are always served (reach internal|public); the toggle picks public.
|
||||
const [isPublic, setIsPublic] = useState(
|
||||
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
|
||||
)
|
||||
const { element: envEditor, merged } = useEnvSecrets(
|
||||
obj(obj(m.defaults).env) as Record<string, string>,
|
||||
)
|
||||
@@ -32,8 +35,8 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
delete config.id
|
||||
config.description = description || undefined
|
||||
config.root = root || "dist"
|
||||
if (isPublic) config.public = true
|
||||
else delete config.public
|
||||
config.reach = isPublic ? "public" : "internal"
|
||||
delete config.public
|
||||
const env = merged()
|
||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||
else if (config.defaults) delete (config.defaults as Obj).env
|
||||
@@ -58,19 +61,22 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
hint="The built directory the gateway serves, relative to the program source (e.g. dist, public)."
|
||||
/>
|
||||
<Field
|
||||
label="Public"
|
||||
hint="Also expose this site to the public internet via the Cloudflare tunnel."
|
||||
label="Reach"
|
||||
hint="How far this static site is served. internal: <name>.<gateway.domain>. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
|
||||
>
|
||||
<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"}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={isPublic ? "public" : "internal"}
|
||||
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">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
<span className="font-mono text-[var(--muted)] text-xs">
|
||||
{isPublic ? `${dep.id}.<gateway.public_domain>` : `${dep.id}.<gateway.domain>`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
|
||||
Reference in New Issue
Block a user