mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(gateway): skip Tailscale Control UI pairing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
role,
|
||||
trustedProxyAuthOk,
|
||||
resolvedAuth.mode,
|
||||
authMethod,
|
||||
);
|
||||
if (device && devicePublicKey) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
|
||||
Reference in New Issue
Block a user