Skip to content

Commit eeb7209

Browse files
authored
Gateway: add healthz/readyz probe endpoints for container checks (openclaw#31272)
* Gateway: add HTTP liveness/readiness probe routes * Gateway tests: cover probe route auth bypass and methods * Docker Compose: add gateway /healthz healthcheck * Docs: document Docker probe endpoints * Dockerfile: note built-in probe endpoints * Gateway: make probe routes fallback-only to avoid shadowing * Gateway tests: verify probe paths do not shadow plugin routes * Changelog: note gateway container probe endpoints
1 parent 0a1eac6 commit eeb7209

6 files changed

Lines changed: 199 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- 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.
910
- 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.
1011
- 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.
1112
- 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.

Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ USER node
9292
# - Use --network host, OR
9393
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
9494
#
95-
# For container platforms requiring external health checks:
96-
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
97-
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
95+
# Built-in probe endpoints for container health checks:
96+
# - GET /healthz (liveness) and GET /readyz (readiness)
97+
# - aliases: /health and /ready
98+
# For external access from host/ingress, override bind to "lan" and set auth.
9899
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ services:
2626
"--port",
2727
"18789",
2828
]
29+
healthcheck:
30+
test:
31+
[
32+
"CMD",
33+
"node",
34+
"-e",
35+
"fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
36+
]
37+
interval: 30s
38+
timeout: 5s
39+
retries: 5
40+
start_period: 20s
2941

3042
openclaw-cli:
3143
image: ${OPENCLAW_IMAGE:-openclaw:local}

docs/install/docker.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,18 @@ to capture a callback on `http://127.0.0.1:1455/auth/callback`. In Docker or
392392
headless setups that callback can show a browser error. Copy the full redirect
393393
URL you land on and paste it back into the wizard to finish auth.
394394

395-
### Health check
395+
### Health checks
396+
397+
Container probe endpoints (no auth required):
398+
399+
```bash
400+
curl -fsS http://127.0.0.1:18789/healthz
401+
curl -fsS http://127.0.0.1:18789/readyz
402+
```
403+
404+
Aliases: `/health` and `/ready`.
405+
406+
Authenticated deep health snapshot (gateway + channels):
396407

