From 64c901d91fe0fc1a97e39992f9fa09035d8b3b13 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 May 2026 21:06:34 -0700 Subject: [PATCH] Properly lock the ip selection through writes to db --- server/lib/blueprints/clientResources.ts | 10 +- server/lib/calculateUserClientsForOrgs.ts | 13 +- server/lib/ip.ts | 241 ++++----- server/routers/client/pickClientDefaults.ts | 4 +- server/routers/newt/registerNewt.ts | 146 +++--- server/routers/site/createSite.ts | 456 +++++++++--------- server/routers/site/pickSiteDefaults.ts | 4 +- .../siteResource/createSiteResource.ts | 275 ++++++----- 8 files changed, 598 insertions(+), 551 deletions(-) diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 67291bd0f..9b46b723c 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -364,8 +364,14 @@ export async function updateClientResources( }); } else { let aliasAddress: string | null = null; + let releaseAliasLock: (() => Promise) | null = null; if (resourceData.mode === "host" || resourceData.mode === "http") { - aliasAddress = await getNextAvailableAliasAddress(orgId, trx); + const { value, release } = await getNextAvailableAliasAddress( + orgId, + trx + ); + aliasAddress = value; + releaseAliasLock = release; } let domainInfo: @@ -427,6 +433,8 @@ export async function updateClientResources( }) .returning(); + await releaseAliasLock?.(); + const siteResourceId = newResource.siteResourceId; for (const site of allSites) { diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 6354dd81f..090bf4d8c 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs( ]; // Get next available subnet - const newSubnet = await getNextAvailableClientSubnet( - orgId, - transaction - ); - if (!newSubnet) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found` - ); - continue; - } + const { value: newSubnet, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId, transaction); const subnet = newSubnet.split("/")[0]; const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; @@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs( .insert(clients) .values(newClientData) .returning(); + await releaseSubnetLock(); existingClientCache.set( getOrgOlmKey(orgId, olm.olmId), newClient diff --git a/server/lib/ip.ts b/server/lib/ip.ts index e80f6baf8..373191947 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean { export async function getNextAvailableClientSubnet( orgId: string, transaction: Transaction | typeof db = db -): Promise { - return await lockManager.withLock( - `client-subnet-allocation:${orgId}`, - async () => { - const [org] = await transaction - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); +): Promise<{ value: string; release: () => Promise }> { + const lockKey = `client-subnet-allocation:${orgId}`; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + try { + const [org] = await transaction + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - if (!org.subnet) { - throw new Error( - `Organization with ID ${orgId} has no subnet defined` - ); - } - - const existingAddressesSites = await transaction - .select({ - address: sites.address - }) - .from(sites) - .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); - - const existingAddressesClients = await transaction - .select({ - address: clients.subnet - }) - .from(clients) - .where( - and(isNotNull(clients.subnet), eq(clients.orgId, orgId)) - ); - - const addresses = [ - ...existingAddressesSites.map( - (site) => `${site.address?.split("/")[0]}/32` - ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org - ...existingAddressesClients.map( - (client) => `${client.address.split("/")}/32` - ) - ].filter((address) => address !== null) as string[]; - - const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } - - return subnet; + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); } - ); + + if (!org.subnet) { + throw new Error( + `Organization with ID ${orgId} has no subnet defined` + ); + } + + const existingAddressesSites = await transaction + .select({ + address: sites.address + }) + .from(sites) + .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); + + const existingAddressesClients = await transaction + .select({ + address: clients.subnet + }) + .from(clients) + .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); + + const addresses = [ + ...existingAddressesSites.map( + (site) => `${site.address?.split("/")[0]}/32` + ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org + ...existingAddressesClients.map( + (client) => `${client.address.split("/")[0]}/32` + ) + ].filter((address) => address !== null) as string[]; + + const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } export async function getNextAvailableAliasAddress( orgId: string, trx: Transaction | typeof db = db -): Promise { - return await lockManager.withLock( - `alias-address-allocation:${orgId}`, - async () => { - const [org] = await trx - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); +): Promise<{ value: string; release: () => Promise }> { + const lockKey = `alias-address-allocation:${orgId}`; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + try { + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - if (!org.subnet) { - throw new Error( - `Organization with ID ${orgId} has no subnet defined` - ); - } - - if (!org.utilitySubnet) { - throw new Error( - `Organization with ID ${orgId} has no utility subnet defined` - ); - } - - const existingAddresses = await trx - .select({ - aliasAddress: siteResources.aliasAddress - }) - .from(siteResources) - .where( - and( - isNotNull(siteResources.aliasAddress), - eq(siteResources.orgId, orgId) - ) - ); - - const addresses = [ - ...existingAddresses.map( - (site) => `${site.aliasAddress?.split("/")[0]}/32` - ), - // reserve a /29 for the dns server and other stuff - `${org.utilitySubnet.split("/")[0]}/29` - ].filter((address) => address !== null) as string[]; - - let subnet = findNextAvailableCidr( - addresses, - 32, - org.utilitySubnet - ); - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } - - // remove the cidr - subnet = subnet.split("/")[0]; - - return subnet; + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); } - ); + + if (!org.subnet) { + throw new Error( + `Organization with ID ${orgId} has no subnet defined` + ); + } + + if (!org.utilitySubnet) { + throw new Error( + `Organization with ID ${orgId} has no utility subnet defined` + ); + } + + const existingAddresses = await trx + .select({ + aliasAddress: siteResources.aliasAddress + }) + .from(siteResources) + .where( + and( + isNotNull(siteResources.aliasAddress), + eq(siteResources.orgId, orgId) + ) + ); + + const addresses = [ + ...existingAddresses.map( + (site) => `${site.aliasAddress?.split("/")[0]}/32` + ), + // reserve a /29 for the dns server and other stuff + `${org.utilitySubnet.split("/")[0]}/29` + ].filter((address) => address !== null) as string[]; + + let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + // remove the cidr + subnet = subnet.split("/")[0]; + + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } -export async function getNextAvailableOrgSubnet(): Promise { - return await lockManager.withLock("org-subnet-allocation", async () => { +export async function getNextAvailableOrgSubnet(): Promise<{ + value: string; + release: () => Promise; +}> { + const lockKey = "org-subnet-allocation"; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey); + + try { const existingAddresses = await db .select({ subnet: orgs.subnet @@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise { throw new Error("No available subnets remaining in space"); } - return subnet; - }); + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } export function generateRemoteSubnets( diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts index 5dffd77d7..ece774dfb 100644 --- a/server/routers/client/pickClientDefaults.ts +++ b/server/routers/client/pickClientDefaults.ts @@ -51,7 +51,9 @@ export async function pickClientDefaults( const olmId = generateId(15); const secret = generateId(48); - const newSubnet = await getNextAvailableClientSubnet(orgId); + const { value: newSubnet, release } = + await getNextAvailableClientSubnet(orgId); + await release(); // release immediately — this endpoint only previews the next available value if (!newSubnet) { return next( createHttpError( diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index b79118b58..3adcfb467 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -203,84 +203,82 @@ export async function registerNewt( let newSiteId: number | undefined; - await db.transaction(async (trx) => { - const newClientAddress = await getNextAvailableClientSubnet(orgId); - if (!newClientAddress) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "No available subnet found" - ) - ); - } + const { value: newClientAddress, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId); + try { + await db.transaction(async (trx) => { + let clientAddress = newClientAddress.split("/")[0]; + clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org - let clientAddress = newClientAddress.split("/")[0]; - clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org + // Create the site (type "newt", name = niceId) + const [newSite] = await trx + .insert(sites) + .values({ + orgId, + name: name || niceId, + niceId, + address: clientAddress, + type: "newt", + dockerSocketEnabled: true, + status: keyRecord.approveNewSites + ? "approved" + : "pending" + }) + .returning(); - // Create the site (type "newt", name = niceId) - const [newSite] = await trx - .insert(sites) - .values({ - orgId, - name: name || niceId, - niceId, - address: clientAddress, - type: "newt", - dockerSocketEnabled: true, - status: keyRecord.approveNewSites ? "approved" : "pending" - }) - .returning(); + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); - await logsDb.insert(statusHistory).values({ - entityType: "site", - entityId: newSite.siteId, - orgId: orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000) + newSiteId = newSite.siteId; + + // Grant admin role access to the new site + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found for org ${orgId}`); + } + + await trx.insert(roleSites).values({ + roleId: adminRole.roleId, + siteId: newSite.siteId + }); + + // Create the newt for this site + await trx.insert(newts).values({ + newtId, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + + // Consume the provisioning key - cascade removes siteProvisioningKeyOrg + await trx + .update(siteProvisioningKeys) + .set({ + lastUsed: moment().toISOString(), + numUsed: sql`${siteProvisioningKeys.numUsed} + 1` + }) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + provisioningKeyId + ) + ); + + await usageService.add(orgId, FeatureId.SITES, 1, trx); }); - - newSiteId = newSite.siteId; - - // Grant admin role access to the new site - const [adminRole] = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (!adminRole) { - throw new Error(`Admin role not found for org ${orgId}`); - } - - await trx.insert(roleSites).values({ - roleId: adminRole.roleId, - siteId: newSite.siteId - }); - - // Create the newt for this site - await trx.insert(newts).values({ - newtId, - secretHash, - siteId: newSite.siteId, - dateCreated: moment().toISOString() - }); - - // Consume the provisioning key - cascade removes siteProvisioningKeyOrg - await trx - .update(siteProvisioningKeys) - .set({ - lastUsed: moment().toISOString(), - numUsed: sql`${siteProvisioningKeys.numUsed} + 1` - }) - .where( - eq( - siteProvisioningKeys.siteProvisioningKeyId, - provisioningKeyId - ) - ); - - await usageService.add(orgId, FeatureId.SITES, 1, trx); - }); + } finally { + await releaseSubnetLock(); + } logger.info( `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 29eb4935d..f6445342f 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -174,6 +174,7 @@ export async function createSite( } let updatedAddress = null; + let releaseSubnetLock: (() => Promise) | null = null; if (address) { if (!org.subnet) { return next( @@ -244,147 +245,22 @@ export async function createSite( ); } } else { - const newClientAddress = await getNextAvailableClientSubnet(orgId); - if (!newClientAddress) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "No available address found" - ) - ); - } - + const { value: newClientAddress, release } = + await getNextAvailableClientSubnet(orgId); + releaseSubnetLock = release; updatedAddress = newClientAddress.split("/")[0]; } - if (subnet && exitNodeId) { - //make sure the subnet is in the range of the exit node if provided - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, exitNodeId)); - - if (!exitNode) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Exit node not found") - ); - } - - if (!exitNode.address) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Exit node has no subnet defined" - ) - ); - } - - const subnetIp = subnet.split("/")[0]; - - if (!isIpInCidr(subnetIp, exitNode.address)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Subnet is not in the CIDR range of the exit node address." - ) - ); - } - - // lets also make sure there is no overlap with other sites on the exit node - const sitesQuery = await db - .select({ - subnet: sites.subnet - }) - .from(sites) - .where( - and( - eq(sites.exitNodeId, exitNodeId), - eq(sites.subnet, subnet) - ) - ); - - if (sitesQuery.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - `Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.` - ) - ); - } - } - - let updatedNiceId = niceId; - if (!niceId) { - updatedNiceId = await getUniqueSiteName(orgId); - } else { - // make sure the niceId is unique - const existingSite = await db - .select() - .from(sites) - .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) - .limit(1); - - if (existingSite.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - `Nice ID ${niceId} already exists. Please choose a different one.` - ) - ); - } - } - let newSite: Site | undefined; - await db.transaction(async (trx) => { - if (type == "newt") { - [newSite] = await trx - .insert(sites) - .values({ - // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT - orgId, - name, - niceId: updatedNiceId!, - address: updatedAddress || null, - type, - dockerSocketEnabled: true, - status: "approved" - }) - .returning(); - - await logsDb.insert(statusHistory).values({ - entityType: "site", - entityId: newSite.siteId, - orgId: orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000) - }); - } else if (type == "wireguard") { - // we are creating a site with an exit node (tunneled) - if (!subnet) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Subnet is required for tunneled sites" - ) - ); - } - - if (!exitNodeId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Exit node ID is required for tunneled sites" - ) - ); - } - - const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( - exitNodeId, - orgId - ); + try { + if (subnet && exitNodeId) { + //make sure the subnet is in the range of the exit node if provided + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)); if (!exitNode) { - logger.warn("Exit node not found"); return next( createHttpError( HttpCode.NOT_FOUND, @@ -393,118 +269,246 @@ export async function createSite( ); } - if (!hasAccess) { - logger.warn("Not authorized to use this exit node"); + if (!exitNode.address) { return next( createHttpError( - HttpCode.FORBIDDEN, - "Not authorized to use this exit node" + HttpCode.BAD_REQUEST, + "Exit node has no subnet defined" ) ); } - [newSite] = await trx - .insert(sites) - .values({ - orgId, - exitNodeId, - name, - niceId: updatedNiceId!, - subnet, - type, - pubKey: pubKey || null, - status: "approved" + const subnetIp = subnet.split("/")[0]; + + if (!isIpInCidr(subnetIp, exitNode.address)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subnet is not in the CIDR range of the exit node address." + ) + ); + } + + // lets also make sure there is no overlap with other sites on the exit node + const sitesQuery = await db + .select({ + subnet: sites.subnet }) - .returning(); - } else if (type == "local") { - [newSite] = await trx - .insert(sites) - .values({ - exitNodeId: exitNodeId || null, - orgId, - name, - niceId: updatedNiceId!, - type, - dockerSocketEnabled: false, - online: true, - subnet: "0.0.0.0/32", - status: "approved" - }) - .returning(); + .from(sites) + .where( + and( + eq(sites.exitNodeId, exitNodeId), + eq(sites.subnet, subnet) + ) + ); + + if (sitesQuery.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.` + ) + ); + } + } + + let updatedNiceId = niceId; + if (!niceId) { + updatedNiceId = await getUniqueSiteName(orgId); } else { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Site type not recognized" + // make sure the niceId is unique + const existingSite = await db + .select() + .from(sites) + .where( + and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)) ) - ); + .limit(1); + + if (existingSite.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Nice ID ${niceId} already exists. Please choose a different one.` + ) + ); + } } - const adminRole = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); + await db.transaction(async (trx) => { + if (type == "newt") { + [newSite] = await trx + .insert(sites) + .values({ + // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT + orgId, + name, + niceId: updatedNiceId!, + address: updatedAddress || null, + type, + dockerSocketEnabled: true, + status: "approved" + }) + .returning(); - if (adminRole.length === 0) { - return next( - createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) - ); - } + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + } else if (type == "wireguard") { + // we are creating a site with an exit node (tunneled) + if (!subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subnet is required for tunneled sites" + ) + ); + } - await trx.insert(roleSites).values({ - roleId: adminRole[0].roleId, - siteId: newSite.siteId - }); + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for tunneled sites" + ) + ); + } - if ( - req.user && - !req.userOrgRoleIds?.includes(adminRole[0].roleId) - ) { - // make sure the user can access the site - trx.insert(userSites).values({ - userId: req.user?.userId!, + const { exitNode, hasAccess } = + await verifyExitNodeOrgAccess(exitNodeId, orgId); + + if (!exitNode) { + logger.warn("Exit node not found"); + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Exit node not found" + ) + ); + } + + if (!hasAccess) { + logger.warn("Not authorized to use this exit node"); + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Not authorized to use this exit node" + ) + ); + } + + [newSite] = await trx + .insert(sites) + .values({ + orgId, + exitNodeId, + name, + niceId: updatedNiceId!, + subnet, + type, + pubKey: pubKey || null, + status: "approved" + }) + .returning(); + } else if (type == "local") { + [newSite] = await trx + .insert(sites) + .values({ + exitNodeId: exitNodeId || null, + orgId, + name, + niceId: updatedNiceId!, + type, + dockerSocketEnabled: false, + online: true, + subnet: "0.0.0.0/32", + status: "approved" + }) + .returning(); + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site type not recognized" + ) + ); + } + + const adminRole = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Admin role not found` + ) + ); + } + + await trx.insert(roleSites).values({ + roleId: adminRole[0].roleId, siteId: newSite.siteId }); - } - // add the peer to the exit node - if (type == "newt") { - const secretHash = await hashPassword(updatedNewtSecret); - - await trx.insert(newts).values({ - newtId: updatedNewtId, - secretHash, - siteId: newSite.siteId, - dateCreated: moment().toISOString() - }); - } else if (type == "wireguard") { - if (!pubKey) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Public key is required for wireguard sites" - ) - ); + if ( + req.user && + !req.userOrgRoleIds?.includes(adminRole[0].roleId) + ) { + // make sure the user can access the site + trx.insert(userSites).values({ + userId: req.user?.userId!, + siteId: newSite.siteId + }); } - if (!exitNodeId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Exit node ID is required for wireguard sites" - ) - ); + // add the peer to the exit node + if (type == "newt") { + const secretHash = await hashPassword(updatedNewtSecret); + + await trx.insert(newts).values({ + newtId: updatedNewtId, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + } else if (type == "wireguard") { + if (!pubKey) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Public key is required for wireguard sites" + ) + ); + } + + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for wireguard sites" + ) + ); + } + + await addPeer(exitNodeId, { + publicKey: pubKey, + allowedIps: [] + }); } - await addPeer(exitNodeId, { - publicKey: pubKey, - allowedIps: [] - }); - } - - await usageService.add(orgId, FeatureId.SITES, 1, trx); - }); + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + } finally { + await releaseSubnetLock?.(); + } if (!newSite) { return next( diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 4e6e3bb17..736726577 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -119,7 +119,9 @@ export async function pickSiteDefaults( ); } - const newClientAddress = await getNextAvailableClientSubnet(orgId); + const { value: newClientAddress, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId); + await releaseSubnetLock(); // release immediately — this endpoint only previews the next available value if (!newClientAddress) { return next( createHttpError( diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 25f71df3f..db52864ca 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -397,144 +397,163 @@ export async function createSiteResource( } let aliasAddress: string | null = null; + let releaseAliasLock: (() => Promise) | null = null; if (mode === "host" || mode === "http") { - aliasAddress = await getNextAvailableAliasAddress(orgId); + const { value, release } = + await getNextAvailableAliasAddress(orgId); + aliasAddress = value; + releaseAliasLock = release; } let newSiteResource: SiteResource | undefined; - await db.transaction(async (trx) => { - const [network] = await trx - .insert(networks) - .values({ - scope: "resource", - orgId: orgId - }) - .returning(); + try { + await db.transaction(async (trx) => { + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); - if (!network) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Failed to create network` - ) - ); - } - - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } - - // Create the site resource - const insertValues: typeof siteResources.$inferInsert = { - niceId: updatedNiceId!, - orgId, - name, - mode, - ssl, - networkId: network.networkId, - destination: destination, // the ssh can be null - scheme, - destinationPort, - enabled, - alias: alias ? alias.trim() : null, - aliasAddress, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" ? "" : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false - domainId, - subdomain: finalSubdomain, - fullDomain - }; - if (isLicensedSshPam) { - if (authDaemonPort !== undefined) - insertValues.authDaemonPort = authDaemonPort; - if (authDaemonMode !== undefined) - insertValues.authDaemonMode = authDaemonMode; - if (pamMode !== undefined) insertValues.pamMode = pamMode; - } - [newSiteResource] = await trx - .insert(siteResources) - .values(insertValues) - .returning(); - - const siteResourceId = newSiteResource.siteResourceId; - - //////////////////// update the associations //////////////////// - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: network.networkId - }); - } - - const [adminRole] = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (!adminRole) { - return next( - createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) - ); - } - - await trx.insert(roleSiteResources).values({ - roleId: adminRole.roleId, - siteResourceId: siteResourceId - }); - - if (roleIds.length > 0) { - await trx - .insert(roleSiteResources) - .values( - roleIds.map((roleId) => ({ roleId, siteResourceId })) - ); - } - - if (userIds.length > 0) { - await trx - .insert(userSiteResources) - .values( - userIds.map((userId) => ({ userId, siteResourceId })) - ); - } - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - - for (const siteToAssign of sitesToAssign) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteToAssign.siteId)) - .limit(1); - - if (!newt) { + if (!network) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Newt not found for site ${siteToAssign.siteId}` + HttpCode.INTERNAL_SERVER_ERROR, + `Failed to create network` ) ); } - } - }); + + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } + + // Create the site resource + const insertValues: typeof siteResources.$inferInsert = { + niceId: updatedNiceId!, + orgId, + name, + mode, + ssl, + networkId: network.networkId, + destination: destination, // the ssh can be null + scheme, + destinationPort, + enabled, + alias: alias ? alias.trim() : null, + aliasAddress, + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false + domainId, + subdomain: finalSubdomain, + fullDomain + }; + if (isLicensedSshPam) { + if (authDaemonPort !== undefined) + insertValues.authDaemonPort = authDaemonPort; + if (authDaemonMode !== undefined) + insertValues.authDaemonMode = authDaemonMode; + if (pamMode !== undefined) insertValues.pamMode = pamMode; + } + [newSiteResource] = await trx + .insert(siteResources) + .values(insertValues) + .returning(); + + const siteResourceId = newSiteResource.siteResourceId; + + //////////////////// update the associations //////////////////// + + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: network.networkId + }); + } + + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Admin role not found` + ) + ); + } + + await trx.insert(roleSiteResources).values({ + roleId: adminRole.roleId, + siteResourceId: siteResourceId + }); + + if (roleIds.length > 0) { + await trx + .insert(roleSiteResources) + .values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); + } + + if (userIds.length > 0) { + await trx + .insert(userSiteResources) + .values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + + for (const siteToAssign of sitesToAssign) { + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteToAssign.siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt not found for site ${siteToAssign.siteId}` + ) + ); + } + } + }); + } finally { + await releaseAliasLock?.(); + } if (!newSiteResource) { return next(