fix(gateway): skip Tailscale Control UI pairing

This commit is contained in:
Peter Steinberger
2026-04-26 09:41:44 +01:00
parent 5ab5b75348
commit e29d3516bf
6 changed files with 73 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.

View File

@@ -145,9 +145,18 @@ export function registerAuthModesSuite(): void {
describe("tailscale auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
const tailscaleOrigin = "https://gateway.tailnet.ts.net";
beforeAll(async () => {
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: {
auth: testState.gatewayAuth,
controlUi: testState.gatewayControlUi,
},
});
port = await getFreePort();
server = await startGatewayServer(port);
});
@@ -158,6 +167,7 @@ export function registerAuthModesSuite(): void {
beforeEach(() => {
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
testTailscaleWhois.value = { login: "peter", name: "Peter" };
});
@@ -173,6 +183,20 @@ export function registerAuthModesSuite(): void {
ws.close();
});
test("skips pairing for tailscale-authenticated control ui with device identity", async () => {
const ws = await openTailscaleWs(port, { origin: tailscaleOrigin });
const res = await connectReq(ws, {
skipDefaultAuth: true,
client: {
...CONTROL_UI_CLIENT,
},
});
expect(res.ok, JSON.stringify(res)).toBe(true);
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(true);
ws.close();
});
test("connects with shared token but clears scopes when tailscale auth skips device", async () => {
const ws = await openTailscaleWs(port);
const res = await connectReq(ws, { token: "secret", device: null });

View File

@@ -74,7 +74,7 @@ const readConnectChallengeNonce = async (ws: WebSocket) => {
return String(nonce);
};
const openTailscaleWs = async (port: number) => {
const openTailscaleWs = async (port: number, headers?: Record<string, string>) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: {
"x-forwarded-for": "100.64.0.1",
@@ -82,6 +82,7 @@ const openTailscaleWs = async (port: number) => {
"x-forwarded-host": "gateway.tailnet.ts.net",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
...headers,
},
});
trackConnectChallengeNonce(ws);

View File

@@ -251,6 +251,47 @@ describe("ws connect policy", () => {
expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false);
});
test("tailscale auth skips pairing only for operator control-ui with device identity", () => {
const device = {
id: "dev-1",
publicKey: "pk",
signature: "sig",
signedAt: Date.now(),
nonce: "nonce-1",
};
const controlUiWithDevice = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: undefined,
deviceRaw: device,
});
const controlUiWithoutDevice = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: undefined,
deviceRaw: null,
});
const nonControlUiWithDevice = resolveControlUiAuthPolicy({
isControlUi: false,
controlUiConfig: undefined,
deviceRaw: device,
});
expect(
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "tailscale"),
).toBe(true);
expect(
shouldSkipControlUiPairing(controlUiWithoutDevice, "operator", false, "token", "tailscale"),
).toBe(false);
expect(
shouldSkipControlUiPairing(controlUiWithDevice, "node", false, "token", "tailscale"),
).toBe(false);
expect(
shouldSkipControlUiPairing(nonControlUiWithDevice, "operator", false, "token", "tailscale"),
).toBe(false);
expect(
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "token"),
).toBe(false);
});
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
const cases: Array<{
role: "operator" | "node";

View File

@@ -39,10 +39,14 @@ export function shouldSkipControlUiPairing(
role: GatewayRole,
trustedProxyAuthOk = false,
authMode?: string,
authMethod?: string,
): boolean {
if (trustedProxyAuthOk) {
return true;
}
if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) {
return true;
}
// When auth is completely disabled (mode=none), there is no shared secret
// or token to gate pairing. Requiring pairing in this configuration adds
// friction without security value since any client can already connect

View File

@@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: {
role,
trustedProxyAuthOk,
resolvedAuth.mode,
authMethod,
);
if (device && devicePublicKey) {
const formatAuditList = (items: string[] | undefined): string => {