397408
```bash
398409
docker compose exec openclaw-gateway node dist/index.js health --token "$OPENCLAW_GATEWAY_TOKEN"

src/gateway/server-http.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,43 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
7373
res.end(JSON.stringify(body));
7474
}
7575

76+
const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
77+
["/health", "live"],
78+
["/healthz", "live"],
79+
["/ready", "ready"],
80+
["/readyz", "ready"],
81+
]);
82+
83+
function handleGatewayProbeRequest(
84+
req: IncomingMessage,
85+
res: ServerResponse,
86+
requestPath: string,
87+
): boolean {
88+
const status = GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath);
89+
if (!status) {
90+
return false;
91+
}
92+
93+
const method = (req.method ?? "GET").toUpperCase();
94+
if (method !== "GET" && method !== "HEAD") {
95+
res.statusCode = 405;
96+
res.setHeader("Allow", "GET, HEAD");
97+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
98+
res.end("Method Not Allowed");
99+
return true;
100+
}
101+
102+
res.statusCode = 200;
103+
res.setHeader("Content-Type", "application/json; charset=utf-8");
104+
res.setHeader("Cache-Control", "no-store");
105+
if (method === "HEAD") {
106+
res.end();
107+
return true;
108+
}
109+
res.end(JSON.stringify({ ok: true, status }));
110+
return true;
111+
}
112+
76113
function writeUpgradeAuthFailure(
77114
socket: { write: (chunk: string) => void },
78115
auth: GatewayAuthResult,
@@ -491,6 +528,9 @@ export function createGatewayHttpServer(opts: {
491528
return;
492529
}
493530
}
531+
if (handleGatewayProbeRequest(req, res, requestPath)) {
532+
return;
533+
}
494534

495535
res.statusCode = 404;
496536
res.setHeader("Content-Type", "text/plain; charset=utf-8");

src/gateway/server.plugin-http-auth.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,136 @@ describe("gateway plugin HTTP auth boundary", () => {
243243
});
244244
});
245245

246+
test("serves unauthenticated liveness/readiness probe routes when no other route handles them", async () => {
247+
const resolvedAuth: ResolvedGatewayAuth = {
248+
mode: "token",
249+
token: "test-token",
250+
password: undefined,
251+
allowTailscale: false,
252+
};
253+
254+
await withTempConfig({
255+
cfg: { gateway: { trustedProxies: [] } },
256+
prefix: "openclaw-plugin-http-probes-test-",
257+
run: async () => {
258+
const server = createGatewayHttpServer({
259+
canvasHost: null,
260+
clients: new Set(),
261+
controlUiEnabled: false,
262+
controlUiBasePath: "/__control__",
263+
openAiChatCompletionsEnabled: false,
264+
openResponsesEnabled: false,
265+
handleHooksRequest: async () => false,
266+
resolvedAuth,
267+
});
268+
269+
const probeCases = [
270+
{ path: "/health", status: "live" },
271+
{ path: "/healthz", status: "live" },
272+
{ path: "/ready", status: "ready" },
273+
{ path: "/readyz", status: "ready" },
274+
] as const;
275+
276+
for (const probeCase of probeCases) {
277+
const response = createResponse();
278+
await dispatchRequest(server, createRequest({ path: probeCase.path }), response.res);
279+
expect(response.res.statusCode, probeCase.path).toBe(200);
280+
expect(response.getBody(), probeCase.path).toBe(
281+
JSON.stringify({ ok: true, status: probeCase.status }),
282+
);
283+
}
284+
},
285+
});
286+
});
287+
288+
test("does not shadow plugin routes mounted on probe paths", async () => {
289+
const resolvedAuth: ResolvedGatewayAuth = {
290+
mode: "none",
291+
token: undefined,
292+
password: undefined,
293+
allowTailscale: false,
294+
};
295+
296+
await withTempConfig({
297+
cfg: { gateway: { trustedProxies: [] } },
298+
prefix: "openclaw-plugin-http-probes-shadow-test-",
299+
run: async () => {
300+
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
301+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
302+
if (pathname === "/healthz") {
303+
res.statusCode = 200;
304+
res.setHeader("Content-Type", "application/json; charset=utf-8");
305+
res.end(JSON.stringify({ ok: true, route: "plugin-health" }));
306+
return true;
307+
}
308+
return false;
309+
});
310+
const server = createGatewayHttpServer({
311+
canvasHost: null,
312+
clients: new Set(),
313+
controlUiEnabled: false,
314+
controlUiBasePath: "/__control__",
315+
openAiChatCompletionsEnabled: false,
316+
openResponsesEnabled: false,
317+
handleHooksRequest: async () => false,
318+
handlePluginRequest,
319+
resolvedAuth,
320+
});
321+
322+
const response = createResponse();
323+
await dispatchRequest(server, createRequest({ path: "/healthz" }), response.res);
324+
expect(response.res.statusCode).toBe(200);
325+
expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" }));
326+
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
327+
},
328+
});
329+
});
330+
331+
test("rejects non-GET/HEAD methods on probe routes", async () => {
332+
const resolvedAuth: ResolvedGatewayAuth = {
333+
mode: "none",
334+
token: undefined,
335+
password: undefined,
336+
allowTailscale: false,
337+
};
338+
339+
await withTempConfig({
340+
cfg: { gateway: { trustedProxies: [] } },
341+
prefix: "openclaw-plugin-http-probes-method-test-",
342+
run: async () => {
343+
const server = createGatewayHttpServer({
344+
canvasHost: null,
345+
clients: new Set(),
346+
controlUiEnabled: false,
347+
controlUiBasePath: "/__control__",
348+
openAiChatCompletionsEnabled: false,
349+
openResponsesEnabled: false,
350+
handleHooksRequest: async () => false,
351+
resolvedAuth,
352+
});
353+
354+
const postResponse = createResponse();
355+
await dispatchRequest(
356+
server,
357+
createRequest({ path: "/healthz", method: "POST" }),
358+
postResponse.res,
359+
);
360+
expect(postResponse.res.statusCode).toBe(405);
361+
expect(postResponse.setHeader).toHaveBeenCalledWith("Allow", "GET, HEAD");
362+
expect(postResponse.getBody()).toBe("Method Not Allowed");
363+
364+
const headResponse = createResponse();
365+
await dispatchRequest(
366+
server,
367+
createRequest({ path: "/readyz", method: "HEAD" }),
368+
headResponse.res,
369+
);
370+
expect(headResponse.res.statusCode).toBe(200);
371+
expect(headResponse.getBody()).toBe("");
372+
},
373+
});
374+
});
375+
246376
test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => {
247377
const resolvedAuth: ResolvedGatewayAuth = {
248378
mode: "token",

0 commit comments

Comments
 (0)