mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
Gateway: add healthz/readyz probe endpoints for container checks (#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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, "live" | "ready">([
|
||||
["/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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user