diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 4c87147906f9..a843c09ebb6a 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -30,6 +30,7 @@ const resolveGatewayAuthMock = vi.hoisted(() => allowTailscale: false, })), ); +const resolveGatewayBindHostMock = vi.hoisted(() => vi.fn(async () => "127.0.0.1")); const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); const createInstallPlanFixture = vi.hoisted(() => { @@ -119,6 +120,14 @@ vi.mock("../../gateway/auth.js", () => ({ resolveGatewayAuth: resolveGatewayAuthMock, })); +vi.mock("../../gateway/net.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayBindHost: resolveGatewayBindHostMock, + }; +}); + vi.mock("../../secrets/resolve.js", () => ({ resolveSecretRefValues: resolveSecretRefValuesMock, })); @@ -267,6 +276,7 @@ describe("runDaemonInstall", () => { resolveIsNixModeMock.mockReset(); resolveSecretInputRefMock.mockReset(); resolveGatewayAuthMock.mockReset(); + resolveGatewayBindHostMock.mockReset(); resolveSecretRefValuesMock.mockReset(); randomTokenMock.mockReset(); buildGatewayInstallPlanMock.mockReset(); @@ -298,6 +308,7 @@ describe("runDaemonInstall", () => { password: undefined, allowTailscale: false, }); + resolveGatewayBindHostMock.mockResolvedValue("127.0.0.1"); resolveSecretRefValuesMock.mockResolvedValue(new Map()); randomTokenMock.mockReturnValue("generated-token"); buildGatewayInstallPlanMock.mockImplementation(createInstallPlanFixture); @@ -495,6 +506,144 @@ describe("runDaemonInstall", () => { }); }); + it("blocks managed install when explicit no-auth would bind to LAN", async () => { + const config = { + gateway: { + mode: "local", + bind: "lan", + auth: { + mode: "none", + token: "test-token", + }, + }, + }; + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config, + sourceConfig: config, + }); + resolveGatewayAuthMock.mockReturnValue({ + mode: "none", + token: "test-token", + password: undefined, + allowTailscale: false, + }); + resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0"); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed[0]?.message).toContain("Gateway install blocked"); + expect(actionState.failed[0]?.message).toContain("gateway.bind=lan"); + expect(actionState.failed[0]?.message).toContain("gateway.auth.mode=none"); + expect(actionState.failed[0]?.message).toContain("openclaw config set gateway.auth.mode token"); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "custom bind resolving to a network interface", + bind: "custom" as const, + customBindHost: "192.168.1.20", + resolvedHost: "192.168.1.20", + blocked: true, + message: undefined, + }, + { + name: "tailnet bind resolving to a tailnet interface", + bind: "tailnet" as const, + customBindHost: undefined, + resolvedHost: "100.64.0.20", + blocked: true, + message: undefined, + }, + { + name: "tailnet bind falling back to loopback", + bind: "tailnet" as const, + customBindHost: undefined, + resolvedHost: "127.0.0.1", + blocked: true, + message: "can later resolve to a Tailnet interface", + }, + { + name: "loopback bind", + bind: "loopback" as const, + customBindHost: undefined, + resolvedHost: "127.0.0.1", + blocked: false, + message: undefined, + }, + ])("handles explicit no-auth for $name", async (testCase) => { + const config = { + gateway: { + mode: "local" as const, + bind: testCase.bind, + customBindHost: testCase.customBindHost, + auth: { mode: "none" as const }, + }, + }; + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config, + sourceConfig: config, + }); + resolveGatewayAuthMock.mockReturnValue({ + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }); + resolveGatewayBindHostMock.mockResolvedValue(testCase.resolvedHost); + + await runDaemonInstall({ json: true }); + + expect(resolveGatewayBindHostMock).toHaveBeenCalledWith(testCase.bind, testCase.customBindHost); + if (testCase.blocked) { + expect(actionState.failed[0]?.message).toContain(`gateway.bind=${testCase.bind}`); + if (testCase.message) { + expect(actionState.failed[0]?.message).toContain(testCase.message); + } + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + } else { + expect(actionState.failed).toStrictEqual([]); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + } + }); + + it("allows a managed LAN install with trusted-proxy auth", async () => { + const config = { + gateway: { + mode: "local" as const, + bind: "lan" as const, + trustedProxies: ["127.0.0.1"], + auth: { mode: "trusted-proxy" as const }, + }, + }; + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config, + sourceConfig: config, + }); + resolveGatewayAuthMock.mockReturnValue({ + mode: "trusted-proxy", + token: undefined, + password: undefined, + allowTailscale: false, + }); + resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0"); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toStrictEqual([]); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + }); + it("does not persist gateway mode when runtime validation fails", async () => { readConfigFileSnapshotMock.mockResolvedValue({ exists: true, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 4a31fb8bd9a2..a938d4bc6868 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -12,12 +12,19 @@ import { resolveFutureConfigActionBlock } from "../../config/future-version-guar import { readConfigFileSnapshotForWrite } from "../../config/io.js"; import { replaceConfigFile } from "../../config/mutate.js"; import { resolveGatewayPort } from "../../config/paths.js"; +import type { GatewayBindMode } from "../../config/types.gateway.js"; import type { OpenClawConfig } from "../../config/types.js"; import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js"; import { readEmbeddedGatewayToken } from "../../daemon/service-audit.js"; import { resolveGatewayService } from "../../daemon/service.js"; import type { GatewayServiceCommandConfig } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; +import { resolveGatewayAuth } from "../../gateway/auth.js"; +import { + defaultGatewayBindMode, + isLoopbackHost, + resolveGatewayBindHost, +} from "../../gateway/net.js"; import { formatExternalSupervisorActionRequired, isGatewayExternallySupervised, @@ -38,6 +45,46 @@ import { } from "./shared.js"; import type { DaemonInstallOptions } from "./types.js"; +function resolveGatewayInstallBindMode(cfg: OpenClawConfig): GatewayBindMode { + return cfg.gateway?.bind ?? defaultGatewayBindMode(cfg.gateway?.tailscale?.mode ?? "off"); +} + +function formatNoAuthNonLoopbackInstallBlock(params: { + bind: GatewayBindMode; + bindHost: string; + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): string | undefined { + const auth = resolveGatewayAuth({ + authConfig: params.config.gateway?.auth, + env: params.env, + tailscaleMode: params.config.gateway?.tailscale?.mode ?? "off", + }); + const bindCanExposeNetwork = params.bind === "tailnet" || !isLoopbackHost(params.bindHost); + if (auth.mode !== "none" || !bindCanExposeNetwork) { + return undefined; + } + const bindReason = + params.bind === "tailnet" && isLoopbackHost(params.bindHost) + ? `gateway.bind=tailnet currently resolves to ${params.bindHost} but can later resolve to a Tailnet interface` + : `gateway.bind=${params.bind} resolves to ${params.bindHost}`; + const hints: string[] = [`${bindReason}, but gateway.auth.mode=none disables Gateway auth.`]; + if (normalizeOptionalString(auth.token)) { + hints.push( + `This config already has gateway.auth.token; run ${formatCliCommand("openclaw config set gateway.auth.mode token")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`, + ); + } else if (normalizeOptionalString(auth.password)) { + hints.push( + `This config already has gateway.auth.password; run ${formatCliCommand("openclaw config set gateway.auth.mode password")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`, + ); + } else { + hints.push( + `Configure token/password auth, use trusted-proxy auth, or set ${formatCliCommand("openclaw config set gateway.bind loopback")} before installing the managed service.`, + ); + } + return hints.join(" "); +} + /** Merge safe existing service environment into the current install invocation environment. */ export function mergeInstallInvocationEnv(params: { env: NodeJS.ProcessEnv; @@ -205,6 +252,18 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } } + const installBind = resolveGatewayInstallBindMode(cfg); + const installBindHost = await resolveGatewayBindHost(installBind, cfg.gateway?.customBindHost); + const noAuthNonLoopbackBlock = formatNoAuthNonLoopbackInstallBlock({ + bind: installBind, + bindHost: installBindHost, + config: cfg, + env: installEnv, + }); + if (noAuthNonLoopbackBlock) { + fail(`Gateway install blocked: ${noAuthNonLoopbackBlock}`); + return; + } if (loaded) { if (!opts.force) { const autoRefreshMessage = await getGatewayServiceAutoRefreshMessage({