diff --git a/messages/en-US.json b/messages/en-US.json index 0071ceec0..4a3937d5b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1957,7 +1957,7 @@ "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudo": "Allow sudo", "sshSudoCommands": "Sudo Commands", - "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.", + "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.", "sshCreateHomeDir": "Create Home Directory", "sshUnixGroups": "Unix Groups", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.", diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index a9a2759c4..e5543d5ef 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1826,3 +1826,77 @@ export async function verifyClientAssociationsCache( extraSiteIds: extraSiteIds.sort((a, b) => a - b) }; } + +// cleanupSiteAssociations efficiently removes all client associations for a +// site that is being deleted. Instead of calling +// rebuildClientAssociationsFromSiteResource once per site resource (which is +// O(resources) in DB round-trips and message fan-out), this function performs +// a single bulk lookup of affected clients and site resources, deletes all +// cache rows at once, and fires all peer/proxy removal messages in parallel. +// +// The caller is responsible for deleting the site row itself (and for sending +// the newt/wg/terminate signal to the newt process). +export async function cleanupSiteAssociations( + site: Site, + trx: Transaction | typeof db = db +): Promise { + const siteId = site.siteId; + + logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`); + + // 1. Find every client currently cached against this site. + const cachedSiteClientRows = await trx + .select({ clientId: clientSitesAssociationsCache.clientId }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); + + const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId); + + // 2. Load full client details (needed for WireGuard public-key references). + const allClients = + cachedClientIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where(inArray(clients.clientId, cachedClientIds)) + : []; + + // 6. Bulk-delete all cache entries for this site. Do this before sending + // destination-update messages so updateClientSiteDestinations computes + // the correct (post-deletion) set of destinations. + await trx + .delete(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); + + logger.debug( + `cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}` + ); + + // 7. Fire all removal messages in parallel. + const jobs: Promise[] = []; + + for (const client of allClients) { + // Tell each olm to drop the site's WireGuard peer. + if (site.publicKey) { + jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey)); + } + + // Recompute and push updated relay destinations (now excluding this site). + if (client.pubKey && client.subnet) { + jobs.push(updateClientSiteDestinations(client, trx)); + } + } + + await Promise.all(jobs).catch((error) => { + logger.error( + `cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`, + error + ); + }); + + logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`); +} diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 10ecbbf1e..a3542f970 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Site, siteNetworks, siteResources } from "@server/db"; -import { newts, newtSessions, sites } from "@server/db"; -import { eq, inArray } from "drizzle-orm"; +import { db } from "@server/db"; +import { newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -11,7 +11,7 @@ import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; @@ -63,7 +63,11 @@ export async function deleteSite( ); } - let deletedNewtId: string | null = null; + const [deletedNewt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); await db.transaction(async (trx) => { if (site.type == "wireguard") { @@ -71,56 +75,24 @@ export async function deleteSite( await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - const networks = await trx - .select({ networkId: siteNetworks.networkId }) - .from(siteNetworks) - .where(eq(siteNetworks.siteId, siteId)); + // Clean up all client associations and send peer/proxy removal + // messages in a single efficient pass before deleting the row. + await cleanupSiteAssociations(site, trx); - // loop through them - const updatedSiteResources = await trx - .select() - .from(siteResources) - .where( - inArray( - siteResources.networkId, - networks.map((n) => n.networkId) - ) - ); - for (const siteResource of updatedSiteResources) { - await rebuildClientAssociationsFromSiteResource( - siteResource, - trx - ); - } - - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, siteId)) - .returning(); - if (deletedNewt) { - deletedNewtId = deletedNewt.newtId; - - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where(eq(newtSessions.newtId, deletedNewt.newtId)); - } + await trx.delete(sites).where(eq(sites.siteId, siteId)); } - await trx.delete(sites).where(eq(sites.siteId, siteId)); - await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); // Send termination message outside of transaction to prevent blocking - if (deletedNewtId) { + if (deletedNewt) { const payload = { type: `newt/wg/terminate`, data: {} }; // Don't await this to prevent blocking the response - sendToClient(deletedNewtId, payload).catch((error) => { + sendToClient(deletedNewt.newtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", error diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 34c7b58fe..cb424988a 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -15,10 +15,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { - rebuildClientAssociationsFromClient, - rebuildClientAssociationsFromSiteResource -} from "@server/lib/rebuildClientAssociations"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; const batchAddClientToSiteResourcesParamsSchema = z .object({