diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index c72a4af..0fb5d81 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -74,7 +74,7 @@ export function CreateDeploymentForm({ }, } } - if (port && expose) base.proxy = { caddy: {} } + if (port && expose) base.proxy = true return base } diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx index c60318a..e298d21 100644 --- a/app/src/components/detail/ServiceFields.tsx +++ b/app/src/components/detail/ServiceFields.tsx @@ -18,7 +18,6 @@ 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) - const caddyRaw = obj(m.proxy).caddy const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) @@ -29,8 +28,8 @@ 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 . when proxy.caddy is present + enabled. - const [expose, setExpose] = useState(caddyRaw !== undefined && obj(caddyRaw).enable !== false) + // Exposed at . when proxy is true. + const [expose, setExpose] = useState(m.proxy === true) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record) @@ -62,7 +61,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { delete config.expose } - if (expose) config.proxy = { caddy: {} } + if (expose) config.proxy = true else delete config.proxy const env = merged() diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 15d3509..66f5c5a 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -103,7 +103,7 @@ def _summary_from_service( if svc.expose and svc.expose.http: port = svc.expose.http.internal.port health_path = svc.expose.http.health_path - subdomain = name if (svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable) else None + subdomain = name if svc.proxy else None managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable) @@ -278,7 +278,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu port = svc.expose.http.internal.port health_path = svc.expose.http.health_path # Exposed at . when the proxy checkbox is on. - subdomain = name if (svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable) else None + subdomain = name if svc.proxy else None managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable) systemd_info = _make_systemd_info(name) if managed else None diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 3f7dd66..801f3b3 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -77,7 +77,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "health_path": "/health", } }, - "proxy": {"caddy": {"path_prefix": "/test-svc"}}, + "proxy": True, "manage": {"systemd": {}}, }, }, diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 5faed89..47d18d7 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -8,14 +8,12 @@ import subprocess from castle_cli.config import REPOS_DIR, load_config, save_config from castle_cli.manifest import ( BuildSpec, - CaddySpec, DefaultsSpec, ExposeSpec, HttpExposeSpec, HttpInternal, ManageSpec, ProgramSpec, - ProxySpec, RunPython, ServiceSpec, SystemdSpec, @@ -121,7 +119,7 @@ def run_create(args: argparse.Namespace) -> int: health_path="/health", ) ), - proxy=ProxySpec(caddy=CaddySpec()), # expose at . + proxy=True, # expose at . manage=ManageSpec(systemd=SystemdSpec()), # python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's # computed port/data dir to those vars (explicit, no hidden injection). diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index f6c9d64..60b19f0 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -11,14 +11,12 @@ import argparse from castle_cli.config import load_config, save_config from castle_cli.manifest import ( - CaddySpec, DefaultsSpec, ExposeSpec, HttpExposeSpec, HttpInternal, JobSpec, ManageSpec, - ProxySpec, RunCommand, RunPython, ServiceSpec, @@ -62,7 +60,7 @@ def run_service_create(args: argparse.Namespace) -> int: run = _run_spec(args.runner, args.run or args.program or name, name) expose = None - proxy = None + proxy = False if args.port is not None: expose = ExposeSpec( http=HttpExposeSpec( @@ -70,9 +68,8 @@ def run_service_create(args: argparse.Namespace) -> int: health_path=args.health, ) ) - if not args.no_proxy: - # Expose at . (the subdomain is the service name). - proxy = ProxySpec(caddy=CaddySpec()) + # Expose at . (the subdomain is the service name). + proxy = not args.no_proxy config.services[name] = ServiceSpec( id=name, @@ -90,7 +87,7 @@ def run_service_create(args: argparse.Namespace) -> int: print(f" runs: {args.runner} ({args.run or args.program or name})") if expose: print(f" port: {args.port}") - if proxy and proxy.caddy: + if proxy: print(f" subdomain: {name}.") print(f"\nNext: castle service deploy {name} && castle service start {name}") return 0 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index d56ac85..2981ece 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -119,7 +119,7 @@ def run_info(args: argparse.Namespace) -> int: print(f" {BOLD}port{RESET}: {http.internal.port}") if http.health_path: print(f" {BOLD}health{RESET}: {http.health_path}") - if service.proxy and service.proxy.caddy and service.proxy.caddy.enable: + if service.proxy: print(f" {BOLD}subdomain{RESET}: {name}.") if service.manage and service.manage.systemd: sd = service.manage.systemd diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index b5a89b7..194422b 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -3,7 +3,6 @@ from castle_core.manifest import * # noqa: F401, F403 from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers BuildSpec, - CaddySpec, Capability, CommandsSpec, DefaultsSpec, @@ -15,7 +14,6 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ JobSpec, ManageSpec, ProgramSpec, - ProxySpec, ReadinessHttpGet, RestartPolicy, RunBase, diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 08aab09..1b6e04e 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -61,7 +61,7 @@ def service_proxy_targets(name: str, svc: ServiceSpec) -> ProxyTargets: port = None if svc.expose and svc.expose.http: port = svc.expose.http.internal.port - expose = bool(svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable) + expose = bool(svc.proxy) base_url = getattr(svc.run, "base_url", None) return expose, port, base_url diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index c612d3b..7e08098 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -145,18 +145,6 @@ class ExposeSpec(BaseModel): http: HttpExposeSpec | None = None -class CaddySpec(BaseModel): - # The "expose" checkbox: when present and enabled, the gateway routes - # . to this service (whole host → backend root). - # The subdomain is always the service name — rename the service to change it. - enable: bool = True - extra_snippets: list[str] = Field(default_factory=list) - - -class ProxySpec(BaseModel): - caddy: CaddySpec | None = None - - # --------------------- # Build spec # --------------------- @@ -277,7 +265,9 @@ class ServiceSpec(BaseModel): run: RunSpec expose: ExposeSpec | None = None - proxy: ProxySpec | None = None + # Expose this service at . through the gateway + # (the subdomain is the service name). False → reachable only at its host:port. + proxy: bool = False manage: ManageSpec | None = None defaults: DefaultsSpec | None = None diff --git a/core/tests/conftest.py b/core/tests/conftest.py index a9df045..009ed03 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -58,9 +58,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "health_path": "/health", } }, - "proxy": { - "caddy": {"path_prefix": "/test-svc"}, - }, + "proxy": True, "manage": { "systemd": {}, }, diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 5bb6aa5..644b338 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -11,12 +11,10 @@ from castle_core.generators.caddyfile import ( ) from castle_core.manifest import ( BuildSpec, - CaddySpec, ExposeSpec, HttpExposeSpec, HttpInternal, ProgramSpec, - ProxySpec, RunPython, ServiceSpec, ) @@ -151,7 +149,7 @@ def _service(port: int, *, expose: bool) -> ServiceSpec: return ServiceSpec( run=RunPython(runner="python", program="svc"), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))), - proxy=ProxySpec(caddy=CaddySpec()) if expose else None, + proxy=expose, ) diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 3a5e715..0227959 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -46,7 +46,7 @@ class TestLoadConfig: """Service has correct proxy spec.""" config = load_config(castle_root) svc = config.services["test-svc"] - assert svc.proxy.caddy.enable is True # exposed at . + assert svc.proxy is True # exposed at . def test_service_run_spec(self, castle_root: Path) -> None: """Service has correct RunSpec.""" diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index 1982ce8..a14b275 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -5,14 +5,12 @@ from __future__ import annotations import pytest from castle_core.manifest import ( BuildSpec, - CaddySpec, ProgramSpec, ExposeSpec, HttpExposeSpec, HttpInternal, JobSpec, ManageSpec, - ProxySpec, RunCommand, RunPython, RunRemote, @@ -92,9 +90,9 @@ class TestServiceSpec: s = ServiceSpec( id="svc", run=RunPython(runner="python", program="svc"), - proxy=ProxySpec(caddy=CaddySpec()), + proxy=True, ) - assert s.proxy.caddy.enable is True + assert s.proxy is True def test_service_with_manage(self) -> None: """Service with systemd management.""" @@ -185,10 +183,10 @@ class TestModelSerialization: internal=HttpInternal(port=9001), health_path="/health" ) ), - proxy=ProxySpec(caddy=CaddySpec()), + proxy=True, manage=ManageSpec(systemd=SystemdSpec()), ) data = s.model_dump(exclude_none=True, exclude={"id"}) assert data["run"]["runner"] == "python" assert data["expose"]["http"]["internal"]["port"] == 9001 - assert data["proxy"]["caddy"]["enable"] is True + assert data["proxy"] is True diff --git a/docs/design.md b/docs/design.md index e8a6ef8..72eccd9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -170,9 +170,7 @@ services: http: internal: { port: 9001 } health_path: /health - proxy: - caddy: - path_prefix: /central-context + proxy: true # expose at central-context. manage: systemd: {} diff --git a/docs/dns-and-tls.md b/docs/dns-and-tls.md index 267f226..c157a3d 100644 --- a/docs/dns-and-tls.md +++ b/docs/dns-and-tls.md @@ -12,13 +12,13 @@ browser on your LAN: 2. **Trust** — is the certificate the node serves one the browser accepts? *(TLS)* Castle answers #1 by leaning on your LAN's own DNS, and #2 with a per-node choice -of three TLS modes. They're orthogonal: you pick a resolution strategy and a trust +of two TLS modes. They're orthogonal: you pick a resolution strategy and a trust strategy, and any working combination is fine. ## The gateway is the single ingress Every gateway-reachable service goes through the Caddy **gateway**. Exposure is a -single **checkbox** (`proxy.caddy` on a service): +single **checkbox** (`proxy: true` on a service): - **unchecked** — the service is reachable only at its own `host:port` (no gateway route, no DNS name). @@ -116,7 +116,7 @@ origin — moving a service onto HTTPS changes its origin. ## Putting a service on trusted HTTPS — the recipe -1. **Check the box.** Add `proxy: { caddy: {} }` to the service — it's now exposed +1. **Check the box.** Add `proxy: true` to the service — it's now exposed at `.` (rename the service to change the name). 2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone (§DNS). Verify: `dig +short .` → the node's IP. diff --git a/docs/registry.md b/docs/registry.md index 75c3b30..979c498 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -79,8 +79,7 @@ expose: http: internal: { port: 9001 } health_path: /health -proxy: - caddy: {} # expose at my-service. +proxy: true # expose at my-service. manage: systemd: {} ``` @@ -272,13 +271,12 @@ expose: ### `proxy` — Expose the service at a subdomain -`proxy.caddy` is a **checkbox**: present (and `enable: true`, the default) means the -gateway routes **`.`** to this service; absent means -the service is reachable only at its own `host:port`. +`proxy` is a **checkbox** (a bool): `true` means the gateway routes +**`.`** to this service; omitted/`false` means the +service is reachable only at its own `host:port`. ```yaml -proxy: - caddy: {} # expose at . +proxy: true # expose at . ``` The subdomain is always the service name — there's nothing to customize (rename the @@ -334,7 +332,7 @@ version number"). `gateway.tls` has two values: | `acme` | one `*.` `:443` site | matcher inside the wildcard site | **real Let's Encrypt wildcard, no CA install** | Path-prefix and static routes always stay on the HTTP `:` site — the way to -put a service on HTTPS is to give it a `proxy.caddy.host`. A node with no public +put a service on HTTPS is to set `proxy: true` (and use acme mode). A node with no public domain stays on `off` (plain HTTP; use `localhost`/direct ports for anything that needs a secure context). @@ -387,7 +385,7 @@ it needs **no inbound exposure and no public A records** for the services. Only **LAN DNS** resolves `*.` to the gateway's private IP. (HTTP-01 can't validate a wildcard, so DNS-01 — and thus the provider token — is mandatory here.) -Every subdomain is the **service name**: a service checks the `proxy.caddy` box and +Every subdomain is the **service name**: a service sets `proxy: true` and is published at `.`. Services stay domain-agnostic (switching `gateway.domain` needs no service edits). One `*.` site means a single cert covers every route — adding a service needs no new cert. @@ -560,8 +558,7 @@ services: http: internal: { port: 9001 } health_path: /health - proxy: - caddy: {} # expose at my-service. + proxy: true # expose at my-service. manage: systemd: {} ``` diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 3d7afec..122f665 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -123,8 +123,7 @@ services: http: internal: { port: 9001 } health_path: /health - proxy: - caddy: { path_prefix: /my-service } + proxy: true # expose at my-service. manage: systemd: {} ``` diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index e3b0521..11d4f2a 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -56,9 +56,9 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" export default defineConfig({ - // Castle sets VITE_BASE to the gateway serve prefix at build time (`//`, - // or `/` for the root app). Reading it here makes the bundle's absolute asset - // URLs resolve when served behind the gateway at a subpath — no hand-tuning. + // Castle sets VITE_BASE=/ at build time (every frontend serves at its + // subdomain root). Reading it here keeps the bundle.s absolute asset + // URLs resolving at the root — no hand-tuning. base: process.env.VITE_BASE ?? "/", plugins: [react(), tailwindcss()], resolve: { @@ -157,8 +157,7 @@ services: expose: http: internal: { port: 5173 } - proxy: - caddy: {} # expose the dev server at my-frontend. + proxy: true # expose the dev server at my-frontend. ``` See @docs/registry.md for the full registry reference. diff --git a/docs/stacks/supabase.md b/docs/stacks/supabase.md index c2e2ca1..89cd3b5 100644 --- a/docs/stacks/supabase.md +++ b/docs/stacks/supabase.md @@ -117,11 +117,12 @@ the function holds credentials and can meter usage. ## Gateway & secure context -A `public` app is fine on the gateway's HTTP static route at `/my-app/`. An app -that uses **auth or WebCrypto** needs a **secure context**, so give it its own -HTTPS host route instead — `proxy.caddy.host: my-app.lan` with `gateway.tls: -internal` — exactly like the substrate itself (`supabase.lan`). See the "HTTPS for -host routes" section of @docs/registry.md. +A supabase app is a `frontend` program (its `public/` is served in place), so the +gateway serves it at its own subdomain `.`. With +`gateway.tls: acme` that subdomain is HTTPS — a **secure context**, which apps +using **auth or WebCrypto** require — with no private CA to install. (The substrate +service itself is likewise at `supabase.`.) See +@docs/dns-and-tls.md. ## Commands