diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index bb66cc886634..fb9dde7b4ee8 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -167,10 +167,10 @@ fail closed before QR/setup-code issuance. For `gateway.bind=lan` setup URLs, OpenClaw detects persistent Tailscale Serve HTTPS roots that proxy the active Gateway's loopback port and advertises them -alongside the LAN route. Specific-interface `custom` and `tailnet` binds do not -receive that fallback because a loopback Serve proxy cannot reach those -listeners. The iOS app probes the advertised routes in order and saves the first -reachable endpoint. +alongside the LAN route. The setup command adds this fallback only +for `lan`; `custom` and `tailnet` keep their explicitly advertised routes. The +iOS app probes the advertised routes in order and saves the first reachable +endpoint. ### Approve a node device diff --git a/docs/cli/dashboard.md b/docs/cli/dashboard.md index 97a18f46f427..cbd12a9c5edc 100644 --- a/docs/cli/dashboard.md +++ b/docs/cli/dashboard.md @@ -23,6 +23,8 @@ Notes: - Resolves configured `gateway.auth.token` SecretRefs when possible. - Follows `gateway.tls.enabled`: TLS-enabled gateways print/open `https://` Control UI URLs and connect over `wss://`. +- For `lan` or a wildcard `custom` bind, same-host launches always use loopback because a wildcard is not a browser destination. Plaintext `tailnet` and `custom` binds also use `127.0.0.1` so the browser has a secure context; TLS-enabled specific hosts keep the configured address so certificate names match. +- Before delivering an authenticated loopback URL for a specific-interface bind, the command probes the configured interface and verifies that it and `127.0.0.1` are owned by the same Gateway process. Ambiguous listener ownership fails closed with status guidance. - For SecretRef-managed tokens (resolved or unresolved), the printed/copied/opened URL never includes the token, so external secrets do not leak into terminal output, clipboard history, or browser-launch arguments. - If `gateway.auth.token` is SecretRef-managed but unresolved, the command prints a non-tokenized URL and remediation guidance instead of an invalid token placeholder. - If clipboard/browser delivery fails for a token-authenticated URL, the command logs a safe manual-auth hint naming `OPENCLAW_GATEWAY_TOKEN`, `gateway.auth.token`, and the URL fragment key `token`, without printing the token value. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 98b4299267da..3ab5643a0a26 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -100,7 +100,7 @@ openclaw gateway run # equivalent, explicit form `--claude-cli-logs` is a deprecated alias for `--cli-backend-logs`. -For `--bind custom`, set `gateway.customBindHost` to an IPv4 address; the Gateway falls back to `0.0.0.0` if that address is unavailable. IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway. +For `--bind custom`, set `gateway.customBindHost` to an IPv4 address. Any address other than `127.0.0.1` or `0.0.0.0` also requires `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Wildcard `0.0.0.0` does not add a separate required alias. IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway. ## Restart the Gateway diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 566d158aeba5..d846795b612a 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -53,7 +53,7 @@ Pairing-mutation scopes and `operator.admin` still require a separate approved o Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs: use Tailscale Serve/Funnel or a `wss://` gateway URL for those. Private LAN addresses and `.local` Bonjour hosts remain supported over plain `ws://`. -When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. Specific-interface `custom` and `tailnet` binds do not receive that fallback because a loopback Serve proxy cannot reach those listeners. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients. +When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. The QR command adds this fallback only for `lan`; `custom` and `tailnet` keep their explicitly advertised routes. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients. With `--remote`, one of `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel` is required. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ada1be0f401e..8fee27bfa8c7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -590,7 +590,7 @@ See [Inferred commitments](/concepts/commitments). - `mode`: `local` (run gateway) or `remote` (connect to remote gateway). Gateway refuses to start unless `local`. - `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`. -- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`. +- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IPv4 when available, otherwise loopback), or `custom` (one IPv4 address). A resolved `tailnet` address and any `custom` address other than `127.0.0.1` or `0.0.0.0` require `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Non-loopback exposure remains limited to the selected interface. - **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`). - **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces. - **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index e277df818ba8..2293075301c9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -581,7 +581,7 @@ Gateway auth is required by default - with no valid auth path configured, the Ga Pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and Tailnet `*.ts.net` gateway URLs; for other trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass (process environment only, not an `openclaw.json` key). Mobile pairing and Android manual/scanned gateway routes are stricter: cleartext only for loopback, while private-LAN, link-local, `.local`, and dotless hostnames must use TLS unless you explicitly opt into the trusted private-network cleartext path. -Device pairing is auto-approved for direct local loopback connects (plus a narrow backend/container-local self-connect path for trusted shared-secret helper flows); Tailnet and LAN connects, including same-host tailnet binds, are treated as remote and still need approval. Forwarded-header evidence on a loopback request disqualifies loopback locality; metadata-upgrade auto-approval is scoped narrowly. See [Gateway pairing](/gateway/pairing). +Device pairing is auto-approved for direct local loopback connects (plus a narrow backend/container-local self-connect path for trusted shared-secret helper flows); Tailnet and LAN connects, including same-host connections to a tailnet address, are treated as remote and still need approval. A resolved `tailnet` address or `custom` address other than `127.0.0.1` or `0.0.0.0` adds a separate `127.0.0.1` listener; only connections to that local listener receive loopback semantics. Forwarded-header evidence on a loopback request disqualifies loopback locality; metadata-upgrade auto-approval is scoped narrowly. See [Gateway pairing](/gateway/pairing). Auth modes: diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index 0853cbd0f39e..3a4254425790 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -67,7 +67,7 @@ Connect from another Tailnet device: - WebSocket: `ws://:18789` -Loopback (`http://127.0.0.1:18789`) will **not** work in this mode. +When a bindable Tailnet IPv4 is present, the Gateway also requires `http://127.0.0.1:18789` for authenticated same-host clients. If no Tailnet address is available at startup, it falls back to loopback only; restart after Tailscale becomes available to add direct Tailnet access. Neither path adds LAN or public exposure. ### Public internet (Funnel + shared password) @@ -121,8 +121,8 @@ Scope of the bypass: - `gateway.tailscale.serviceName` applies only to Serve mode and is passed to `tailscale serve --service=`. The value must use Tailscale's `svc:` format, for example `svc:openclaw`. Tailscale requires Service hosts to be tagged nodes, and the Service may need admin-console approval before Serve can publish it. - `gateway.tailscale.resetOnExit` undoes `tailscale serve`/`tailscale funnel` configuration on shutdown. - `gateway.tailscale.preserveFunnel: true` keeps an externally configured `tailscale funnel` route alive across gateway restarts. With `mode: "serve"`, OpenClaw checks `tailscale funnel status` before re-applying Serve and skips it when a Funnel route already covers the gateway port. The OpenClaw-managed Funnel password-only policy is unchanged. -- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel). -- `gateway.bind: "auto"` prefers loopback; use `tailnet` for Tailnet-only binding. +- `gateway.bind: "tailnet"` uses a direct Tailnet bind (no HTTPS, no Serve/Funnel) plus required local `127.0.0.1` when a Tailnet IPv4 is available; otherwise it falls back to loopback only. +- `gateway.bind: "auto"` prefers loopback; use `tailnet` to limit network exposure to the Tailnet while retaining same-host loopback access. - Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over the same Gateway WS endpoint, so Serve works for node access too. ### Tailscale prerequisites and limits diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 73311b61354f..8c3ff3463b0a 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -92,6 +92,14 @@ The tmux pane runs the raw watcher: node scripts/watch-node.mjs gateway --force ``` +Stop an installed Gateway service before watching the same port: + +```bash +pnpm openclaw gateway stop +``` + +The watcher's `--force` clears the current listener, but it does not disable a supervised service. A launchd, systemd, or Scheduled Task service can otherwise respawn and replace the watched Gateway. + Foreground mode without tmux: ```bash diff --git a/docs/help/faq.md b/docs/help/faq.md index b4ec1c330ed7..01b2377bf9a2 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1220,12 +1220,12 @@ Model Q&A - defaults, selection, aliases, switching, failover, auth profiles - l - - `tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine is not on Tailscale (or the interface is down), there is nothing to bind to. + + `tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine is not on Tailscale (or the interface is down), the Gateway falls back to loopback instead of exposing another network interface. - Fix: start Tailscale on that host, or switch to `gateway.bind: "loopback"` / `"lan"`. + Fix: start Tailscale on that host and restart the Gateway, or switch explicitly to `gateway.bind: "loopback"` / `"lan"`. - `tailnet` is explicit; `auto` prefers loopback. Use `gateway.bind: "tailnet"` for a tailnet-only bind. + `tailnet` is explicit; `auto` prefers loopback. Use `gateway.bind: "tailnet"` to limit non-loopback exposure to the Tailnet while retaining the required same-host `127.0.0.1` listener. diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index f97b9e0bf7ab..b842b6edf122 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -1327,6 +1327,7 @@ const shouldSkipWatchRuntimeSync = (deps, requirement) => !hasMissingRequiredRuntimePostBuildOutput(deps); const isGatewayClientCommand = (args) => + args[0] === "dashboard" || (args[0] === "gateway" && (args[1] === "call" || args[1] === "status")) || (args[0] === "agent" && !args.includes("--local")); @@ -1507,6 +1508,9 @@ export async function runNodeMain(params = {}) { } const buildExitCode = await withRunNodeBuildLock(deps, async () => { + if (shouldFastPathExistingDistForGatewayClient(deps)) { + return 0; + } const lockedBuildRequirement = resolveBuildRequirement(deps); if (!lockedBuildRequirement.shouldBuild) { const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); diff --git a/src/cli/ports.ts b/src/cli/ports.ts index a2fb030e5f2d..6565534b4ae8 100644 --- a/src/cli/ports.ts +++ b/src/cli/ports.ts @@ -275,16 +275,17 @@ export async function forceFreePortAndWait( let killed: PortProcess[] = []; let useFuserFallback = false; - if (!(await isPortBusy(port))) { - return { killed, waitedMs: 0, escalatedToSigkill: false }; - } - try { killed = forceFreePort(port); } catch (err) { if (!isRecoverableLsofError(err)) { throw err; } + // Keep --force usable on minimal systems when the bind probe can confirm + // the port is free; otherwise use fuser to cover listeners lsof cannot inspect. + if (!(await isPortBusy(port))) { + return { killed, waitedMs: 0, escalatedToSigkill: false }; + } useFuserFallback = true; killed = killPortWithFuser(port, "SIGTERM"); } diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts index b6f4f86ab571..62bd936f14f9 100644 --- a/src/cli/program.force.test.ts +++ b/src/cli/program.force.test.ts @@ -86,8 +86,13 @@ describe("gateway --force helpers", () => { expect(listPortListeners(18789)).toStrictEqual([]); }); - it("skips lsof when the port is already bindable", async () => { + it("returns without cleanup when lsof and the bind probe find no listener", async () => { probePortUsageMock.mockResolvedValue("free"); + (execFileSync as unknown as Mock).mockImplementation(() => { + const err = new Error("no matches") as NodeJS.ErrnoException & { status?: number }; + err.status = 1; + throw err; + }); const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 }); @@ -96,7 +101,46 @@ describe("gateway --force helpers", () => { waitedMs: 0, escalatedToSigkill: false, }); - expect(execFileSync).not.toHaveBeenCalled(); + expect(execFileSync).toHaveBeenCalledOnce(); + expect(probePortUsageMock).toHaveBeenCalledWith(18789); + }); + + it("kills an interface-specific listener even when the bind probe would report free", async () => { + probePortUsageMock.mockResolvedValue("free"); + (execFileSync as unknown as Mock) + .mockReturnValueOnce(["p42", "cnode", ""].join("\n")) + .mockReturnValue(""); + const killMock = vi.fn(); + process.kill = killMock; + + const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 }); + + expect(killMock).toHaveBeenCalledWith(42, "SIGTERM"); + expect(result).toEqual({ + killed: [{ pid: 42, command: "node" }], + waitedMs: 0, + escalatedToSigkill: false, + }); + expect(probePortUsageMock).not.toHaveBeenCalled(); + }); + + it("returns without fuser when lsof is unavailable and the bind probe reports free", async () => { + probePortUsageMock.mockResolvedValue("free"); + (execFileSync as unknown as Mock).mockImplementation(() => { + const err = new Error("not found") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + }); + + const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 }); + + expect(result).toEqual({ + killed: [], + waitedMs: 0, + escalatedToSigkill: false, + }); + expect(execFileSync).toHaveBeenCalledOnce(); + expect(probePortUsageMock).toHaveBeenCalledWith(18789); }); it("fails closed when lsof has a malformed PID and fuser cannot identify one", async () => { diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index ebac59b7c20e..3146ba2815fa 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -70,7 +70,7 @@ export async function promptGatewayConfig( { value: "tailnet", label: "Tailnet (Tailscale IP)", - hint: "Bind to your Tailscale IP only (100.x.x.x)", + hint: "Bind to your Tailscale IP plus local loopback", }, { value: "auto", @@ -85,7 +85,7 @@ export async function promptGatewayConfig( { value: "custom", label: "Custom IP", - hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable", + hint: "Specific IPv4s also bind 127.0.0.1", }, ], }), diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index 97af7cd071e6..f91454629a45 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ resolveGatewayPort: vi.fn(), resolveControlUiLinks: vi.fn(), copyToClipboard: vi.fn(), + openUrl: vi.fn(), + inspectPortUsage: vi.fn(), ensureGatewayReadyForOperation: vi.fn(), })); @@ -19,7 +21,7 @@ vi.mock("../config/config.js", () => ({ vi.mock("./onboard-helpers.js", () => ({ resolveControlUiLinks: mocks.resolveControlUiLinks, detectBrowserOpenSupport: vi.fn(), - openUrl: vi.fn(), + openUrl: mocks.openUrl, formatControlUiSshHint: vi.fn(() => "ssh hint"), })); @@ -27,6 +29,10 @@ vi.mock("../infra/clipboard.js", () => ({ copyToClipboard: mocks.copyToClipboard, })); +vi.mock("../infra/ports-inspect.js", () => ({ + inspectPortUsage: mocks.inspectPortUsage, +})); + vi.mock("./gateway-readiness.js", () => ({ ensureGatewayReadyForOperation: mocks.ensureGatewayReadyForOperation, })); @@ -37,28 +43,37 @@ const runtime = { exit: vi.fn(), }; -function mockSnapshot(params?: { +type SnapshotParams = { token?: string; bind?: GatewayBindMode; customBindHost?: string; -}) { + tlsEnabled?: boolean; +}; + +function createSnapshot(params?: SnapshotParams) { const token = params?.token ?? "abc123"; - mocks.readConfigFileSnapshot.mockResolvedValue({ + const gateway = { + auth: { token }, + bind: params?.bind, + customBindHost: params?.customBindHost, + ...(params?.tlsEnabled === undefined ? {} : { tls: { enabled: params.tlsEnabled } }), + }; + return { path: "/tmp/openclaw.json", exists: true, raw: "{}", parsed: {}, valid: true, config: { - gateway: { - auth: { token }, - bind: params?.bind, - customBindHost: params?.customBindHost, - }, + gateway, }, issues: [], legacyIssues: [], - }); + }; +} + +function mockSnapshot(params?: SnapshotParams) { + mocks.readConfigFileSnapshot.mockResolvedValue(createSnapshot(params)); mocks.resolveGatewayPort.mockReturnValue(18789); mocks.resolveControlUiLinks.mockReturnValue({ httpUrl: "http://127.0.0.1:18789/", @@ -67,12 +82,42 @@ function mockSnapshot(params?: { mocks.copyToClipboard.mockResolvedValue(true); } +function mockSpecificDashboardLinks(params: { + bind: "custom" | "tailnet"; + host: string; + tlsEnabled?: boolean; +}) { + const scheme = params.tlsEnabled ? { http: "https", ws: "wss" } : { http: "http", ws: "ws" }; + mocks.resolveControlUiLinks.mockImplementation(({ bind }: { bind: GatewayBindMode }) => + bind === params.bind + ? { + httpUrl: `${scheme.http}://${params.host}:18789/`, + wsUrl: `${scheme.ws}://${params.host}:18789`, + } + : { httpUrl: "http://127.0.0.1:18789/", wsUrl: "ws://127.0.0.1:18789" }, + ); +} + +function mockAliasOwnership(host: string, loopbackPid = 4242) { + mocks.inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [ + { pid: 4242, commandLine: "openclaw-gateway", address: `${host}:18789` }, + { pid: loopbackPid, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + ], + hints: [], + }); +} + describe("dashboardCommand bind selection", () => { beforeEach(() => { mocks.readConfigFileSnapshot.mockClear(); mocks.resolveGatewayPort.mockClear(); mocks.resolveControlUiLinks.mockClear(); mocks.copyToClipboard.mockClear(); + mocks.openUrl.mockClear(); + mocks.inspectPortUsage.mockReset(); mocks.ensureGatewayReadyForOperation.mockReset(); mocks.ensureGatewayReadyForOperation.mockResolvedValue({ ready: true, @@ -101,31 +146,142 @@ describe("dashboardCommand bind selection", () => { }); }); - it("preserves custom bind mode", async () => { - mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }); + it("maps a TLS-enabled wildcard custom bind to loopback", async () => { + mockSnapshot({ bind: "custom", customBindHost: "0.0.0.0", tlsEnabled: true }); await dashboardCommand(runtime, { noOpen: true }); expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ port: 18789, - bind: "custom", - customBindHost: "10.0.0.5", + bind: "loopback", + customBindHost: "0.0.0.0", basePath: undefined, - tlsEnabled: false, + tlsEnabled: true, }); }); - it("preserves tailnet bind mode", async () => { - mockSnapshot({ bind: "tailnet" }); + it.each([ + { bind: "custom" as const, host: "10.0.0.5", customBindHost: "10.0.0.5" }, + { bind: "tailnet" as const, host: "100.64.0.1", customBindHost: undefined }, + ])("maps plain-http $bind bind to a verified loopback URL", async (params) => { + mockSnapshot({ bind: params.bind, customBindHost: params.customBindHost }); + mockSpecificDashboardLinks(params); + mockAliasOwnership(params.host); await dashboardCommand(runtime, { noOpen: true }); + expect(mocks.ensureGatewayReadyForOperation).toHaveBeenCalledWith( + expect.objectContaining({ + probeUrl: `ws://${params.host}:18789`, + readyWhenReachable: true, + }), + ); + expect(mocks.ensureGatewayReadyForOperation.mock.invocationCallOrder[0]).toBeLessThan( + mocks.inspectPortUsage.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ port: 18789, - bind: "tailnet", - customBindHost: undefined, + bind: "loopback", + customBindHost: params.customBindHost, basePath: undefined, tlsEnabled: false, }); + expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); + }); + + it("refuses an authenticated loopback URL owned by a different process", async () => { + mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }); + mockSpecificDashboardLinks({ bind: "custom", host: "10.0.0.5" }); + mockAliasOwnership("10.0.0.5", 4343); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.copyToClipboard).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("refusing to copy or open an authenticated URL"), + ); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:")); + expect(mocks.openUrl).not.toHaveBeenCalled(); + }); + + it("refuses URL delivery when loopback ownership inspection fails", async () => { + mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }); + mockSpecificDashboardLinks({ bind: "custom", host: "10.0.0.5" }); + mocks.inspectPortUsage.mockRejectedValue(new Error("inspection unavailable")); + + await dashboardCommand(runtime); + + expect(mocks.copyToClipboard).not.toHaveBeenCalled(); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:")); + }); + + it("re-probes a changed endpoint after recovery before URL delivery", async () => { + mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }); + mocks.readConfigFileSnapshot + .mockResolvedValueOnce(createSnapshot({ bind: "custom", customBindHost: "10.0.0.5" })) + .mockResolvedValueOnce(createSnapshot({ bind: "custom", customBindHost: "10.0.0.6" })); + mocks.resolveControlUiLinks.mockImplementation( + ({ bind, customBindHost }: { bind: GatewayBindMode; customBindHost?: string }) => + bind === "custom" + ? { + httpUrl: `http://${customBindHost}:18789/`, + wsUrl: `ws://${customBindHost}:18789`, + } + : { httpUrl: "http://127.0.0.1:18789/", wsUrl: "ws://127.0.0.1:18789" }, + ); + mocks.ensureGatewayReadyForOperation + .mockResolvedValueOnce({ ready: true, status: {}, recovered: true }) + .mockResolvedValueOnce({ + ready: false, + status: {}, + reason: "Gateway probe failed", + recoverable: false, + }); + + await dashboardCommand(runtime, { noOpen: true, yes: true }); + + expect(mocks.ensureGatewayReadyForOperation).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ probeUrl: "ws://10.0.0.5:18789" }), + ); + expect(mocks.ensureGatewayReadyForOperation).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + probeUrl: "ws://10.0.0.6:18789", + readyWhenReachable: true, + allowInstall: false, + interactive: false, + }), + ); + expect(mocks.inspectPortUsage).not.toHaveBeenCalled(); + expect(mocks.copyToClipboard).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:")); + }); + + it.each([ + { bind: "custom" as const, host: "10.0.0.5", customBindHost: "10.0.0.5" }, + { bind: "tailnet" as const, host: "100.64.0.1", customBindHost: undefined }, + ])("preserves a specific $bind bind when TLS provides a secure context", async (params) => { + mockSnapshot({ + bind: params.bind, + customBindHost: params.customBindHost, + tlsEnabled: true, + }); + mockSpecificDashboardLinks({ ...params, tlsEnabled: true }); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.ensureGatewayReadyForOperation).toHaveBeenCalledWith( + expect.objectContaining({ probeUrl: `wss://${params.host}:18789` }), + ); + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: params.bind, + customBindHost: params.customBindHost, + basePath: undefined, + tlsEnabled: true, + }); + expect(mocks.inspectPortUsage).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index d46a4ed47898..3613c0f39f23 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -2,6 +2,8 @@ import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js"; import { copyToClipboard } from "../infra/clipboard.js"; +import { isSameProcessSpecificIpv4WithLoopbackListeners } from "../infra/ports-format.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { ensureGatewayReadyForOperation } from "./gateway-readiness.js"; @@ -31,15 +33,45 @@ async function resolveDashboardTarget() { }); const token = resolvedToken.token ?? ""; - // LAN URLs fail secure-context checks in browsers. - // Coerce only lan->loopback and preserve other bind modes. - const links = resolveControlUiLinks({ + const tlsEnabled = cfg.gateway?.tls?.enabled === true; + // A wildcard LAN address is not a browser destination, while plain HTTP on a + // specific interface fails secure-context checks. Same-host launches use loopback; + // TLS keeps specific hosts so certificate names continue to match. + const customBindIsWildcard = bind === "custom" && customBindHost?.trim() === "0.0.0.0"; + const dashboardBind = + bind === "lan" || + customBindIsWildcard || + (!tlsEnabled && (bind === "tailnet" || bind === "custom")) + ? "loopback" + : bind; + const configuredLinks = resolveControlUiLinks({ port, - bind: bind === "lan" ? "loopback" : bind, + bind, customBindHost, basePath, - tlsEnabled: cfg.gateway?.tls?.enabled === true, + tlsEnabled, }); + const links = + dashboardBind === bind + ? configuredLinks + : resolveControlUiLinks({ + port, + bind: dashboardBind, + customBindHost, + basePath, + tlsEnabled, + }); + const loopbackAliasHost = (() => { + if (dashboardBind !== "loopback" || (bind !== "tailnet" && bind !== "custom")) { + return undefined; + } + try { + const host = new URL(configuredLinks.wsUrl).hostname; + return host === "127.0.0.1" || host === "0.0.0.0" ? undefined : host; + } catch { + return undefined; + } + })(); // Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args. const includeTokenInUrl = token.length > 0 && !resolvedToken.secretRefConfigured; // Prefer URL fragment to avoid leaking auth tokens via query params. @@ -55,27 +87,81 @@ async function resolveDashboardTarget() { token, includeTokenInUrl, dashboardUrl, + probeUrl: loopbackAliasHost ? configuredLinks.wsUrl : links.wsUrl, + loopbackAliasHost, }; } +async function hasVerifiedLoopbackAlias( + target: Awaited>, +): Promise { + const expectedHost = target.loopbackAliasHost; + if (!expectedHost) { + return true; + } + const portUsage = await inspectPortUsage(target.port).catch(() => undefined); + // The configured-address probe establishes Gateway identity. This local PID check only proves + // that the process also owns the loopback endpoint before credentials are delivered there. + return Boolean( + portUsage && + isSameProcessSpecificIpv4WithLoopbackListeners(portUsage.listeners, target.port, expectedHost), + ); +} + +async function ensureDashboardTargetReady(params: { + target: Awaited>; + runtime: RuntimeEnv; + yes?: boolean; + allowRecovery?: boolean; +}) { + return ensureGatewayReadyForOperation({ + runtime: params.runtime, + operation: "open the dashboard", + yes: params.yes, + probeUrl: params.target.probeUrl, + // First-time CLI probes intentionally lack paired operator scope. Gateway + // handshake evidence plus the same-PID alias check below proves the target. + readyWhenReachable: true, + ...(params.allowRecovery === false ? { allowInstall: false, interactive: false } : {}), + }); +} + /** Open or print the Control UI dashboard URL after ensuring the Gateway is reachable. */ export async function dashboardCommand( runtime: RuntimeEnv = defaultRuntime, options: DashboardOptions = {}, ) { const initialTarget = await resolveDashboardTarget(); - const readiness = await ensureGatewayReadyForOperation({ + const readiness = await ensureDashboardTargetReady({ + target: initialTarget, runtime, - operation: "open the dashboard", yes: options.yes, - probeUrl: initialTarget.links.wsUrl, - readyWhenReachable: true, }); if (!readiness.ready) { return; } const target = readiness.recovered ? await resolveDashboardTarget() : initialTarget; + const recoveryChangedProbe = target.probeUrl !== initialTarget.probeUrl; + if (readiness.recovered && recoveryChangedProbe) { + // Recovery may install or start against a changed config. Prove the final + // endpoint without triggering a second lifecycle action before URL delivery. + const finalReadiness = await ensureDashboardTargetReady({ + target, + runtime, + allowRecovery: false, + }); + if (!finalReadiness.ready) { + return; + } + } + if (!(await hasVerifiedLoopbackAlias(target))) { + runtime.error( + "Dashboard loopback listener could not be verified as the configured Gateway; refusing to copy or open an authenticated URL.", + ); + runtime.log("Restart the Gateway, then run `openclaw gateway status --deep` for details."); + return; + } const { port, basePath, links, resolvedToken, token, includeTokenInUrl, dashboardUrl } = target; runtime.log(`Dashboard URL: ${links.httpUrl}`); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f7298d87d47b..14b9569c5780 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -98,9 +98,9 @@ export const FIELD_HELP: Record = { "gateway.mode": 'Gateway operation mode: "local" runs channels and agent runtime on this host, while "remote" connects through remote transport. Keep "local" unless you intentionally run a split remote gateway topology.', "gateway.bind": - 'Network bind profile: "auto", "lan", "loopback", "custom", or "tailnet" to control interface exposure. Keep "loopback" or "auto" for safest local operation unless external clients must connect.', + 'Network bind profile: "auto", "lan", "loopback", "custom", or "tailnet" to control interface exposure. Keep "loopback" for local-only operation; "auto" can expose all interfaces.', "gateway.customBindHost": - "Explicit bind host/IP used when gateway.bind is set to custom for manual interface targeting. Use a precise address and avoid wildcard binds unless external exposure is required.", + "IPv4 address used for a custom bind. Specific IPv4s also require the same Gateway port on 127.0.0.1; avoid 0.0.0.0 unless all-interface exposure is required.", "gateway.controlUi": "Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.", "gateway.controlUi.enabled": diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 5420932c6fba..13e45a6be932 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -517,8 +517,8 @@ export type GatewayConfig = { * - auto: Loopback (127.0.0.1) if available, else 0.0.0.0 (fallback to all interfaces) * - lan: 0.0.0.0 (all interfaces, no fallback, current BYOH path is IPv4-only) * - loopback: 127.0.0.1 (local-only) - * - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback - * - custom: User-specified IPv4 address, fallback to 0.0.0.0 if unavailable (requires customBindHost) + * - tailnet: Tailnet IPv4 plus 127.0.0.1 if available, else loopback only + * - custom: User-specified IPv4 address (requires customBindHost); specific IPv4s also bind 127.0.0.1 * IPv6-only BYOH is not natively supported on this path today. Use an IPv4 sidecar or proxy. * Default: loopback (127.0.0.1). */ diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 15c3d7dbf32c..525da0a78578 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -357,6 +357,22 @@ describe("resolveGatewayListenHosts", () => { }, expected: ["0.0.0.0"], }, + { + name: "IPv6 host passthrough", + host: "::1", + canBindToHost: async () => { + throw new Error("should not be called"); + }, + expected: ["::1"], + }, + { + name: "specific non-loopback host with loopback alias available", + host: "100.64.0.1", + canBindToHost: async () => { + throw new Error("should not be called"); + }, + expected: ["100.64.0.1", "127.0.0.1"], + }, { name: "loopback with IPv6 available", host: "127.0.0.1", @@ -385,6 +401,14 @@ describe("resolveGatewayListenHosts", () => { expect(canBindToHost).not.toHaveBeenCalled(); }); + it("still adds the IPv4 loopback alias for a specific host on Windows", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const canBindToHost = vi.fn().mockResolvedValue(true); + const hosts = await resolveGatewayListenHosts("100.64.0.1", { canBindToHost }); + expect(hosts).toEqual(["100.64.0.1", "127.0.0.1"]); + expect(canBindToHost).not.toHaveBeenCalled(); + }); + it("still includes ::1 on non-Windows when IPv6 is bindable", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const canBindToHost = vi.fn().mockResolvedValue(true); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 193cc2c2a730..59b8333f5685 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -245,7 +245,7 @@ export { * - lan: always 0.0.0.0 (no fallback) * - tailnet: Tailnet IPv4 if available, else loopback * - auto: 0.0.0.0 inside containers (Docker/Podman/K8s); loopback otherwise - * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable + * - custom: User-specified IPv4; unavailable values resolve to 0.0.0.0 for caller validation * * @returns The bind address to use (never null) */ @@ -287,7 +287,7 @@ export async function resolveGatewayBindHost( if (isValidIPv4(host) && (await canBindToHost(host))) { return host; } - // Custom IP failed → fall back to LAN + // Runtime startup rejects this fallback; status/display callers remain best-effort. return "0.0.0.0"; } @@ -354,7 +354,13 @@ export async function resolveGatewayListenHosts( opts?: { canBindToHost?: (host: string) => Promise }, ): Promise { if (bindHost !== "127.0.0.1") { - return [bindHost]; + if (!isValidIPv4(bindHost) || bindHost === "0.0.0.0") { + return [bindHost]; + } + // Same-host clients use the canonical loopback URL even when external access is + // pinned to one interface. Startup requires both listeners so a foreign loopback + // process cannot receive credentials intended for the local Gateway. + return [bindHost, "127.0.0.1"]; } // Windows: uv_tcp_bind6 creates a dual-stack socket (no UV_TCP_IPV6ONLY), which // also accepts ::ffff:127.0.0.1 connections. Binding both ::1 and 127.0.0.1 on diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 8ab09da78f44..2fc90ffe3df3 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -174,6 +174,12 @@ describe("resolveGatewayRuntimeConfig", () => { host: "0.0.0.0", expectedMessage: "gateway bind=loopback resolved to non-loopback host", }, + { + name: "tailnet binding that falls through to wildcard", + cfg: { gateway: { bind: "tailnet" as const, auth: TOKEN_AUTH } }, + host: "0.0.0.0", + expectedMessage: "gateway bind=tailnet could not resolve a Tailscale or loopback address", + }, { name: "custom bind without customBindHost", cfg: { gateway: { bind: "custom" as const, auth: TOKEN_AUTH } }, diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 1bbaebe91671..8d9708f2c262 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -73,6 +73,11 @@ export async function resolveGatewayRuntimeConfig(params: { `gateway bind=loopback resolved to non-loopback host ${bindHost}; refusing fallback to a network bind`, ); } + if (bindMode === "tailnet" && bindHost === "0.0.0.0") { + throw new Error( + "gateway bind=tailnet could not resolve a Tailscale or loopback address; refusing wildcard fallback", + ); + } if (bindMode === "custom") { const configuredCustomBindHost = customBindHost?.trim(); if (!configuredCustomBindHost) { diff --git a/src/gateway/server-runtime-state.test.ts b/src/gateway/server-runtime-state.test.ts index 68245a21865b..3cbe92108a87 100644 --- a/src/gateway/server-runtime-state.test.ts +++ b/src/gateway/server-runtime-state.test.ts @@ -1,7 +1,7 @@ /** * Gateway runtime state construction tests. */ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginChannelRegistry, @@ -18,6 +18,22 @@ import { } from "../plugins/runtime.js"; import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js"; +const mocks = vi.hoisted(() => ({ + listenGatewayHttpServer: vi.fn( + async (_params: { bindHost: string; retryEaddrinuse?: boolean }) => {}, + ), + resolveGatewayListenHosts: vi.fn(async (_bindHost: string) => ["127.0.0.1"]), +})); + +vi.mock("./server/http-listen.js", () => ({ + listenGatewayHttpServer: mocks.listenGatewayHttpServer, +})); + +vi.mock("./net.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveGatewayListenHosts: mocks.resolveGatewayListenHosts }; +}); + function createRegistryWithRoute(path: string) { const registry = createEmptyPluginRegistry(); registry.httpRoutes.push({ @@ -32,6 +48,13 @@ function createRegistryWithRoute(path: string) { } describe("createGatewayRuntimeState", () => { + beforeEach(() => { + mocks.listenGatewayHttpServer.mockReset(); + mocks.listenGatewayHttpServer.mockResolvedValue(undefined); + mocks.resolveGatewayListenHosts.mockReset(); + mocks.resolveGatewayListenHosts.mockResolvedValue(["127.0.0.1"]); + }); + afterEach(() => { releasePinnedPluginHttpRouteRegistry(); releasePinnedPluginChannelRegistry(); @@ -60,4 +83,45 @@ describe("createGatewayRuntimeState", () => { expect(getActivePluginSessionExtensionRegistry()).toBe(startupRegistry); expect(getActivePluginChannelRegistry()).toBe(startupRegistry); }); + + it("fails startup when the required IPv4 loopback alias cannot bind", async () => { + const warn = vi.fn(); + mocks.resolveGatewayListenHosts.mockResolvedValue(["100.64.0.1", "127.0.0.1"]); + mocks.listenGatewayHttpServer.mockImplementation(async ({ bindHost }) => { + if (bindHost === "127.0.0.1") { + throw new Error("loopback occupied"); + } + }); + const runtimeState = await createGatewayRuntimeStateForTest(undefined, { + bindHost: "100.64.0.1", + log: { info: () => {}, warn }, + }); + + await expect(runtimeState.startListening()).rejects.toThrow("loopback occupied"); + await expect(runtimeState.startListening()).rejects.toThrow("loopback occupied"); + expect(mocks.listenGatewayHttpServer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ bindHost: "127.0.0.1", retryEaddrinuse: false }), + ); + expect(mocks.listenGatewayHttpServer).toHaveBeenCalledTimes(1); + expect(runtimeState.httpBindHosts).toEqual([]); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias")); + }); + + it("keeps the optional IPv6 loopback alias non-fatal", async () => { + const warn = vi.fn(); + mocks.resolveGatewayListenHosts.mockResolvedValue(["127.0.0.1", "::1"]); + mocks.listenGatewayHttpServer.mockImplementation(async ({ bindHost }) => { + if (bindHost === "::1") { + throw new Error("IPv6 unavailable"); + } + }); + const runtimeState = await createGatewayRuntimeStateForTest(undefined, { + log: { info: () => {}, warn }, + }); + + await expect(runtimeState.startListening()).resolves.toBeUndefined(); + expect(runtimeState.httpBindHosts).toEqual(["127.0.0.1"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias ::1")); + }); }); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 01dc029363f2..e30f9cde2780 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -306,23 +306,38 @@ export async function createGatewayRuntimeState(params: { await startListeningPromise; return; } - // Listening is idempotent for callers racing startup; reset the promise only on failure so - // a transient bind error can be retried after the caller handles it. + // Listening is idempotent for callers racing startup. A failure is terminal for this runtime + // state; the startup owner tears down every partially bound HTTP/WS server before retrying. startListeningPromise = (async () => { - for (const [index, host] of bindHosts.entries()) { + const requiredAlias = + params.bindHost !== "127.0.0.1" && bindHosts.includes("127.0.0.1") + ? "127.0.0.1" + : undefined; + // Claim the trusted local endpoint before exposing the selected interface. This prevents + // another loopback listener from receiving credentials while startup is still resolving. + const listenOrder = requiredAlias + ? [requiredAlias, ...bindHosts.filter((host) => host !== requiredAlias)] + : bindHosts; + const boundHosts = new Set(); + for (const host of listenOrder) { + const index = bindHosts.indexOf(host); const server = httpServers[index]; if (!server) { throw new Error(`Missing gateway HTTP server for bind host ${host}`); } + // Specific IPv4 modes rely on this canonical local endpoint for authenticated + // helpers. A collision must fail startup instead of sending credentials to it. + const requiredLoopbackAlias = host === requiredAlias; try { await listenGatewayHttpServer({ httpServer: server, bindHost: host, port: params.port, + retryEaddrinuse: !requiredLoopbackAlias, }); - httpBindHosts.push(host); + boundHosts.add(host); } catch (err) { - if (host === bindHosts[0]) { + if (host === bindHosts[0] || requiredLoopbackAlias) { throw err; } params.log.warn( @@ -330,16 +345,12 @@ export async function createGatewayRuntimeState(params: { ); } } + httpBindHosts.push(...bindHosts.filter((host) => boundHosts.has(host))); if (httpBindHosts.length === 0) { throw new Error("Gateway HTTP server failed to start"); } })(); - try { - await startListeningPromise; - } catch (err) { - startListeningPromise = null; - throw err; - } + await startListeningPromise; }; const agentRunSeq = new Map(); const dedupe = new Map(); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f275a7201b4f..9eb529b91b5d 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -466,7 +466,7 @@ export type GatewayServerOptions = { * Bind address policy for the Gateway WebSocket/HTTP server. * - loopback: 127.0.0.1 * - lan: 0.0.0.0 - * - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10) + * - tailnet: bind to the Tailscale IPv4 address (100.64.0.0/10) and local 127.0.0.1 * - auto: prefer loopback, else LAN */ bind?: import("../config/config.js").GatewayBindMode; diff --git a/src/gateway/server.startup-websocket-race.test.ts b/src/gateway/server.startup-websocket-race.test.ts index b880af6ab135..7f56a6f24dea 100644 --- a/src/gateway/server.startup-websocket-race.test.ts +++ b/src/gateway/server.startup-websocket-race.test.ts @@ -2,6 +2,7 @@ // gateway reports its listen step as ready. import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; +import { tryListenOnPort } from "../infra/ports-probe.js"; import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js"; import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js"; @@ -89,4 +90,49 @@ describe("gateway startup websocket readiness", () => { } } }); + + it("serves a specific IPv4 bind and its required loopback alias", async () => { + const previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "0"; + let server: GatewayServerForTest | undefined; + const clients: WebSocket[] = []; + try { + const port = await getFreePort(); + server = await startGatewayServer(port, { + host: "127.0.0.2", + auth: { mode: "none" }, + }); + + clients.push( + await connectWebSocket(`ws://127.0.0.1:${port}`), + await connectWebSocket(`ws://127.0.0.2:${port}`), + ); + } finally { + await Promise.all(clients.map(async (client) => await disconnectWebSocket(client))); + if (server) { + await server.close(); + } + if (previousMinimal === undefined) { + delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + } else { + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal; + } + } + }); + + it("releases the loopback alias when the selected bind fails", async () => { + const port = await getFreePort(); + + await expect( + startGatewayServer(port, { + bind: "lan", + host: "192.0.2.1", + auth: { mode: "token", token: "test-token" }, + }), + ).rejects.toThrow("failed to bind gateway socket"); + + await expect( + tryListenOnPort({ host: "127.0.0.1", port, exclusive: true }), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/gateway/server/http-listen.test.ts b/src/gateway/server/http-listen.test.ts index 02586d596174..58e199b176f8 100644 --- a/src/gateway/server/http-listen.test.ts +++ b/src/gateway/server/http-listen.test.ts @@ -82,6 +82,26 @@ describe("listenGatewayHttpServer", () => { expect(fake.closeCalls).toBe(20); }); + it("fails immediately when EADDRINUSE retries are disabled", async () => { + sleepMock.mockClear(); + const fake = createFakeHttpServer([ + { kind: "error", code: "EADDRINUSE" }, + { kind: "listening" }, + ]); + + await expect( + listenGatewayHttpServer({ + httpServer: fake as unknown as HttpServer, + bindHost: "127.0.0.1", + port: 18789, + retryEaddrinuse: false, + }), + ).rejects.toBeInstanceOf(GatewayLockError); + + expect(fake.closeCalls).toBe(0); + expect(sleepMock).not.toHaveBeenCalled(); + }); + it("wraps non-EADDRINUSE errors as GatewayLockError", async () => { sleepMock.mockClear(); const fake = createFakeHttpServer([{ kind: "error", code: "EACCES" }]); diff --git a/src/gateway/server/http-listen.ts b/src/gateway/server/http-listen.ts index 68bd1f5a2a33..03ad14f0dc1e 100644 --- a/src/gateway/server/http-listen.ts +++ b/src/gateway/server/http-listen.ts @@ -21,10 +21,12 @@ export async function listenGatewayHttpServer(params: { httpServer: HttpServer; bindHost: string; port: number; + retryEaddrinuse?: boolean; }) { - const { httpServer, bindHost, port } = params; + const { httpServer, bindHost, port, retryEaddrinuse = true } = params; + const maxRetries = retryEaddrinuse ? EADDRINUSE_MAX_RETRIES : 0; - for (const attempt of Array.from({ length: EADDRINUSE_MAX_RETRIES + 1 }, (_, index) => index)) { + for (const attempt of Array.from({ length: maxRetries + 1 }, (_, index) => index)) { try { await new Promise((resolve, reject) => { const onError = (err: NodeJS.ErrnoException) => { @@ -42,7 +44,7 @@ export async function listenGatewayHttpServer(params: { return; // bound successfully } catch (err) { const code = (err as NodeJS.ErrnoException).code; - if (code === "EADDRINUSE" && attempt < EADDRINUSE_MAX_RETRIES) { + if (code === "EADDRINUSE" && attempt < maxRetries) { // Port may still be in TIME_WAIT after a recent process exit; retry. await closeServerQuietly(httpServer); await sleep(EADDRINUSE_RETRY_INTERVAL_MS); diff --git a/src/gateway/test-helpers.server-runtime-state.ts b/src/gateway/test-helpers.server-runtime-state.ts index 194392baafe3..0b1419fa6d89 100644 --- a/src/gateway/test-helpers.server-runtime-state.ts +++ b/src/gateway/test-helpers.server-runtime-state.ts @@ -11,6 +11,7 @@ type GatewayRuntimeStateParams = Parameters[0] /** Creates a minimal gateway runtime state with optional plugin registry fixture. */ export async function createGatewayRuntimeStateForTest( pluginRegistry: GatewayRuntimeStateParams["pluginRegistry"] = createEmptyPluginRegistry(), + overrides: Partial = {}, ) { return await createGatewayRuntimeState({ cfg: {}, @@ -30,5 +31,6 @@ export async function createGatewayRuntimeStateForTest( log: { info: () => {}, warn: () => {} }, logHooks: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, logPlugins: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, + ...overrides, }); } diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index 090034cdcfa5..f3c3c9fa8588 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -8,6 +8,7 @@ import { formatPortListener, isDualStackLoopbackGatewayListeners, isExpectedGatewayListeners, + isSameProcessSpecificIpv4WithLoopbackListeners, isSingleExpectedGatewayListener, } from "./ports-format.js"; @@ -86,6 +87,76 @@ describe("ports-format", () => { expect(buildPortHints(listeners, 18789)).toEqual([]); }); + it("treats a single-process specific IPv4 plus loopback alias as benign", () => { + const listeners = [ + { pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }, + { pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + ]; + + expect(isExpectedGatewayListeners(listeners, 18789)).toBe(true); + expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe( + true, + ); + expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "10.0.0.5")).toBe( + false, + ); + expect(buildPortHints(listeners, 18789)).toEqual([]); + }); + + it("checks exact alias ownership without relying on process display metadata", () => { + const listeners = [ + { pid: 4242, commandLine: "opaque-wrapper", address: "100.64.0.1:18789" }, + { pid: 4242, commandLine: "opaque-wrapper", address: "127.0.0.1:18789" }, + ]; + + expect(isExpectedGatewayListeners(listeners, 18789)).toBe(false); + expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe( + true, + ); + }); + + it.each([ + [ + "mixed process ids", + [ + { pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }, + { pid: 4243, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + ], + ], + [ + "an IPv6 selected address", + [ + { pid: 4242, commandLine: "openclaw-gateway", address: "[fd7a:115c:a1e0::1]:18789" }, + { pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + ], + ], + [ + "a missing loopback alias", + [{ pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }], + ], + [ + "missing process metadata", + [ + { commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }, + { commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + ], + ], + [ + "an extra listener", + [ + { pid: 4242, commandLine: "openclaw-gateway", address: "100.64.0.1:18789" }, + { pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" }, + { pid: 4242, commandLine: "openclaw-gateway", address: "[::1]:18789" }, + ], + ], + ])("rejects specific-address ownership with %s", (_label, listeners) => { + expect(isExpectedGatewayListeners(listeners, 18789)).toBe(false); + expect(isSameProcessSpecificIpv4WithLoopbackListeners(listeners, 18789, "100.64.0.1")).toBe( + false, + ); + expect(buildPortHints(listeners, 18789)).toContain(gatewayAlreadyRunningHint); + }); + it.each([ "127.0.0.1:18789", "[::1]:18789", diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index 3c02f1673f30..c27d545db1cd 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -1,4 +1,5 @@ // Formats port probe results for diagnostics and CLI output. +import net from "node:net"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../cli/command-format.js"; import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js"; @@ -90,28 +91,44 @@ function isExpectedGatewayBindAddress(host: string): boolean { return classifyLoopbackAddressFamily(host) !== null || isWildcardAddress(host); } +type ParsedGatewayListener = { pid: number; host: string }; + +function parsePortListeners( + listeners: PortListener[], + port: number, +): ParsedGatewayListener[] | null { + const parsedListeners: ParsedGatewayListener[] = []; + for (const listener of listeners) { + const pid = listener.pid; + if (typeof pid !== "number" || !Number.isFinite(pid) || typeof listener.address !== "string") { + return null; + } + const address = parseListenerAddress(listener.address); + if (!address || address.port !== port) { + return null; + } + parsedListeners.push({ pid, host: address.host }); + } + return parsedListeners; +} + +function parseGatewayListeners( + listeners: PortListener[], + port: number, +): ParsedGatewayListener[] | null { + if (listeners.some((listener) => classifyPortListener(listener, port) !== "gateway")) { + return null; + } + return parsePortListeners(listeners, port); +} + /** Returns true for one Gateway listener bound to an expected loopback or wildcard address. */ export function isSingleExpectedGatewayListener(listeners: PortListener[], port: number): boolean { if (listeners.length !== 1) { return false; } - const [listener] = listeners; - if (!listener || classifyPortListener(listener, port) !== "gateway") { - return false; - } - const pid = listener.pid; - if (typeof pid !== "number" || !Number.isFinite(pid)) { - return false; - } - if (typeof listener.address !== "string") { - return false; - } - const parsedAddress = parseListenerAddress(listener.address); - return Boolean( - parsedAddress && - parsedAddress.port === port && - isExpectedGatewayBindAddress(parsedAddress.host), - ); + const parsed = parseGatewayListeners(listeners, port); + return Boolean(parsed?.[0] && isExpectedGatewayBindAddress(parsed[0].host)); } /** Returns true for one Gateway process represented by separate IPv4 and IPv6 loopback rows. */ @@ -122,38 +139,61 @@ export function isDualStackLoopbackGatewayListeners( if (listeners.length < 2) { return false; } - const pids = new Set(); - const families = new Set<"ipv4" | "ipv6">(); - for (const listener of listeners) { - if (classifyPortListener(listener, port) !== "gateway") { - return false; - } - const pid = listener.pid; - if (typeof pid !== "number" || !Number.isFinite(pid)) { - return false; - } - pids.add(pid); - if (typeof listener.address !== "string") { - return false; - } - const parsedAddress = parseListenerAddress(listener.address); - if (!parsedAddress || parsedAddress.port !== port) { - return false; - } - const family = classifyLoopbackAddressFamily(parsedAddress.host); - if (!family) { - return false; - } - families.add(family); + const parsed = parseGatewayListeners(listeners, port); + if (!parsed) { + return false; } - return pids.size === 1 && families.has("ipv4") && families.has("ipv6"); + const pids = new Set(parsed.map(({ pid }) => pid)); + const families = new Set(parsed.map(({ host }) => classifyLoopbackAddressFamily(host))); + return pids.size === 1 && !families.has(null) && families.has("ipv4") && families.has("ipv6"); +} + +function parsedListenersOwnSpecificIpv4WithLoopback(parsed: ParsedGatewayListener[]): boolean { + if (new Set(parsed.map(({ pid }) => pid)).size !== 1) { + return false; + } + const hosts = new Set(parsed.map(({ host }) => host)); + const specificHosts = [...hosts].filter( + (host) => host !== "127.0.0.1" && net.isIP(host) === 4 && !isWildcardAddress(host), + ); + return hosts.has("127.0.0.1") && specificHosts.length > 0; +} + +/** Checks one Gateway PID owns both an exact IPv4 interface and canonical loopback. */ +function isSpecificIpv4WithLoopbackGatewayListeners( + listeners: PortListener[], + port: number, +): boolean { + if (listeners.length !== 2) { + return false; + } + const parsed = parseGatewayListeners(listeners, port); + return Boolean(parsed && parsedListenersOwnSpecificIpv4WithLoopback(parsed)); +} + +/** Checks one PID owns an expected IPv4 interface and canonical loopback. */ +export function isSameProcessSpecificIpv4WithLoopbackListeners( + listeners: PortListener[], + port: number, + expectedSpecificHost: string, +): boolean { + if (listeners.length !== 2) { + return false; + } + const parsed = parsePortListeners(listeners, port); + return Boolean( + parsed && + parsedListenersOwnSpecificIpv4WithLoopback(parsed) && + parsed.some(({ host }) => host === expectedSpecificHost), + ); } /** Returns true when listener rows describe a benign Gateway bind pattern. */ export function isExpectedGatewayListeners(listeners: PortListener[], port: number): boolean { return ( isSingleExpectedGatewayListener(listeners, port) || - isDualStackLoopbackGatewayListeners(listeners, port) + isDualStackLoopbackGatewayListeners(listeners, port) || + isSpecificIpv4WithLoopbackGatewayListeners(listeners, port) ); } diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 52a612195fdb..c57751c8c5bd 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -180,10 +180,6 @@ function statusCommandSpawn() { return [process.execPath, "openclaw.mjs", "status"]; } -function gatewayCallStatusCommandSpawn() { - return [process.execPath, "openclaw.mjs", "gateway", "call", "status", "--json"]; -} - function resolvePath(tmp: string, relativePath: string) { return path.join(tmp, relativePath); } @@ -321,8 +317,9 @@ async function runStatusCommand(params: { }); } -async function runGatewayCallStatusCommand(params: { +async function runGatewayClientCommand(params: { tmp: string; + args: string[]; spawn: (cmd: string, args: string[]) => ReturnType; spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; env?: Record; @@ -333,7 +330,7 @@ async function runGatewayCallStatusCommand(params: { }) { return await runNodeMain({ cwd: params.tmp, - args: ["gateway", "call", "status", "--json"], + args: params.args, env: { ...process.env, OPENCLAW_RUNNER_LOG: "0", @@ -1913,7 +1910,12 @@ describe("run-node script", () => { }); }); - it("does not rebuild for gateway client calls against an existing dirty dist", async () => { + it.each([ + { label: "gateway RPC", args: ["gateway", "call", "status", "--json"] }, + { label: "gateway status", args: ["gateway", "status", "--json"] }, + { label: "remote agent", args: ["agent", "--message", "hello"] }, + { label: "dashboard", args: ["dashboard", "--no-open", "--yes"] }, + ])("does not rebuild for $label calls against an existing dirty dist", async ({ args }) => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { @@ -1935,15 +1937,93 @@ describe("run-node script", () => { gitHead: "abc123\n", gitStatus: ` M ${ROOT_SRC}\n`, }); - const exitCode = await runGatewayCallStatusCommand({ + const exitCode = await runGatewayClientCommand({ tmp, + args, spawn, spawnSync, runRuntimePostBuild, }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([gatewayCallStatusCommandSpawn()]); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", ...args]]); + expect(runRuntimePostBuild).not.toHaveBeenCalled(); + }); + }); + + it("rechecks a dirty dashboard client after waiting for an active build", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + await fs.rm(resolvePath(tmp, BUILD_STAMP)); + await fs.rm(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP)); + + const lockProcess = Object.assign(createFakeProcess(), { + kill: vi.fn(() => true), + }) as unknown as NodeJS.Process; + const releaseLock = await acquireRunNodeBuildLock({ + cwd: tmp, + args: ["gateway"], + env: { OPENCLAW_RUNNER_LOG: "0" }, + fs: fsSync, + process: lockProcess, + stderr: { write: () => true } as unknown as NodeJS.WriteStream, + }); + let markWaiting!: () => void; + const waitingForLock = new Promise((resolve) => { + markWaiting = resolve; + }); + const stderr = { + write: (chunk: string | Buffer) => { + if (String(chunk).includes("Waiting for TypeScript/runtime artifact lock")) { + markWaiting(); + } + return true; + }, + } as unknown as NodeJS.WriteStream; + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: ` M ${ROOT_SRC}\n`, + }); + const clientRun = runNodeMain({ + cwd: tmp, + args: ["dashboard", "--no-open", "--yes"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "1", + OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", + }, + spawn, + spawnSync, + process: lockProcess, + stderr, + runRuntimePostBuild, + execPath: process.execPath, + platform: process.platform, + }); + + await waitingForLock; + await fs.writeFile(resolvePath(tmp, BUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); + await fs.writeFile(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); + releaseLock(); + + await expect(clientRun).resolves.toBe(0); + expect(spawnCalls).toEqual([ + [process.execPath, "openclaw.mjs", "dashboard", "--no-open", "--yes"], + ]); expect(runRuntimePostBuild).not.toHaveBeenCalled(); }); }); diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index d168d51c5386..f4691a18e798 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -71,14 +71,14 @@ export const en = { bindAuto: "Auto (Loopback -> LAN)", bindAutoHint: "Try loopback first", bindCustom: "Custom IP", - bindCustomHint: "Bind to one local address", + bindCustomHint: "Specific IPv4s also bind 127.0.0.1", bindCustomIp: "Custom IP address", bindLan: "LAN (0.0.0.0)", bindLanHint: "Reachable on your local network", bindLoopback: "Loopback (127.0.0.1)", bindLoopbackHint: "This machine only", bindTailnet: "Tailnet (Tailscale IP)", - bindTailnetHint: "Reachable over Tailscale", + bindTailnetHint: "Reachable over Tailscale and local loopback", existingPasswordConfirm: "Use existing gateway password ({password})?", existingTokenConfirm: "Use existing gateway token ({token})?", passwordPrompt: "Gateway password", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 4a6a934fe5ab..46d9185ad322 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -70,14 +70,14 @@ export const zh_CN = { bindAuto: "自动(Loopback -> LAN)", bindAutoHint: "先尝试 loopback", bindCustom: "自定义 IP", - bindCustomHint: "绑定到一个本机地址", + bindCustomHint: "指定 IPv4 也会绑定 127.0.0.1", bindCustomIp: "自定义 IP 地址", bindLan: "LAN (0.0.0.0)", bindLanHint: "局域网可访问", bindLoopback: "Loopback (127.0.0.1)", bindLoopbackHint: "仅本机可访问", bindTailnet: "Tailnet(Tailscale IP)", - bindTailnetHint: "通过 Tailscale 访问", + bindTailnetHint: "可通过 Tailscale 和本机环回地址访问", existingPasswordConfirm: "使用已有 Gateway 密码({password})?", existingTokenConfirm: "使用已有 Gateway 令牌({token})?", passwordPrompt: "Gateway 密码", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index c0a80923d56c..1b71727b7cc7 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -70,14 +70,14 @@ export const zh_TW = { bindAuto: "自動(Loopback -> LAN)", bindAutoHint: "先嘗試 loopback", bindCustom: "自訂 IP", - bindCustomHint: "綁定到一個本機位址", + bindCustomHint: "指定 IPv4 也會綁定 127.0.0.1", bindCustomIp: "自訂 IP 位址", bindLan: "LAN (0.0.0.0)", bindLanHint: "區域網路可存取", bindLoopback: "Loopback (127.0.0.1)", bindLoopbackHint: "僅本機可存取", bindTailnet: "Tailnet(Tailscale IP)", - bindTailnetHint: "透過 Tailscale 存取", + bindTailnetHint: "可透過 Tailscale 和本機迴路位址存取", existingPasswordConfirm: "使用既有 Gateway 密碼({password})?", existingTokenConfirm: "使用既有 Gateway 權杖({token})?", passwordPrompt: "Gateway 密碼", diff --git a/test/vitest/vitest.commands-light-paths.mjs b/test/vitest/vitest.commands-light-paths.mjs index aa58652a584b..0e7597426cf2 100644 --- a/test/vitest/vitest.commands-light-paths.mjs +++ b/test/vitest/vitest.commands-light-paths.mjs @@ -8,6 +8,10 @@ const commandsLightEntries = [ source: "src/commands/dashboard.links.ts", test: "src/commands/dashboard.links.test.ts", }, + { + source: "src/commands/dashboard.ts", + test: "src/commands/dashboard.test.ts", + }, { test: "src/commands/daemon-install-helpers.test.ts" }, { source: "src/commands/doctor-browser.ts", test: "src/commands/doctor-browser.test.ts" }, {