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
+49 -24
View File
@@ -1,30 +1,55 @@
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> { /**
// Get all existing subnets from routes table * Reserves the next available exit node subnet.
const existingAddresses = await db *
.select({ * Exit node subnets must never overlap with one another - regardless of
address: exitNodes.address * 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
.from(exitNodes); * returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
const addresses = existingAddresses.map((a) => a.address); * concurrent callers can race and pick the same subnet.
let subnet = findNextAvailableCidr( */
addresses, export async function getNextAvailableSubnet(
config.getRawConfig().gerbil.block_size, trx: Transaction | typeof db = db
config.getRawConfig().gerbil.subnet_group ): Promise<{ value: string; release: () => Promise<void> }> {
); const lockKey = "exit-node-subnet-allocation";
if (!subnet) { const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
throw new Error("No available subnets remaining in space"); if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey, acquired);
// replace the last octet with 1 try {
subnet = // Get all existing subnets from routes table
subnet.split(".").slice(0, 3).join(".") + const existingAddresses = await trx
".1" + .select({
"/" + address: exitNodes.address
subnet.split("/")[1]; })
return subnet; .from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }
+34 -30
View File
@@ -29,37 +29,41 @@ 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();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes try {
// const listenPort = await getNextAvailablePort(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
const listenPort = config.getRawConfig().gerbil.start_port; // const listenPort = await getNextAvailablePort();
let subEndpoint = ""; const listenPort = config.getRawConfig().gerbil.start_port;
if (config.getRawConfig().gerbil.use_subdomain) { let subEndpoint = "";
subEndpoint = await getUniqueExitNodeEndpointName(); if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} 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,89 +189,106 @@ export async function createRemoteExitNode(
); );
} }
await db.transaction(async (trx) => { // If this remote exit node isn't already backing an exit node in
if (!existingExitNode) { // another org, we're about to create a brand new one. Reserve a
const [res] = await trx // subnet for it up front so the allocation lock is held across the
.insert(exitNodes) // whole insert - this guarantees exit node subnets never overlap,
.values({ // even under concurrent creation, which matters for HA setups.
name: remoteExitNodeId, let releaseSubnetLock: (() => Promise<void>) | null = null;
address, let newExitNodeAddress: string | null = null;
endpoint: "", if (!existingExitNode) {
publicKey: "", const { value, release } = await getNextAvailableSubnet();
listenPort: 0, newExitNodeAddress = value;
online: false, releaseSubnetLock = release;
type: "remoteExitNode" }
})
.returning();
existingExitNode = res;
}
if (!existingRemoteExitNode) { try {
await trx.insert(remoteExitNodes).values({ await db.transaction(async (trx) => {
remoteExitNodeId: remoteExitNodeId, if (!existingExitNode) {
secretHash, const [res] = await trx
dateCreated: moment().toISOString(), .insert(exitNodes)
exitNodeId: existingExitNode.exitNodeId .values({
}); name: remoteExitNodeId,
} else { address: newExitNodeAddress!,
// update the existing remote exit node endpoint: "",
await trx publicKey: "",
.update(remoteExitNodes) listenPort: 0,
.set({ online: false,
exitNodeId: existingExitNode.exitNodeId type: "remoteExitNode"
}) })
.where( .returning();
eq( existingExitNode = res;
remoteExitNodes.remoteExitNodeId,
existingRemoteExitNode.remoteExitNodeId
)
);
}
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);
} }
}
}); if (!existingRemoteExitNode) {
await trx.insert(remoteExitNodes).values({
remoteExitNodeId: remoteExitNodeId,
secretHash,
dateCreated: moment().toISOString(),
exitNodeId: existingExitNode.exitNodeId
});
} else {
// update the existing remote exit node
await trx
.update(remoteExitNodes)
.set({
exitNodeId: existingExitNode.exitNodeId
})
.where(
eq(
remoteExitNodes.remoteExitNodeId,
existingRemoteExitNode.remoteExitNodeId
)
);
}
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);
}
}
});
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);
+34 -30
View File
@@ -13,37 +13,41 @@ 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();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes try {
// const listenPort = await getNextAvailablePort(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
const listenPort = config.getRawConfig().gerbil.start_port; // const listenPort = await getNextAvailablePort();
let subEndpoint = ""; const listenPort = config.getRawConfig().gerbil.start_port;
if (config.getRawConfig().gerbil.use_subdomain) { let subEndpoint = "";
subEndpoint = await getUniqueExitNodeEndpointName(); if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db