mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 11:23:31 +02:00
fix(gateway): auto-bind to 0.0.0.0 inside container environments
This commit is contained in:
committed by
Peter Steinberger
parent
4a91b4f3a5
commit
c857e93735
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user