fix: honor trusted proxy client IPs (PR #1654)
Thanks @ndbroadbent. Co-authored-by: Nathan Broadbent <git@ndbroadbent.com>
This commit is contained in:
parent
2684a364c6
commit
e6e71457e0
15 changed files with 189 additions and 20 deletions
|
|
@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent.
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
|
|
|
||||||
|
|
@ -2846,6 +2846,11 @@ Related docs:
|
||||||
- [Tailscale](/gateway/tailscale)
|
- [Tailscale](/gateway/tailscale)
|
||||||
- [Remote access](/gateway/remote)
|
- [Remote access](/gateway/remote)
|
||||||
|
|
||||||
|
Trusted proxies:
|
||||||
|
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
|
||||||
|
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||||
|
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,11 @@ Tailscale.
|
||||||
you terminate TLS or proxy in front of the gateway, disable
|
you terminate TLS or proxy in front of the gateway, disable
|
||||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||||
|
|
||||||
|
Trusted proxies:
|
||||||
|
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
|
||||||
|
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||||
|
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
|
||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||||
|
|
||||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
### 0.6.1) Browser control server over Tailscale (recommended)
|
||||||
|
|
|
||||||
|
|
@ -218,4 +218,10 @@ export type GatewayConfig = {
|
||||||
tls?: GatewayTlsConfig;
|
tls?: GatewayTlsConfig;
|
||||||
http?: GatewayHttpConfig;
|
http?: GatewayHttpConfig;
|
||||||
nodes?: GatewayNodesConfig;
|
nodes?: GatewayNodesConfig;
|
||||||
|
/**
|
||||||
|
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||||
|
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||||
|
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
|
||||||
|
*/
|
||||||
|
trustedProxies?: string[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ export const ClawdbotSchema = z
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
trustedProxies: z.array(z.string()).optional(),
|
||||||
tailscale: z
|
tailscale: z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
|
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,19 @@ describe("gateway auth", () => {
|
||||||
expect(res.method).toBe("tailscale");
|
expect(res.method).toBe("tailscale");
|
||||||
expect(res.user).toBe("peter");
|
expect(res.user).toBe("peter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats trusted proxy loopback clients as direct", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: { mode: "none", allowTailscale: true },
|
||||||
|
connectAuth: null,
|
||||||
|
trustedProxies: ["10.0.0.2"],
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "10.0.0.2" },
|
||||||
|
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.method).toBe("none");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||||
|
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
|
||||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
export type ResolvedGatewayAuth = {
|
||||||
|
|
@ -53,9 +54,26 @@ function getHostName(hostHeader?: string): string {
|
||||||
return name ?? "";
|
return name ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestClientIp(
|
||||||
|
req?: IncomingMessage,
|
||||||
|
trustedProxies?: string[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!req) return undefined;
|
||||||
|
return resolveGatewayClientIp({
|
||||||
|
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||||
|
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
|
||||||
|
realIp: headerValue(req.headers?.["x-real-ip"]),
|
||||||
|
trustedProxies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||||
if (!req) return false;
|
if (!req) return false;
|
||||||
const clientIp = req.socket?.remoteAddress ?? "";
|
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||||
if (!isLoopbackAddress(clientIp)) return false;
|
if (!isLoopbackAddress(clientIp)) return false;
|
||||||
|
|
||||||
const host = getHostName(req.headers?.host);
|
const host = getHostName(req.headers?.host);
|
||||||
|
|
@ -68,7 +86,8 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||||
req.headers?.["x-forwarded-host"],
|
req.headers?.["x-forwarded-host"],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
||||||
|
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||||
|
|
@ -135,9 +154,10 @@ export async function authorizeGatewayConnect(params: {
|
||||||
auth: ResolvedGatewayAuth;
|
auth: ResolvedGatewayAuth;
|
||||||
connectAuth?: ConnectAuth | null;
|
connectAuth?: ConnectAuth | null;
|
||||||
req?: IncomingMessage;
|
req?: IncomingMessage;
|
||||||
|
trustedProxies?: string[];
|
||||||
}): Promise<GatewayAuthResult> {
|
}): Promise<GatewayAuthResult> {
|
||||||
const { auth, connectAuth, req } = params;
|
const { auth, connectAuth, req, trustedProxies } = params;
|
||||||
const localDirect = isLocalDirectRequest(req);
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||||
|
|
||||||
if (auth.allowTailscale && !localDirect) {
|
if (auth.allowTailscale && !localDirect) {
|
||||||
const tailscaleUser = getTailscaleUser(req);
|
const tailscaleUser = getTailscaleUser(req);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isLocalGatewayAddress } from "./net.js";
|
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
|
||||||
|
|
||||||
describe("gateway net", () => {
|
describe("gateway net", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -38,4 +38,40 @@ describe("gateway net", () => {
|
||||||
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
||||||
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses forwarded-for when remote is a trusted proxy", () => {
|
||||||
|
const clientIp = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "10.0.0.2",
|
||||||
|
forwardedFor: "203.0.113.9, 10.0.0.2",
|
||||||
|
trustedProxies: ["10.0.0.2"],
|
||||||
|
});
|
||||||
|
expect(clientIp).toBe("203.0.113.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores forwarded-for from untrusted proxies", () => {
|
||||||
|
const clientIp = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "10.0.0.3",
|
||||||
|
forwardedFor: "203.0.113.9",
|
||||||
|
trustedProxies: ["10.0.0.2"],
|
||||||
|
});
|
||||||
|
expect(clientIp).toBe("10.0.0.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
||||||
|
const clientIp = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "::ffff:10.0.0.2",
|
||||||
|
forwardedFor: "203.0.113.9:1234",
|
||||||
|
trustedProxies: ["10.0.0.2"],
|
||||||
|
});
|
||||||
|
expect(clientIp).toBe("203.0.113.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to x-real-ip when forwarded-for is missing", () => {
|
||||||
|
const clientIp = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "10.0.0.2",
|
||||||
|
realIp: "203.0.113.10",
|
||||||
|
trustedProxies: ["10.0.0.2"],
|
||||||
|
});
|
||||||
|
expect(clientIp).toBe("203.0.113.10");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,56 @@ function normalizeIPv4MappedAddress(ip: string): string {
|
||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIp(ip: string | undefined): string | undefined {
|
||||||
|
const trimmed = ip?.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOptionalPort(ip: string): string {
|
||||||
|
if (ip.startsWith("[")) {
|
||||||
|
const end = ip.indexOf("]");
|
||||||
|
if (end !== -1) return ip.slice(1, end);
|
||||||
|
}
|
||||||
|
if (net.isIP(ip)) return ip;
|
||||||
|
const lastColon = ip.lastIndexOf(":");
|
||||||
|
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
|
||||||
|
const candidate = ip.slice(0, lastColon);
|
||||||
|
if (net.isIP(candidate) === 4) return candidate;
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
||||||
|
const raw = forwardedFor?.split(",")[0]?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return normalizeIp(stripOptionalPort(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRealIp(realIp?: string): string | undefined {
|
||||||
|
const raw = realIp?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return normalizeIp(stripOptionalPort(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||||
|
const normalized = normalizeIp(ip);
|
||||||
|
if (!normalized || !trustedProxies || trustedProxies.length === 0) return false;
|
||||||
|
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayClientIp(params: {
|
||||||
|
remoteAddr?: string;
|
||||||
|
forwardedFor?: string;
|
||||||
|
realIp?: string;
|
||||||
|
trustedProxies?: string[];
|
||||||
|
}): string | undefined {
|
||||||
|
const remote = normalizeIp(params.remoteAddr);
|
||||||
|
if (!remote) return undefined;
|
||||||
|
if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote;
|
||||||
|
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
|
||||||
|
}
|
||||||
|
|
||||||
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||||
if (isLoopbackAddress(ip)) return true;
|
if (isLoopbackAddress(ip)) return true;
|
||||||
if (!ip) return false;
|
if (!ip) return false;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./h
|
||||||
type OpenAiHttpOptions = {
|
type OpenAiHttpOptions = {
|
||||||
auth: ResolvedGatewayAuth;
|
auth: ResolvedGatewayAuth;
|
||||||
maxBodyBytes?: number;
|
maxBodyBytes?: number;
|
||||||
|
trustedProxies?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenAiChatMessage = {
|
type OpenAiChatMessage = {
|
||||||
|
|
@ -168,6 +169,7 @@ export async function handleOpenAiHttpRequest(
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
connectAuth: { token, password: token },
|
connectAuth: { token, password: token },
|
||||||
req,
|
req,
|
||||||
|
trustedProxies: opts.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ type OpenResponsesHttpOptions = {
|
||||||
auth: ResolvedGatewayAuth;
|
auth: ResolvedGatewayAuth;
|
||||||
maxBodyBytes?: number;
|
maxBodyBytes?: number;
|
||||||
config?: GatewayHttpResponsesConfig;
|
config?: GatewayHttpResponsesConfig;
|
||||||
|
trustedProxies?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
|
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
@ -331,6 +332,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
connectAuth: { token, password: token },
|
connectAuth: { token, password: token },
|
||||||
req,
|
req,
|
||||||
|
trustedProxies: opts.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
|
|
|
||||||
|
|
@ -227,21 +227,36 @@ export function createGatewayHttpServer(opts: {
|
||||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const configSnapshot = loadConfig();
|
||||||
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
if (await handleHooksRequest(req, res)) return;
|
if (await handleHooksRequest(req, res)) return;
|
||||||
if (await handleSlackHttpRequest(req, res)) return;
|
if (await handleSlackHttpRequest(req, res)) return;
|
||||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||||
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
|
if (
|
||||||
|
await handleToolsInvokeHttpRequest(req, res, {
|
||||||
|
auth: resolvedAuth,
|
||||||
|
trustedProxies,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (openResponsesEnabled) {
|
if (openResponsesEnabled) {
|
||||||
if (
|
if (
|
||||||
await handleOpenResponsesHttpRequest(req, res, {
|
await handleOpenResponsesHttpRequest(req, res, {
|
||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
config: openResponsesConfig,
|
config: openResponsesConfig,
|
||||||
|
trustedProxies,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (openAiChatCompletionsEnabled) {
|
if (openAiChatCompletionsEnabled) {
|
||||||
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
if (
|
||||||
|
await handleOpenAiHttpRequest(req, res, {
|
||||||
|
auth: resolvedAuth,
|
||||||
|
trustedProxies,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
if (await handleA2uiHttpRequest(req, res)) return;
|
if (await handleA2uiHttpRequest(req, res)) return;
|
||||||
|
|
@ -251,14 +266,14 @@ export function createGatewayHttpServer(opts: {
|
||||||
if (
|
if (
|
||||||
handleControlUiAvatarRequest(req, res, {
|
handleControlUiAvatarRequest(req, res, {
|
||||||
basePath: controlUiBasePath,
|
basePath: controlUiBasePath,
|
||||||
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
|
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (
|
if (
|
||||||
handleControlUiHttpRequest(req, res, {
|
handleControlUiHttpRequest(req, res, {
|
||||||
basePath: controlUiBasePath,
|
basePath: controlUiBasePath,
|
||||||
config: loadConfig(),
|
config: configSnapshot,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||||
const requestOrigin = headerValue(upgradeReq.headers.origin);
|
const requestOrigin = headerValue(upgradeReq.headers.origin);
|
||||||
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
|
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
|
||||||
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
||||||
|
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
|
||||||
|
|
||||||
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
||||||
const canvasHostOverride =
|
const canvasHostOverride =
|
||||||
|
|
@ -228,6 +229,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||||
connId,
|
connId,
|
||||||
remoteAddr,
|
remoteAddr,
|
||||||
forwardedFor,
|
forwardedFor,
|
||||||
|
realIp,
|
||||||
requestHost,
|
requestHost,
|
||||||
requestOrigin,
|
requestOrigin,
|
||||||
requestUserAgent,
|
requestUserAgent,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||||
import { authorizeGatewayConnect } from "../../auth.js";
|
import { authorizeGatewayConnect } from "../../auth.js";
|
||||||
import { loadConfig } from "../../../config/config.js";
|
import { loadConfig } from "../../../config/config.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLocalGatewayAddress } from "../../net.js";
|
import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js";
|
||||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
|
|
@ -104,6 +104,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
connId: string;
|
connId: string;
|
||||||
remoteAddr?: string;
|
remoteAddr?: string;
|
||||||
forwardedFor?: string;
|
forwardedFor?: string;
|
||||||
|
realIp?: string;
|
||||||
requestHost?: string;
|
requestHost?: string;
|
||||||
requestOrigin?: string;
|
requestOrigin?: string;
|
||||||
requestUserAgent?: string;
|
requestUserAgent?: string;
|
||||||
|
|
@ -133,6 +134,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
connId,
|
connId,
|
||||||
remoteAddr,
|
remoteAddr,
|
||||||
forwardedFor,
|
forwardedFor,
|
||||||
|
realIp,
|
||||||
requestHost,
|
requestHost,
|
||||||
requestOrigin,
|
requestOrigin,
|
||||||
requestUserAgent,
|
requestUserAgent,
|
||||||
|
|
@ -157,6 +159,11 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
logWsControl,
|
logWsControl,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
const configSnapshot = loadConfig();
|
||||||
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
|
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
|
||||||
|
const isLocalClient = isLocalGatewayAddress(clientIp);
|
||||||
|
|
||||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||||
|
|
||||||
socket.on("message", async (data) => {
|
socket.on("message", async (data) => {
|
||||||
|
|
@ -300,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
const allowInsecureControlUi =
|
const allowInsecureControlUi =
|
||||||
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
|
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
const canSkipDevice =
|
const canSkipDevice =
|
||||||
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
||||||
|
|
||||||
|
|
@ -380,7 +387,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
close(1008, "device signature expired");
|
close(1008, "device signature expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
|
const nonceRequired = !isLocalClient;
|
||||||
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||||
if (nonceRequired && !providedNonce) {
|
if (nonceRequired && !providedNonce) {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
|
|
@ -495,6 +502,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
connectAuth: connectParams.auth,
|
connectAuth: connectParams.auth,
|
||||||
req: upgradeReq,
|
req: upgradeReq,
|
||||||
|
trustedProxies,
|
||||||
});
|
});
|
||||||
let authOk = authResult.ok;
|
let authOk = authResult.ok;
|
||||||
let authMethod = authResult.method ?? "none";
|
let authMethod = authResult.method ?? "none";
|
||||||
|
|
@ -556,8 +564,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
clientMode: connectParams.client.mode,
|
clientMode: connectParams.client.mode,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: remoteAddr,
|
remoteIp: clientIp,
|
||||||
silent: isLocalGatewayAddress(remoteAddr),
|
silent: isLocalClient,
|
||||||
});
|
});
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
|
|
@ -640,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
clientMode: connectParams.client.mode,
|
clientMode: connectParams.client.mode,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: remoteAddr,
|
remoteIp: clientIp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -689,7 +697,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
if (presenceKey) {
|
if (presenceKey) {
|
||||||
upsertPresence(presenceKey, {
|
upsertPresence(presenceKey, {
|
||||||
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
||||||
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
|
ip: isLocalClient ? undefined : clientIp,
|
||||||
version: connectParams.client.version,
|
version: connectParams.client.version,
|
||||||
platform: connectParams.client.platform,
|
platform: connectParams.client.platform,
|
||||||
deviceFamily: connectParams.client.deviceFamily,
|
deviceFamily: connectParams.client.deviceFamily,
|
||||||
|
|
@ -748,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||||
setHandshakeState("connected");
|
setHandshakeState("connected");
|
||||||
if (role === "node") {
|
if (role === "node") {
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp });
|
||||||
const instanceIdRaw = connectParams.client.instanceId;
|
const instanceIdRaw = connectParams.client.instanceId;
|
||||||
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
||||||
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ function mergeActionIntoArgsIfSupported(params: {
|
||||||
export async function handleToolsInvokeHttpRequest(
|
export async function handleToolsInvokeHttpRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
|
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||||
if (url.pathname !== "/tools/invoke") return false;
|
if (url.pathname !== "/tools/invoke") return false;
|
||||||
|
|
@ -80,11 +80,13 @@ export async function handleToolsInvokeHttpRequest(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
const token = getBearerToken(req);
|
const token = getBearerToken(req);
|
||||||
const authResult = await authorizeGatewayConnect({
|
const authResult = await authorizeGatewayConnect({
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
connectAuth: token ? { token, password: token } : null,
|
connectAuth: token ? { token, password: token } : null,
|
||||||
req,
|
req,
|
||||||
|
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
|
|
@ -110,7 +112,6 @@ export async function handleToolsInvokeHttpRequest(
|
||||||
: {}
|
: {}
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const rawSessionKey = resolveSessionKeyFromBody(body);
|
const rawSessionKey = resolveSessionKeyFromBody(body);
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
|
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue