diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index d0db18555..1e2d822e7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -107,7 +107,7 @@ export const sites = sqliteTable("sites", { }), name: text("name").notNull(), pubKey: text("pubKey"), - subnet: text("subnet"), + exitNodeSubnet: text("exitNodeSubnet"), megabytesIn: integer("bytesIn").default(0), megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), @@ -599,6 +599,7 @@ export const clients = sqliteTable("clients", { pubKey: text("pubKey"), olmId: text("olmId"), // to lock it to a specific olm optionally subnet: text("subnet").notNull(), + exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), lastBandwidthUpdate: text("lastBandwidthUpdate"), diff --git a/server/lib/exitNodes/getUniqueSubnetForExitNode.ts b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts new file mode 100644 index 000000000..728049eb1 --- /dev/null +++ b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts @@ -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 { + 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 + ); +} diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index 459995074..18474e27d 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -3,3 +3,4 @@ export * from "./exitNodeComms"; export * from "./subnet"; export * from "./getCurrentExitNodeId"; export * from "./calculateExitNodeWeight"; +export * from "./getUniqueSubnetForExitNode"; diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index eae579634..f8b83f5f7 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -966,7 +966,7 @@ export async function updateClientSiteDestinations( .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); for (const site of sitesData) { - if (!site.sites.subnet) { + if (!site.sites.exitNodeSubnet) { logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); continue; } @@ -1002,7 +1002,7 @@ export async function updateClientSiteDestinations( sourcePort: parsedEndpoint.port, 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 } ] @@ -1010,7 +1010,7 @@ export async function updateClientSiteDestinations( } else { // add to the existing destinations 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 }); } diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index c63b5b718..3e84cd570 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -87,7 +87,7 @@ export async function getTraefikConfig( siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, // Domain cert resolver fields domainCertResolver: domains.certResolver, diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index d47352e04..959b9c897 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -134,7 +134,7 @@ export async function getTraefikConfig( siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, // Namespace domainNamespaceId: domainNamespaces.domainNamespaceId, diff --git a/server/private/routers/re-key/reGenerateSiteSecret.ts b/server/private/routers/re-key/reGenerateSiteSecret.ts index 6e69eb3f1..2bb29b202 100644 --- a/server/private/routers/re-key/reGenerateSiteSecret.ts +++ b/server/private/routers/re-key/reGenerateSiteSecret.ts @@ -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 addPeer(site.exitNodeId, { publicKey: pubKey, diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts index 23d538e62..a9a6037e1 100644 --- a/server/routers/gerbil/getAllRelays.ts +++ b/server/routers/gerbil/getAllRelays.ts @@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Filter to sites with the required fields up front so the rest of the // function can safely treat endpoint/subnet/listenPort as defined. const validSites = sitesRes.filter( - (s) => s.endpoint && s.subnet && s.listenPort + (s) => s.endpoint && s.exitNodeSubnet && s.listenPort ); if (validSites.length === 0) { @@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { if ( peer.orgId == null || !peer.endpoint || - !peer.subnet || + !peer.exitNodeSubnet || !peer.listenPort ) { continue; @@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Process each site using the pre-fetched data. for (const site of validSites) { 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 }; @@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { continue; } 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 }); } diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 1557edce9..77921c39f 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) { and( eq(sites.exitNodeId, exitNode.exitNodeId), isNotNull(sites.pubKey), - isNotNull(sites.subnet) + isNotNull(sites.exitNodeSubnet) ) ); @@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) { } else if (site.type === "newt") { return { publicKey: site.pubKey, - allowedIps: [site.subnet!] + allowedIps: [site.exitNodeSubnet!] }; } return { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index fad3f277d..8bdd2e428 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -186,7 +186,7 @@ export async function updateAndGenerateEndpointDestinations( .select({ siteId: sites.siteId, newtId: newts.newtId, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, listenPort: sites.listenPort, publicKey: sites.publicKey, endpoint: clientSitesAssociationsCache.endpoint, diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index cb8b8d931..169c8a0f0 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .limit(1); if ( exitNode.reachableAt && - existingSite.subnet && + existingSite.exitNodeSubnet && existingSite.listenPort ) { const payload = { oldDestination: { - destinationIP: existingSite.subnet?.split("/")[0], + destinationIP: existingSite.exitNodeSubnet?.split("/")[0], destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }, newDestination: { - destinationIP: site.subnet?.split("/")[0], + destinationIP: site.exitNodeSubnet?.split("/")[0], 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)); } - 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 { message: { diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 27956e845..b04df1c83 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -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 { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { findNextAvailableCidr } from "@server/lib/ip"; import { ExitNodePingResult, selectBestExitNode, verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; +import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; -import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -85,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - let siteSubnet = oldSite.subnet; + let siteSubnet = oldSite.exitNodeSubnet; 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 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; } - const newSubnet = await getUniqueSubnetForSite(exitNode); + const newSubnet = await getUniqueSubnetForExitNode(exitNode); if (!newSubnet) { logger.error( @@ -122,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, exitNodeId: exitNodeId, - subnet: newSubnet + exitNodeSubnet: newSubnet }) .where(eq(sites.siteId, siteId)) .returning(); @@ -241,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { excludeSender: false // Include sender in broadcast }; }; - -async function getUniqueSubnetForSite( - exitNode: ExitNode, - trx: Transaction | typeof db = db -): Promise { - 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 - ); -} diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 37355eae5..ed1b0cfae 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -167,7 +167,7 @@ export async function buildSiteConfigurationForOlmClient( peerOps.push(deletePeer(site.siteId, client.pubKey!)); } - if (!site.subnet) { + if (!site.exitNodeSubnet) { logger.debug(`Site ${site.siteId} has no subnet, skipping`); continue; } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 6b0002b83..c0c395fb5 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -22,7 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks"; 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 { 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_TTL_SECONDS = 1800; @@ -297,10 +302,50 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { 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 ( client.pubKey !== publicKey || client.archived || - client.exitNodeId !== exitNodeId + client.exitNodeId !== exitNodeId || + client.exitNodeSubnet !== clientSubnet ) { logger.info( "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", @@ -312,7 +357,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey, 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)); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index b24608609..574751e93 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -311,13 +311,13 @@ export async function createSite( // lets also make sure there is no overlap with other sites on the exit node const sitesQuery = await db .select({ - subnet: sites.subnet + subnet: sites.exitNodeSubnet }) .from(sites) .where( and( eq(sites.exitNodeId, exitNodeId), - eq(sites.subnet, subnet) + eq(sites.exitNodeSubnet, subnet) ) ); @@ -427,7 +427,7 @@ export async function createSite( exitNodeId, name, niceId: updatedNiceId!, - subnet, + exitNodeSubnet: subnet, type, pubKey: pubKey || null, status: "approved" @@ -444,7 +444,7 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32", + exitNodeSubnet: "0.0.0.0/32", status: "approved" }) .returning(); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index cdc3a5630..0d1a588a1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -125,7 +125,7 @@ function querySitesBase() { niceId: sites.niceId, name: sites.name, pubKey: sites.pubKey, - subnet: sites.subnet, + subnet: sites.exitNodeSubnet, megabytesIn: sites.megabytesIn, megabytesOut: sites.megabytesOut, orgName: orgs.name, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index ea04a4ada..c65749112 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({ clientAddress: z.string().optional() }); - registry.registerPath({ method: "get", path: "/org/{orgId}/pick-site-defaults", @@ -60,7 +59,9 @@ registry.registerPath({ description: "Successful response", content: { "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 const sitesQuery = await db .select({ - subnet: sites.subnet + subnet: sites.exitNodeSubnet }) .from(sites) .where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index cf2cd6b5f..84fa00cec 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -263,7 +263,7 @@ export async function createTarget( // make sure the target is within the site subnet if ( site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) + !isIpInCidr(targetData.ip, site.exitNodeSubnet!) ) { return next( createHttpError( diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 41b8f62eb..62b166939 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -72,8 +72,7 @@ export default function CredentialsPage() { const { data: latestVersions } = useQuery( productUpdatesQueries.latestVersion(true) ); - const newtVersion = - latestVersions?.data?.newt?.latestVersion ?? "latest"; + const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest"; // Fetch site defaults for wireguard sites to show in obfuscated config useEffect(() => { @@ -354,7 +353,7 @@ export default function CredentialsPage() { text={generateObfuscatedWireGuardConfig( { subnet: - site?.subnet || + site?.exitNodeSubnet || siteDefaults?.subnet || null, address: