From a7e44944fb1bb4f74375e545e264e9b0f0b3723c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:43:00 -0400 Subject: [PATCH] Use ip to find user --- server/db/pg/schema/schema.ts | 4 +- server/routers/aiGateway/chatCompletions.ts | 95 ++++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e253cd6d5..51552233c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -99,7 +99,7 @@ export const sites = pgTable( name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), - exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node + exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR megabytesIn: real("bytesIn").default(0), megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), @@ -1234,7 +1234,7 @@ export const clients = pgTable( olmId: text("olmId"), // to lock it to a specific olm optionally name: varchar("name").notNull(), pubKey: varchar("pubKey"), - exitNodeSubnet: varchar("exitNodeSubnet").notNull(), + exitNodeSubnet: varchar("exitNodeSubnet").notNull(), // INCLUDES THE CIDR megabytesIn: real("bytesIn"), megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index d8ec7f37c..c9c32c2ba 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -4,7 +4,9 @@ import { AiProvider, aiModels, aiProviders, + clients, db, + exitNodes, resourceAiModels, resources, siteResourceAiModels, @@ -25,9 +27,64 @@ import { validateSessionToken } from "@server/auth/sessions/app"; import { getUserOrgRoles } from "@server/lib/userOrgRoles"; +import { isIpInCidr } from "@server/lib/ip"; +import { localCache } from "@server/lib/cache"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; +// Short-lived local caches so a burst of requests from the same IP/user +// doesn't hit the database on every single request. None of this is +// security-critical to cache aggressively (identity is re-derived from the +// session cookie or from a client's exit-node-scoped subnet each time), so +// a small TTL is just an efficiency win, not a trust boundary. +const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges"; +const EXIT_NODE_RANGES_TTL_SEC = 6000; +const CLIENT_BY_IP_TTL_SEC = 30; +const REQUEST_USER_TTL_SEC = 30; + +type CachedClient = { clientId: number; userId: string | null } | null; + +// The set of CIDRs an exit node manages; client exitNodeSubnets are always +// /32s carved out of one of these ranges. Checking against this small, +// cacheable list lets us skip the (much more frequent) per-IP client lookup +// entirely for traffic that could never match a client anyway. +async function getExitNodeRanges(): Promise { + const cached = localCache.get(EXIT_NODE_RANGES_CACHE_KEY); + if (cached) { + return cached; + } + + const rows = await db + .select({ address: exitNodes.address }) + .from(exitNodes); + const ranges = rows.map((r) => r.address); + + localCache.set( + EXIT_NODE_RANGES_CACHE_KEY, + ranges, + EXIT_NODE_RANGES_TTL_SEC + ); + return ranges; +} + +async function findClientByIp(ip: string): Promise { + const cacheKey = `aiGateway:clientByIp:${ip}`; + const cached = localCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const [client] = await db + .select({ clientId: clients.clientId, userId: clients.userId }) + .from(clients) + .where(eq(clients.exitNodeSubnet, `${ip}/32`)) + .limit(1); + + const result: CachedClient = client || null; + localCache.set(cacheKey, result, CLIENT_BY_IP_TTL_SEC); + return result; +} + type ResolvedTarget = { resourceId: number | null; orgId: string | null; @@ -48,6 +105,12 @@ async function buildRequestUser( userId: string, orgId: string | null ): Promise { + const cacheKey = `aiGateway:requestUser:${userId}:${orgId || ""}`; + const cached = localCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const [user] = await db .select() .from(users) @@ -55,18 +118,22 @@ async function buildRequestUser( .limit(1); if (!user) { + localCache.set(cacheKey, null, REQUEST_USER_TTL_SEC); return null; } const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : []; - return { + const requestUser: RequestUser = { userId: user.userId, username: user.username, email: user.email, name: user.name, role: orgRoles.map((r) => r.roleName).join(", ") || null }; + + localCache.set(cacheKey, requestUser, REQUEST_USER_TTL_SEC); + return requestUser; } async function resolveRequestUser( @@ -86,7 +153,31 @@ async function resolveRequestUser( } } - return null; + // TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE + + // No session cookie - fall back to identifying the caller by source IP. + // A client's exitNodeSubnet is a /32 handed out from one of our exit + // node's address ranges, so an IP that isn't inside any of those ranges + // can never belong to a client and we can skip the DB entirely. + const ip = req.ip; + if (!ip) { + return null; + } + + const exitNodeRanges = await getExitNodeRanges(); + const inExitNodeRange = exitNodeRanges.some((range) => + isIpInCidr(ip, range) + ); + if (!inExitNodeRange) { + return null; + } + + const client = await findClientByIp(ip); + if (!client || !client.userId) { + return null; + } + + return buildRequestUser(client.userId, orgId); } async function resolveTarget(host: string): Promise {