diff --git a/items/openclaw/blueprint.yaml b/items/openclaw/blueprint.yaml index b0ff101..28d2e07 100644 --- a/items/openclaw/blueprint.yaml +++ b/items/openclaw/blueprint.yaml @@ -2,11 +2,33 @@ specVersion: v1 kind: BLUEPRINT metadata: slug: openclaw - version: 1 + version: 2 spec: components: - web: + # `gateway` is declared before `proxy` because map order is graph order, and + # `proxy` consumes an output of this node. The connection graph is a legal + # cycle either way (blueprint §4.2), but declaring the producer first means a + # validator walking the map in order meets `gateway.address` before the wire + # that reads it. + # + # PRIVATE — reachable only from inside this deployment's mesh, i.e. only + # through `proxy`. + gateway: component: ./components/openclaw.yaml size: general.standard.small - connections: {} + connections: + publicOrigin: + fromRole: proxy + fromOutput: publicUrl + # The deployment's only public face. It authenticates the browser with a + # username and password, then forwards to `gateway` with the authenticated + # identity attached, which is what lets OpenClaw enrol the device inside the + # handshake instead of demanding a pairing approval no catalog user can give. + proxy: + component: ./components/auth-proxy.yaml + size: general.standard.small + connections: + openclawUpstream: + fromRole: gateway + fromOutput: address parameters: {} diff --git a/items/openclaw/components/auth-proxy.yaml b/items/openclaw/components/auth-proxy.yaml new file mode 100644 index 0000000..a32293f --- /dev/null +++ b/items/openclaw/components/auth-proxy.yaml @@ -0,0 +1,164 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 2 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + # Pinned to a patch tag rather than the 2.10 series alias: a series alias + # moves under whoever curates the registry, which is the same objection + # COMP-SRC-001 raises against :latest, only slower. + ref: docker.io/library/caddy:2.10.2-alpine + # This node is the deployment's front door. It authenticates the browser with + # a username and password, then forwards to the OpenClaw gateway with the + # authenticated identity attached as `X-Forwarded-User`. OpenClaw runs in + # `trusted-proxy` auth mode and enrols the browser's device automatically on + # the strength of that header, so the pairing round trip resolves inside the + # handshake and never reaches the user as an error. + # + # The Caddyfile is written at boot rather than baked into an image, because a + # catalog item may not ship a bespoke image. Two values are only knowable at + # deploy time — the wired upstream address and the operator's chosen password + # — and Caddy stores passwords as bcrypt, so the hash is computed here with + # the `caddy` binary the image already carries. The plaintext password is + # never written to disk. + # + # Every block-opening `{` is the last token on its line, because that is what + # the Caddyfile grammar requires — `handle /x { respond "ok" 200 }` on one + # line does not parse. An inline `{placeholder}` is a single token and is not + # affected, which is why the `header_up` lines are fine as written. + # + # `{http.auth.user.id}` is Caddy's placeholder for the authenticated + # username, expanded by Caddy at request time. It survives printf as a + # literal and is NOT a shell expansion. + # + # `header_up` with no `+` prefix SETS the header, replacing whatever the + # client sent. That is the security property this whole design rests on: a + # browser cannot smuggle its own `X-Forwarded-User` past the proxy and have + # the gateway believe it. Verified end to end against Caddy 2.10.2 — a + # request carrying a forged `X-Forwarded-User: attacker` arrives upstream as + # the authenticated username instead. + # + # X-Forwarded-Proto and X-Forwarded-Host are not set here; `reverse_proxy` + # already sets both, and Caddy warns that restating them is redundant. + # + # /__proxy_health is matched before the authenticated route so the readiness + # probe is not answered with a 401. It exposes nothing but the string "ok". + # + # `reverse_proxy` upgrades WebSocket connections natively, which this item + # depends on: the Control UI speaks to the gateway over a WebSocket on the + # same port it fetched the page from. + command: >- + sh -c 'set -e; + HASH="$(caddy hash-password --plaintext "$PROXY_PASSWORD")"; + printf "%s\n" + "{" + " admin off" + " auto_https off" + "}" + ":8080 {" + " handle /__proxy_health {" + " respond \"ok\" 200" + " }" + " handle {" + " basic_auth {" + " $PROXY_USERNAME \"$HASH\"" + " }" + " reverse_proxy $OPENCLAW_UPSTREAM {" + " header_up X-Forwarded-User {http.auth.user.id}" + " }" + " }" + "}" > /etc/caddy/Caddyfile; + exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile' + endpoints: + primary: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + # Unauthenticated by design — see the Caddyfile note above. A probe that + # had to authenticate would couple traffic admission to a user password. + path: /__proxy_health + endpoint: primary + initialDelaySeconds: 5 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 10 + liveness: + path: /__proxy_health + endpoint: primary + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + contract: + inputs: + # Wired from the openclaw node's `address` output by the blueprint, so the + # proxy learns the gateway's mesh-internal host:port without either + # document naming a hostname the platform assigns. + openclawUpstream: + schema: + type: STRING + semanticType: HTTP_SERVICE + isRequired: true + suppliedBy: CONNECTION + # §6.1: a CONNECTION input never reaches the install form, so `ui` MUST + # be null. Omitted rather than written as an explicit `null` — both + # satisfy the rule, and omission is the form that cannot be mistaken for + # a present-but-empty value by a consumer that distinguishes the two. + target: + envVarKey: OPENCLAW_UPSTREAM + description: >- + Mesh-internal host:port of the OpenClaw gateway this proxy fronts. + proxyUsername: + schema: + type: STRING + default: admin + # Caddyfile tokens are whitespace-delimited, and this value is + # interpolated into one. The grammar also keeps out the quoting and + # brace characters that would let a username restructure the file. + pattern: "^[A-Za-z0-9._-]{1,64}$" + isRequired: true + suppliedBy: USER + ui: + label: Username + target: + envVarKey: PROXY_USERNAME + description: >- + Username for signing in to the Control UI. + proxyPassword: + schema: + type: STRING + isSensitive: true + # 16 characters minimum. This is the only credential in front of an + # admin surface that can run shell commands, reachable from the public + # internet, so it is held to more than a memorable word. + pattern: "^.{16,}$" + isRequired: true + suppliedBy: USER + ui: + label: Password + target: + envVarKey: PROXY_PASSWORD + description: >- + Password for signing in to the Control UI. At least 16 characters. + Stored only as a bcrypt hash inside the running container. + outputs: + # Consumed by the openclaw node, which needs this deployment's browser + # origin in `gateway.controlUi.allowedOrigins`. It is the PROXY's public + # URL now, not the gateway's — the gateway endpoint is PRIVATE. + # + # Component spec 6.2 permits this alongside the inbound `openclawUpstream` + # wire: an output derives from its own workload's addressing and never from + # an inbound connection, so the resulting two-node cycle resolves. + publicUrl: + schema: + type: STRING + format: ENDPOINT_URL + description: >- + Public https URL of this deployment's Control UI, as the browser sees + it. Wire it into the gateway's browser-origin allowlist. + valueFrom: DERIVED + value: null diff --git a/items/openclaw/components/openclaw.yaml b/items/openclaw/components/openclaw.yaml index 87ab1b1..809e81c 100755 --- a/items/openclaw/components/openclaw.yaml +++ b/items/openclaw/components/openclaw.yaml @@ -1,19 +1,37 @@ specVersion: v1 kind: COMPONENT metadata: - version: 1 + version: 2 spec: workload: kind: SERVICE source: type: IMAGE # Latest stable calendar release (beta tags are not pinned for the catalog). - ref: ghcr.io/openclaw/openclaw:2026.7.1 + # + # 2026.8.2 rather than 2026.8.1 deliberately. PR #3 bumped to 2026.8.1 and + # was reverted over its in-place upgrade path; 2026.8.2 is the release that + # hardened exactly that, stopping incomplete session migrations before + # claiming success, preserving newer configuration ahead of last-known-good + # recovery, failing updates clearly when a session migration still blocks + # startup, and restoring in-container CLI access. The 2026.8.1 objection + # does not carry forward unchanged. + # + # This line and the trusted-proxy config below are coupled, in BOTH + # directions. `deviceAutoApprove` does not exist before 2026.8.1, so this + # tag cannot go backward without the pairing gate returning. And + # `gateway.controlUi.dangerouslyDisableDeviceAuth` is not a fallback on + # this tag: 2026.8.1 dropped the `allowBypass` branch from + # `shouldSkipControlUiPairing` (2026.7.1 ends + # `return role === "operator" && policy.allowBypass`; 2026.8.x ends + # `return null`), and upstream calls the key "a retired break-glass input, + # now fully inert" with `doctor --fix` deleting it. + ref: ghcr.io/openclaw/openclaw:2026.8.2 # Converge config through OpenClaw's own `config set` CLI, then exec the # stock gateway entrypoint (`tini -s --` passes argv through, so `sh -c` works). # # The Control UI origin allowlist (`gateway.controlUi.allowedOrigins`) is - # CONFIG-FILE-ONLY — verified against 2026.7.1, whose bundle declares no + # CONFIG-FILE-ONLY — re-verified against 2026.8.2, whose bundle declares no # matching env var — and without this deployment's public origin in it the # gateway rejects the browser with "Browser origin not allowed". # @@ -22,31 +40,78 @@ spec: # normalized document carrying the `meta.lastTouchedVersion` stamp the gateway # expects, whereas a hand-rolled file is rejected as clobbered config # ("existing config is missing gateway.mode"). Verified live: after - # `config set`, the origin survives gateway startup and /readyz returns 200. + # `config set`, the origin survives gateway startup and the probe returns 200. # # Run unconditionally on every boot rather than behind a first-boot guard — # the config lives on a persistent volume, so a guard would pin the very first # boot's origin forever and leave a re-deployed app permanently unreachable. # `config set` is declarative, so re-applying is a no-op. + # + # AUTH LIVES IN THE auth-proxy NODE, not here. This endpoint is PRIVATE, so + # nothing outside the deployment's own mesh can reach the gateway at all; + # every browser arrives through the proxy, which authenticates it with a + # username and password first. + # + # `gateway.auth.mode: "trusted-proxy"` then accepts the identity the proxy + # attaches as `X-Forwarded-User`, and `deviceAutoApprove` enrols that + # browser's device on the strength of it. That is what removes the pairing + # error rather than merely shortening it: upstream resolves the enrolment + # inside the handshake — "New browser operator devices (including Control UI + # and WebChat) ... resolve automatically" — so the browser never sees a + # rejection, and no watcher is needed. It is also the only mechanism of this + # shape that exists on 2026.8.x, where + # `gateway.controlUi.dangerouslyDisableDeviceAuth` is retired and inert. + # + # `deviceAutoApprove.scopes` names operator.admin deliberately. The default + # list is read/write/approvals/questions, which would leave the operator of + # their own single-tenant deployment unable to change settings. Upstream + # reports this as a critical `security audit` finding because on a shared + # gateway it lets every proxy-authenticated user self-grant admin; here the + # proxy admits exactly one credential, so the set of proxy-authenticated + # users and the set of intended admins are the same set. + # + # `trustedProxies` carries the RFC1918 ranges rather than a single address + # because the proxy's mesh IP is assigned at deploy time and no document here + # can name it. That is sound only because this endpoint is PRIVATE: the + # ranges describe who may *assert* identity headers, and nothing outside the + # mesh can reach the port to try. Upstream additionally rejects a source + # matching the gateway's own interfaces as a spoofing guard. command: >- sh -c 'set -e; node /app/dist/index.js config set --batch-json "[{\"path\":\"gateway.mode\",\"value\":\"local\"}, {\"path\":\"gateway.bind\",\"value\":\"lan\"}, + {\"path\":\"gateway.trustedProxies\",\"value\":[\"10.0.0.0/8\",\"172.16.0.0/12\",\"192.168.0.0/16\"]}, + {\"path\":\"gateway.auth.mode\",\"value\":\"trusted-proxy\"}, + {\"path\":\"gateway.auth.trustedProxy.userHeader\",\"value\":\"x-forwarded-user\"}, + {\"path\":\"gateway.auth.trustedProxy.deviceAutoApprove.enabled\",\"value\":true}, + {\"path\":\"gateway.auth.trustedProxy.deviceAutoApprove.scopes\",\"value\":[\"operator.read\",\"operator.write\",\"operator.approvals\",\"operator.questions\",\"operator.admin\"]}, {\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":[\"${OPENCLAW_PUBLIC_ORIGIN%/}\"]}]"; exec node openclaw.mjs gateway' endpoints: primary: + # PRIVATE: the auth-proxy node is this deployment's only front door. + # Making it PUBLIC would publish an unauthenticated route straight past + # the proxy, because trusted-proxy mode trusts the header rather than a + # shared secret. containerPort: 18789 protocol: HTTP - visibility: PUBLIC + visibility: PRIVATE health: readiness: - # /readyz is the machine-readable gate; /healthz can return the Control UI - # HTML shell with a 200, which would falsely read as ready. - path: /readyz + # /startupz, not /readyz. Upstream's health table (docs/gateway/health.md + # in 2026.8.2) assigns /startupz to "Orchestrator startup and traffic + # admission" and /readyz to "Operator monitoring that should surface hard + # channel failures", because /readyz additionally runs deep per-channel + # readiness. Upstream states the consequence outright: "A broken Telegram + # or other channel account can make /readyz return 503 without taking a + # healthy Control UI out of service through /startupz." On /readyz one + # lapsed messaging credential would pull a working Control UI from + # routing. + path: /startupz endpoint: primary - # Node boot + SQLite migrations run before the gate opens on first start. + # Node boot, SQLite migrations, and the startup-safe config migrations + # 2026.8.x runs all complete before the gate opens on first start. initialDelaySeconds: 45 timeoutSeconds: 5 successThreshold: 1 @@ -84,40 +149,32 @@ spec: mountPath: /home/node/.config/openclaw contract: inputs: - # Master gateway token: auto-minted at deploy, shown on the Configuration tab, and - # pasted into the Control UI on first visit. 32 bytes of hex == a 64-char token, - # matching the project's `openssl rand -hex 32` guidance. - gatewayToken: - schema: - type: STRING - isSensitive: true - isRequired: true - suppliedBy: USER - ui: - label: Gateway token - target: - envVarKey: OPENCLAW_GATEWAY_TOKEN - generator: - byteLength: 32 - encoding: HEX - description: >- - Master token authenticating the Control UI, WebSocket streams, and HTTP API. + # No gateway token. Under `gateway.auth.mode: "trusted-proxy"` the gateway + # authenticates on the proxy's identity header rather than a shared secret, + # so a token input would be an unused credential on the install form — and + # a misleading one, since pasting it into the Control UI would do nothing. + # The credential a user actually types lives on the auth-proxy node. + # + # Wired from the proxy's `publicUrl`, not `platformDefault: PUBLIC_URL`. + # This node's endpoint is PRIVATE and PUBLIC_URL must resolve to a public + # endpoint of the right address form (§6.1), so the platform default no + # longer has anything to resolve to. The browser origin this gateway must + # accept is the proxy's public URL, which is exactly what the wire carries. publicOrigin: schema: type: STRING - isRequired: false - suppliedBy: USER - ui: - label: Public origin + format: ENDPOINT_URL + isRequired: true + suppliedBy: CONNECTION + # §6.1: a CONNECTION input never reaches the install form, so `ui` MUST + # be null. Omitted rather than written as an explicit `null`, matching + # the auth-proxy node. target: envVarKey: OPENCLAW_PUBLIC_ORIGIN - platformDefault: - source: PUBLIC_URL description: >- - Public https origin of this deployment's Control UI, written into - gateway.controlUi.allowedOrigins at first boot so browser sessions from - the public hostname are accepted. Leave blank to use this deployment's - own generated URL; override only for a custom domain. + Public https origin browsers reach this deployment on, written into + gateway.controlUi.allowedOrigins at boot so sessions from that + hostname are accepted. Supplied by the auth-proxy node. timezone: schema: type: STRING @@ -132,4 +189,18 @@ spec: target: envVarKey: TZ description: IANA timezone for the agent's scheduled tasks and temporal reasoning. - outputs: {} + outputs: + # Consumed by the auth-proxy node as its `reverse_proxy` upstream. Neither + # document can name the hostname the platform assigns, so the wire carries + # it. §6.2 permits this alongside the inbound `publicOrigin` wire: an + # output derives from this workload's own addressing and never from an + # inbound connection, so the two-node cycle resolves. + address: + schema: + type: STRING + semanticType: HTTP_SERVICE + description: >- + Mesh-internal `host:port` of the OpenClaw gateway, resolvable only by + workloads in the same deployment. Wire it into a fronting proxy. + valueFrom: DERIVED + value: null diff --git a/items/openclaw/listing.yaml b/items/openclaw/listing.yaml index c1762c1..7e889b4 100755 --- a/items/openclaw/listing.yaml +++ b/items/openclaw/listing.yaml @@ -2,7 +2,7 @@ specVersion: v1 kind: LISTING metadata: slug: openclaw - version: 1 + version: 2 spec: listingKind: BLUEPRINT displayName: OpenClaw @@ -24,17 +24,35 @@ spec: providers behind one gateway. ## Defaults - Single-container deployment of `ghcr.io/openclaw/openclaw:2026.7.1` - with the Control UI on port 18789 behind the platform's HTTPS edge. - Access is gated by an auto-generated gateway token — copy it from the - deployment's Configuration tab and enter it on your first visit, then - add your model-provider keys under Settings. The deployment's public - origin is written into the gateway's browser-origin allowlist - automatically; override it only for a custom domain. Two persistent - volumes (10 GiB - at `/home/node/.openclaw`, 1 GiB at `/home/node/.config/openclaw`) - keep configuration, session history, and the OAuth-token encryption - key across restarts. + Two containers: `ghcr.io/openclaw/openclaw:2026.8.2` running the agent + gateway, and a `caddy:2.10.2-alpine` sign-in proxy in front of it. Only + the proxy is reachable from the internet; the gateway listens on the + deployment's private mesh and can be reached solely through it. + + Pick a username and password at install time and you're done — open the + URL, sign in, and add your model-provider keys under Settings. Two + persistent volumes (10 GiB at `/home/node/.openclaw`, 1 GiB at + `/home/node/.config/openclaw`) keep configuration, session history, and + the OAuth-token encryption key across restarts. + + ## Access and security + Sign in with the username and password you chose. There is no token to + copy, no per-device approval, and no setup step after deploying — any + device can sign in, and the browser is enrolled automatically as part of + signing in. + + A default OpenClaw install works differently: it requires a shared token + and then a one-time approval of each new browser, run as + `openclaw devices approve` on the Gateway host. A one-click deployment + gives you no host shell for that. Here the proxy authenticates you and + passes your identity to the gateway, which trusts it and enrols your + browser during the handshake, so the approval never surfaces. + + Your password is stored only as a bcrypt hash inside the running + container, and the proxy overwrites the identity header on every request, + so a browser cannot forge one. The password guards an admin surface that + can run tools and shell commands, so choose a strong one — at least 16 + characters is enforced — and don't reuse it. Messaging-channel onboarding (WhatsApp, Telegram, Discord) requires the interactive CLI and is not available in this deployment yet.