* feat(gateway): add auth rate-limiting & brute-force protection Add a per-IP sliding-window rate limiter to Gateway authentication endpoints (HTTP, WebSocket upgrade, and WS message-level auth). When gateway.auth.rateLimit is configured, failed auth attempts are tracked per client IP. Once the threshold is exceeded within the sliding window, further attempts are blocked with HTTP 429 + Retry-After until the lockout period expires. Loopback addresses are exempt by default so local CLI sessions are never locked out. The limiter is only created when explicitly configured (undefined otherwise), keeping the feature fully opt-in and backward-compatible. * fix(gateway): isolate auth rate-limit scopes and normalize 429 responses --------- Co-authored-by: buerbaumer <buerbaumer@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import type { Server as HttpServer } from "node:http";
|
|
import { WebSocketServer } from "ws";
|
|
import type { CliDeps } from "../cli/deps.js";
|
|
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import type { PluginRegistry } from "../plugins/registry.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
|
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
|
import type { ControlUiRootState } from "./control-ui.js";
|
|
import type { HooksConfigResolved } from "./hooks.js";
|
|
import type { DedupeEntry } from "./server-shared.js";
|
|
import type { GatewayTlsRuntime } from "./server/tls.js";
|
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
|
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
|
|
import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
|
|
import { resolveGatewayListenHosts } from "./net.js";
|
|
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
|
import {
|
|
type ChatRunEntry,
|
|
createChatRunState,
|
|
createToolEventRecipientRegistry,
|
|
} from "./server-chat.js";
|
|
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
|
|
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
|
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
|
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
|
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
|
|
|
export async function createGatewayRuntimeState(params: {
|
|
cfg: import("../config/config.js").OpenClawConfig;
|
|
bindHost: string;
|
|
port: number;
|
|
controlUiEnabled: boolean;
|
|
controlUiBasePath: string;
|
|
controlUiRoot?: ControlUiRootState;
|
|
openAiChatCompletionsEnabled: boolean;
|
|
openResponsesEnabled: boolean;
|
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
|
resolvedAuth: ResolvedGatewayAuth;
|
|
/** Optional rate limiter for auth brute-force protection. */
|
|
rateLimiter?: AuthRateLimiter;
|
|
gatewayTls?: GatewayTlsRuntime;
|
|
hooksConfig: () => HooksConfigResolved | null;
|
|
pluginRegistry: PluginRegistry;
|
|
deps: CliDeps;
|
|
canvasRuntime: RuntimeEnv;
|
|
canvasHostEnabled: boolean;
|
|
allowCanvasHostInTests?: boolean;
|
|
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
|
log: { info: (msg: string) => void; warn: (msg: string) => void };
|
|
logHooks: ReturnType<typeof createSubsystemLogger>;
|
|
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
|
}): Promise<{
|
|
canvasHost: CanvasHostHandler | null;
|
|
httpServer: HttpServer;
|
|
httpServers: HttpServer[];
|
|
httpBindHosts: string[];
|
|
wss: WebSocketServer;
|
|
clients: Set<GatewayWsClient>;
|
|
broadcast: (
|
|
event: string,
|
|
payload: unknown,
|
|
opts?: {
|
|
dropIfSlow?: boolean;
|
|
stateVersion?: { presence?: number; health?: number };
|
|
},
|
|
) => void;
|
|
broadcastToConnIds: (
|
|
event: string,
|
|
payload: unknown,
|
|
connIds: ReadonlySet<string>,
|
|
opts?: {
|
|
dropIfSlow?: boolean;
|
|
stateVersion?: { presence?: number; health?: number };
|
|
},
|
|
) => void;
|
|
agentRunSeq: Map<string, number>;
|
|
dedupe: Map<string, DedupeEntry>;
|
|
chatRunState: ReturnType<typeof createChatRunState>;
|
|
chatRunBuffers: Map<string, string>;
|
|
chatDeltaSentAt: Map<string, number>;
|
|
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
|
removeChatRun: (
|
|
sessionId: string,
|
|
clientRunId: string,
|
|
sessionKey?: string,
|
|
) => ChatRunEntry | undefined;
|
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
|
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
|
|
}> {
|
|
let canvasHost: CanvasHostHandler | null = null;
|
|
if (params.canvasHostEnabled) {
|
|
try {
|
|
const handler = await createCanvasHostHandler({
|
|
runtime: params.canvasRuntime,
|
|
rootDir: params.cfg.canvasHost?.root,
|
|
basePath: CANVAS_HOST_PATH,
|
|
allowInTests: params.allowCanvasHostInTests,
|
|
liveReload: params.cfg.canvasHost?.liveReload,
|
|
});
|
|
if (handler.rootDir) {
|
|
canvasHost = handler;
|
|
params.logCanvas.info(
|
|
`canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
params.logCanvas.warn(`canvas host failed to start: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
const clients = new Set<GatewayWsClient>();
|
|
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
|
|
|
const handleHooksRequest = createGatewayHooksRequestHandler({
|
|
deps: params.deps,
|
|
getHooksConfig: params.hooksConfig,
|
|
bindHost: params.bindHost,
|
|
port: params.port,
|
|
logHooks: params.logHooks,
|
|
});
|
|
|
|
const handlePluginRequest = createGatewayPluginRequestHandler({
|
|
registry: params.pluginRegistry,
|
|
log: params.logPlugins,
|
|
});
|
|
|
|
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
|
const httpServers: HttpServer[] = [];
|
|
const httpBindHosts: string[] = [];
|
|
for (const host of bindHosts) {
|
|
const httpServer = createGatewayHttpServer({
|
|
canvasHost,
|
|
clients,
|
|
controlUiEnabled: params.controlUiEnabled,
|
|
controlUiBasePath: params.controlUiBasePath,
|
|
controlUiRoot: params.controlUiRoot,
|
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
|
openResponsesEnabled: params.openResponsesEnabled,
|
|
openResponsesConfig: params.openResponsesConfig,
|
|
handleHooksRequest,
|
|
handlePluginRequest,
|
|
resolvedAuth: params.resolvedAuth,
|
|
rateLimiter: params.rateLimiter,
|
|
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
|
});
|
|
try {
|
|
await listenGatewayHttpServer({
|
|
httpServer,
|
|
bindHost: host,
|
|
port: params.port,
|
|
});
|
|
httpServers.push(httpServer);
|
|
httpBindHosts.push(host);
|
|
} catch (err) {
|
|
if (host === bindHosts[0]) {
|
|
throw err;
|
|
}
|
|
params.log.warn(
|
|
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
|
|
);
|
|
}
|
|
}
|
|
const httpServer = httpServers[0];
|
|
if (!httpServer) {
|
|
throw new Error("Gateway HTTP server failed to start");
|
|
}
|
|
|
|
const wss = new WebSocketServer({
|
|
noServer: true,
|
|
maxPayload: MAX_PAYLOAD_BYTES,
|
|
});
|
|
for (const server of httpServers) {
|
|
attachGatewayUpgradeHandler({
|
|
httpServer: server,
|
|
wss,
|
|
canvasHost,
|
|
clients,
|
|
resolvedAuth: params.resolvedAuth,
|
|
rateLimiter: params.rateLimiter,
|
|
});
|
|
}
|
|
|
|
const agentRunSeq = new Map<string, number>();
|
|
const dedupe = new Map<string, DedupeEntry>();
|
|
const chatRunState = createChatRunState();
|
|
const chatRunRegistry = chatRunState.registry;
|
|
const chatRunBuffers = chatRunState.buffers;
|
|
const chatDeltaSentAt = chatRunState.deltaSentAt;
|
|
const addChatRun = chatRunRegistry.add;
|
|
const removeChatRun = chatRunRegistry.remove;
|
|
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
|
|
const toolEventRecipients = createToolEventRecipientRegistry();
|
|
|
|
return {
|
|
canvasHost,
|
|
httpServer,
|
|
httpServers,
|
|
httpBindHosts,
|
|
wss,
|
|
clients,
|
|
broadcast,
|
|
broadcastToConnIds,
|
|
agentRunSeq,
|
|
dedupe,
|
|
chatRunState,
|
|
chatRunBuffers,
|
|
chatDeltaSentAt,
|
|
addChatRun,
|
|
removeChatRun,
|
|
chatAbortControllers,
|
|
toolEventRecipients,
|
|
};
|
|
}
|