diff --git a/server/lib/exitNodes/subnet.ts b/server/lib/exitNodes/subnet.ts index 8c4f3e99e..15d986426 100644 --- a/server/lib/exitNodes/subnet.ts +++ b/server/lib/exitNodes/subnet.ts @@ -1,20 +1,26 @@ -import { db, exitNodes, Transaction } from "@server/db"; +import { db, exitNodes, exitNodeOrgs, Transaction } from "@server/db"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; import { lockManager } from "#dynamic/lib/lock"; +import { eq } from "drizzle-orm"; /** * Reserves the next available exit node subnet. * - * Exit node subnets must never overlap with one another - regardless of - * which org(s) they belong to - since HA exit nodes can end up routing for - * the same org. This acquires a lock that the caller MUST release (via the - * returned `release`) only after the chosen address has been durably - * persisted (e.g. after the enclosing transaction commits), otherwise - * concurrent callers can race and pick the same subnet. + * There isn't enough address space to give every exit node in every org a + * globally unique subnet, so we only guarantee uniqueness among exit nodes + * that already belong to the same org - that's all that actually matters, + * since HA only routes multiple exit nodes for a single org. Pass `orgId` to + * scope the search to that org's existing exit nodes; without it, the search + * considers every exit node (used by flows with no org context, e.g. the + * initial gerbil exit node bootstrap). This acquires a lock that the caller + * MUST release (via the returned `release`) only after the chosen address + * has been durably persisted (e.g. after the enclosing transaction commits), + * otherwise concurrent callers can race and pick the same subnet. */ export async function getNextAvailableSubnet( - trx: Transaction | typeof db = db + trx: Transaction | typeof db = db, + orgId?: string ): Promise<{ value: string; release: () => Promise }> { const lockKey = "exit-node-subnet-allocation"; const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); @@ -24,12 +30,19 @@ export async function getNextAvailableSubnet( const release = () => lockManager.releaseLock(lockKey, acquired); try { - // Get all existing subnets from routes table - const existingAddresses = await trx - .select({ - address: exitNodes.address - }) - .from(exitNodes); + // Get existing subnets, scoped to this org's exit nodes when known + const existingAddresses = orgId + ? await trx + .select({ address: exitNodes.address }) + .from(exitNodes) + .innerJoin( + exitNodeOrgs, + eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId) + ) + .where(eq(exitNodeOrgs.orgId, orgId)) + : await trx + .select({ address: exitNodes.address }) + .from(exitNodes); const addresses = existingAddresses.map((a) => a.address); let subnet = findNextAvailableCidr( diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index bf86ed107..3462e13ff 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -191,13 +191,20 @@ export async function createRemoteExitNode( // If this remote exit node isn't already backing an exit node in // another org, we're about to create a brand new one. Reserve a - // subnet for it up front so the allocation lock is held across the - // whole insert - this guarantees exit node subnets never overlap, - // even under concurrent creation, which matters for HA setups. + // subnet for it up front, scoped to this org's existing exit nodes, + // so the allocation lock is held across the whole insert - this + // guarantees exit node subnets never overlap within the org, even + // under concurrent creation, which matters for HA setups. Subnets + // may still be reused across different orgs; there isn't enough + // address space to avoid that, and it isn't necessary since HA only + // routes multiple exit nodes for the same org. let releaseSubnetLock: (() => Promise) | null = null; let newExitNodeAddress: string | null = null; if (!existingExitNode) { - const { value, release } = await getNextAvailableSubnet(); + const { value, release } = await getNextAvailableSubnet( + db, + orgId + ); newExitNodeAddress = value; releaseSubnetLock = release; }