From c857e93735239b134e3609922e86fbeee7929b50 Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Mon, 6 Apr 2026 18:26:59 +0800 Subject: [PATCH] fix(gateway): auto-bind to 0.0.0.0 inside container environments --- src/cli/daemon-cli/status.gather.ts | 5 +- src/cli/gateway-cli/run.ts | 48 ++++-- src/commands/doctor-security.ts | 7 +- src/config/gateway-control-ui-origins.ts | 31 +++- src/gateway/net.test.ts | 176 ++++++++++++++++++++++ src/gateway/net.ts | 83 +++++++++- src/gateway/server-runtime-config.test.ts | 105 ++++++++++++- src/gateway/server-runtime-config.ts | 17 ++- src/gateway/startup-control-ui-origins.ts | 5 +- 9 files changed, 445 insertions(+), 32 deletions(-) diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 86985e39080..bd79691685a 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,6 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { trimToUndefined } from "../../gateway/credentials.js"; +import { defaultGatewayBindMode } from "../../gateway/net.js"; import { inspectBestEffortPrimaryTailnetIPv4, resolveBestEffortGatewayBindHostForDisplay, @@ -260,7 +261,9 @@ async function resolveGatewayStatusSummary(params: { const portSource: GatewayStatusSummary["portSource"] = portFromArgs ? "service args" : "env/config"; - const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback"; + const statusTailscaleMode = params.daemonCfg.gateway?.tailscale?.mode ?? "off"; + const bindMode: GatewayBindMode = + params.daemonCfg.gateway?.bind ?? defaultGatewayBindMode(statusTailscaleMode); const customBindHost = params.daemonCfg.gateway?.customBindHost; const { bindHost, warning: bindHostWarning } = await resolveBestEffortGatewayBindHostForDisplay({ bindMode, diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 0ecc88833e4..caaace42508 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; import { readSecretFromFile } from "../../acp/secret-file.js"; -import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js"; +import type { + GatewayAuthMode, + GatewayBindMode, + GatewayTailscaleMode, +} from "../../config/config.js"; import { CONFIG_PATH, loadConfig, @@ -12,6 +16,7 @@ import { } from "../../config/config.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { resolveGatewayAuth } from "../../gateway/auth.js"; +import { defaultGatewayBindMode, isContainerEnvironment } from "../../gateway/net.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; @@ -294,20 +299,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.error("Invalid port"); defaultRuntime.exit(1); } - const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; - const bind = - bindRaw === "loopback" || - bindRaw === "lan" || - bindRaw === "auto" || - bindRaw === "custom" || - bindRaw === "tailnet" - ? bindRaw - : null; - if (!bind) { + // Only capture the *explicit* bind value here. The container-aware + // default is deferred until after Tailscale mode is known (see below) + // so that Tailscale's loopback constraint is respected. + const VALID_BIND_MODES = new Set(["loopback", "lan", "auto", "custom", "tailnet"]); + const bindExplicitRawStr = (toOptionString(opts.bind) ?? cfg.gateway?.bind)?.trim() || undefined; + if (bindExplicitRawStr !== undefined && !VALID_BIND_MODES.has(bindExplicitRawStr)) { defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); defaultRuntime.exit(1); return; } + const bindExplicitRaw = bindExplicitRawStr as GatewayBindMode | undefined; if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) { const stale = cleanStaleGatewayProcessesSync(port); if (stale.length > 0) { @@ -340,11 +342,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } // After killing, verify the port is actually bindable (handles TIME_WAIT). const bindProbeHost = - bind === "loopback" + bindExplicitRaw === "loopback" ? "127.0.0.1" - : bind === "lan" + : bindExplicitRaw === "lan" ? "0.0.0.0" - : bind === "custom" + : bindExplicitRaw === "custom" ? toOptionString(cfg.gateway?.customBindHost) : undefined; const bindWaitMs = await waitForPortBindable(port, { @@ -383,6 +385,15 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } + // Now that Tailscale mode is known, compute the effective bind mode. + const effectiveTailscaleMode = tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off"; + const bind = (bindExplicitRaw ?? defaultGatewayBindMode(effectiveTailscaleMode)) as + | "loopback" + | "lan" + | "auto" + | "custom" + | "tailnet"; + let passwordRaw: string | undefined; try { passwordRaw = resolveGatewayPasswordOption(opts); @@ -487,7 +498,14 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, - "Set gateway.auth.token/password (or OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD) or pass --token/--password.", + ...(isContainerEnvironment() + ? [ + "Container environment detected \u2014 the gateway defaults to bind=auto (0.0.0.0) for port-forwarding compatibility.", + "Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, or pass --token/--password to start with auth.", + ] + : [ + "Set gateway.auth.token/password (or OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD) or pass --token/--password.", + ]), ...authHints, ] .filter(Boolean) diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 7c0da18716b..443f4bbe330 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; +import { defaultGatewayBindMode, isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; @@ -183,7 +183,8 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { // Check for dangerous gateway binding configurations // that expose the gateway to network without proper auth - const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const gatewayBind = (cfg.gateway?.bind ?? defaultGatewayBindMode(tailscaleMode)) as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) @@ -197,7 +198,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { const resolvedAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env, - tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + tailscaleMode, }); const authToken = resolvedAuth.token?.trim() ?? ""; const authPassword = resolvedAuth.password?.trim() ?? ""; diff --git a/src/config/gateway-control-ui-origins.ts b/src/config/gateway-control-ui-origins.ts index 9ff1fd5a1dc..654fdbd6a73 100644 --- a/src/config/gateway-control-ui-origins.ts +++ b/src/config/gateway-control-ui-origins.ts @@ -1,10 +1,10 @@ import type { OpenClawConfig } from "./config.js"; import { DEFAULT_GATEWAY_PORT } from "./paths.js"; -export type GatewayNonLoopbackBindMode = "lan" | "tailnet" | "custom"; +export type GatewayNonLoopbackBindMode = "lan" | "tailnet" | "custom" | "auto"; export function isGatewayNonLoopbackBindMode(bind: unknown): bind is GatewayNonLoopbackBindMode { - return bind === "lan" || bind === "tailnet" || bind === "custom"; + return bind === "lan" || bind === "tailnet" || bind === "custom" || bind === "auto"; } export function hasConfiguredControlUiAllowedOrigins(params: { @@ -45,18 +45,33 @@ export function buildDefaultControlUiAllowedOrigins(params: { export function ensureControlUiAllowedOriginsForNonLoopbackBind( config: OpenClawConfig, - opts?: { defaultPort?: number; requireControlUiEnabled?: boolean }, + opts?: { + defaultPort?: number; + requireControlUiEnabled?: boolean; + /** Optional container-detection callback. When provided and `gateway.bind` + * is unset, the function is called to determine whether the runtime will + * default to `"auto"` (container) so that origins can be seeded + * proactively. Keeping this as an injected callback avoids a hard + * dependency from the config layer on the gateway runtime layer. */ + isContainerEnvironment?: () => boolean; + }, ): { config: OpenClawConfig; seededOrigins: string[] | null; bind: GatewayNonLoopbackBindMode | null; } { const bind = config.gateway?.bind; - if (!isGatewayNonLoopbackBindMode(bind)) { + // When bind is unset (undefined) and we are inside a container, the runtime + // will default to "auto" → 0.0.0.0 via defaultGatewayBindMode(). We must + // seed origins *before* resolveGatewayRuntimeConfig runs, otherwise the + // non-loopback Control UI origin check will hard-fail on startup. + const effectiveBind: typeof bind = + bind ?? (opts?.isContainerEnvironment?.() ? "auto" : undefined); + if (!isGatewayNonLoopbackBindMode(effectiveBind)) { return { config, seededOrigins: null, bind: null }; } if (opts?.requireControlUiEnabled && config.gateway?.controlUi?.enabled === false) { - return { config, seededOrigins: null, bind }; + return { config, seededOrigins: null, bind: effectiveBind }; } if ( hasConfiguredControlUiAllowedOrigins({ @@ -65,13 +80,13 @@ export function ensureControlUiAllowedOriginsForNonLoopbackBind( config.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback, }) ) { - return { config, seededOrigins: null, bind }; + return { config, seededOrigins: null, bind: effectiveBind }; } const port = resolveGatewayPortWithDefault(config.gateway?.port, opts?.defaultPort); const seededOrigins = buildDefaultControlUiAllowedOrigins({ port, - bind, + bind: effectiveBind, customBindHost: config.gateway?.customBindHost, }); return { @@ -86,6 +101,6 @@ export function ensureControlUiAllowedOriginsForNonLoopbackBind( }, }, seededOrigins, - bind, + bind: effectiveBind, }; } diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index e03ee65d063..319f1b54f2c 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -2,6 +2,9 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js"; import { + __resetContainerCacheForTest, + defaultGatewayBindMode, + isContainerEnvironment, isLocalishHost, isLoopbackHost, isPrivateOrLoopbackAddress, @@ -10,6 +13,7 @@ import { isTrustedProxyAddress, pickPrimaryLanIPv4, resolveClientIp, + resolveGatewayBindHost, resolveGatewayListenHosts, resolveHostName, } from "./net.js"; @@ -456,6 +460,178 @@ describe("isPrivateOrLoopbackHost", () => { }); }); +describe("isContainerEnvironment", () => { + afterEach(() => { + __resetContainerCacheForTest(); + vi.restoreAllMocks(); + }); + + it("returns false on a typical non-container host", () => { + // Mock fs.accessSync to throw (no /.dockerenv) and fs.readFileSync to + // return a cgroup file without container markers. + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("12:memory:/user.slice/user-1000.slice\n"); + expect(isContainerEnvironment()).toBe(false); + }); + + it("returns true when /.dockerenv exists", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(isContainerEnvironment()).toBe(true); + }); + + it("returns true when /proc/1/cgroup contains docker marker", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("12:memory:/docker/abc123def456\n"); + expect(isContainerEnvironment()).toBe(true); + }); + + it("returns true when /proc/1/cgroup contains kubepods marker", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("11:cpuset:/kubepods/besteffort/pod-abc\n"); + expect(isContainerEnvironment()).toBe(true); + }); + + it("returns true when /proc/1/cgroup contains containerd with container ID", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + "0::/system.slice/containerd/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n", + ); + expect(isContainerEnvironment()).toBe(true); + }); + + it("returns false when /proc/1/cgroup contains containerd.service (host machine)", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("0::/system.slice/containerd.service\n"); + expect(isContainerEnvironment()).toBe(false); + }); + + it("returns true for cgroup v2 kubepods.slice path", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod123.slice/cri-containerd-abc123.scope\n", + ); + expect(isContainerEnvironment()).toBe(true); + }); + + it("returns true for cgroup v2 cri-containerd scope path", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + "0::/system.slice/cri-containerd-a1b2c3d4e5f6.scope\n", + ); + expect(isContainerEnvironment()).toBe(true); + }); + + it("caches the result across calls", () => { + const fs = require("node:fs"); + const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(isContainerEnvironment()).toBe(true); + expect(isContainerEnvironment()).toBe(true); + // accessSync should only be called once due to caching + expect(accessSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveGatewayBindHost", () => { + afterEach(() => { + __resetContainerCacheForTest(); + vi.restoreAllMocks(); + }); + + it("returns 127.0.0.1 for loopback mode", async () => { + expect(await resolveGatewayBindHost("loopback")).toBe("127.0.0.1"); + }); + + it("returns 0.0.0.0 for lan mode", async () => { + expect(await resolveGatewayBindHost("lan")).toBe("0.0.0.0"); + }); + + it("returns 127.0.0.1 for auto mode on non-container host", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("12:memory:/user.slice\n"); + expect(await resolveGatewayBindHost("auto")).toBe("127.0.0.1"); + }); + + it("returns 0.0.0.0 for auto mode inside a container", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(await resolveGatewayBindHost("auto")).toBe("0.0.0.0"); + }); + + it("defaults to loopback when bind is undefined (non-container)", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("12:memory:/user.slice\n"); + expect(await resolveGatewayBindHost(undefined)).toBe("127.0.0.1"); + }); +}); + +describe("defaultGatewayBindMode", () => { + afterEach(() => { + __resetContainerCacheForTest(); + vi.restoreAllMocks(); + }); + + it("returns loopback on non-container host", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("12:memory:/user.slice\n"); + expect(defaultGatewayBindMode()).toBe("loopback"); + }); + + it("returns auto inside a container", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(defaultGatewayBindMode()).toBe("auto"); + }); + + it("returns loopback inside a container when tailscale serve is active", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(defaultGatewayBindMode("serve")).toBe("loopback"); + }); + + it("returns loopback inside a container when tailscale funnel is active", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(defaultGatewayBindMode("funnel")).toBe("loopback"); + }); + + it("returns auto inside a container when tailscale is off", () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + expect(defaultGatewayBindMode("off")).toBe("auto"); + }); +}); + describe("isSecureWebSocketUrl", () => { it.each([ // wss:// always accepted diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 7fd0d2b27a7..3b7e714c874 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import type { IncomingMessage } from "node:http"; import net from "node:net"; import { @@ -222,6 +223,58 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { return false; } +/** + * Detect whether the current process is running inside a container + * (Docker, Podman, or Kubernetes). + * + * Uses two reliable heuristics: + * 1. Presence of `/.dockerenv` (set by Docker and Podman). + * 2. Presence of container-related cgroup entries in `/proc/1/cgroup` + * (covers Docker, containerd, and Kubernetes pods). + * + * The result is cached after the first call so filesystem access + * happens at most once per process lifetime. + */ +let _containerCacheResult: boolean | undefined; +export function isContainerEnvironment(): boolean { + if (_containerCacheResult !== undefined) { + return _containerCacheResult; + } + _containerCacheResult = detectContainerEnvironment(); + return _containerCacheResult; +} + +function detectContainerEnvironment(): boolean { + // 1. /.dockerenv exists in Docker and Podman containers. + try { + fs.accessSync("/.dockerenv", fs.constants.F_OK); + return true; + } catch { + // not present — continue + } + // 2. /proc/1/cgroup contains docker, containerd, kubepods, or lxc markers. + // Covers both cgroup v1 (/docker/, /kubepods/...) and cgroup v2 + // (kubepods.slice, cri-containerd-.scope) path formats. + try { + const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8"); + if ( + /\/docker\/|cri-containerd-[0-9a-f]|containerd\/[0-9a-f]{64}|\/kubepods[/.]|\blxc\b/.test( + cgroup, + ) + ) { + return true; + } + } catch { + // /proc may not exist (macOS, Windows) — not a container + } + return false; +} + +/** @internal — test-only helper to reset the cached container detection result. */ +export function __resetContainerCacheForTest(): void { + _containerCacheResult = undefined; +} + /** * Resolves gateway bind host with fallback strategy. * @@ -229,7 +282,7 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { * - loopback: 127.0.0.1 (rarely fails, but handled gracefully) * - lan: always 0.0.0.0 (no fallback) * - tailnet: Tailnet IPv4 if available, else loopback - * - auto: Loopback if available, else 0.0.0.0 + * - auto: 0.0.0.0 inside containers (Docker/Podman/K8s); loopback otherwise * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable * * @returns The bind address to use (never null) @@ -277,6 +330,11 @@ export async function resolveGatewayBindHost( } if (mode === "auto") { + // Inside a container, loopback is unreachable from the host network + // namespace, so prefer 0.0.0.0 to make port-forwarding work. + if (isContainerEnvironment()) { + return "0.0.0.0"; + } if (await canBindToHost("127.0.0.1")) { return "127.0.0.1"; } @@ -286,6 +344,29 @@ export async function resolveGatewayBindHost( return "0.0.0.0"; } +/** + * Returns the effective default bind mode when `gateway.bind` is not explicitly + * configured. Inside a detected container environment the default is `"auto"` + * (which resolves to `0.0.0.0` for port-forwarding compatibility); on bare-metal + * / VM hosts the default remains `"loopback"`. + * + * When {@link tailscaleMode} is `"serve"` or `"funnel"`, the function always + * returns `"loopback"` because Tailscale serve/funnel architecturally requires + * a loopback bind — container auto-detection must never override this. + * + * This function is the **single source of truth** for the unset-bind default + * and MUST be used by all codepaths that need the effective bind mode (runtime + * config, CLI startup, doctor diagnostics, status gathering, etc.). + */ +export function defaultGatewayBindMode( + tailscaleMode?: string, +): import("../config/config.js").GatewayBindMode { + if (tailscaleMode && tailscaleMode !== "off") { + return "loopback"; + } + return isContainerEnvironment() ? "auto" : "loopback"; +} + /** * Test if we can bind to a specific host address. * Creates a temporary server, attempts to bind, then closes it. diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 2fcffcea1ad..bba742c735c 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetContainerCacheForTest } from "./net.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; const TRUSTED_PROXY_AUTH = { @@ -254,6 +255,108 @@ describe("resolveGatewayRuntimeConfig", () => { }); }); + describe("container-aware bind default", () => { + afterEach(() => { + __resetContainerCacheForTest(); + vi.restoreAllMocks(); + }); + + it("defaults to auto (0.0.0.0) inside a container with auth configured", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + auth: TOKEN_AUTH, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }, + port: 18789, + }); + expect(result.bindHost).toBe("0.0.0.0"); + }); + + it("rejects container auto-bind with auth but without allowedOrigins (origin check preserved)", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + await expect( + resolveGatewayRuntimeConfig({ + cfg: { gateway: { auth: TOKEN_AUTH } }, + port: 18789, + }), + ).rejects.toThrow(/non-loopback Control UI requires gateway\.controlUi\.allowedOrigins/); + }); + + it("rejects container auto-bind without auth (security invariant preserved)", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + await expect( + resolveGatewayRuntimeConfig({ + cfg: { gateway: { auth: { mode: "none" } } }, + port: 18789, + }), + ).rejects.toThrow(/refusing to bind gateway/); + }); + + it("respects explicit loopback config even inside a container", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + const result = await resolveGatewayRuntimeConfig({ + cfg: { gateway: { bind: "loopback", auth: { mode: "none" } } }, + port: 18789, + }); + expect(result.bindHost).toBe("127.0.0.1"); + }); + + it("falls back to loopback inside a container when tailscale serve is enabled", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + auth: { mode: "none" }, + tailscale: { mode: "serve" }, + }, + }, + port: 18789, + }); + // Tailscale serve requires loopback — container auto-detection must not + // override this constraint when bind is unset. + expect(result.bindHost).toBe("127.0.0.1"); + }); + + it("falls back to loopback inside a container when tailscale funnel is enabled", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + auth: { mode: "password", password: "test-pw" }, + tailscale: { mode: "funnel" }, + }, + }, + port: 18789, + }); + expect(result.bindHost).toBe("127.0.0.1"); + }); + + it("respects explicit lan config inside a container (requires auth)", async () => { + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "lan", + auth: TOKEN_AUTH, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }, + port: 18789, + }); + expect(result.bindHost).toBe("0.0.0.0"); + }); + }); + describe("HTTP security headers", () => { const cases = [ { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index f7cc5d1718f..86aa02d6440 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -11,7 +11,12 @@ import { } from "./auth.js"; import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; -import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js"; +import { + defaultGatewayBindMode, + isLoopbackHost, + isValidIPv4, + resolveGatewayBindHost, +} from "./net.js"; import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { @@ -43,7 +48,15 @@ export async function resolveGatewayRuntimeConfig(params: { auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; }): Promise { - const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback"; + // Tailscale serve/funnel hard-requires loopback. When bind is not + // explicitly set, we must resolve Tailscale mode *before* choosing the + // bind default so that container auto-detection does not override the + // Tailscale loopback constraint. + const tailscaleModeEarly = + (params.tailscale?.mode ?? params.cfg.gateway?.tailscale?.mode) || "off"; + const bindExplicit = params.bind ?? params.cfg.gateway?.bind; + const bindMode = + bindExplicit ?? (tailscaleModeEarly !== "off" ? "loopback" : defaultGatewayBindMode()); const customBindHost = params.cfg.gateway?.customBindHost; const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost)); if (bindMode === "loopback" && !isLoopbackHost(bindHost)) { diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts index abbe9774978..33be85ad73c 100644 --- a/src/gateway/startup-control-ui-origins.ts +++ b/src/gateway/startup-control-ui-origins.ts @@ -3,13 +3,16 @@ import { ensureControlUiAllowedOriginsForNonLoopbackBind, type GatewayNonLoopbackBindMode, } from "../config/gateway-control-ui-origins.js"; +import { isContainerEnvironment } from "./net.js"; export async function maybeSeedControlUiAllowedOriginsAtStartup(params: { config: OpenClawConfig; writeConfig: (config: OpenClawConfig) => Promise; log: { info: (msg: string) => void; warn: (msg: string) => void }; }): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> { - const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config); + const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config, { + isContainerEnvironment, + }); if (!seeded.seededOrigins || !seeded.bind) { return { config: params.config, persistedAllowedOriginsSeed: false }; }