feat(gateway): add HTTP health check endpoints and Cloudflare Tunnel docs

Add unauthenticated /healthz and /health endpoints for load balancer probes.
Returns {"status":"ok"} with HTTP 200 when the gateway is running.

Also adds documentation for:
- Cloudflare Tunnel as an alternative to Tailscale for secure gateway exposure
- Health check endpoint usage for K8s, AWS ALB, and Cloudflare monitoring

Closes #1971

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Glucksberg 2026-01-26 04:44:28 +00:00
parent e38ae0eda5
commit b3533ac0be
3 changed files with 88 additions and 1 deletions

View File

@ -446,7 +446,61 @@ Avoid:
- Exposing relay/control ports over LAN or public Internet.
- Tailscale Funnel for browser control endpoints (public exposure).
### 0.7) Secrets on disk (whats sensitive)
### 0.6.2) Cloudflare Tunnel (alternative to Tailscale)
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) provides another way to expose your Gateway securely without opening firewall ports. The tunnel runs a local daemon (`cloudflared`) that connects outbound to Cloudflare's edge, then routes traffic to your local Gateway.
**Quick tunnel (ephemeral, for testing):**
```bash
# Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
brew install cloudflared # macOS
# or: sudo apt install cloudflared # Debian/Ubuntu
# Start a quick tunnel (generates a random *.trycloudflare.com URL)
cloudflared tunnel --url http://127.0.0.1:18789
```
**Persistent tunnel (recommended for production):**
1. Authenticate: `cloudflared tunnel login`
2. Create tunnel: `cloudflared tunnel create clawdbot-gateway`
3. Configure `~/.cloudflared/config.yml`:
```yaml
tunnel: <tunnel-id>
credentials-file: /path/to/credentials.json
ingress:
- hostname: gateway.yourdomain.com
service: http://127.0.0.1:18789
- service: http_status:404
```
4. Route DNS: `cloudflared tunnel route dns clawdbot-gateway gateway.yourdomain.com`
5. Run: `cloudflared tunnel run clawdbot-gateway`
**Security notes:**
- Always set `gateway.auth.mode: "token"` when exposing via Cloudflare Tunnel.
- Use Cloudflare Access policies for additional authentication (SSO, email verification).
- The Gateway binds to loopback; only `cloudflared` can reach it.
- Monitor tunnel metrics in the Cloudflare dashboard.
### 0.6.3) Health check endpoint
The Gateway exposes unauthenticated health check endpoints for load balancer probes:
- `GET /healthz` - Returns `{"status":"ok"}` with HTTP 200 when the Gateway is running.
- `GET /health` - Alias for `/healthz`.
These endpoints:
- Do **not** require authentication (safe for external probes).
- Do **not** expose any sensitive information.
- Return `Cache-Control: no-cache, no-store` to prevent caching.
Use these for Kubernetes liveness/readiness probes, AWS ALB health checks, or Cloudflare origin health monitoring.
### 0.7) Secrets on disk (what's sensitive)
Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain secrets or private data:

View File

@ -235,6 +235,17 @@ export function createGatewayHttpServer(opts: {
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
// Health check endpoint for load balancers (no auth required).
// Returns 200 OK if the gateway is running. Does not expose any sensitive info.
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/health")) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-store");
res.end(JSON.stringify({ status: "ok" }));
return;
}
try {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];

View File

@ -298,4 +298,26 @@ describe("gateway server health/presence", () => {
ws.close();
});
test("HTTP /healthz endpoint returns 200 OK", async () => {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("cache-control")).toBe("no-cache, no-store");
const body = await res.json();
expect(body).toEqual({ status: "ok" });
});
test("HTTP /health endpoint returns 200 OK", async () => {
const res = await fetch(`http://127.0.0.1:${port}/health`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const body = await res.json();
expect(body).toEqual({ status: "ok" });
});
test("HTTP health endpoints only accept GET", async () => {
const postRes = await fetch(`http://127.0.0.1:${port}/healthz`, { method: "POST" });
expect(postRes.status).not.toBe(200);
});
});