mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-22 09:01:46 +03:00
feat(gateway): add trusted-proxy auth mode (#15940)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 279d4b304f83186fda44dfe63a729406a835dafa Co-authored-by: nickytonline <833231+nickytonline@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||||
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
||||||
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
||||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
public let configpath: String?
|
public let configpath: String?
|
||||||
public let statedir: String?
|
public let statedir: String?
|
||||||
public let sessiondefaults: [String: AnyCodable]?
|
public let sessiondefaults: [String: AnyCodable]?
|
||||||
|
public let authmode: AnyCodable?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
presence: [PresenceEntry],
|
presence: [PresenceEntry],
|
||||||
@@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
uptimems: Int,
|
uptimems: Int,
|
||||||
configpath: String?,
|
configpath: String?,
|
||||||
statedir: String?,
|
statedir: String?,
|
||||||
sessiondefaults: [String: AnyCodable]?
|
sessiondefaults: [String: AnyCodable]?,
|
||||||
|
authmode: AnyCodable?
|
||||||
) {
|
) {
|
||||||
self.presence = presence
|
self.presence = presence
|
||||||
self.health = health
|
self.health = health
|
||||||
@@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
self.configpath = configpath
|
self.configpath = configpath
|
||||||
self.statedir = statedir
|
self.statedir = statedir
|
||||||
self.sessiondefaults = sessiondefaults
|
self.sessiondefaults = sessiondefaults
|
||||||
|
self.authmode = authmode
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case presence
|
case presence
|
||||||
@@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
case configpath = "configPath"
|
case configpath = "configPath"
|
||||||
case statedir = "stateDir"
|
case statedir = "stateDir"
|
||||||
case sessiondefaults = "sessionDefaults"
|
case sessiondefaults = "sessionDefaults"
|
||||||
|
case authmode = "authMode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
public let configpath: String?
|
public let configpath: String?
|
||||||
public let statedir: String?
|
public let statedir: String?
|
||||||
public let sessiondefaults: [String: AnyCodable]?
|
public let sessiondefaults: [String: AnyCodable]?
|
||||||
|
public let authmode: AnyCodable?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
presence: [PresenceEntry],
|
presence: [PresenceEntry],
|
||||||
@@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
uptimems: Int,
|
uptimems: Int,
|
||||||
configpath: String?,
|
configpath: String?,
|
||||||
statedir: String?,
|
statedir: String?,
|
||||||
sessiondefaults: [String: AnyCodable]?
|
sessiondefaults: [String: AnyCodable]?,
|
||||||
|
authmode: AnyCodable?
|
||||||
) {
|
) {
|
||||||
self.presence = presence
|
self.presence = presence
|
||||||
self.health = health
|
self.health = health
|
||||||
@@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
self.configpath = configpath
|
self.configpath = configpath
|
||||||
self.statedir = statedir
|
self.statedir = statedir
|
||||||
self.sessiondefaults = sessiondefaults
|
self.sessiondefaults = sessiondefaults
|
||||||
|
self.authmode = authmode
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case presence
|
case presence
|
||||||
@@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
case configpath = "configPath"
|
case configpath = "configPath"
|
||||||
case statedir = "stateDir"
|
case statedir = "stateDir"
|
||||||
case sessiondefaults = "sessionDefaults"
|
case sessiondefaults = "sessionDefaults"
|
||||||
|
case authmode = "authMode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)"
|
||||||
|
read_when:
|
||||||
|
- Running OpenClaw behind an identity-aware proxy
|
||||||
|
- Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw
|
||||||
|
- Fixing WebSocket 1008 unauthorized errors with reverse proxy setups
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trusted Proxy Auth
|
||||||
|
|
||||||
|
> ⚠️ **Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `trusted-proxy` auth mode when:
|
||||||
|
|
||||||
|
- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth)
|
||||||
|
- Your proxy handles all authentication and passes user identity via headers
|
||||||
|
- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway
|
||||||
|
- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads
|
||||||
|
|
||||||
|
## When NOT to Use
|
||||||
|
|
||||||
|
- If your proxy doesn't authenticate users (just a TLS terminator or load balancer)
|
||||||
|
- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access)
|
||||||
|
- If you're unsure whether your proxy correctly strips/overwrites forwarded headers
|
||||||
|
- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.)
|
||||||
|
2. Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`)
|
||||||
|
3. OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`)
|
||||||
|
4. OpenClaw extracts the user identity from the configured header
|
||||||
|
5. If everything checks out, the request is authorized
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
// Must bind to network interface (not loopback)
|
||||||
|
bind: "lan",
|
||||||
|
|
||||||
|
// CRITICAL: Only add your proxy's IP(s) here
|
||||||
|
trustedProxies: ["10.0.0.1", "172.17.0.1"],
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
// Header containing authenticated user identity (required)
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
|
||||||
|
// Optional: headers that MUST be present (proxy verification)
|
||||||
|
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||||
|
|
||||||
|
// Optional: restrict to specific users (empty = allow all)
|
||||||
|
allowUsers: ["nick@example.com", "admin@company.org"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Reference
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
| ------------------------------------------- | -------- | --------------------------------------------------------------------------- |
|
||||||
|
| `gateway.trustedProxies` | Yes | Array of proxy IP addresses to trust. Requests from other IPs are rejected. |
|
||||||
|
| `gateway.auth.mode` | Yes | Must be `"trusted-proxy"` |
|
||||||
|
| `gateway.auth.trustedProxy.userHeader` | Yes | Header name containing the authenticated user identity |
|
||||||
|
| `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted |
|
||||||
|
| `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. |
|
||||||
|
|
||||||
|
## Proxy Setup Examples
|
||||||
|
|
||||||
|
### Pomerium
|
||||||
|
|
||||||
|
Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["10.0.0.1"], // Pomerium's IP
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-pomerium-claim-email",
|
||||||
|
requiredHeaders: ["x-pomerium-jwt-assertion"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pomerium config snippet:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
routes:
|
||||||
|
- from: https://openclaw.example.com
|
||||||
|
to: http://openclaw-gateway:18789
|
||||||
|
policy:
|
||||||
|
- allow:
|
||||||
|
or:
|
||||||
|
- email:
|
||||||
|
is: nick@example.com
|
||||||
|
pass_identity_headers: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy with OAuth
|
||||||
|
|
||||||
|
Caddy with the `caddy-security` plugin can authenticate users and pass identity headers.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["127.0.0.1"], // Caddy's IP (if on same host)
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddyfile snippet:
|
||||||
|
|
||||||
|
```
|
||||||
|
openclaw.example.com {
|
||||||
|
authenticate with oauth2_provider
|
||||||
|
authorize with policy1
|
||||||
|
|
||||||
|
reverse_proxy openclaw:18789 {
|
||||||
|
header_up X-Forwarded-User {http.auth.user.email}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### nginx + oauth2-proxy
|
||||||
|
|
||||||
|
oauth2-proxy authenticates users and passes identity in `x-auth-request-email`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-auth-request-email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
nginx config snippet:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
auth_request /oauth2/auth;
|
||||||
|
auth_request_set $user $upstream_http_x_auth_request_email;
|
||||||
|
|
||||||
|
proxy_pass http://openclaw:18789;
|
||||||
|
proxy_set_header X-Auth-Request-Email $user;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traefik with Forward Auth
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["172.17.0.1"], // Traefik container IP
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
Before enabling trusted-proxy auth, verify:
|
||||||
|
|
||||||
|
- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy
|
||||||
|
- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets
|
||||||
|
- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients
|
||||||
|
- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS
|
||||||
|
- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated
|
||||||
|
|
||||||
|
## Security Audit
|
||||||
|
|
||||||
|
`openclaw security audit` will flag trusted-proxy auth with a **critical** severity finding. This is intentional — it's a reminder that you're delegating security to your proxy setup.
|
||||||
|
|
||||||
|
The audit checks for:
|
||||||
|
|
||||||
|
- Missing `trustedProxies` configuration
|
||||||
|
- Missing `userHeader` configuration
|
||||||
|
- Empty `allowUsers` (allows any authenticated user)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "trusted_proxy_untrusted_source"
|
||||||
|
|
||||||
|
The request didn't come from an IP in `gateway.trustedProxies`. Check:
|
||||||
|
|
||||||
|
- Is the proxy IP correct? (Docker container IPs can change)
|
||||||
|
- Is there a load balancer in front of your proxy?
|
||||||
|
- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs
|
||||||
|
|
||||||
|
### "trusted_proxy_user_missing"
|
||||||
|
|
||||||
|
The user header was empty or missing. Check:
|
||||||
|
|
||||||
|
- Is your proxy configured to pass identity headers?
|
||||||
|
- Is the header name correct? (case-insensitive, but spelling matters)
|
||||||
|
- Is the user actually authenticated at the proxy?
|
||||||
|
|
||||||
|
### "trusted*proxy_missing_header*\*"
|
||||||
|
|
||||||
|
A required header wasn't present. Check:
|
||||||
|
|
||||||
|
- Your proxy configuration for those specific headers
|
||||||
|
- Whether headers are being stripped somewhere in the chain
|
||||||
|
|
||||||
|
### "trusted_proxy_user_not_allowed"
|
||||||
|
|
||||||
|
The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist.
|
||||||
|
|
||||||
|
### WebSocket Still Failing
|
||||||
|
|
||||||
|
Make sure your proxy:
|
||||||
|
|
||||||
|
- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`)
|
||||||
|
- Passes the identity headers on WebSocket upgrade requests (not just HTTP)
|
||||||
|
- Doesn't have a separate auth path for WebSocket connections
|
||||||
|
|
||||||
|
## Migration from Token Auth
|
||||||
|
|
||||||
|
If you're moving from token auth to trusted-proxy:
|
||||||
|
|
||||||
|
1. Configure your proxy to authenticate users and pass headers
|
||||||
|
2. Test the proxy setup independently (curl with headers)
|
||||||
|
3. Update OpenClaw config with trusted-proxy auth
|
||||||
|
4. Restart the Gateway
|
||||||
|
5. Test WebSocket connections from the Control UI
|
||||||
|
6. Run `openclaw security audit` and review findings
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Security](/gateway/security) — full security guide
|
||||||
|
- [Configuration](/gateway/configuration) — config reference
|
||||||
|
- [Remote Access](/gateway/remote) — other remote access patterns
|
||||||
|
- [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { OpenClawConfig } from "../config/types.js";
|
||||||
|
import { ensureBrowserControlAuth } from "./control-auth.js";
|
||||||
|
|
||||||
|
describe("ensureBrowserControlAuth", () => {
|
||||||
|
describe("trusted-proxy mode", () => {
|
||||||
|
it("should not auto-generate token when auth mode is trusted-proxy", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustedProxies: ["192.168.1.1"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ensureBrowserControlAuth({
|
||||||
|
cfg,
|
||||||
|
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.generatedToken).toBeUndefined();
|
||||||
|
expect(result.auth.token).toBeUndefined();
|
||||||
|
expect(result.auth.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password mode", () => {
|
||||||
|
it("should not auto-generate token when auth mode is password (even if password not set)", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ensureBrowserControlAuth({
|
||||||
|
cfg,
|
||||||
|
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.generatedToken).toBeUndefined();
|
||||||
|
expect(result.auth.token).toBeUndefined();
|
||||||
|
expect(result.auth.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token mode", () => {
|
||||||
|
it("should return existing token if configured", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
token: "existing-token-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ensureBrowserControlAuth({
|
||||||
|
cfg,
|
||||||
|
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.generatedToken).toBeUndefined();
|
||||||
|
expect(result.auth.token).toBe("existing-token-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip auto-generation in test environment", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ensureBrowserControlAuth({
|
||||||
|
cfg,
|
||||||
|
env: { NODE_ENV: "test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.generatedToken).toBeUndefined();
|
||||||
|
expect(result.auth.token).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: {
|
|||||||
return { auth };
|
return { auth };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.cfg.gateway?.auth?.mode === "trusted-proxy") {
|
||||||
|
return { auth };
|
||||||
|
}
|
||||||
|
|
||||||
// Re-read latest config to avoid racing with concurrent config writers.
|
// Re-read latest config to avoid racing with concurrent config writers.
|
||||||
const latestCfg = loadConfig();
|
const latestCfg = loadConfig();
|
||||||
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
|
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
|
||||||
@@ -67,6 +71,9 @@ export async function ensureBrowserControlAuth(params: {
|
|||||||
if (latestCfg.gateway?.auth?.mode === "password") {
|
if (latestCfg.gateway?.auth?.mode === "password") {
|
||||||
return { auth: latestAuth };
|
return { auth: latestAuth };
|
||||||
}
|
}
|
||||||
|
if (latestCfg.gateway?.auth?.mode === "trusted-proxy") {
|
||||||
|
return { auth: latestAuth };
|
||||||
|
}
|
||||||
|
|
||||||
const generatedToken = crypto.randomBytes(24).toString("hex");
|
const generatedToken = crypto.randomBytes(24).toString("hex");
|
||||||
const nextCfg: OpenClawConfig = {
|
const nextCfg: OpenClawConfig = {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (bind !== "loopback" && !hasSharedSecret) {
|
if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
[
|
[
|
||||||
`Refusing to bind gateway to ${bind} without auth.`,
|
`Refusing to bind gateway to ${bind} without auth.`,
|
||||||
|
|||||||
@@ -117,4 +117,94 @@ describe("buildGatewayAuthConfig", () => {
|
|||||||
expect(typeof result?.token).toBe("string");
|
expect(typeof result?.token).toBe("string");
|
||||||
expect(result?.token?.length).toBeGreaterThan(0);
|
expect(result?.token?.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds trusted-proxy config with all options", () => {
|
||||||
|
const result = buildGatewayAuthConfig({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||||
|
allowUsers: ["nick@example.com", "admin@company.com"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||||
|
allowUsers: ["nick@example.com", "admin@company.com"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds trusted-proxy config with only userHeader", () => {
|
||||||
|
const result = buildGatewayAuthConfig({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-remote-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-remote-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves allowTailscale when switching to trusted-proxy", () => {
|
||||||
|
const result = buildGatewayAuthConfig({
|
||||||
|
existing: {
|
||||||
|
mode: "token",
|
||||||
|
token: "abc",
|
||||||
|
allowTailscale: true,
|
||||||
|
},
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: true,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when trusted-proxy mode lacks trustedProxy config", () => {
|
||||||
|
expect(() => {
|
||||||
|
buildGatewayAuthConfig({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
// missing trustedProxy
|
||||||
|
});
|
||||||
|
}).toThrow("trustedProxy config is required when mode is trusted-proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops token and password when switching to trusted-proxy", () => {
|
||||||
|
const result = buildGatewayAuthConfig({
|
||||||
|
existing: {
|
||||||
|
mode: "token",
|
||||||
|
token: "abc",
|
||||||
|
password: "secret",
|
||||||
|
},
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).not.toHaveProperty("token");
|
||||||
|
expect(result).not.toHaveProperty("password");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { promptCustomApiConfig } from "./onboard-custom.js";
|
import { promptCustomApiConfig } from "./onboard-custom.js";
|
||||||
import { randomToken } from "./onboard-helpers.js";
|
import { randomToken } from "./onboard-helpers.js";
|
||||||
|
|
||||||
type GatewayAuthChoice = "token" | "password";
|
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
|
||||||
|
|
||||||
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
|
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
|
||||||
function sanitizeTokenValue(value: string | undefined): string | undefined {
|
function sanitizeTokenValue(value: string | undefined): string | undefined {
|
||||||
@@ -40,6 +40,11 @@ export function buildGatewayAuthConfig(params: {
|
|||||||
mode: GatewayAuthChoice;
|
mode: GatewayAuthChoice;
|
||||||
token?: string;
|
token?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
trustedProxy?: {
|
||||||
|
userHeader: string;
|
||||||
|
requiredHeaders?: string[];
|
||||||
|
allowUsers?: string[];
|
||||||
|
};
|
||||||
}): GatewayAuthConfig | undefined {
|
}): GatewayAuthConfig | undefined {
|
||||||
const allowTailscale = params.existing?.allowTailscale;
|
const allowTailscale = params.existing?.allowTailscale;
|
||||||
const base: GatewayAuthConfig = {};
|
const base: GatewayAuthConfig = {};
|
||||||
@@ -52,8 +57,17 @@ export function buildGatewayAuthConfig(params: {
|
|||||||
const token = sanitizeTokenValue(params.token) ?? randomToken();
|
const token = sanitizeTokenValue(params.token) ?? randomToken();
|
||||||
return { ...base, mode: "token", token };
|
return { ...base, mode: "token", token };
|
||||||
}
|
}
|
||||||
const password = params.password?.trim();
|
if (params.mode === "password") {
|
||||||
return { ...base, mode: "password", ...(password && { password }) };
|
const password = params.password?.trim();
|
||||||
|
return { ...base, mode: "password", ...(password && { password }) };
|
||||||
|
}
|
||||||
|
if (params.mode === "trusted-proxy") {
|
||||||
|
if (!params.trustedProxy) {
|
||||||
|
throw new Error("trustedProxy config is required when mode is trusted-proxy");
|
||||||
|
}
|
||||||
|
return { ...base, mode: "trusted-proxy", trustedProxy: params.trustedProxy };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promptAuthConfig(
|
export async function promptAuthConfig(
|
||||||
|
|||||||
@@ -97,4 +97,99 @@ describe("promptGatewayConfig", () => {
|
|||||||
expect(call?.password).not.toBe("undefined");
|
expect(call?.password).not.toBe("undefined");
|
||||||
expect(call?.password).toBe("");
|
expect(call?.password).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||||
|
// Flow: loopback bind → trusted-proxy auth → tailscale off
|
||||||
|
const selectQueue = ["loopback", "trusted-proxy", "off"];
|
||||||
|
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||||
|
// Port prompt, userHeader, requiredHeaders, allowUsers, trustedProxies
|
||||||
|
const textQueue = [
|
||||||
|
"18789",
|
||||||
|
"x-forwarded-user",
|
||||||
|
"x-forwarded-proto,x-forwarded-host",
|
||||||
|
"nick@example.com",
|
||||||
|
"10.0.1.10,192.168.1.5",
|
||||||
|
];
|
||||||
|
mocks.text.mockImplementation(async () => textQueue.shift());
|
||||||
|
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
|
||||||
|
mode,
|
||||||
|
trustedProxy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promptGatewayConfig({}, runtime);
|
||||||
|
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
|
||||||
|
|
||||||
|
expect(call?.mode).toBe("trusted-proxy");
|
||||||
|
expect(call?.trustedProxy).toEqual({
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||||
|
allowUsers: ["nick@example.com"],
|
||||||
|
});
|
||||||
|
expect(result.config.gateway?.bind).toBe("lan");
|
||||||
|
expect(result.config.gateway?.trustedProxies).toEqual(["10.0.1.10", "192.168.1.5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles trusted-proxy with no optional fields", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||||
|
const selectQueue = ["loopback", "trusted-proxy", "off"];
|
||||||
|
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||||
|
// Port prompt, userHeader (only required), empty requiredHeaders, empty allowUsers, trustedProxies
|
||||||
|
const textQueue = ["18789", "x-remote-user", "", "", "10.0.0.1"];
|
||||||
|
mocks.text.mockImplementation(async () => textQueue.shift());
|
||||||
|
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
|
||||||
|
mode,
|
||||||
|
trustedProxy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promptGatewayConfig({}, runtime);
|
||||||
|
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
|
||||||
|
|
||||||
|
expect(call?.mode).toBe("trusted-proxy");
|
||||||
|
expect(call?.trustedProxy).toEqual({
|
||||||
|
userHeader: "x-remote-user",
|
||||||
|
// requiredHeaders and allowUsers should be undefined when empty
|
||||||
|
});
|
||||||
|
expect(result.config.gateway?.bind).toBe("lan");
|
||||||
|
expect(result.config.gateway?.trustedProxies).toEqual(["10.0.0.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces tailscale off when trusted-proxy is selected", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||||
|
const selectQueue = ["loopback", "trusted-proxy", "serve"];
|
||||||
|
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||||
|
const textQueue = ["18789", "x-forwarded-user", "", "", "10.0.0.1"];
|
||||||
|
mocks.text.mockImplementation(async () => textQueue.shift());
|
||||||
|
mocks.confirm.mockResolvedValue(true);
|
||||||
|
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
|
||||||
|
mode,
|
||||||
|
trustedProxy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promptGatewayConfig({}, runtime);
|
||||||
|
expect(result.config.gateway?.bind).toBe("lan");
|
||||||
|
expect(result.config.gateway?.tailscale?.mode).toBe("off");
|
||||||
|
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
validateGatewayPasswordInput,
|
validateGatewayPasswordInput,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
|
|
||||||
type GatewayAuthChoice = "token" | "password";
|
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
|
||||||
|
|
||||||
export async function promptGatewayConfig(
|
export async function promptGatewayConfig(
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
@@ -103,13 +103,18 @@ export async function promptGatewayConfig(
|
|||||||
options: [
|
options: [
|
||||||
{ value: "token", label: "Token", hint: "Recommended default" },
|
{ value: "token", label: "Token", hint: "Recommended default" },
|
||||||
{ value: "password", label: "Password" },
|
{ value: "password", label: "Password" },
|
||||||
|
{
|
||||||
|
value: "trusted-proxy",
|
||||||
|
label: "Trusted Proxy",
|
||||||
|
hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
initialValue: "token",
|
initialValue: "token",
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
) as GatewayAuthChoice;
|
) as GatewayAuthChoice;
|
||||||
|
|
||||||
const tailscaleMode = guardCancel(
|
let tailscaleMode = guardCancel(
|
||||||
await select({
|
await select({
|
||||||
message: "Tailscale exposure",
|
message: "Tailscale exposure",
|
||||||
options: [
|
options: [
|
||||||
@@ -175,8 +180,25 @@ export async function promptGatewayConfig(
|
|||||||
authMode = "password";
|
authMode = "password";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authMode === "trusted-proxy" && bind === "loopback") {
|
||||||
|
note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note");
|
||||||
|
bind = "lan";
|
||||||
|
}
|
||||||
|
if (authMode === "trusted-proxy" && tailscaleMode !== "off") {
|
||||||
|
note(
|
||||||
|
"Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.",
|
||||||
|
"Note",
|
||||||
|
);
|
||||||
|
tailscaleMode = "off";
|
||||||
|
tailscaleResetOnExit = false;
|
||||||
|
}
|
||||||
|
|
||||||
let gatewayToken: string | undefined;
|
let gatewayToken: string | undefined;
|
||||||
let gatewayPassword: string | undefined;
|
let gatewayPassword: string | undefined;
|
||||||
|
let trustedProxyConfig:
|
||||||
|
| { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] }
|
||||||
|
| undefined;
|
||||||
|
let trustedProxies: string[] | undefined;
|
||||||
let next = cfg;
|
let next = cfg;
|
||||||
|
|
||||||
if (authMode === "token") {
|
if (authMode === "token") {
|
||||||
@@ -201,11 +223,88 @@ export async function promptGatewayConfig(
|
|||||||
gatewayPassword = String(password ?? "").trim();
|
gatewayPassword = String(password ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authMode === "trusted-proxy") {
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
"Trusted proxy mode: OpenClaw trusts user identity from a reverse proxy.",
|
||||||
|
"The proxy must authenticate users and pass identity via headers.",
|
||||||
|
"Only requests from specified proxy IPs will be trusted.",
|
||||||
|
"",
|
||||||
|
"Common use cases: Pomerium, Caddy + OAuth, Traefik + forward auth",
|
||||||
|
"Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth",
|
||||||
|
].join("\n"),
|
||||||
|
"Trusted Proxy Auth",
|
||||||
|
);
|
||||||
|
|
||||||
|
const userHeader = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Header containing user identity",
|
||||||
|
placeholder: "x-forwarded-user",
|
||||||
|
initialValue: "x-forwarded-user",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "User header is required"),
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const requiredHeadersRaw = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Required headers (comma-separated, optional)",
|
||||||
|
placeholder: "x-forwarded-proto,x-forwarded-host",
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
const requiredHeaders = requiredHeadersRaw
|
||||||
|
? String(requiredHeadersRaw)
|
||||||
|
.split(",")
|
||||||
|
.map((h) => h.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allowUsersRaw = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Allowed users (comma-separated, blank = all authenticated users)",
|
||||||
|
placeholder: "nick@example.com,admin@company.com",
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
const allowUsers = allowUsersRaw
|
||||||
|
? String(allowUsersRaw)
|
||||||
|
.split(",")
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const trustedProxiesRaw = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Trusted proxy IPs (comma-separated)",
|
||||||
|
placeholder: "10.0.1.10,192.168.1.5",
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value || String(value).trim() === "") {
|
||||||
|
return "At least one trusted proxy IP is required";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
trustedProxies = String(trustedProxiesRaw)
|
||||||
|
.split(",")
|
||||||
|
.map((ip) => ip.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
trustedProxyConfig = {
|
||||||
|
userHeader: String(userHeader).trim(),
|
||||||
|
requiredHeaders: requiredHeaders.length > 0 ? requiredHeaders : undefined,
|
||||||
|
allowUsers: allowUsers.length > 0 ? allowUsers : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const authConfig = buildGatewayAuthConfig({
|
const authConfig = buildGatewayAuthConfig({
|
||||||
existing: next.gateway?.auth,
|
existing: next.gateway?.auth,
|
||||||
mode: authMode,
|
mode: authMode,
|
||||||
token: gatewayToken,
|
token: gatewayToken,
|
||||||
password: gatewayPassword,
|
password: gatewayPassword,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
next = {
|
next = {
|
||||||
@@ -217,6 +316,7 @@ export async function promptGatewayConfig(
|
|||||||
bind,
|
bind,
|
||||||
auth: authConfig,
|
auth: authConfig,
|
||||||
...(customBindHost && { customBindHost }),
|
...(customBindHost && { customBindHost }),
|
||||||
|
...(trustedProxies && { trustedProxies }),
|
||||||
tailscale: {
|
tailscale: {
|
||||||
...next.gateway?.tailscale,
|
...next.gateway?.tailscale,
|
||||||
mode: tailscaleMode,
|
mode: tailscaleMode,
|
||||||
|
|||||||
@@ -76,7 +76,32 @@ export type GatewayControlUiConfig = {
|
|||||||
dangerouslyDisableDeviceAuth?: boolean;
|
dangerouslyDisableDeviceAuth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthMode = "token" | "password";
|
export type GatewayAuthMode = "token" | "password" | "trusted-proxy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for trusted reverse proxy authentication.
|
||||||
|
* Used when Clawdbot runs behind an identity-aware proxy (Pomerium, Caddy + OAuth, etc.)
|
||||||
|
* that handles authentication and passes user identity via headers.
|
||||||
|
*/
|
||||||
|
export type GatewayTrustedProxyConfig = {
|
||||||
|
/**
|
||||||
|
* Header name containing the authenticated user identity (required).
|
||||||
|
* Common values: "x-forwarded-user", "x-remote-user", "x-pomerium-claim-email"
|
||||||
|
*/
|
||||||
|
userHeader: string;
|
||||||
|
/**
|
||||||
|
* Additional headers that MUST be present for the request to be trusted.
|
||||||
|
* Use this to verify the request actually came through the proxy.
|
||||||
|
* Example: ["x-forwarded-proto", "x-forwarded-host"]
|
||||||
|
*/
|
||||||
|
requiredHeaders?: string[];
|
||||||
|
/**
|
||||||
|
* Optional allowlist of user identities that can access the gateway.
|
||||||
|
* If empty or omitted, all authenticated users from the proxy are allowed.
|
||||||
|
* Example: ["nick@example.com", "admin@company.org"]
|
||||||
|
*/
|
||||||
|
allowUsers?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type GatewayAuthConfig = {
|
export type GatewayAuthConfig = {
|
||||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||||
@@ -89,6 +114,11 @@ export type GatewayAuthConfig = {
|
|||||||
allowTailscale?: boolean;
|
allowTailscale?: boolean;
|
||||||
/** Rate-limit configuration for failed authentication attempts. */
|
/** Rate-limit configuration for failed authentication attempts. */
|
||||||
rateLimit?: GatewayAuthRateLimitConfig;
|
rateLimit?: GatewayAuthRateLimitConfig;
|
||||||
|
/**
|
||||||
|
* Configuration for trusted-proxy auth mode.
|
||||||
|
* Required when mode is "trusted-proxy".
|
||||||
|
*/
|
||||||
|
trustedProxy?: GatewayTrustedProxyConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthRateLimitConfig = {
|
export type GatewayAuthRateLimitConfig = {
|
||||||
|
|||||||
@@ -398,10 +398,29 @@ export const OpenClawSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
auth: z
|
auth: z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
mode: z
|
||||||
|
.union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")])
|
||||||
|
.optional(),
|
||||||
token: z.string().optional().register(sensitive),
|
token: z.string().optional().register(sensitive),
|
||||||
password: z.string().optional().register(sensitive),
|
password: z.string().optional().register(sensitive),
|
||||||
allowTailscale: z.boolean().optional(),
|
allowTailscale: z.boolean().optional(),
|
||||||
|
rateLimit: z
|
||||||
|
.object({
|
||||||
|
maxAttempts: z.number().optional(),
|
||||||
|
windowMs: z.number().optional(),
|
||||||
|
lockoutMs: z.number().optional(),
|
||||||
|
exemptLoopback: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
trustedProxy: z
|
||||||
|
.object({
|
||||||
|
userHeader: z.string().min(1, "userHeader is required for trusted-proxy mode"),
|
||||||
|
requiredHeaders: z.array(z.string()).optional(),
|
||||||
|
allowUsers: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
+280
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import { authorizeGatewayConnect } from "./auth.js";
|
import { authorizeGatewayConnect, resolveGatewayAuth } from "./auth.js";
|
||||||
|
|
||||||
function createLimiterSpy(): AuthRateLimiter & {
|
function createLimiterSpy(): AuthRateLimiter & {
|
||||||
check: ReturnType<typeof vi.fn>;
|
check: ReturnType<typeof vi.fn>;
|
||||||
@@ -18,6 +18,38 @@ function createLimiterSpy(): AuthRateLimiter & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway auth", () => {
|
describe("gateway auth", () => {
|
||||||
|
it("resolves token/password from OPENCLAW gateway env vars", () => {
|
||||||
|
expect(
|
||||||
|
resolveGatewayAuth({
|
||||||
|
authConfig: {},
|
||||||
|
env: {
|
||||||
|
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
mode: "password",
|
||||||
|
token: "env-token",
|
||||||
|
password: "env-password",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve legacy CLAWDBOT gateway env vars", () => {
|
||||||
|
expect(
|
||||||
|
resolveGatewayAuth({
|
||||||
|
authConfig: {},
|
||||||
|
env: {
|
||||||
|
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
|
||||||
|
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
mode: "none",
|
||||||
|
token: undefined,
|
||||||
|
password: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not throw when req is missing socket", async () => {
|
it("does not throw when req is missing socket", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||||
@@ -149,3 +181,250 @@ describe("gateway auth", () => {
|
|||||||
expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope");
|
expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("trusted-proxy auth", () => {
|
||||||
|
const trustedProxyConfig = {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
requiredHeaders: ["x-forwarded-proto"],
|
||||||
|
allowUsers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts valid request from trusted proxy", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "nick@example.com",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.method).toBe("trusted-proxy");
|
||||||
|
expect(res.user).toBe("nick@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects request from untrusted source", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "192.168.1.100" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "attacker@evil.com",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_untrusted_source");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects request with missing user header", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
// missing x-forwarded-user
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_user_missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects request with missing required headers", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "nick@example.com",
|
||||||
|
// missing x-forwarded-proto
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects user not in allowlist", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
allowUsers: ["admin@example.com", "nick@example.com"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "stranger@other.com",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_user_not_allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts user in allowlist", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
allowUsers: ["admin@example.com", "nick@example.com"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "nick@example.com",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.method).toBe("trusted-proxy");
|
||||||
|
expect(res.user).toBe("nick@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when no trustedProxies configured", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: trustedProxyConfig,
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: [],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "nick@example.com",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_no_proxies_configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when trustedProxy config missing", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
// trustedProxy missing
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": "nick@example.com",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("trusted_proxy_config_missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports Pomerium-style headers", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-pomerium-claim-email",
|
||||||
|
requiredHeaders: ["x-pomerium-jwt-assertion"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["172.17.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "172.17.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-pomerium-claim-email": "nick@example.com",
|
||||||
|
"x-pomerium-jwt-assertion": "eyJ...",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.method).toBe("trusted-proxy");
|
||||||
|
expect(res.user).toBe("nick@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from user header value", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
allowTailscale: false,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.1" },
|
||||||
|
headers: {
|
||||||
|
host: "gateway.local",
|
||||||
|
"x-forwarded-user": " nick@example.com ",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.user).toBe("nick@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+102
-14
@@ -1,5 +1,9 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
import type {
|
||||||
|
GatewayAuthConfig,
|
||||||
|
GatewayTailscaleMode,
|
||||||
|
GatewayTrustedProxyConfig,
|
||||||
|
} from "../config/config.js";
|
||||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,18 +18,19 @@ import {
|
|||||||
resolveGatewayClientIp,
|
resolveGatewayClientIp,
|
||||||
} from "./net.js";
|
} from "./net.js";
|
||||||
|
|
||||||
export type ResolvedGatewayAuthMode = "token" | "password";
|
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
export type ResolvedGatewayAuth = {
|
||||||
mode: ResolvedGatewayAuthMode;
|
mode: ResolvedGatewayAuthMode;
|
||||||
token?: string;
|
token?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
allowTailscale: boolean;
|
allowTailscale: boolean;
|
||||||
|
trustedProxy?: GatewayTrustedProxyConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthResult = {
|
export type GatewayAuthResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
method?: "token" | "password" | "tailscale" | "device-token";
|
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
|
||||||
user?: string;
|
user?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
/** Present when the request was blocked by the rate limiter. */
|
/** Present when the request was blocked by the rate limiter. */
|
||||||
@@ -192,21 +197,31 @@ export function resolveGatewayAuth(params: {
|
|||||||
}): ResolvedGatewayAuth {
|
}): ResolvedGatewayAuth {
|
||||||
const authConfig = params.authConfig ?? {};
|
const authConfig = params.authConfig ?? {};
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const token =
|
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
|
||||||
authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
|
||||||
const password =
|
const trustedProxy = authConfig.trustedProxy;
|
||||||
authConfig.password ??
|
|
||||||
env.OPENCLAW_GATEWAY_PASSWORD ??
|
let mode: ResolvedGatewayAuth["mode"];
|
||||||
env.CLAWDBOT_GATEWAY_PASSWORD ??
|
if (authConfig.mode) {
|
||||||
undefined;
|
mode = authConfig.mode;
|
||||||
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
|
} else if (password) {
|
||||||
|
mode = "password";
|
||||||
|
} else if (token) {
|
||||||
|
mode = "token";
|
||||||
|
} else {
|
||||||
|
mode = "none";
|
||||||
|
}
|
||||||
|
|
||||||
const allowTailscale =
|
const allowTailscale =
|
||||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
authConfig.allowTailscale ??
|
||||||
|
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
token,
|
token,
|
||||||
password,
|
password,
|
||||||
allowTailscale,
|
allowTailscale,
|
||||||
|
trustedProxy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +237,61 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
|||||||
if (auth.mode === "password" && !auth.password) {
|
if (auth.mode === "password" && !auth.password) {
|
||||||
throw new Error("gateway auth mode is password, but no password was configured");
|
throw new Error("gateway auth mode is password, but no password was configured");
|
||||||
}
|
}
|
||||||
|
if (auth.mode === "trusted-proxy") {
|
||||||
|
if (!auth.trustedProxy) {
|
||||||
|
throw new Error(
|
||||||
|
"gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") {
|
||||||
|
throw new Error(
|
||||||
|
"gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request came from a trusted proxy and extract user identity.
|
||||||
|
* Returns the user identity if valid, or null with a reason if not.
|
||||||
|
*/
|
||||||
|
function authorizeTrustedProxy(params: {
|
||||||
|
req?: IncomingMessage;
|
||||||
|
trustedProxies?: string[];
|
||||||
|
trustedProxyConfig: GatewayTrustedProxyConfig;
|
||||||
|
}): { user: string } | { reason: string } {
|
||||||
|
const { req, trustedProxies, trustedProxyConfig } = params;
|
||||||
|
|
||||||
|
if (!req) {
|
||||||
|
return { reason: "trusted_proxy_no_request" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteAddr = req.socket?.remoteAddress;
|
||||||
|
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) {
|
||||||
|
return { reason: "trusted_proxy_untrusted_source" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
|
||||||
|
for (const header of requiredHeaders) {
|
||||||
|
const value = headerValue(req.headers[header.toLowerCase()]);
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
return { reason: `trusted_proxy_missing_header_${header}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);
|
||||||
|
if (!userHeaderValue || userHeaderValue.trim() === "") {
|
||||||
|
return { reason: "trusted_proxy_user_missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userHeaderValue.trim();
|
||||||
|
|
||||||
|
const allowUsers = trustedProxyConfig.allowUsers ?? [];
|
||||||
|
if (allowUsers.length > 0 && !allowUsers.includes(user)) {
|
||||||
|
return { reason: "trusted_proxy_user_not_allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authorizeGatewayConnect(params: {
|
export async function authorizeGatewayConnect(params: {
|
||||||
@@ -241,7 +311,26 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||||
|
|
||||||
// --- Rate-limit gate ---
|
if (auth.mode === "trusted-proxy") {
|
||||||
|
if (!auth.trustedProxy) {
|
||||||
|
return { ok: false, reason: "trusted_proxy_config_missing" };
|
||||||
|
}
|
||||||
|
if (!trustedProxies || trustedProxies.length === 0) {
|
||||||
|
return { ok: false, reason: "trusted_proxy_no_proxies_configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = authorizeTrustedProxy({
|
||||||
|
req,
|
||||||
|
trustedProxies,
|
||||||
|
trustedProxyConfig: auth.trustedProxy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("user" in result) {
|
||||||
|
return { ok: true, method: "trusted-proxy", user: result.user };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: result.reason };
|
||||||
|
}
|
||||||
|
|
||||||
const limiter = params.rateLimiter;
|
const limiter = params.rateLimiter;
|
||||||
const ip =
|
const ip =
|
||||||
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
|
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
|
||||||
@@ -264,7 +353,6 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
tailscaleWhois,
|
tailscaleWhois,
|
||||||
});
|
});
|
||||||
if (tailscaleCheck.ok) {
|
if (tailscaleCheck.ok) {
|
||||||
// Successful auth – reset rate-limit counter for this IP.
|
|
||||||
limiter?.reset(ip, rateLimitScope);
|
limiter?.reset(ip, rateLimitScope);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -2,10 +2,108 @@ import os from "node:os";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
isPrivateOrLoopbackAddress,
|
isPrivateOrLoopbackAddress,
|
||||||
|
isTrustedProxyAddress,
|
||||||
pickPrimaryLanIPv4,
|
pickPrimaryLanIPv4,
|
||||||
resolveGatewayListenHosts,
|
resolveGatewayListenHosts,
|
||||||
} from "./net.js";
|
} from "./net.js";
|
||||||
|
|
||||||
|
describe("isTrustedProxyAddress", () => {
|
||||||
|
describe("exact IP matching", () => {
|
||||||
|
it("returns true when IP matches exactly", () => {
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when IP does not match", () => {
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when IP matches one of multiple proxies", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CIDR subnet matching", () => {
|
||||||
|
it("returns true when IP is within /24 subnet", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true);
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true);
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when IP is outside /24 subnet", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false);
|
||||||
|
expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when IP is within /16 subnet", () => {
|
||||||
|
expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true);
|
||||||
|
expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when IP is outside /16 subnet", () => {
|
||||||
|
expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when IP is within /32 subnet (single IP)", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when IP does not match /32 subnet", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed exact IPs and CIDR notation", () => {
|
||||||
|
const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"];
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match
|
||||||
|
expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match
|
||||||
|
expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backward compatibility", () => {
|
||||||
|
it("preserves exact IP matching behavior (no CIDR notation)", () => {
|
||||||
|
// Old configs with exact IPs should work exactly as before
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
|
||||||
|
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => {
|
||||||
|
// "10.42.0.1" without /32 should match ONLY that exact IP
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true);
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false);
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => {
|
||||||
|
// Existing normalizeIp() behavior should be preserved
|
||||||
|
expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("returns false when IP is undefined", () => {
|
||||||
|
expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when trustedProxies is undefined", () => {
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when trustedProxies is empty", () => {
|
||||||
|
expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid CIDR notation", () => {
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix
|
||||||
|
expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveGatewayListenHosts", () => {
|
describe("resolveGatewayListenHosts", () => {
|
||||||
it("returns the input host when not loopback", async () => {
|
it("returns the input host when not loopback", async () => {
|
||||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||||
|
|||||||
+54
-1
@@ -139,12 +139,65 @@ function parseRealIp(realIp?: string): string | undefined {
|
|||||||
return normalizeIp(stripOptionalPort(raw));
|
return normalizeIp(stripOptionalPort(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP address matches a CIDR block.
|
||||||
|
* Supports IPv4 CIDR notation (e.g., "10.42.0.0/24").
|
||||||
|
*
|
||||||
|
* @param ip - The IP address to check (e.g., "10.42.0.59")
|
||||||
|
* @param cidr - The CIDR block (e.g., "10.42.0.0/24")
|
||||||
|
* @returns True if the IP is within the CIDR block
|
||||||
|
*/
|
||||||
|
function ipMatchesCIDR(ip: string, cidr: string): boolean {
|
||||||
|
// Handle exact IP match (no CIDR notation)
|
||||||
|
if (!cidr.includes("/")) {
|
||||||
|
return ip === cidr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subnet, prefixLenStr] = cidr.split("/");
|
||||||
|
const prefixLen = parseInt(prefixLenStr, 10);
|
||||||
|
|
||||||
|
// Validate prefix length
|
||||||
|
if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert IPs to 32-bit integers
|
||||||
|
const ipParts = ip.split(".").map((p) => parseInt(p, 10));
|
||||||
|
const subnetParts = subnet.split(".").map((p) => parseInt(p, 10));
|
||||||
|
|
||||||
|
// Validate IP format
|
||||||
|
if (
|
||||||
|
ipParts.length !== 4 ||
|
||||||
|
subnetParts.length !== 4 ||
|
||||||
|
ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) ||
|
||||||
|
subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||||
|
const subnetInt =
|
||||||
|
(subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
||||||
|
|
||||||
|
// Create mask and compare
|
||||||
|
const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen);
|
||||||
|
return (ipInt & mask) === (subnetInt & mask);
|
||||||
|
}
|
||||||
|
|
||||||
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||||
const normalized = normalizeIp(ip);
|
const normalized = normalizeIp(ip);
|
||||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
|
|
||||||
|
return trustedProxies.some((proxy) => {
|
||||||
|
// Handle CIDR notation
|
||||||
|
if (proxy.includes("/")) {
|
||||||
|
return ipMatchesCIDR(normalized, proxy);
|
||||||
|
}
|
||||||
|
// Exact IP match
|
||||||
|
return normalizeIp(proxy) === normalized;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGatewayClientIp(params: {
|
export function resolveGatewayClientIp(params: {
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export const SnapshotSchema = Type.Object(
|
|||||||
configPath: Type.Optional(NonEmptyString),
|
configPath: Type.Optional(NonEmptyString),
|
||||||
stateDir: Type.Optional(NonEmptyString),
|
stateDir: Type.Optional(NonEmptyString),
|
||||||
sessionDefaults: Type.Optional(SessionDefaultsSchema),
|
sessionDefaults: Type.Optional(SessionDefaultsSchema),
|
||||||
|
authMode: Type.Optional(
|
||||||
|
Type.Union([
|
||||||
|
Type.Literal("none"),
|
||||||
|
Type.Literal("token"),
|
||||||
|
Type.Literal("password"),
|
||||||
|
Type.Literal("trusted-proxy"),
|
||||||
|
]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||||
|
|
||||||
|
describe("resolveGatewayRuntimeConfig", () => {
|
||||||
|
describe("trusted-proxy auth mode", () => {
|
||||||
|
// This test validates BOTH validation layers:
|
||||||
|
// 1. CLI validation in src/cli/gateway-cli/run.ts (line 246)
|
||||||
|
// 2. Runtime config validation in src/gateway/server-runtime-config.ts (line 99)
|
||||||
|
// Both must allow lan binding when authMode === "trusted-proxy"
|
||||||
|
it("should allow lan binding with trusted-proxy auth mode", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" as const,
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy" as const,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustedProxies: ["192.168.1.1"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.authMode).toBe("trusted-proxy");
|
||||||
|
expect(result.bindHost).toBe("0.0.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject loopback binding with trusted-proxy auth mode", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: {
|
||||||
|
bind: "loopback" as const,
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy" as const,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustedProxies: ["192.168.1.1"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("gateway auth mode=trusted-proxy makes no sense with bind=loopback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject trusted-proxy without trustedProxies configured", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" as const,
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy" as const,
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustedProxies: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token/password auth modes", () => {
|
||||||
|
it("should reject token mode without token configured", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" as const,
|
||||||
|
auth: {
|
||||||
|
mode: "token" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("gateway auth mode is token, but no token was configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow lan binding with token", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" as const,
|
||||||
|
auth: {
|
||||||
|
mode: "token" as const,
|
||||||
|
token: "test-token-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.authMode).toBe("token");
|
||||||
|
expect(result.bindHost).toBe("0.0.0.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,8 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
||||||
|
|
||||||
|
const trustedProxies = params.cfg.gateway?.trustedProxies ?? [];
|
||||||
|
|
||||||
assertGatewayAuthConfigured(resolvedAuth);
|
assertGatewayAuthConfigured(resolvedAuth);
|
||||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -94,12 +96,25 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
||||||
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
|
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
|
||||||
}
|
}
|
||||||
if (!isLoopbackHost(bindHost) && !hasSharedSecret) {
|
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
|
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authMode === "trusted-proxy") {
|
||||||
|
if (isLoopbackHost(bindHost)) {
|
||||||
|
throw new Error(
|
||||||
|
"gateway auth mode=trusted-proxy makes no sense with bind=loopback; use bind=lan or bind=custom with gateway.trustedProxies configured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trustedProxies.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bindHost,
|
bindHost,
|
||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js";
|
|||||||
import { resolveMainSessionKey } from "../../config/sessions.js";
|
import { resolveMainSessionKey } from "../../config/sessions.js";
|
||||||
import { listSystemPresence } from "../../infra/system-presence.js";
|
import { listSystemPresence } from "../../infra/system-presence.js";
|
||||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||||
|
import { resolveGatewayAuth } from "../auth.js";
|
||||||
|
|
||||||
let presenceVersion = 1;
|
let presenceVersion = 1;
|
||||||
let healthVersion = 1;
|
let healthVersion = 1;
|
||||||
@@ -20,6 +21,7 @@ export function buildGatewaySnapshot(): Snapshot {
|
|||||||
const scope = cfg.session?.scope ?? "per-sender";
|
const scope = cfg.session?.scope ?? "per-sender";
|
||||||
const presence = listSystemPresence();
|
const presence = listSystemPresence();
|
||||||
const uptimeMs = Math.round(process.uptime() * 1000);
|
const uptimeMs = Math.round(process.uptime() * 1000);
|
||||||
|
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
|
||||||
// Health is async; caller should await getHealthSnapshot and replace later if needed.
|
// Health is async; caller should await getHealthSnapshot and replace later if needed.
|
||||||
const emptyHealth: unknown = {};
|
const emptyHealth: unknown = {};
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +38,7 @@ export function buildGatewaySnapshot(): Snapshot {
|
|||||||
mainSessionKey,
|
mainSessionKey,
|
||||||
scope,
|
scope,
|
||||||
},
|
},
|
||||||
|
authMode: auth.mode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,7 +449,10 @@ export function collectSecretsInConfigFindings(cfg: OpenClawConfig): SecurityAud
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
export function collectHooksHardeningFindings(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): SecurityAuditFinding[] {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
if (cfg.hooks?.enabled !== true) {
|
if (cfg.hooks?.enabled !== true) {
|
||||||
return findings;
|
return findings;
|
||||||
@@ -468,13 +471,20 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
|
|||||||
const gatewayAuth = resolveGatewayAuth({
|
const gatewayAuth = resolveGatewayAuth({
|
||||||
authConfig: cfg.gateway?.auth,
|
authConfig: cfg.gateway?.auth,
|
||||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
|
const openclawGatewayToken =
|
||||||
|
typeof env.OPENCLAW_GATEWAY_TOKEN === "string" && env.OPENCLAW_GATEWAY_TOKEN.trim()
|
||||||
|
? env.OPENCLAW_GATEWAY_TOKEN.trim()
|
||||||
|
: null;
|
||||||
const gatewayToken =
|
const gatewayToken =
|
||||||
gatewayAuth.mode === "token" &&
|
gatewayAuth.mode === "token" &&
|
||||||
typeof gatewayAuth.token === "string" &&
|
typeof gatewayAuth.token === "string" &&
|
||||||
gatewayAuth.token.trim()
|
gatewayAuth.token.trim()
|
||||||
? gatewayAuth.token.trim()
|
? gatewayAuth.token.trim()
|
||||||
: null;
|
: openclawGatewayToken
|
||||||
|
? openclawGatewayToken
|
||||||
|
: null;
|
||||||
if (token && gatewayToken && token === gatewayToken) {
|
if (token && gatewayToken && token === gatewayToken) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "hooks.token_reuse_gateway_token",
|
checkId: "hooks.token_reuse_gateway_token",
|
||||||
@@ -545,6 +555,33 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectGatewayHttpSessionKeyOverrideFindings(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
): SecurityAuditFinding[] {
|
||||||
|
const findings: SecurityAuditFinding[] = [];
|
||||||
|
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
||||||
|
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
|
||||||
|
if (!chatCompletionsEnabled && !responsesEnabled) {
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledEndpoints = [
|
||||||
|
chatCompletionsEnabled ? "/v1/chat/completions" : null,
|
||||||
|
responsesEnabled ? "/v1/responses" : null,
|
||||||
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.http.session_key_override_enabled",
|
||||||
|
severity: "info",
|
||||||
|
title: "HTTP API session-key override is enabled",
|
||||||
|
detail:
|
||||||
|
`${enabledEndpoints.join(", ")} accept x-openclaw-session-key for per-request session routing. ` +
|
||||||
|
"Treat API credential holders as trusted principals.",
|
||||||
|
});
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
const configuredPaths: string[] = [];
|
const configuredPaths: string[] = [];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
export {
|
export {
|
||||||
collectAttackSurfaceSummaryFindings,
|
collectAttackSurfaceSummaryFindings,
|
||||||
collectExposureMatrixFindings,
|
collectExposureMatrixFindings,
|
||||||
|
collectGatewayHttpSessionKeyOverrideFindings,
|
||||||
collectHooksHardeningFindings,
|
collectHooksHardeningFindings,
|
||||||
collectMinimalProfileOverrideFindings,
|
collectMinimalProfileOverrideFindings,
|
||||||
collectModelHygieneFindings,
|
collectModelHygieneFindings,
|
||||||
|
|||||||
+155
-15
@@ -95,23 +95,42 @@ describe("security audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("flags non-loopback bind without auth as critical", async () => {
|
it("flags non-loopback bind without auth as critical", async () => {
|
||||||
const cfg: OpenClawConfig = {
|
// Clear env tokens so resolveGatewayAuth defaults to mode=none
|
||||||
gateway: {
|
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
bind: "lan",
|
const prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||||
auth: {},
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
},
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||||
};
|
|
||||||
|
|
||||||
const res = await runSecurityAudit({
|
try {
|
||||||
config: cfg,
|
const cfg: OpenClawConfig = {
|
||||||
env: {},
|
gateway: {
|
||||||
includeFilesystem: false,
|
bind: "lan",
|
||||||
includeChannelSecurity: false,
|
auth: {},
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
expect(
|
const res = await runSecurityAudit({
|
||||||
res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"),
|
config: cfg,
|
||||||
).toBe(true);
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Restore env
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
if (prevPassword === undefined) {
|
||||||
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns when non-loopback bind has auth but no auth rate limit", async () => {
|
it("warns when non-loopback bind has auth but no auth rate limit", async () => {
|
||||||
@@ -593,6 +612,127 @@ describe("security audit", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flags trusted-proxy auth mode without generic shared-secret findings", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "gateway.trusted_proxy_auth",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(res.findings.some((f) => f.checkId === "gateway.bind_no_auth")).toBe(false);
|
||||||
|
expect(res.findings.some((f) => f.checkId === "gateway.auth_no_rate_limit")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags trusted-proxy auth without trustedProxies configured", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: [],
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "gateway.trusted_proxy_no_proxies",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags trusted-proxy auth without userHeader configured", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {} as never,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "gateway.trusted_proxy_no_user_header",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when trusted-proxy auth allows all users", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
auth: {
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxy: {
|
||||||
|
userHeader: "x-forwarded-user",
|
||||||
|
allowUsers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "gateway.trusted_proxy_no_allowlist",
|
||||||
|
severity: "warn",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("warns when multiple DM senders share the main session", async () => {
|
it("warns when multiple DM senders share the main session", async () => {
|
||||||
const cfg: OpenClawConfig = { session: { dmScope: "main" } };
|
const cfg: OpenClawConfig = { session: { dmScope: "main" } };
|
||||||
const plugins: ChannelPlugin[] = [
|
const plugins: ChannelPlugin[] = [
|
||||||
|
|||||||
+58
-19
@@ -12,6 +12,7 @@ import { collectChannelSecurityFindings } from "./audit-channel.js";
|
|||||||
import {
|
import {
|
||||||
collectAttackSurfaceSummaryFindings,
|
collectAttackSurfaceSummaryFindings,
|
||||||
collectExposureMatrixFindings,
|
collectExposureMatrixFindings,
|
||||||
|
collectGatewayHttpSessionKeyOverrideFindings,
|
||||||
collectHooksHardeningFindings,
|
collectHooksHardeningFindings,
|
||||||
collectIncludeFilePermFindings,
|
collectIncludeFilePermFindings,
|
||||||
collectInstalledSkillsCodeSafetyFindings,
|
collectInstalledSkillsCodeSafetyFindings,
|
||||||
@@ -257,10 +258,7 @@ function collectGatewayConfigFindings(
|
|||||||
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
||||||
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
|
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
|
||||||
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
||||||
const remotelyExposed =
|
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
|
||||||
bind !== "loopback" || tailscaleMode === "serve" || tailscaleMode === "funnel";
|
|
||||||
|
|
||||||
if (bind !== "loopback" && !hasSharedSecret) {
|
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.bind_no_auth",
|
checkId: "gateway.bind_no_auth",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
@@ -346,26 +344,66 @@ function collectGatewayConfigFindings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
if (auth.mode === "trusted-proxy") {
|
||||||
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
|
const trustedProxies = cfg.gateway?.trustedProxies ?? [];
|
||||||
if (chatCompletionsEnabled || responsesEnabled) {
|
const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy;
|
||||||
const enabledEndpoints = [
|
|
||||||
chatCompletionsEnabled ? "/v1/chat/completions" : null,
|
|
||||||
responsesEnabled ? "/v1/responses" : null,
|
|
||||||
].filter((value): value is string => Boolean(value));
|
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.http.session_key_override_enabled",
|
checkId: "gateway.trusted_proxy_auth",
|
||||||
severity: remotelyExposed ? "warn" : "info",
|
severity: "critical",
|
||||||
title: "HTTP APIs accept explicit session key override headers",
|
title: "Trusted-proxy auth mode enabled",
|
||||||
detail:
|
detail:
|
||||||
`${enabledEndpoints.join(", ")} support x-openclaw-session-key. ` +
|
'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' +
|
||||||
"Any authenticated caller can route requests into arbitrary sessions.",
|
"Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " +
|
||||||
|
"only contains IPs of your actual proxy servers.",
|
||||||
remediation:
|
remediation:
|
||||||
"Treat HTTP API credentials as full-trust, disable unused endpoints, and avoid sharing tokens across tenants.",
|
"Verify: (1) Your proxy terminates TLS and authenticates users. " +
|
||||||
|
"(2) gateway.trustedProxies is restricted to proxy IPs only. " +
|
||||||
|
"(3) Direct access to the Gateway port is blocked by firewall. " +
|
||||||
|
"See /gateway/trusted-proxy-auth for setup guidance.",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (trustedProxies.length === 0) {
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.trusted_proxy_no_proxies",
|
||||||
|
severity: "critical",
|
||||||
|
title: "Trusted-proxy auth enabled but no trusted proxies configured",
|
||||||
|
detail:
|
||||||
|
'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' +
|
||||||
|
"All requests will be rejected.",
|
||||||
|
remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trustedProxyConfig?.userHeader) {
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.trusted_proxy_no_user_header",
|
||||||
|
severity: "critical",
|
||||||
|
title: "Trusted-proxy auth missing userHeader config",
|
||||||
|
detail:
|
||||||
|
'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.',
|
||||||
|
remediation:
|
||||||
|
"Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " +
|
||||||
|
'(e.g., "x-forwarded-user", "x-pomerium-claim-email").',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowUsers = trustedProxyConfig?.allowUsers ?? [];
|
||||||
|
if (allowUsers.length === 0) {
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.trusted_proxy_no_allowlist",
|
||||||
|
severity: "warn",
|
||||||
|
title: "Trusted-proxy auth allows all authenticated users",
|
||||||
|
detail:
|
||||||
|
"gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.",
|
||||||
|
remediation:
|
||||||
|
"Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " +
|
||||||
|
'(e.g., ["nick@example.com"]).',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bind !== "loopback" && !cfg.gateway?.auth?.rateLimit) {
|
if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.auth_no_rate_limit",
|
checkId: "gateway.auth_no_rate_limit",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
@@ -570,7 +608,8 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||||
findings.push(...collectLoggingFindings(cfg));
|
findings.push(...collectLoggingFindings(cfg));
|
||||||
findings.push(...collectElevatedFindings(cfg));
|
findings.push(...collectElevatedFindings(cfg));
|
||||||
findings.push(...collectHooksHardeningFindings(cfg));
|
findings.push(...collectHooksHardeningFindings(cfg, env));
|
||||||
|
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
||||||
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
||||||
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
||||||
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { connectGateway } from "./app-gateway.ts";
|
||||||
|
|
||||||
|
type GatewayClientMock = {
|
||||||
|
start: ReturnType<typeof vi.fn>;
|
||||||
|
stop: ReturnType<typeof vi.fn>;
|
||||||
|
emitClose: (code: number, reason?: string) => void;
|
||||||
|
emitGap: (expected: number, received: number) => void;
|
||||||
|
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gatewayClientInstances: GatewayClientMock[] = [];
|
||||||
|
|
||||||
|
vi.mock("./gateway.ts", () => {
|
||||||
|
class GatewayBrowserClient {
|
||||||
|
readonly start = vi.fn();
|
||||||
|
readonly stop = vi.fn();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private opts: {
|
||||||
|
onClose?: (info: { code: number; reason: string }) => void;
|
||||||
|
onGap?: (info: { expected: number; received: number }) => void;
|
||||||
|
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
gatewayClientInstances.push({
|
||||||
|
start: this.start,
|
||||||
|
stop: this.stop,
|
||||||
|
emitClose: (code, reason) => {
|
||||||
|
this.opts.onClose?.({ code, reason: reason ?? "" });
|
||||||
|
},
|
||||||
|
emitGap: (expected, received) => {
|
||||||
|
this.opts.onGap?.({ expected, received });
|
||||||
|
},
|
||||||
|
emitEvent: (evt) => {
|
||||||
|
this.opts.onEvent?.(evt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { GatewayBrowserClient };
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost() {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
gatewayUrl: "ws://127.0.0.1:18789",
|
||||||
|
token: "",
|
||||||
|
sessionKey: "main",
|
||||||
|
lastActiveSessionKey: "main",
|
||||||
|
theme: "system",
|
||||||
|
chatFocusMode: false,
|
||||||
|
chatShowThinking: true,
|
||||||
|
splitRatio: 0.6,
|
||||||
|
navCollapsed: false,
|
||||||
|
navGroupsCollapsed: {},
|
||||||
|
},
|
||||||
|
password: "",
|
||||||
|
client: null,
|
||||||
|
connected: false,
|
||||||
|
hello: null,
|
||||||
|
lastError: null,
|
||||||
|
eventLogBuffer: [],
|
||||||
|
eventLog: [],
|
||||||
|
tab: "overview",
|
||||||
|
presenceEntries: [],
|
||||||
|
presenceError: null,
|
||||||
|
presenceStatus: null,
|
||||||
|
agentsLoading: false,
|
||||||
|
agentsList: null,
|
||||||
|
agentsError: null,
|
||||||
|
debugHealth: null,
|
||||||
|
assistantName: "OpenClaw",
|
||||||
|
assistantAvatar: null,
|
||||||
|
assistantAgentId: null,
|
||||||
|
sessionKey: "main",
|
||||||
|
chatRunId: null,
|
||||||
|
refreshSessionsAfterChat: new Set<string>(),
|
||||||
|
execApprovalQueue: [],
|
||||||
|
execApprovalError: null,
|
||||||
|
} as unknown as Parameters<typeof connectGateway>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("connectGateway", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
gatewayClientInstances.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale client onGap callbacks after reconnect", () => {
|
||||||
|
const host = createHost();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const firstClient = gatewayClientInstances[0];
|
||||||
|
expect(firstClient).toBeDefined();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const secondClient = gatewayClientInstances[1];
|
||||||
|
expect(secondClient).toBeDefined();
|
||||||
|
|
||||||
|
firstClient.emitGap(10, 13);
|
||||||
|
expect(host.lastError).toBeNull();
|
||||||
|
|
||||||
|
secondClient.emitGap(20, 24);
|
||||||
|
expect(host.lastError).toBe(
|
||||||
|
"event gap detected (expected seq 20, got 24); refresh recommended",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale client onEvent callbacks after reconnect", () => {
|
||||||
|
const host = createHost();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const firstClient = gatewayClientInstances[0];
|
||||||
|
expect(firstClient).toBeDefined();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const secondClient = gatewayClientInstances[1];
|
||||||
|
expect(secondClient).toBeDefined();
|
||||||
|
|
||||||
|
firstClient.emitEvent({ event: "presence", payload: { presence: [{ host: "stale" }] } });
|
||||||
|
expect(host.eventLogBuffer).toHaveLength(0);
|
||||||
|
|
||||||
|
secondClient.emitEvent({ event: "presence", payload: { presence: [{ host: "active" }] } });
|
||||||
|
expect(host.eventLogBuffer).toHaveLength(1);
|
||||||
|
expect(host.eventLogBuffer[0]?.event).toBe("presence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale client onClose callbacks after reconnect", () => {
|
||||||
|
const host = createHost();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const firstClient = gatewayClientInstances[0];
|
||||||
|
expect(firstClient).toBeDefined();
|
||||||
|
|
||||||
|
connectGateway(host);
|
||||||
|
const secondClient = gatewayClientInstances[1];
|
||||||
|
expect(secondClient).toBeDefined();
|
||||||
|
|
||||||
|
firstClient.emitClose(1005);
|
||||||
|
expect(host.lastError).toBeNull();
|
||||||
|
|
||||||
|
secondClient.emitClose(1005);
|
||||||
|
expect(host.lastError).toBe("disconnected (1005): no reason");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,14 +122,17 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
host.execApprovalQueue = [];
|
host.execApprovalQueue = [];
|
||||||
host.execApprovalError = null;
|
host.execApprovalError = null;
|
||||||
|
|
||||||
host.client?.stop();
|
const previousClient = host.client;
|
||||||
host.client = new GatewayBrowserClient({
|
const client = new GatewayBrowserClient({
|
||||||
url: host.settings.gatewayUrl,
|
url: host.settings.gatewayUrl,
|
||||||
token: host.settings.token.trim() ? host.settings.token : undefined,
|
token: host.settings.token.trim() ? host.settings.token : undefined,
|
||||||
password: host.password.trim() ? host.password : undefined,
|
password: host.password.trim() ? host.password : undefined,
|
||||||
clientName: "openclaw-control-ui",
|
clientName: "openclaw-control-ui",
|
||||||
mode: "webchat",
|
mode: "webchat",
|
||||||
onHello: (hello) => {
|
onHello: (hello) => {
|
||||||
|
if (host.client !== client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
host.connected = true;
|
host.connected = true;
|
||||||
host.lastError = null;
|
host.lastError = null;
|
||||||
host.hello = hello;
|
host.hello = hello;
|
||||||
@@ -147,18 +150,31 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||||
},
|
},
|
||||||
onClose: ({ code, reason }) => {
|
onClose: ({ code, reason }) => {
|
||||||
|
if (host.client !== client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
host.connected = false;
|
host.connected = false;
|
||||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||||
if (code !== 1012) {
|
if (code !== 1012) {
|
||||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEvent: (evt) => handleGatewayEvent(host, evt),
|
onEvent: (evt) => {
|
||||||
|
if (host.client !== client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleGatewayEvent(host, evt);
|
||||||
|
},
|
||||||
onGap: ({ expected, received }) => {
|
onGap: ({ expected, received }) => {
|
||||||
|
if (host.client !== client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
host.client.start();
|
host.client = client;
|
||||||
|
previousClient?.stop();
|
||||||
|
client.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||||
|
|||||||
+37
-25
@@ -24,10 +24,16 @@ export type OverviewProps = {
|
|||||||
|
|
||||||
export function renderOverview(props: OverviewProps) {
|
export function renderOverview(props: OverviewProps) {
|
||||||
const snapshot = props.hello?.snapshot as
|
const snapshot = props.hello?.snapshot as
|
||||||
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
|
| {
|
||||||
|
uptimeMs?: number;
|
||||||
|
policy?: { tickIntervalMs?: number };
|
||||||
|
authMode?: "none" | "token" | "password" | "trusted-proxy";
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
|
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
|
||||||
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
||||||
|
const authMode = snapshot?.authMode;
|
||||||
|
const isTrustedProxy = authMode === "trusted-proxy";
|
||||||
const authHint = (() => {
|
const authHint = (() => {
|
||||||
if (props.connected || !props.lastError) {
|
if (props.connected || !props.lastError) {
|
||||||
return null;
|
return null;
|
||||||
@@ -136,29 +142,35 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
placeholder="ws://100.x.y.z:18789"
|
placeholder="ws://100.x.y.z:18789"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
${
|
||||||
<span>Gateway Token</span>
|
isTrustedProxy
|
||||||
<input
|
? ""
|
||||||
.value=${props.settings.token}
|
: html`
|
||||||
@input=${(e: Event) => {
|
<label class="field">
|
||||||
const v = (e.target as HTMLInputElement).value;
|
<span>Gateway Token</span>
|
||||||
props.onSettingsChange({ ...props.settings, token: v });
|
<input
|
||||||
}}
|
.value=${props.settings.token}
|
||||||
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
@input=${(e: Event) => {
|
||||||
/>
|
const v = (e.target as HTMLInputElement).value;
|
||||||
</label>
|
props.onSettingsChange({ ...props.settings, token: v });
|
||||||
<label class="field">
|
}}
|
||||||
<span>Password (not stored)</span>
|
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
||||||
<input
|
/>
|
||||||
type="password"
|
</label>
|
||||||
.value=${props.password}
|
<label class="field">
|
||||||
@input=${(e: Event) => {
|
<span>Password (not stored)</span>
|
||||||
const v = (e.target as HTMLInputElement).value;
|
<input
|
||||||
props.onPasswordChange(v);
|
type="password"
|
||||||
}}
|
.value=${props.password}
|
||||||
placeholder="system or shared password"
|
@input=${(e: Event) => {
|
||||||
/>
|
const v = (e.target as HTMLInputElement).value;
|
||||||
</label>
|
props.onPasswordChange(v);
|
||||||
|
}}
|
||||||
|
placeholder="system or shared password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Default Session Key</span>
|
<span>Default Session Key</span>
|
||||||
<input
|
<input
|
||||||
@@ -173,7 +185,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
<div class="row" style="margin-top: 14px;">
|
<div class="row" style="margin-top: 14px;">
|
||||||
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
|
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
|
||||||
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
|
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
|
||||||
<span class="muted">Click Connect to apply connection changes.</span>
|
<span class="muted">${isTrustedProxy ? "Authenticated via trusted proxy." : "Click Connect to apply connection changes."}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user