diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 65fd60db4..c4cab51de 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 (what’s 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: +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: diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index e84c0ed43..36f696e24 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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 ?? []; diff --git a/src/gateway/server.health.e2e.test.ts b/src/gateway/server.health.e2e.test.ts index 17b3abe56..07a35e382 100644 --- a/src/gateway/server.health.e2e.test.ts +++ b/src/gateway/server.health.e2e.test.ts @@ -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); + }); });