Add ${public_url} defaults.env placeholder for gateway-facing origin

Services often need their own public origin — the URL a browser loads them
at through the gateway — to populate CORS/WebSocket/secure-context allowlists.
Previously this had to be hardcoded in the app's own config, which silently
broke when the subdomain-routing migration changed the address (openclaw's
Control UI rejected its new origin).

${public_url} resolves at deploy to the service's gateway-facing base URL:
https://<name>.<gateway.domain> when exposed under tls: acme, else the
node-local http://localhost:<port>. It tracks gateway.domain, so a domain
change needs no per-app edit. Jobs (not exposed) don't get it.

Matches the TLS mode as the raw lowercased string == "acme", the same way the
Caddyfile generator does (the TLSMode enum is stale).
This commit is contained in:
2026-06-30 22:28:50 -07:00
parent d0075300e3
commit c28198dab6
2 changed files with 34 additions and 5 deletions

View File

@@ -211,11 +211,35 @@ def _reload_gateway(messages: list[str]) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _env_context(name: str, config_key: str, port: int | None) -> dict[str, str]: def _public_url(
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}.""" config: CastleConfig, name: str, exposed: bool, port: int | None
) -> str | None:
"""The service's publicly-reachable base URL — the ``${public_url}`` placeholder.
When the service is exposed through the gateway under acme TLS, this is its
trusted subdomain ``https://<name>.<gateway.domain>`` — the origin an app must
allowlist for CORS/WebSocket/secure-context to work behind the gateway. It
tracks ``gateway.domain`` automatically, so a domain change needs no app edit.
Off mode / port-only falls back to the node-local ``http://localhost:<port>``;
``None`` when there's no port to reach it on (nothing to interpolate).
"""
gw = config.gateway
if exposed and str(gw.tls or "").lower() == "acme" and gw.domain:
return f"https://{name}.{gw.domain}"
if port is not None:
return f"http://localhost:{port}"
return None
def _env_context(
name: str, config_key: str, port: int | None, public_url: str | None = None
) -> dict[str, str]:
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}."""
ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)} ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)}
if port is not None: if port is not None:
ctx["port"] = str(port) ctx["port"] = str(port)
if public_url is not None:
ctx["public_url"] = public_url
return ctx return ctx
@@ -276,12 +300,16 @@ def _build_deployed_service(
health_path = svc.expose.http.health_path health_path = svc.expose.http.health_path
# Env is exactly what's declared in defaults.env — no hidden convention # Env is exactly what's declared in defaults.env — no hidden convention
# injection. ${port}/${data_dir}/${name} let the program's own env var names # injection. ${port}/${data_dir}/${name}/${public_url} let the program's own
# map to castle's computed values without hardcoding them. Secret-bearing vars # env var names map to castle's computed values without hardcoding them.
# Secret-bearing vars
# are split out so they never land in the unit file or process argv — they're # are split out so they never land in the unit file or process argv — they're
# written to a mode-0600 env file referenced via EnvironmentFile=/--env-file. # written to a mode-0600 env file referenced via EnvironmentFile=/--env-file.
raw_env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {} raw_env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
env, secret_env = resolve_env_split(raw_env, _env_context(name, config_key, port)) public_url = _public_url(config, name, expose, port)
env, secret_env = resolve_env_split(
raw_env, _env_context(name, config_key, port, public_url)
)
secret_env_file = _write_secret_env_file(name, secret_env) secret_env_file = _write_secret_env_file(name, secret_env)
# `command`-runner services resolve a tool on PATH → ensure it's installed. # `command`-runner services resolve a tool on PATH → ensure it's installed.

View File

@@ -469,6 +469,7 @@ Values may contain placeholders that castle resolves at deploy:
| `${port}` | the service's `expose.http.internal.port` (so it can't drift) | | `${port}` | the service's `expose.http.internal.port` (so it can't drift) |
| `${data_dir}` | `$CASTLE_DATA_DIR/<program-or-name>` (the dedicated data volume) | | `${data_dir}` | `$CASTLE_DATA_DIR/<program-or-name>` (the dedicated data volume) |
| `${name}` | the deployment name | | `${name}` | the deployment name |
| `${public_url}` | the service's gateway-facing base URL — `https://<name>.<domain>` when exposed under `tls: acme`, else the node-local `http://localhost:<port>`. The origin an app allowlists (CORS/WebSocket/secure-context); tracks `gateway.domain`, so a domain change needs no app edit. |
| `${secret:NAME}` | the contents of `~/.castle/secrets/NAME` | | `${secret:NAME}` | the contents of `~/.castle/secrets/NAME` |
Hardcode the values instead if you prefer; the placeholders just save you from Hardcode the values instead if you prefer; the placeholders just save you from