fix(gateway): auto-bind to 0.0.0.0 inside container environments

This commit is contained in:
openperf
2026-04-06 18:26:59 +08:00
committed by Peter Steinberger
parent 4a91b4f3a5
commit c857e93735
9 changed files with 445 additions and 32 deletions

View File

@@ -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,

View File

@@ -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<string>(["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)

View File

@@ -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() ?? "";

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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/<id>, /kubepods/...) and cgroup v2
// (kubepods.slice, cri-containerd-<id>.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.

View File

@@ -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 = [
{

View File

@@ -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<GatewayRuntimeConfig> {
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)) {

View File

@@ -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<void>;
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 };
}