Unique subnets for exit nodes

This commit is contained in:
Owen
2026-07-02 20:54:59 -04:00
parent 1bf3d2cdd6
commit f87e136f6b
4 changed files with 215 additions and 167 deletions
+29 -4
View File
@@ -1,10 +1,31 @@
import { db, exitNodes } from "@server/db"; import { db, exitNodes, 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";
export async function getNextAvailableSubnet(): Promise<string> { /**
* 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.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
// Get all existing subnets from routes table // Get all existing subnets from routes table
const existingAddresses = await db const existingAddresses = await trx
.select({ .select({
address: exitNodes.address address: exitNodes.address
}) })
@@ -26,5 +47,9 @@ export async function getNextAvailableSubnet(): Promise<string> {
".1" + ".1" +
"/" + "/" +
subnet.split("/")[1]; subnet.split("/")[1];
return subnet; return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }
@@ -29,7 +29,8 @@ export async function createExitNode(
.where(eq(exitNodes.publicKey, publicKey)); .where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -60,6 +61,9 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the reachable at // update the reachable at
[exitNode] = await db [exitNode] = await db
@@ -114,8 +114,6 @@ export async function createRemoteExitNode(
} }
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -191,13 +189,27 @@ 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.
let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null;
if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet();
newExitNodeAddress = value;
releaseSubnetLock = release;
}
try {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (!existingExitNode) { if (!existingExitNode) {
const [res] = await trx const [res] = await trx
.insert(exitNodes) .insert(exitNodes)
.values({ .values({
name: remoteExitNodeId, name: remoteExitNodeId,
address, address: newExitNodeAddress!,
endpoint: "", endpoint: "",
publicKey: "", publicKey: "",
listenPort: 0, listenPort: 0,
@@ -274,6 +286,9 @@ export async function createRemoteExitNode(
} }
} }
}); });
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);
+5 -1
View File
@@ -13,7 +13,8 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -44,6 +45,9 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db