Skip to content

Commit e29d351

Browse files
committed
fix(gateway): skip Tailscale Control UI pairing
1 parent 5ab5b75 commit e29d351

6 files changed

Lines changed: 73 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
### Fixes
1010

11+
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
1112
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
1213
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
1314
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.

src/gateway/server.auth.modes.suite.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,18 @@ export function registerAuthModesSuite(): void {
145145
describe("tailscale auth", () => {
146146
let server: Awaited<ReturnType<typeof startGatewayServer>>;
147147
let port: number;
148+
const tailscaleOrigin = "https://gateway.tailnet.ts.net";
148149

149150
beforeAll(async () => {
150151
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
152+
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
153+
const { writeConfigFile } = await import("../config/config.js");
154+
await writeConfigFile({
155+
gateway: {
156+
auth: testState.gatewayAuth,
157+
controlUi: testState.gatewayControlUi,
158+
},
159+
});
151160
port = await getFreePort();
152161
server = await startGatewayServer(port);
153162
});
@@ -158,6 +167,7 @@ export function registerAuthModesSuite(): void {
158167

159168
beforeEach(() => {
160169
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
170+
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
161171
testTailscaleWhois.value = { login: "peter", name: "Peter" };
162172
});
163173

@@ -173,6 +183,20 @@ export function registerAuthModesSuite(): void {
173183
ws.close();
174184
});
175185

186+
test("skips pairing for tailscale-authenticated control ui with device identity", async () => {
187+
const ws = await openTailscaleWs(port, { origin: tailscaleOrigin });
188+
const res = await connectReq(ws, {
189+
skipDefaultAuth: true,
190+
client: {
191+
...CONTROL_UI_CLIENT,
192+
},
193+
});
194+
expect(res.ok, JSON.stringify(res)).toBe(true);
195+
const status = await rpcReq(ws, "status");
196+
expect(status.ok).toBe(true);
197+
ws.close();
198+
});
199+
176200
test("connects with shared token but clears scopes when tailscale auth skips device", async () => {
177201
const ws = await openTailscaleWs(port);
178202
const res = await connectReq(ws, { token: "secret", device: null });

src/gateway/server.auth.shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,15 @@ const readConnectChallengeNonce = async (ws: WebSocket) => {
7474
return String(nonce);
7575
};
7676

77-
const openTailscaleWs = async (port: number) => {
77+
const openTailscaleWs = async (port: number, headers?: Record<string, string>) => {
7878
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
7979
headers: {
8080
"x-forwarded-for": "100.64.0.1",
8181
"x-forwarded-proto": "https",
8282
"x-forwarded-host": "gateway.tailnet.ts.net",
8383
"tailscale-user-login": "peter",
8484
"tailscale-user-name": "Peter",
85+
...headers,
8586
},
8687
});
8788
trackConnectChallengeNonce(ws);

src/gateway/server/ws-connection/connect-policy.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,47 @@ describe("ws connect policy", () => {
251251
expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false);
252252
});
253253

254+
test("tailscale auth skips pairing only for operator control-ui with device identity", () => {
255+
const device = {
256+
id: "dev-1",
257+
publicKey: "pk",
258+
signature: "sig",
259+
signedAt: Date.now(),
260+
nonce: "nonce-1",
261+
};
262+
const controlUiWithDevice = resolveControlUiAuthPolicy({
263+
isControlUi: true,
264+
controlUiConfig: undefined,
265+
deviceRaw: device,
266+
});
267+
const controlUiWithoutDevice = resolveControlUiAuthPolicy({
268+
isControlUi: true,
269+
controlUiConfig: undefined,
270+
deviceRaw: null,
271+
});
272+
const nonControlUiWithDevice = resolveControlUiAuthPolicy({
273+
isControlUi: false,
274+
controlUiConfig: undefined,
275+
deviceRaw: device,
276+
});
277+
278+
expect(
279+
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "tailscale"),
280+
).toBe(true);
281+
expect(
282+
shouldSkipControlUiPairing(controlUiWithoutDevice, "operator", false, "token", "tailscale"),
283+
).toBe(false);
284+
expect(
285+
shouldSkipControlUiPairing(controlUiWithDevice, "node", false, "token", "tailscale"),
286+
).toBe(false);
287+
expect(
288+
shouldSkipControlUiPairing(nonControlUiWithDevice, "operator", false, "token", "tailscale"),
289+
).toBe(false);
290+
expect(
291+
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "token"),
292+
).toBe(false);
293+
});
294+
254295
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
255296
const cases: Array<{
256297
role: "operator" | "node";

src/gateway/server/ws-connection/connect-policy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ export function shouldSkipControlUiPairing(
3939
role: GatewayRole,
4040
trustedProxyAuthOk = false,
4141
authMode?: string,
42+
authMethod?: string,
4243
): boolean {
4344
if (trustedProxyAuthOk) {
4445
return true;
4546
}
47+
if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) {
48+
return true;
49+
}
4650
// When auth is completely disabled (mode=none), there is no shared secret
4751
// or token to gate pairing. Requiring pairing in this configuration adds
4852
// friction without security value since any client can already connect

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: {
844844
role,
845845
trustedProxyAuthOk,
846846
resolvedAuth.mode,
847+
authMethod,
847848
);
848849
if (device && devicePublicKey) {
849850
const formatAuditList = (items: string[] | undefined): string => {

0 commit comments

Comments
 (0)