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:
Vincent Koc
2026-03-01 20:36:58 -08:00
committed by GitHub
parent 0a1eac6b0b
commit eeb72097ba
6 changed files with 199 additions and 4 deletions

View File

@@ -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");

View File

@@ -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",