Scope exit node creation to orgs

This commit is contained in:
Owen
2026-08-24 10:44:54 -04:00
parent 19ce236262
commit 9b0e049a21
2 changed files with 38 additions and 18 deletions
+27 -14
View File
@@ -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 config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { eq } from "drizzle-orm";
/** /**
* Reserves the next available exit node subnet. * Reserves the next available exit node subnet.
* *
* Exit node subnets must never overlap with one another - regardless of * There isn't enough address space to give every exit node in every org a
* which org(s) they belong to - since HA exit nodes can end up routing for * globally unique subnet, so we only guarantee uniqueness among exit nodes
* the same org. This acquires a lock that the caller MUST release (via the * that already belong to the same org - that's all that actually matters,
* returned `release`) only after the chosen address has been durably * since HA only routes multiple exit nodes for a single org. Pass `orgId` to
* persisted (e.g. after the enclosing transaction commits), otherwise * scope the search to that org's existing exit nodes; without it, the search
* concurrent callers can race and pick the same subnet. * 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( export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db,
orgId?: string
): Promise<{ value: string; release: () => Promise<void> }> { ): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation"; const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
@@ -24,12 +30,19 @@ export async function getNextAvailableSubnet(
const release = () => lockManager.releaseLock(lockKey, acquired); const release = () => lockManager.releaseLock(lockKey, acquired);
try { try {
// Get all existing subnets from routes table // Get existing subnets, scoped to this org's exit nodes when known
const existingAddresses = await trx const existingAddresses = orgId
.select({ ? await trx
address: exitNodes.address .select({ address: exitNodes.address })
}) .from(exitNodes)
.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); const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr( let subnet = findNextAvailableCidr(
@@ -191,13 +191,20 @@ export async function createRemoteExitNode(
// If this remote exit node isn't already backing an exit node in // 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 // 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 // subnet for it up front, scoped to this org's existing exit nodes,
// whole insert - this guarantees exit node subnets never overlap, // so the allocation lock is held across the whole insert - this
// even under concurrent creation, which matters for HA setups. // 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<void>) | null = null; let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null; let newExitNodeAddress: string | null = null;
if (!existingExitNode) { if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet(); const { value, release } = await getNextAvailableSubnet(
db,
orgId
);
newExitNodeAddress = value; newExitNodeAddress = value;
releaseSubnetLock = release; releaseSubnetLock = release;
} }