mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: block no-auth managed gateway LAN installs (#98022)
* fix: block no-auth managed gateway LAN installs * test(gateway): cover managed install bind resolution Co-authored-by: luyifan <al3060388206@gmail.com> * test(gateway): drop unrelated env cleanup * test(gateway): use synthetic token fixture * fix(gateway): reject dynamic tailnet no-auth installs Co-authored-by: luyifan <al3060388206@gmail.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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<typeof import("../../gateway/net.js")>();
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user