diff --git a/CHANGELOG.md b/CHANGELOG.md index 741c7e791e9..999b04f2ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc. - Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus. - Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz. - Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor. diff --git a/Dockerfile b/Dockerfile index 4bddcaf8f21..7e2baae51ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,7 +92,8 @@ USER node # - Use --network host, OR # - Override --bind to "lan" (0.0.0.0) and set auth credentials # -# For container platforms requiring external health checks: -# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var -# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"] +# Built-in probe endpoints for container health checks: +# - GET /healthz (liveness) and GET /readyz (readiness) +# - aliases: /health and /ready +# For external access from host/ingress, override bind to "lan" and set auth. CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"] diff --git a/docker-compose.yml b/docker-compose.yml index 843abb48f50..7177c7d1ac3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,18 @@ services: "--port", "18789", ] + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s openclaw-cli: image: ${OPENCLAW_IMAGE:-openclaw:local} diff --git a/docs/install/docker.md b/docs/install/docker.md index 3ba7b766fbb..5a39333033d 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -392,7 +392,18 @@ to capture a callback on `http://127.0.0.1:1455/auth/callback`. In Docker or headless setups that callback can show a browser error. Copy the full redirect URL you land on and paste it back into the wizard to finish auth. -### Health check +### Health checks + +Container probe endpoints (no auth required): + +```bash +curl -fsS http://127.0.0.1:18789/healthz +curl -fsS http://127.0.0.1:18789/readyz +``` + +Aliases: `/health` and `/ready`. + +Authenticated deep health snapshot (gateway + channels): ```bash docker compose exec openclaw-gateway node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN" diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ad3260f6b6c..fb27a615539 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -73,6 +73,43 @@ function sendJson(res: ServerResponse, status: number, body: unknown) { res.end(JSON.stringify(body)); } +const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ + ["/health", "live"], + ["/healthz", "live"], + ["/ready", "ready"], + ["/readyz", "ready"], +]); + +function handleGatewayProbeRequest( + req: IncomingMessage, + res: ServerResponse, + requestPath: string, +): boolean { + const status = GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath); + if (!status) { + return false; + } + + const method = (req.method ?? "GET").toUpperCase(); + if (method !== "GET" && method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Allow", "GET, HEAD"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + if (method === "HEAD") { + res.end(); + return true; + } + res.end(JSON.stringify({ ok: true, status })); + return true; +} + function writeUpgradeAuthFailure( socket: { write: (chunk: string) => void }, auth: GatewayAuthResult, @@ -491,6 +528,9 @@ export function createGatewayHttpServer(opts: { return; } } + if (handleGatewayProbeRequest(req, res, requestPath)) { + return; + } res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index bee47b6f34c..980521d295c 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -243,6 +243,136 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("serves unauthenticated liveness/readiness probe routes when no other route handles them", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-probes-test-", + run: async () => { + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + resolvedAuth, + }); + + const probeCases = [ + { path: "/health", status: "live" }, + { path: "/healthz", status: "live" }, + { path: "/ready", status: "ready" }, + { path: "/readyz", status: "ready" }, + ] as const; + + for (const probeCase of probeCases) { + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: probeCase.path }), response.res); + expect(response.res.statusCode, probeCase.path).toBe(200); + expect(response.getBody(), probeCase.path).toBe( + JSON.stringify({ ok: true, status: probeCase.status }), + ); + } + }, + }); + }); + + test("does not shadow plugin routes mounted on probe paths", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-probes-shadow-test-", + run: async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/healthz") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "plugin-health" })); + return true; + } + return false; + }); + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: "/healthz" }), response.res); + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" })); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + + test("rejects non-GET/HEAD methods on probe routes", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-probes-method-test-", + run: async () => { + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + resolvedAuth, + }); + + const postResponse = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/healthz", method: "POST" }), + postResponse.res, + ); + expect(postResponse.res.statusCode).toBe(405); + expect(postResponse.setHeader).toHaveBeenCalledWith("Allow", "GET, HEAD"); + expect(postResponse.getBody()).toBe("Method Not Allowed"); + + const headResponse = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/readyz", method: "HEAD" }), + headResponse.res, + ); + expect(headResponse.res.statusCode).toBe(200); + expect(headResponse.getBody()).toBe(""); + }, + }); + }); + test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => { const resolvedAuth: ResolvedGatewayAuth = { mode: "token",