mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(gateway): add trusted-proxy auth mode (#15940)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 279d4b304f
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:
146
ui/src/ui/app-gateway.node.test.ts
Normal file
146
ui/src/ui/app-gateway.node.test.ts
Normal file
@@ -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.execApprovalError = null;
|
||||
|
||||
host.client?.stop();
|
||||
host.client = new GatewayBrowserClient({
|
||||
const previousClient = host.client;
|
||||
const client = new GatewayBrowserClient({
|
||||
url: host.settings.gatewayUrl,
|
||||
token: host.settings.token.trim() ? host.settings.token : undefined,
|
||||
password: host.password.trim() ? host.password : undefined,
|
||||
clientName: "openclaw-control-ui",
|
||||
mode: "webchat",
|
||||
onHello: (hello) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.hello = hello;
|
||||
@@ -147,18 +150,31 @@ export function connectGateway(host: GatewayHost) {
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.connected = false;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
if (code !== 1012) {
|
||||
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 }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -24,10 +24,16 @@ export type OverviewProps = {
|
||||
|
||||
export function renderOverview(props: OverviewProps) {
|
||||
const snapshot = props.hello?.snapshot as
|
||||
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
|
||||
| {
|
||||
uptimeMs?: number;
|
||||
policy?: { tickIntervalMs?: number };
|
||||
authMode?: "none" | "token" | "password" | "trusted-proxy";
|
||||
}
|
||||
| undefined;
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
|
||||
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
||||
const authMode = snapshot?.authMode;
|
||||
const isTrustedProxy = authMode === "trusted-proxy";
|
||||
const authHint = (() => {
|
||||
if (props.connected || !props.lastError) {
|
||||
return null;
|
||||
@@ -136,29 +142,35 @@ export function renderOverview(props: OverviewProps) {
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Gateway Token</span>
|
||||
<input
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, token: v });
|
||||
}}
|
||||
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password (not stored)</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onPasswordChange(v);
|
||||
}}
|
||||
placeholder="system or shared password"
|
||||
/>
|
||||
</label>
|
||||
${
|
||||
isTrustedProxy
|
||||
? ""
|
||||
: html`
|
||||
<label class="field">
|
||||
<span>Gateway Token</span>
|
||||
<input
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, token: v });
|
||||
}}
|
||||
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password (not stored)</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onPasswordChange(v);
|
||||
}}
|
||||
placeholder="system or shared password"
|
||||
/>
|
||||
</label>
|
||||
`
|
||||
}
|
||||
<label class="field">
|
||||
<span>Default Session Key</span>
|
||||
<input
|
||||
@@ -173,7 +185,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button class="btn" @click=${() => props.onConnect()}>Connect</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user