Files
openclaw/src/gateway/server-runtime-config.ts
2026-02-23 19:47:29 +00:00

171 lines
6.4 KiB
TypeScript

import type {
GatewayAuthConfig,
GatewayBindMode,
GatewayTailscaleConfig,
loadConfig,
} from "../config/config.js";
import {
assertGatewayAuthConfigured,
type ResolvedGatewayAuth,
resolveGatewayAuth,
} from "./auth.js";
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { resolveHooksConfig } from "./hooks.js";
import {
isLoopbackHost,
isTrustedProxyAddress,
isValidIPv4,
resolveGatewayBindHost,
} from "./net.js";
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
export type GatewayRuntimeConfig = {
bindHost: string;
controlUiEnabled: boolean;
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
controlUiBasePath: string;
controlUiRoot?: string;
resolvedAuth: ResolvedGatewayAuth;
authMode: ResolvedGatewayAuth["mode"];
tailscaleConfig: GatewayTailscaleConfig;
tailscaleMode: "off" | "serve" | "funnel";
hooksConfig: ReturnType<typeof resolveHooksConfig>;
canvasHostEnabled: boolean;
};
export async function resolveGatewayRuntimeConfig(params: {
cfg: ReturnType<typeof loadConfig>;
port: number;
bind?: GatewayBindMode;
host?: string;
controlUiEnabled?: boolean;
openAiChatCompletionsEnabled?: boolean;
openResponsesEnabled?: boolean;
auth?: GatewayAuthConfig;
tailscale?: GatewayTailscaleConfig;
}): Promise<GatewayRuntimeConfig> {
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
const customBindHost = params.cfg.gateway?.customBindHost;
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
if (bindMode === "loopback" && !isLoopbackHost(bindHost)) {
throw new Error(
`gateway bind=loopback resolved to non-loopback host ${bindHost}; refusing fallback to a network bind`,
);
}
if (bindMode === "custom") {
const configuredCustomBindHost = customBindHost?.trim();
if (!configuredCustomBindHost) {
throw new Error("gateway.bind=custom requires gateway.customBindHost");
}
if (!isValidIPv4(configuredCustomBindHost)) {
throw new Error(
`gateway.bind=custom requires a valid IPv4 customBindHost (got ${configuredCustomBindHost})`,
);
}
if (bindHost !== configuredCustomBindHost) {
throw new Error(
`gateway bind=custom requested ${configuredCustomBindHost} but resolved ${bindHost}; refusing fallback`,
);
}
}
const controlUiEnabled =
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
const openAiChatCompletionsEnabled =
params.openAiChatCompletionsEnabled ??
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
false;
const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses;
const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false;
const strictTransportSecurityConfig =
params.cfg.gateway?.http?.securityHeaders?.strictTransportSecurity;
const strictTransportSecurityHeader =
strictTransportSecurityConfig === false
? undefined
: typeof strictTransportSecurityConfig === "string" &&
strictTransportSecurityConfig.trim().length > 0
? strictTransportSecurityConfig.trim()
: undefined;
const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
const controlUiRootRaw = params.cfg.gateway?.controlUi?.root;
const controlUiRoot =
typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0
? controlUiRootRaw.trim()
: undefined;
const tailscaleBase = params.cfg.gateway?.tailscale ?? {};
const tailscaleOverrides = params.tailscale ?? {};
const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides);
const tailscaleMode = tailscaleConfig.mode ?? "off";
const resolvedAuth = resolveGatewayAuth({
authConfig: params.cfg.gateway?.auth,
authOverride: params.auth,
env: process.env,
tailscaleMode,
});
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0;
const hasPassword =
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
const hasSharedSecret =
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
const hooksConfig = resolveHooksConfig(params.cfg);
const canvasHostEnabled =
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
const trustedProxies = params.cfg.gateway?.trustedProxies ?? [];
assertGatewayAuthConfigured(resolvedAuth);
if (tailscaleMode === "funnel" && authMode !== "password") {
throw new Error(
"tailscale funnel requires gateway auth mode=password (set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD)",
);
}
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
}
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") {
throw new Error(
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
);
}
if (authMode === "trusted-proxy") {
if (trustedProxies.length === 0) {
throw new Error(
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",
);
}
if (isLoopbackHost(bindHost)) {
const hasLoopbackTrustedProxy =
isTrustedProxyAddress("127.0.0.1", trustedProxies) ||
isTrustedProxyAddress("::1", trustedProxies);
if (!hasLoopbackTrustedProxy) {
throw new Error(
"gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR",
);
}
}
}
return {
bindHost,
controlUiEnabled,
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig: openResponsesConfig
? { ...openResponsesConfig, enabled: openResponsesEnabled }
: undefined,
strictTransportSecurityHeader,
controlUiBasePath,
controlUiRoot,
resolvedAuth,
authMode,
tailscaleConfig,
tailscaleMode,
hooksConfig,
canvasHostEnabled,
};
}