Rename subnet for clarity, pick subnet on client

This commit is contained in:
Owen
2026-07-30 12:13:09 -04:00
parent ba24e1c4f5
commit 33dd10c670
19 changed files with 135 additions and 78 deletions
+2 -1
View File
@@ -107,7 +107,7 @@ export const sites = sqliteTable("sites", {
}), }),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0), megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0), megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -599,6 +599,7 @@ export const clients = sqliteTable("clients", {
pubKey: text("pubKey"), pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(), subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"), megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"), megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -0,0 +1,41 @@
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getUniqueSubnetForExitNode(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const [sitesQuery, clientsQuery] = await Promise.all([
trx
.select({ subnet: sites.exitNodeSubnet })
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
trx
.select({ subnet: clients.exitNodeSubnet })
.from(clients)
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
]);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = [...sitesQuery, ...clientsQuery]
.map((row) => row.subnet)
.filter(
(subnet): subnet is string =>
!!subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+1
View File
@@ -3,3 +3,4 @@ export * from "./exitNodeComms";
export * from "./subnet"; export * from "./subnet";
export * from "./getCurrentExitNodeId"; export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight"; export * from "./calculateExitNodeWeight";
export * from "./getUniqueSubnetForExitNode";
+3 -3
View File
@@ -966,7 +966,7 @@ export async function updateClientSiteDestinations(
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.exitNodeSubnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1002,7 +1002,7 @@ export async function updateClientSiteDestinations(
sourcePort: parsedEndpoint.port, sourcePort: parsedEndpoint.port,
destinations: [ destinations: [
{ {
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
] ]
@@ -1010,7 +1010,7 @@ export async function updateClientSiteDestinations(
} else { } else {
// add to the existing destinations // add to the existing destinations
destinations.destinations.push({ destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
+1 -1
View File
@@ -87,7 +87,7 @@ export async function getTraefikConfig(
siteId: sites.siteId, siteId: sites.siteId,
siteType: sites.type, siteType: sites.type,
siteOnline: sites.online, siteOnline: sites.online,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId,
// Domain cert resolver fields // Domain cert resolver fields
domainCertResolver: domains.certResolver, domainCertResolver: domains.certResolver,
@@ -136,7 +136,7 @@ export async function getTraefikConfig(
siteId: sites.siteId, siteId: sites.siteId,
siteType: sites.type, siteType: sites.type,
siteOnline: sites.online, siteOnline: sites.online,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId,
// Namespace // Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId, domainNamespaceId: domainNamespaces.domainNamespaceId,
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
); );
} }
if (site.exitNodeId && site.subnet) { if (site.exitNodeId && site.exitNodeSubnet) {
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
await addPeer(site.exitNodeId, { await addPeer(site.exitNodeId, {
publicKey: pubKey, publicKey: pubKey,
+4 -4
View File
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Filter to sites with the required fields up front so the rest of the // Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined. // function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter( const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort (s) => s.endpoint && s.exitNodeSubnet && s.listenPort
); );
if (validSites.length === 0) { if (validSites.length === 0) {
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
if ( if (
peer.orgId == null || peer.orgId == null ||
!peer.endpoint || !peer.endpoint ||
!peer.subnet || !peer.exitNodeSubnet ||
!peer.listenPort !peer.listenPort
) { ) {
continue; continue;
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Process each site using the pre-fetched data. // Process each site using the pre-fetched data.
for (const site of validSites) { for (const site of validSites) {
const siteDestination: PeerDestination = { const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0], destinationIP: site.exitNodeSubnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}; };
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
continue; continue;
} }
addDestination(site.endpoint!, { addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0], destinationIP: peer.exitNodeSubnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
+2 -2
View File
@@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
and( and(
eq(sites.exitNodeId, exitNode.exitNodeId), eq(sites.exitNodeId, exitNode.exitNodeId),
isNotNull(sites.pubKey), isNotNull(sites.pubKey),
isNotNull(sites.subnet) isNotNull(sites.exitNodeSubnet)
) )
); );
@@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
} else if (site.type === "newt") { } else if (site.type === "newt") {
return { return {
publicKey: site.pubKey, publicKey: site.pubKey,
allowedIps: [site.subnet!] allowedIps: [site.exitNodeSubnet!]
}; };
} }
return { return {
+1 -1
View File
@@ -188,7 +188,7 @@ export async function updateAndGenerateEndpointDestinations(
.select({ .select({
siteId: sites.siteId, siteId: sites.siteId,
newtId: newts.newtId, newtId: newts.newtId,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
listenPort: sites.listenPort, listenPort: sites.listenPort,
publicKey: sites.publicKey, publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint, endpoint: clientSitesAssociationsCache.endpoint,
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
if ( if (
exitNode.reachableAt && exitNode.reachableAt &&
existingSite.subnet && existingSite.exitNodeSubnet &&
existingSite.listenPort existingSite.listenPort
) { ) {
const payload = { const payload = {
oldDestination: { oldDestination: {
destinationIP: existingSite.subnet?.split("/")[0], destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}, },
newDestination: { newDestination: {
destinationIP: site.subnet?.split("/")[0], destinationIP: site.exitNodeSubnet?.split("/")[0],
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
}; };
@@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets)); ({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
} }
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format const targetsToSend = await convertTargetsIfNecessary(
newt.newtId,
dedupedTargets
); // for backward compatibility with old newt versions that don't support the new target format
return { return {
message: { message: {
@@ -1,18 +1,17 @@
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { db, newts, remoteExitNodes } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db"; import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers"; import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { import {
ExitNodePingResult, ExitNodePingResult,
selectBestExitNode, selectBestExitNode,
verifyExitNodeOrgAccess verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes"; } from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { lockManager } from "#dynamic/lib/lock";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
@@ -85,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId); fetchContainers(newt.newtId);
} }
let siteSubnet = oldSite.subnet; let siteSubnet = oldSite.exitNodeSubnet;
let exitNodeIdToQuery = oldSite.exitNodeId; let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { if (
exitNodeId &&
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
) {
// This effectively moves the exit node to the new one // This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@@ -106,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
const newSubnet = await getUniqueSubnetForSite(exitNode); const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) { if (!newSubnet) {
logger.error( logger.error(
@@ -122,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
exitNodeId: exitNodeId, exitNodeId: exitNodeId,
subnet: newSubnet exitNodeSubnet: newSubnet
}) })
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.returning(); .returning();
@@ -241,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
excludeSender: false // Include sender in broadcast excludeSender: false // Include sender in broadcast
}; };
}; };
async function getUniqueSubnetForSite(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const sitesQuery = await trx
.select({
subnet: sites.subnet
})
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery
.map((site) => site.subnet)
.filter(
(subnet) =>
subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
)
.filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,
blockSize,
exitNode.address
);
return newSubnet;
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+1 -1
View File
@@ -167,7 +167,7 @@ export async function buildSiteConfigurationForOlmClient(
peerOps.push(deletePeer(site.siteId, client.pubKey!)); peerOps.push(deletePeer(site.siteId, client.pubKey!));
} }
if (!site.subnet) { if (!site.exitNodeSubnet) {
logger.debug(`Site ${site.siteId} has no subnet, skipping`); logger.debug(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }
+49 -3
View File
@@ -22,7 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
import { ExitNodePingResult, selectBestExitNode } from "#dynamic/lib/exitNodes"; import {
ExitNodePingResult,
selectBestExitNode,
verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -297,10 +302,50 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
exitNodeId = bestPingResult?.exitNodeId; exitNodeId = bestPingResult?.exitNodeId;
} }
let clientSubnet = client.exitNodeSubnet;
if (
exitNodeId &&
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
) {
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
exitNodeId,
client.orgId
);
if (!exitNode) {
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
orgId: client.orgId,
clientId: client.clientId
});
return;
}
if (!hasAccess) {
logger.warn(
"[handleOlmRegisterMessage] Not authorized to use this exit node",
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) {
logger.error(
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
clientSubnet = newSubnet;
}
if ( if (
client.pubKey !== publicKey || client.pubKey !== publicKey ||
client.archived || client.archived ||
client.exitNodeId !== exitNodeId client.exitNodeId !== exitNodeId ||
client.exitNodeSubnet !== clientSubnet
) { ) {
logger.info( logger.info(
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
@@ -312,7 +357,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
archived: false, archived: false,
exitNodeId: exitNodeId // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
exitNodeSubnet: clientSubnet
}) })
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
+4 -4
View File
@@ -311,13 +311,13 @@ export async function createSite(
// lets also make sure there is no overlap with other sites on the exit node // lets also make sure there is no overlap with other sites on the exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where( .where(
and( and(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
eq(sites.subnet, subnet) eq(sites.exitNodeSubnet, subnet)
) )
); );
@@ -427,7 +427,7 @@ export async function createSite(
exitNodeId, exitNodeId,
name, name,
niceId: updatedNiceId!, niceId: updatedNiceId!,
subnet, exitNodeSubnet: subnet,
type, type,
pubKey: pubKey || null, pubKey: pubKey || null,
status: "approved" status: "approved"
@@ -444,7 +444,7 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/32", exitNodeSubnet: "0.0.0.0/32",
status: "approved" status: "approved"
}) })
.returning(); .returning();
+1 -1
View File
@@ -125,7 +125,7 @@ function querySitesBase() {
niceId: sites.niceId, niceId: sites.niceId,
name: sites.name, name: sites.name,
pubKey: sites.pubKey, pubKey: sites.pubKey,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
megabytesIn: sites.megabytesIn, megabytesIn: sites.megabytesIn,
megabytesOut: sites.megabytesOut, megabytesOut: sites.megabytesOut,
orgName: orgs.name, orgName: orgs.name,
+4 -3
View File
@@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({
clientAddress: z.string().optional() clientAddress: z.string().optional()
}); });
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/pick-site-defaults", path: "/org/{orgId}/pick-site-defaults",
@@ -60,7 +59,9 @@ registry.registerPath({
description: "Successful response", description: "Successful response",
content: { content: {
"application/json": { "application/json": {
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema) schema: createApiResponseSchema(
PickSiteDefaultsResponseDataSchema
)
} }
} }
} }
@@ -108,7 +109,7 @@ export async function pickSiteDefaults(
// list all of the sites on that exit node // list all of the sites on that exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); .where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
+1 -1
View File
@@ -263,7 +263,7 @@ export async function createTarget(
// make sure the target is within the site subnet // make sure the target is within the site subnet
if ( if (
site.type == "wireguard" && site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!) !isIpInCidr(targetData.ip, site.exitNodeSubnet!)
) { ) {
return next( return next(
createHttpError( createHttpError(
@@ -72,8 +72,7 @@ export default function CredentialsPage() {
const { data: latestVersions } = useQuery( const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true) productUpdatesQueries.latestVersion(true)
); );
const newtVersion = const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
latestVersions?.data?.newt?.latestVersion ?? "latest";
// Fetch site defaults for wireguard sites to show in obfuscated config // Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => { useEffect(() => {
@@ -354,7 +353,7 @@ export default function CredentialsPage() {
text={generateObfuscatedWireGuardConfig( text={generateObfuscatedWireGuardConfig(
{ {
subnet: subnet:
site?.subnet || site?.exitNodeSubnet ||
siteDefaults?.subnet || siteDefaults?.subnet ||
null, null,
address: address: