From 102a2354075345c4c3b62ec4b64eb2e018be5e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:38 -0700 Subject: [PATCH 01/25] Adjust schema for many to one site resources --- server/db/pg/schema/schema.ts | 12 +++++++++--- server/db/sqlite/schema/schema.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b93c21fd6..685cca0f2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -216,9 +216,6 @@ export const exitNodes = pgTable("exitNodes", { export const siteResources = pgTable("siteResources", { // this is for the clients siteResourceId: serial("siteResourceId").primaryKey(), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), @@ -241,6 +238,15 @@ export const siteResources = pgTable("siteResources", { .default("site") }); +export const siteSiteResources = pgTable("siteSiteResources", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const clientSiteResources = pgTable("clientSiteResources", { clientId: integer("clientId") .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 188caac2b..20fca1c94 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -239,9 +239,6 @@ export const siteResources = sqliteTable("siteResources", { siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), @@ -266,6 +263,15 @@ export const siteResources = sqliteTable("siteResources", { .default("site") }); +export const siteSiteResources = sqliteTable("siteSiteResources", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const clientSiteResources = sqliteTable("clientSiteResources", { clientId: integer("clientId") .notNull() From d8b511b198759692f449b017bf1a770ed7b5540d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:49 -0700 Subject: [PATCH 02/25] Adjust create and update to be many to one --- .../siteResource/createSiteResource.ts | 46 +-- .../siteResource/updateSiteResource.ts | 296 ++++++++++-------- 2 files changed, 193 insertions(+), 149 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b9494776e..273c7c022 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -8,6 +8,7 @@ import { SiteResource, siteResources, sites, + siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -23,7 +24,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -37,7 +38,7 @@ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), - siteId: z.int(), + siteIds: z.array(z.int()), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), @@ -159,7 +160,7 @@ export async function createSiteResource( const { orgId } = parsedParams.data; const { name, - siteId, + siteIds, mode, // protocol, // proxyPort, @@ -178,14 +179,14 @@ export async function createSiteResource( } = parsedBody.data; // Verify the site exists and belongs to the org - const [site] = await db + const sitesToAssign = await db .select() .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))) .limit(1); - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + if (sitesToAssign.length !== siteIds.length) { + return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); } const [org] = await db @@ -289,7 +290,6 @@ export async function createSiteResource( await db.transaction(async (trx) => { // Create the site resource const insertValues: typeof siteResources.$inferInsert = { - siteId, niceId, orgId, name, @@ -317,6 +317,13 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// + for (const siteId of siteIds) { + await trx.insert(siteSiteResources).values({ + siteId: siteId, + siteResourceId: siteResourceId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -359,17 +366,18 @@ export async function createSiteResource( ); } - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + // Not sure what this is doing?? + // const [newt] = await trx + // .select() + // .from(newts) + // .where(eq(newts.siteId, site.siteId)) + // .limit(1); - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); - } + // if (!newt) { + // return next( + // createHttpError(HttpCode.NOT_FOUND, "Newt not found") + // ); + // } await rebuildClientAssociationsFromSiteResource( newSiteResource, @@ -387,7 +395,7 @@ export async function createSiteResource( } logger.info( - `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` + `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` ); return response(res, { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 596ed9a3f..f22c5a047 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -9,6 +9,7 @@ import { roles, roleSiteResources, sites, + siteSiteResources, Transaction, userSiteResources } from "@server/db"; @@ -16,7 +17,7 @@ import { siteResources, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, inArray } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -42,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - siteId: z.int(), + siteIds: z.array(z.int()), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr"]).optional(), @@ -166,7 +167,7 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteId, // because it can change + siteIds, // because it can change mode, destination, alias, @@ -181,16 +182,6 @@ export async function updateSiteResource( authDaemonMode } = parsedBody.data; - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); - } - // Check if site resource exists const [existingSiteResource] = await db .select() @@ -230,6 +221,24 @@ export async function updateSiteResource( ); } + // Verify the site exists and belongs to the org + const sitesToAssign = await db + .select() + .from(sites) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.orgId, existingSiteResource.orgId) + ) + ) + .limit(1); + + if (sitesToAssign.length !== siteIds.length) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); + } + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) @@ -247,25 +256,20 @@ export async function updateSiteResource( ); } - let existingSite = site; - let siteChanged = false; - if (existingSiteResource.siteId !== siteId) { - siteChanged = true; - // get the existing site - [existingSite] = await db - .select() - .from(sites) - .where(eq(sites.siteId, existingSiteResource.siteId)) - .limit(1); + let sitesChanged = false; + const existingSiteIds = await db + .select() + .from(siteSiteResources) + .where(eq(siteSiteResources.siteResourceId, siteResourceId)); - if (!existingSite) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Existing site not found" - ) - ); - } + const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); + const newSiteIdSet = new Set(siteIds); + + if ( + existingSiteIdSet.size !== newSiteIdSet.size || + ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) + ) { + sitesChanged = true; } // make sure the alias is unique within the org if provided @@ -295,7 +299,7 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (siteChanged) { + if (sitesChanged) { // delete the existing site resource await trx .delete(siteResources) @@ -321,7 +325,8 @@ export async function updateSiteResource( const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -335,7 +340,6 @@ export async function updateSiteResource( .update(siteResources) .set({ name: name, - siteId: siteId, mode: mode, destination: destination, enabled: enabled, @@ -423,7 +427,8 @@ export async function updateSiteResource( // Update the site resource const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -437,7 +442,6 @@ export async function updateSiteResource( .update(siteResources) .set({ name: name, - siteId: siteId, mode: mode, destination: destination, enabled: enabled, @@ -454,6 +458,20 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteSiteResources) + .where( + eq(siteSiteResources.siteResourceId, siteResourceId) + ); + + for (const siteId of siteIds) { + await trx.insert(siteSiteResources).values({ + siteId: siteId, + siteResourceId: siteResourceId + }); + } + await trx .delete(clientSiteResources) .where( @@ -524,13 +542,16 @@ export async function updateSiteResource( } logger.info( - `Updated site resource ${siteResourceId} for site ${siteId}` + `Updated site resource ${siteResourceId}` ); await handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, - { siteId: site.siteId, orgId: site.orgId }, + siteIds.map((siteId) => ({ + siteId, + orgId: existingSiteResource.orgId + })), trx ); } @@ -557,7 +578,7 @@ export async function updateSiteResource( export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, - site: { siteId: number; orgId: string }, + sites: { siteId: number; orgId: string }[], trx: Transaction ) { logger.debug( @@ -594,101 +615,116 @@ export async function handleMessagingForUpdatedSiteResource( // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all if (destinationChanged || aliasChanged || portRangesChanged) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged) { - const oldTargets = generateSubnetProxyTargets( - existingSiteResource, - mergedAllClients - ); - const newTargets = generateSubnetProxyTargets( - updatedSiteResource, - mergedAllClients - ); - - await updateTargets(newt.newtId, { - oldTargets: oldTargets, - newTargets: newTargets - }, newt.version); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed - const oldDestinationStillInUseSites = await trx + for (const site of sites) { + const [newt] = await trx .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if destination changed + if (destinationChanged || portRangesChanged) { + const oldTargets = generateSubnetProxyTargets( + existingSiteResource, + mergedAllClients + ); + const newTargets = generateSubnetProxyTargets( + updatedSiteResource, + mergedAllClients ); - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets, + newTargets: newTargets + }, + newt.version + ); + } - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - client.clientId, - updatedSiteResource.siteId, - destinationChanged - ? { - oldRemoteSubnets: !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); + const olmJobs: Promise[] = []; + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + // todo: optimize this query if needed + const oldDestinationStillInUseSites = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteSiteResources, + eq( + siteSiteResources.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteSiteResources.siteId, site.siteId), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + + const oldDestinationStillInUseByASite = + oldDestinationStillInUseSites.length > 0; + + // we also need to update the remote subnets on the olms for each client that has access to this site + olmJobs.push( + updatePeerData( + client.clientId, + site.siteId, + destinationChanged + ? { + oldRemoteSubnets: + !oldDestinationStillInUseByASite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliasChanged + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + ) + ); + } + + await Promise.all(olmJobs); } - - await Promise.all(olmJobs); } } From 7cbe3d42a14bf88176233a07d7988bbf71937b22 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 12:10:04 -0700 Subject: [PATCH 03/25] Working on refactoring --- server/lib/blueprints/applyBlueprint.ts | 56 ++++---- server/lib/blueprints/clientResources.ts | 125 +++++++++++++----- server/lib/blueprints/types.ts | 3 +- .../siteResource/createSiteResource.ts | 27 ++-- .../siteResource/listAllSiteResourcesByOrg.ts | 20 +-- 5 files changed, 156 insertions(+), 75 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index a304bb392..fd189e6ca 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -121,8 +121,8 @@ export async function applyBlueprint({ for (const result of clientResourcesResults) { if ( result.oldSiteResource && - result.oldSiteResource.siteId != - result.newSiteResource.siteId + JSON.stringify(result.newSites?.sort()) !== + JSON.stringify(result.oldSites?.sort()) ) { // query existing associations const existingRoleIds = await trx @@ -222,38 +222,46 @@ export async function applyBlueprint({ trx ); } else { - const [newSite] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, result.newSiteResource.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) + let good = true; + for (const newSite of result.newSites) { + const [site] = await trx + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, newSite.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) ) - ) - .limit(1); + .limit(1); + + if (!site) { + logger.debug( + `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + ); + good = false; + break; + } - if (!newSite) { logger.debug( - `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}` ); - continue; } - logger.debug( - `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` - ); + if (!good) { + continue; + } await handleMessagingForUpdatedSiteResource( result.oldSiteResource, result.newSiteResource, - { - siteId: newSite.sites.siteId, - orgId: newSite.sites.orgId - }, + result.newSites.map((site) => ({ + siteId: site.siteId, + orgId: result.newSiteResource.orgId + })), trx ); } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 80c691c63..2ad36cd9f 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -3,8 +3,10 @@ import { clientSiteResources, roles, roleSiteResources, + Site, SiteResource, siteResources, + siteSiteResources, Transaction, userOrgs, users, @@ -19,6 +21,8 @@ import { getNextAvailableAliasAddress } from "../ip"; export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; + newSites: { siteId: number }[]; + oldSites: { siteId: number }[]; }[]; export async function updateClientResources( @@ -43,36 +47,75 @@ export async function updateClientResources( ) .limit(1); - const resourceSiteId = resourceData.site; - let site; - - if (resourceSiteId) { - // Look up site by niceId - [site] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where( - and( - eq(sites.niceId, resourceSiteId), - eq(sites.orgId, orgId) + const existingSiteIds = await trx + .select({ siteId: sites.siteId }) + .from(siteSiteResources) + .where( + and( + eq( + siteSiteResources.siteResourceId, + existingResource.siteResourceId ) ) - .limit(1); - } else if (siteId) { - // Use the provided siteId directly, but verify it belongs to the org - [site] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); - } else { - throw new Error(`Target site is required`); + ); + + let allSites: { siteId: number }[] = []; + if (resourceData.site) { + let siteSingle; + const resourceSiteId = resourceData.site; + + if (resourceSiteId) { + // Look up site by niceId + [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, resourceSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!siteSingle) { + throw new Error( + `Site not found: ${resourceSiteId} in org ${orgId}` + ); + } + allSites.push(siteSingle); } - if (!site) { - throw new Error( - `Site not found: ${resourceSiteId} in org ${orgId}` - ); + if (resourceData.sites) { + for (const siteNiceId of resourceData.sites) { + const [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, siteNiceId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + if (!site) { + throw new Error( + `Site not found: ${siteId} in org ${orgId}` + ); + } + allSites.push(site); + } } if (existingResource) { @@ -81,7 +124,6 @@ export async function updateClientResources( .update(siteResources) .set({ name: resourceData.name || resourceNiceId, - siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, enabled: true, // hardcoded for now @@ -102,6 +144,17 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; + await trx + .delete(siteSiteResources) + .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + + for (const site of allSites) { + await trx.insert(siteSiteResources).values({ + siteId: site.siteId, + siteResourceId: siteResourceId + }); + } + await trx .delete(clientSiteResources) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); @@ -204,7 +257,9 @@ export async function updateClientResources( results.push({ newSiteResource: updatedResource, - oldSiteResource: existingResource + oldSiteResource: existingResource, + newSites: allSites, + oldSites: existingSiteIds }); } else { let aliasAddress: string | null = null; @@ -218,7 +273,6 @@ export async function updateClientResources( .insert(siteResources) .values({ orgId: orgId, - siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, @@ -235,6 +289,13 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; + for (const site of allSites) { + await trx.insert(siteSiteResources).values({ + siteId: site.siteId, + siteResourceId: siteResourceId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -324,7 +385,11 @@ export async function updateClientResources( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); - results.push({ newSiteResource: newResource }); + results.push({ + newSiteResource: newResource, + newSites: allSites, + oldSites: existingSiteIds + }); } } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 2239e4f9a..efbdb3891 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -312,7 +312,8 @@ export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr"]), - site: z.string(), + site: z.string(), // DEPRECATED IN FAVOR OF sites + sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 273c7c022..4fa8c9960 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -366,18 +366,23 @@ export async function createSiteResource( ); } - // Not sure what this is doing?? - // const [newt] = await trx - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); + 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}` + ) + ); + } + } - // if (!newt) { - // return next( - // createHttpError(HttpCode.NOT_FOUND, "Newt not found") - // ); - // } await rebuildClientAssociationsFromSiteResource( newSiteResource, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3320aa3b7..40736f7c0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites } from "@server/db"; +import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -73,9 +73,9 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { - siteName: string; - siteNiceId: string; - siteAddress: string | null; + siteNames: string[]; + siteNiceIds: string[]; + siteAddresses: (string | null)[]; })[]; }>; @@ -83,7 +83,6 @@ function querySiteResourcesBase() { return db .select({ siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, orgId: siteResources.orgId, niceId: siteResources.niceId, name: siteResources.name, @@ -100,14 +99,17 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address + siteNames: sql`array_agg(${sites.name})`, + siteNiceIds: sql`array_agg(${sites.niceId})`, + siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); + .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) + .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .groupBy(siteResources.siteResourceId); } + registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", From b7421e47ccf8da49cd6e334f6324b70c9dd307e6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:22:04 -0700 Subject: [PATCH 04/25] Switch to using networks --- server/db/pg/schema/schema.ts | 31 ++++++++++++++++++++++++++----- server/db/sqlite/schema/schema.ts | 25 ++++++++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 685cca0f2..d76f4241d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -81,6 +81,10 @@ export const sites = pgTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + networkId: integer("networkId").references( + () => networks.networkId, + { onDelete: "set null" } + ), name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), @@ -219,6 +223,16 @@ export const siteResources = pgTable("siteResources", { orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references( + () => networks.networkId, + { onDelete: "set null" } + ), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { + onDelete: "restrict" + } + ), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" @@ -238,13 +252,19 @@ export const siteResources = pgTable("siteResources", { .default("site") }); -export const siteSiteResources = pgTable("siteSiteResources", { - siteId: integer("siteId") +export const networks = pgTable("networks", { + networkId: serial("networkId").primaryKey(), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + scope: varchar("scope") + .$type<"global" | "resource">() .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") + .default("global"), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const clientSiteResources = pgTable("clientSiteResources", { @@ -1080,3 +1100,4 @@ export type RequestAuditLog = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type Network = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 20fca1c94..c1555d7ee 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -82,6 +82,9 @@ export const sites = sqliteTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), name: text("name").notNull(), pubKey: text("pubKey"), subnet: text("subnet"), @@ -242,6 +245,13 @@ export const siteResources = sqliteTable("siteResources", { orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { onDelete: "restrict" } + ), niceId: text("niceId").notNull(), name: text("name").notNull(), mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" @@ -263,13 +273,17 @@ export const siteResources = sqliteTable("siteResources", { .default("site") }); -export const siteSiteResources = sqliteTable("siteSiteResources", { - siteId: integer("siteId") +export const networks = sqliteTable("networks", { + networkId: integer("networkId").primaryKey({ autoIncrement: true }), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + scope: text("scope") + .$type<"global" | "resource">() .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") + .default("global"), + orgId: text("orgId") .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) + .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const clientSiteResources = sqliteTable("clientSiteResources", { @@ -1164,6 +1178,7 @@ export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type SiteResource = InferSelectModel; +export type Network = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; From 6f2e37948c089b40877155cbad3d4b278d649cfe Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:30:00 -0700 Subject: [PATCH 05/25] Its many to one now --- server/db/pg/schema/schema.ts | 22 ++++++++++++++-------- server/db/sqlite/schema/schema.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d76f4241d..d4817283c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -81,10 +81,6 @@ export const sites = pgTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), - networkId: integer("networkId").references( - () => networks.networkId, - { onDelete: "set null" } - ), name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), @@ -223,10 +219,9 @@ export const siteResources = pgTable("siteResources", { orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - networkId: integer("networkId").references( - () => networks.networkId, - { onDelete: "set null" } - ), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), defaultNetworkId: integer("defaultNetworkId").references( () => networks.networkId, { @@ -267,6 +262,17 @@ export const networks = pgTable("networks", { .notNull() }); +export const siteNetworks = pgTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) +}); + export const clientSiteResources = pgTable("clientSiteResources", { clientId: integer("clientId") .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c1555d7ee..2578e236d 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -286,6 +286,17 @@ export const networks = sqliteTable("networks", { .references(() => orgs.orgId, { onDelete: "cascade" }) }); +export const siteNetworks = sqliteTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) +}); + export const clientSiteResources = sqliteTable("clientSiteResources", { clientId: integer("clientId") .notNull() From 2093bb5357ca4beb70a98235119b28796b7dda2b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:44:59 -0700 Subject: [PATCH 06/25] Remove siteSiteResources --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/blueprints/clientResources.ts | 55 +++++++++++-------- .../siteResource/createSiteResource.ts | 30 ++++++++-- .../siteResource/listAllSiteResourcesByOrg.ts | 8 ++- .../siteResource/updateSiteResource.ts | 34 +++++++----- 6 files changed, 87 insertions(+), 48 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d4817283c..bb4a096df 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", { export const networks = pgTable("networks", { networkId: serial("networkId").primaryKey(), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: varchar("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2578e236d..c28816883 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", { export const networks = sqliteTable("networks", { networkId: integer("networkId").primaryKey({ autoIncrement: true }), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: text("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 2ad36cd9f..42c3b76da 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -5,12 +5,13 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, - siteSiteResources, Transaction, userOrgs, users, - userSiteResources + userSiteResources, + networks } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne, inArray, or } from "drizzle-orm"; @@ -47,17 +48,12 @@ export async function updateClientResources( ) .limit(1); - const existingSiteIds = await trx - .select({ siteId: sites.siteId }) - .from(siteSiteResources) - .where( - and( - eq( - siteSiteResources.siteResourceId, - existingResource.siteResourceId - ) - ) - ); + const existingSiteIds = existingResource?.networkId + ? await trx + .select({ siteId: sites.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, existingResource.networkId)) + : []; let allSites: { siteId: number }[] = []; if (resourceData.site) { @@ -144,15 +140,19 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; - await trx - .delete(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + if (updatedResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedResource.networkId) + ); - for (const site of allSites) { - await trx.insert(siteSiteResources).values({ - siteId: site.siteId, - siteResourceId: siteResourceId - }); + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: updatedResource.networkId + }); + } } await trx @@ -268,12 +268,21 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, niceId: resourceNiceId, + networkId: network.networkId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, @@ -290,9 +299,9 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; for (const site of allSites) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: site.siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4fa8c9960..720b55f6c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -5,10 +5,11 @@ import { orgs, roles, roleSiteResources, + siteNetworks, + networks, SiteResource, siteResources, sites, - siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -186,7 +187,9 @@ export async function createSiteResource( .limit(1); if (sitesToAssign.length !== siteIds.length) { - return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); } const [org] = await db @@ -288,11 +291,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; 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` + ) + ); + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { niceId, orgId, name, + networkId: network.networkId, mode: mode as "host" | "cidr", destination, enabled, @@ -318,9 +339,9 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } @@ -383,7 +404,6 @@ export async function createSiteResource( } } - await rebuildClientAssociationsFromSiteResource( newSiteResource, trx diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 40736f7c0..2d90d69e0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; +import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -99,13 +99,15 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + networkId: siteResources.networkId, + defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) - .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index f22c5a047..338957249 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -8,8 +8,9 @@ import { orgs, roles, roleSiteResources, + siteNetworks, sites, - siteSiteResources, + networks, Transaction, userSiteResources } from "@server/db"; @@ -257,10 +258,14 @@ export async function updateSiteResource( } let sitesChanged = false; - const existingSiteIds = await db - .select() - .from(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + const existingSiteIds = existingSiteResource.networkId + ? await db + .select() + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, existingSiteResource.networkId) + ) + : []; const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); const newSiteIdSet = new Set(siteIds); @@ -460,15 +465,17 @@ export async function updateSiteResource( // delete the site - site resources associations await trx - .delete(siteSiteResources) + .delete(siteNetworks) .where( - eq(siteSiteResources.siteResourceId, siteResourceId) + eq(siteNetworks.networkId, updatedSiteResource.networkId!) + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that ); for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: updatedSiteResource.networkId! + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that }); } @@ -664,10 +671,11 @@ export async function handleMessagingForUpdatedSiteResource( ) ) .innerJoin( - siteSiteResources, + siteNetworks, eq( - siteSiteResources.siteResourceId, - siteResources.siteResourceId + siteNetworks.networkId, + siteResources.networkId + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that ) ) .where( @@ -676,7 +684,7 @@ export async function handleMessagingForUpdatedSiteResource( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteSiteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( siteResources.destination, existingSiteResource.destination From 87524fe8aefad5285cc261bd492afb9e3d575422 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:44:59 -0700 Subject: [PATCH 07/25] Remove siteSiteResources --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/blueprints/clientResources.ts | 56 +++++++++++-------- .../siteResource/createSiteResource.ts | 30 ++++++++-- .../siteResource/listAllSiteResourcesByOrg.ts | 8 ++- .../siteResource/updateSiteResource.ts | 31 +++++----- 6 files changed, 85 insertions(+), 48 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d4817283c..bb4a096df 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", { export const networks = pgTable("networks", { networkId: serial("networkId").primaryKey(), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: varchar("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2578e236d..c28816883 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", { export const networks = sqliteTable("networks", { networkId: integer("networkId").primaryKey({ autoIncrement: true }), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: text("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 2ad36cd9f..dd609936d 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -5,12 +5,13 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, - siteSiteResources, Transaction, userOrgs, users, - userSiteResources + userSiteResources, + networks } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne, inArray, or } from "drizzle-orm"; @@ -47,17 +48,12 @@ export async function updateClientResources( ) .limit(1); - const existingSiteIds = await trx - .select({ siteId: sites.siteId }) - .from(siteSiteResources) - .where( - and( - eq( - siteSiteResources.siteResourceId, - existingResource.siteResourceId - ) - ) - ); + const existingSiteIds = existingResource?.networkId + ? await trx + .select({ siteId: sites.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, existingResource.networkId)) + : []; let allSites: { siteId: number }[] = []; if (resourceData.site) { @@ -144,15 +140,19 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; - await trx - .delete(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + if (updatedResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedResource.networkId) + ); - for (const site of allSites) { - await trx.insert(siteSiteResources).values({ - siteId: site.siteId, - siteResourceId: siteResourceId - }); + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: updatedResource.networkId + }); + } } await trx @@ -268,12 +268,22 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, niceId: resourceNiceId, + networkId: network.networkId, + defaultNetworkId: network.networkId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, @@ -290,9 +300,9 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; for (const site of allSites) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: site.siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4fa8c9960..720b55f6c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -5,10 +5,11 @@ import { orgs, roles, roleSiteResources, + siteNetworks, + networks, SiteResource, siteResources, sites, - siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -186,7 +187,9 @@ export async function createSiteResource( .limit(1); if (sitesToAssign.length !== siteIds.length) { - return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); } const [org] = await db @@ -288,11 +291,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; 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` + ) + ); + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { niceId, orgId, name, + networkId: network.networkId, mode: mode as "host" | "cidr", destination, enabled, @@ -318,9 +339,9 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } @@ -383,7 +404,6 @@ export async function createSiteResource( } } - await rebuildClientAssociationsFromSiteResource( newSiteResource, trx diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 40736f7c0..2d90d69e0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; +import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -99,13 +99,15 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + networkId: siteResources.networkId, + defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) - .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index f22c5a047..f7e1262bb 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -8,8 +8,9 @@ import { orgs, roles, roleSiteResources, + siteNetworks, sites, - siteSiteResources, + networks, Transaction, userSiteResources } from "@server/db"; @@ -257,10 +258,14 @@ export async function updateSiteResource( } let sitesChanged = false; - const existingSiteIds = await db - .select() - .from(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + const existingSiteIds = existingSiteResource.networkId + ? await db + .select() + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, existingSiteResource.networkId) + ) + : []; const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); const newSiteIdSet = new Set(siteIds); @@ -460,15 +465,15 @@ export async function updateSiteResource( // delete the site - site resources associations await trx - .delete(siteSiteResources) + .delete(siteNetworks) .where( - eq(siteSiteResources.siteResourceId, siteResourceId) + eq(siteNetworks.networkId, updatedSiteResource.networkId!) ); for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: updatedSiteResource.networkId! }); } @@ -664,10 +669,10 @@ export async function handleMessagingForUpdatedSiteResource( ) ) .innerJoin( - siteSiteResources, + siteNetworks, eq( - siteSiteResources.siteResourceId, - siteResources.siteResourceId + siteNetworks.networkId, + siteResources.networkId ) ) .where( @@ -676,7 +681,7 @@ export async function handleMessagingForUpdatedSiteResource( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteSiteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( siteResources.destination, existingSiteResource.destination From a1ce7f54a0e511b0ffec46e09241588cd0cd4687 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 20 Mar 2026 09:17:10 -0700 Subject: [PATCH 08/25] Continue to rebase --- server/routers/site/deleteSite.ts | 27 +++++++++++-------- .../siteResource/deleteSiteResource.ts | 21 ++++++++------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 587572535..344f6b4e3 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Site, siteResources } from "@server/db"; +import { db, Site, siteNetworks, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -71,18 +71,23 @@ export async function deleteSite( await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // delete all of the site resources on this site - const siteResourcesOnSite = trx - .delete(siteResources) - .where(eq(siteResources.siteId, siteId)) - .returning(); + const networks = await trx + .select({ networkId: siteNetworks.networkId }) + .from(siteNetworks) + .where(eq(siteNetworks.siteId, siteId)); // loop through them - for (const removedSiteResource of await siteResourcesOnSite) { - await rebuildClientAssociationsFromSiteResource( - removedSiteResource, - trx - ); + for (const network of await networks) { + const [siteResource] = await trx + .select() + .from(siteResources) + .where(eq(siteResources.networkId, network.networkId)); + if (siteResource) { + await rebuildClientAssociationsFromSiteResource( + siteResource, + trx + ); + } } // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 5b50b0ea3..8d08d545d 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -70,17 +70,18 @@ export async function deleteSiteResource( .where(and(eq(siteResources.siteResourceId, siteResourceId))) .returning(); - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, removedSiteResource.siteId)) - .limit(1); + // not sure why this is here... + // const [newt] = await trx + // .select() + // .from(newts) + // .where(eq(newts.siteId, removedSiteResource.siteId)) + // .limit(1); - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); - } + // if (!newt) { + // return next( + // createHttpError(HttpCode.NOT_FOUND, "Newt not found") + // ); + // } await rebuildClientAssociationsFromSiteResource( removedSiteResource, From d85496453f63e9a420c4dce307eb8538563eef7a Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 21 Mar 2026 10:40:12 -0700 Subject: [PATCH 09/25] Change SSH WIP --- server/private/routers/ssh/signSshKey.ts | 129 ++++++++++++----------- 1 file changed, 70 insertions(+), 59 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 5cffb4a34..b9b6fed1a 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -21,7 +21,7 @@ import { roles, roundTripMessageTracker, siteResources, - sites, + siteNetworks, userOrgs } from "@server/db"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -62,11 +62,11 @@ const bodySchema = z export type SignSshKeyResponse = { certificate: string; - messageId: number; + messageIds: number[]; sshUsername: string; sshHost: string; resourceId: number; - siteId: number; + siteIds: number[]; keyId: string; validPrincipals: string[]; validAfter: string; @@ -250,10 +250,7 @@ export async function signSshKey( .update(userOrgs) .set({ pamUsername: usernameToUse }) .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, userId) - ) + and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)) ); } else { usernameToUse = userOrg.pamUsername; @@ -374,21 +371,12 @@ export async function signSshKey( const homedir = roleRow?.sshCreateHomeDir ?? null; const sudoMode = roleRow?.sshSudoMode ?? "none"; - // get the site - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, resource.siteId)) - .limit(1); + const sites = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId!)); - if (!newt) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Site associated with resource not found" - ) - ); - } + const siteIds = sites.map((site) => site.siteId); // Sign the public key const now = BigInt(Math.floor(Date.now() / 1000)); @@ -402,43 +390,64 @@ export async function signSshKey( validBefore: now + validFor }); - const [message] = await db - .insert(roundTripMessageTracker) - .values({ - wsClientId: newt.newtId, - messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000) - }) - .returning(); + const messageIds: number[] = []; + for (const siteId of siteIds) { + // get the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (!message) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create message tracker entry" - ) - ); - } - - await sendToClient(newt.newtId, { - type: `newt/pam/connection`, - data: { - messageId: message.messageId, - orgId: orgId, - agentPort: resource.authDaemonPort ?? 22123, - externalAuthDaemon: resource.authDaemonMode === "remote", - agentHost: resource.destination, - caCert: caKeys.publicKeyOpenSSH, - username: usernameToUse, - niceId: resource.niceId, - metadata: { - sudoMode: sudoMode, - sudoCommands: parsedSudoCommands, - homedir: homedir, - groups: parsedGroups - } + if (!newt) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site associated with resource not found" + ) + ); } - }); + + const [message] = await db + .insert(roundTripMessageTracker) + .values({ + wsClientId: newt.newtId, + messageType: `newt/pam/connection`, + sentAt: Math.floor(Date.now() / 1000) + }) + .returning(); + + if (!message) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create message tracker entry" + ) + ); + } + + messageIds.push(message.messageId); + + await sendToClient(newt.newtId, { + type: `newt/pam/connection`, + data: { + messageId: message.messageId, + orgId: orgId, + agentPort: resource.authDaemonPort ?? 22123, + externalAuthDaemon: resource.authDaemonMode === "remote", + agentHost: resource.destination, + caCert: caKeys.publicKeyOpenSSH, + username: usernameToUse, + niceId: resource.niceId, + metadata: { + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups + } + } + }); + } const expiresIn = Number(validFor); // seconds @@ -459,18 +468,20 @@ export async function signSshKey( metadata: JSON.stringify({ resourceId: resource.siteResourceId, resource: resource.name, - siteId: resource.siteId, + siteIds: siteIds }) }); + // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS + return response(res, { data: { certificate: cert.certificate, - messageId: message.messageId, + messageIds: messageIds, sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, - siteId: resource.siteId, + siteIds: siteIds, keyId: cert.keyId, validPrincipals: cert.validPrincipals, validAfter: cert.validAfter.toISOString(), From c48bc714430a6e52b5f39ec24f04c28413f7e7ef Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:18:34 -0700 Subject: [PATCH 10/25] Update crud endpoints and ui --- server/private/routers/ssh/signSshKey.ts | 6 +- .../routers/siteResource/getSiteResource.ts | 13 +-- .../siteResource/listAllSiteResourcesByOrg.ts | 2 + .../routers/siteResource/listSiteResources.ts | 17 +++- .../settings/resources/client/page.tsx | 8 +- src/components/ClientResourcesTable.tsx | 85 +++++++++++++++---- 6 files changed, 94 insertions(+), 37 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index b9b6fed1a..46976bb1d 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -63,10 +63,12 @@ const bodySchema = z export type SignSshKeyResponse = { certificate: string; messageIds: number[]; + messageId: number; sshUsername: string; sshHost: string; resourceId: number; siteIds: number[]; + siteId: number; keyId: string; validPrincipals: string[]; validAfter: string; @@ -472,16 +474,16 @@ export async function signSshKey( }) }); - // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS - return response(res, { data: { certificate: cert.certificate, messageIds: messageIds, + messageId: messageIds[0], // just pick the first one for backward compatibility sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, siteIds: siteIds, + siteId: siteIds[0], // just pick the first one for backward compatibility keyId: cert.keyId, validPrincipals: cert.validPrincipals, validAfter: cert.validAfter.toISOString(), diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index be28d36e4..2e3dfe87b 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({ .transform((val) => (val ? Number(val) : undefined)) .pipe(z.int().positive().optional()) .optional(), - siteId: z.string().transform(Number).pipe(z.int().positive()), niceId: z.string().optional(), orgId: z.string() }); async function query( siteResourceId?: number, - siteId?: number, niceId?: string, orgId?: string ) { - if (siteResourceId && siteId && orgId) { + if (siteResourceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .limit(1); return siteResource; - } else if (niceId && siteId && orgId) { + } else if (niceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.niceId, niceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) @@ -84,7 +80,6 @@ registry.registerPath({ request: { params: z.object({ niceId: z.string(), - siteId: z.number(), orgId: z.string() }) }, @@ -107,10 +102,10 @@ export async function getSiteResource( ); } - const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; + const { siteResourceId, niceId, orgId } = parsedParams.data; // Get the site resource - const siteResource = await query(siteResourceId, siteId, niceId, orgId); + const siteResource = await query(siteResourceId, niceId, orgId); if (!siteResource) { return next( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 2d90d69e0..759b06d4d 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -73,6 +73,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { + siteIds: number[]; siteNames: string[]; siteNiceIds: string[]; siteAddresses: (string | null)[]; @@ -103,6 +104,7 @@ function querySiteResourcesBase() { defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, + siteIds: sql`array_agg(${sites.siteId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 358aa0497..8a1469f76 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, networks, siteNetworks } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -108,13 +108,21 @@ export async function listSiteResources( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } - // Get site resources + // Get site resources by joining networks to siteResources via siteNetworks const siteResourcesList = await db .select() - .from(siteResources) + .from(siteNetworks) + .innerJoin( + networks, + eq(siteNetworks.networkId, networks.networkId) + ) + .innerJoin( + siteResources, + eq(siteResources.networkId, networks.networkId) + ) .where( and( - eq(siteResources.siteId, siteId), + eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId) ) ) @@ -128,6 +136,7 @@ export async function listSiteResources( .limit(limit) .offset(offset); + return response(res, { data: { siteResources: siteResourcesList }, success: true, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f0f582f0f..8ba3e29e6 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -60,17 +60,17 @@ export default async function ClientResourcesPage( id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, - siteName: siteResource.siteName, - siteAddress: siteResource.siteAddress || null, + siteNames: siteResource.siteNames, + siteAddresses: siteResource.siteAddresses || null, mode: siteResource.mode || ("port" as any), // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, - siteId: siteResource.siteId, + siteIds: siteResource.siteIds, destination: siteResource.destination, // destinationPort: siteResource.destinationPort, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, - siteNiceId: siteResource.siteNiceId, + siteNiceIds: siteResource.siteNiceIds, niceId: siteResource.niceId, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5066f273d..a45dc944e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -21,6 +21,7 @@ import { ArrowUp10Icon, ArrowUpDown, ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -43,14 +44,14 @@ export type InternalResourceRow = { id: number; name: string; orgId: string; - siteName: string; - siteAddress: string | null; + siteNames: string[]; + siteAddresses: (string | null)[]; + siteIds: number[]; + siteNiceIds: string[]; // mode: "host" | "cidr" | "port"; mode: "host" | "cidr"; // protocol: string | null; // proxyPort: number | null; - siteId: number; - siteNiceId: string; destination: string; // destinationPort: number | null; alias: string | null; @@ -136,6 +137,60 @@ export default function ClientResourcesTable({ } }; + function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) { + const { siteNames, siteNiceIds, orgId } = resourceRow; + + if (!siteNames || siteNames.length === 0) { + return -; + } + + if (siteNames.length === 1) { + return ( + + + + ); + } + + return ( + + + + + + {siteNames.map((siteName, idx) => ( + + + {siteName} + + + + ))} + + + ); + } + const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -185,21 +240,11 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", + accessorKey: "siteNames", friendlyName: t("site"), header: () => {t("site")}, cell: ({ row }) => { - const resourceRow = row.original; - return ( - - - - ); + return ; } }, { @@ -399,7 +444,7 @@ export default function ClientResourcesTable({ onConfirm={async () => deleteInternalResource( selectedInternalResource!.id, - selectedInternalResource!.siteId + selectedInternalResource!.siteIds[0] ) } string={selectedInternalResource.name} @@ -433,7 +478,11 @@ export default function ClientResourcesTable({ { From c4f48f5748af5eba3045ccba01a56d3f0cba78bf Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:29:47 -0700 Subject: [PATCH 11/25] WIP - more conversion --- .../handleOlmServerInitAddPeerHandshake.ts | 240 +++++++++--------- .../olm/handleOlmServerPeerAddMessage.ts | 42 ++- 2 files changed, 135 insertions(+), 147 deletions(-) diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index 54badb2dc..0eda41e04 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -4,10 +4,12 @@ import { db, exitNodes, Site, - siteResources + siteNetworks, + siteResources, + sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Olm, sites } from "@server/db"; +import { clients, Olm } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import logger from "@server/logger"; import { initPeerAddHandshake } from "./peers"; @@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( const { siteId, resourceId, chainId } = message.data; - let site: Site | null = null; + const sendCancel = async () => { + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { chainId } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + }; + + let sitesToProcess: Site[] = []; + if (siteId) { - // get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (siteRes) { - site = siteRes; + sitesToProcess = [siteRes]; } - } - - if (resourceId && !site) { + } else if (resourceId) { const resources = await db .select() .from(siteResources) @@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( ); if (!resources || resources.length === 0) { - logger.error(`handleOlmServerPeerAddMessage: Resource not found`); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + logger.error( + `handleOlmServerInitAddPeerHandshake: Resource not found` + ); + await sendCancel(); return; } if (resources.length > 1) { // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches logger.error( - `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` + `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` ); return; } @@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( if (currentResourceAssociationCaches.length === 0) { logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + await sendCancel(); return; } - const siteIdFromResource = resource.siteId; - - // get the site - const [siteRes] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteIdFromResource)); - if (!siteRes) { + if (!resource.networkId) { logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site} not found` + `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` ); + await sendCancel(); return; } - site = siteRes; + // Get all sites associated with this resource's network via siteNetworks + const siteRows = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId)); + + if (!siteRows || siteRows.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` + ); + await sendCancel(); + return; + } + + // Fetch full site objects for all network members + const foundSites = await Promise.all( + siteRows.map(async ({ siteId: sid }) => { + const [s] = await db + .select() + .from(sites) + .where(eq(sites.siteId, sid)) + .limit(1); + return s ?? null; + }) + ); + + sitesToProcess = foundSites.filter((s): s is Site => s !== null); } - if (!site) { - logger.error(`handleOlmServerPeerAddMessage: Site not found`); + if (sitesToProcess.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites to process` + ); + await sendCancel(); return; } - // check if the client can access this site using the cache - const currentSiteAssociationCaches = await db - .select() - .from(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.clientId, client.clientId), - eq(clientSitesAssociationsCache.siteId, site.siteId) - ) - ); + let handshakeInitiated = false; - if (currentSiteAssociationCaches.length === 0) { - logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, + for (const site of sitesToProcess) { + // Check if the client can access this site using the cache + const currentSiteAssociationCaches = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) + ) + ); + + if (currentSiteAssociationCaches.length === 0) { + logger.warn( + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping` + ); + continue; + } + + if (!site.exitNodeId) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` + ); + continue; + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)); + + if (!exitNode) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` + ); + continue; + } + + // Trigger the peer add handshake — if the peer was already added this will be a no-op + await initPeerAddHandshake( + client.clientId, { - type: "olm/wg/peer/chain/cancel", - data: { - chainId + siteId: site.siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint } }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - if (!site.exitNodeId) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - // get the exit node from the side - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)); - - if (!exitNode) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` + chainId ); - return; + + handshakeInitiated = true; } - // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch - // if it has already been added this will be a no-op - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { - siteId: site.siteId, - exitNode: { - publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint - } - }, - olm.olmId, - chainId - ); + if (!handshakeInitiated) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` + ); + await sendCancel(); + } return; -}; +}; \ No newline at end of file diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 64284f493..5f46ea84c 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -1,43 +1,25 @@ import { - Client, clientSiteResourcesAssociationsCache, db, - ExitNode, - Org, - orgs, - roleClients, - roles, + networks, + siteNetworks, siteResources, - Transaction, - userClients, - userOrgs, - users } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, - olms, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; -import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateAliasConfig, - getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; -import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { addPeer as newtAddPeer, - deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( @@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .where( + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, and( - eq(siteResources.siteId, site.siteId), - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(networks.networkId, siteNetworks.networkId), + eq(siteNetworks.siteId, site.siteId) + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId ) ); From 1366901e24c38e12bd8d96e46f45dd51946e75a2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:40:57 -0700 Subject: [PATCH 12/25] Adjust build functions --- server/routers/newt/buildConfiguration.ts | 9 +++++++-- server/routers/olm/buildConfiguration.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index c3a261f03..875a42c7e 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -4,8 +4,10 @@ import { clientSitesAssociationsCache, db, ExitNode, + networks, resources, Site, + siteNetworks, siteResources, targetHealthCheck, targets @@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient( // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); - // Get all enabled site resources for this site + // Get all enabled site resources for this site by joining through siteNetworks and networks const allSiteResources = await db .select() .from(siteResources) - .where(eq(siteResources.siteId, siteId)); + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) + .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) + .where(eq(siteNetworks.siteId, siteId)) + .then((rows) => rows.map((r) => r.siteResources)); const targetsToSend: SubnetProxyTarget[] = []; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index bc2611b1c..4182725d3 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -4,6 +4,8 @@ import { clientSitesAssociationsCache, db, exitNodes, + networks, + siteNetworks, siteResources, sites } from "@server/db"; @@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient( clientSiteResourcesAssociationsCache.siteResourceId ) ) + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, + eq(networks.networkId, siteNetworks.networkId) + ) .where( and( - eq(siteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId @@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient( ) ); + if (jitMode) { // Add site configuration to the array siteConfigurations.push({ From 02033f611f102bd1dfd1bab99b82b1cacea2a1c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 23 Mar 2026 11:44:02 -0700 Subject: [PATCH 13/25] First pass at HA --- server/lib/rebuildClientAssociations.ts | 581 +++++++++++++++--------- 1 file changed, 355 insertions(+), 226 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 121e2c7f0..ece603916 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -11,6 +11,7 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, sites, Transaction, @@ -47,15 +48,23 @@ export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db ) { - // get the site - const [site] = await trx - .select() - .from(sites) - .where(eq(sites.siteId, siteResource.siteId)) - .limit(1); + // get all sites associated with this siteResource via its network + const sitesList = siteResource.networkId + ? await trx + .select() + .from(sites) + .innerJoin( + siteNetworks, + eq(siteNetworks.siteId, sites.siteId) + ) + .where(eq(siteNetworks.networkId, siteResource.networkId)) + .then((rows) => rows.map((row) => row.sites)) + : []; - if (!site) { - throw new Error(`Site with ID ${siteResource.siteId} not found`); + if (sitesList.length === 0) { + logger.warn( + `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` + ); } const roleIds = await trx @@ -136,7 +145,7 @@ export async function getClientSiteResourceAccess( const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); return { - site, + sitesList, mergedAllClients, mergedAllClientIds }; @@ -152,40 +161,51 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { - const siteId = siteResource.siteId; - - const { site, mergedAllClients, mergedAllClientIds } = + const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); /////////// process the client-siteResource associations /////////// - // get all of the clients associated with other resources on this site - const allUpdatedClientsFromOtherResourcesOnThisSite = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .innerJoin( - siteResources, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq(siteResources.siteId, siteId), - ne(siteResources.siteResourceId, siteResource.siteResourceId) - ) - ); + // get all of the clients associated with other resources in the same network, + // joined through siteNetworks so we know which siteId each client belongs to + const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId + ? await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq(siteResources.networkId, siteResource.networkId), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ) + : []; - const allClientIdsFromOtherResourcesOnThisSite = Array.from( - new Set( - allUpdatedClientsFromOtherResourcesOnThisSite.map( - (row) => row.clientId - ) - ) - ); + // Build a per-site map so the loop below can check by siteId rather than + // across the entire network. + const clientsFromOtherResourcesBySite = new Map>(); + for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) { + if (!clientsFromOtherResourcesBySite.has(row.siteId)) { + clientsFromOtherResourcesBySite.set(row.siteId, new Set()); + } + clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); + } const existingClientSiteResources = await trx .select({ @@ -259,82 +279,90 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-site associations /////////// - const existingClientSites = await trx - .select({ - clientId: clientSitesAssociationsCache.clientId - }) - .from(clientSitesAssociationsCache) - .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); + for (const site of sitesList) { + const siteId = site.siteId; - const existingClientSiteIds = existingClientSites.map( - (row) => row.clientId - ); + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); - // Get full client details for existing clients (needed for sending delete messages) - const existingClients = await trx - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .where(inArray(clients.clientId, existingClientSiteIds)); + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); - const clientSitesToAdd = mergedAllClientIds.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = + existingClientSiteIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where(inArray(clients.clientId, existingClientSiteIds)) + : []; - const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ - clientId, - siteId - })); + const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); - if (clientSitesToInsert.length > 0) { - await trx - .insert(clientSitesAssociationsCache) - .values(clientSitesToInsert) - .returning(); - } + const clientSitesToAdd = mergedAllClientIds.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource + ); - // Now remove any client-site associations that should no longer exist - const clientSitesToRemove = existingClientSiteIds.filter( - (clientId) => - !mergedAllClientIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); - if (clientSitesToRemove.length > 0) { - await trx - .delete(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.siteId, siteId), - inArray( - clientSitesAssociationsCache.clientId, - clientSitesToRemove + if (clientSitesToInsert.length > 0) { + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .returning(); + } + + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !mergedAllClientIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource + ); + + if (clientSitesToRemove.length > 0) { + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) ) - ) - ); + ); + } + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); } - /////////// send the messages /////////// - - // Now handle the messages to add/remove peers on both the newt and olm sides - await handleMessagesForSiteClients( - site, - siteId, - mergedAllClients, - existingClients, - clientSitesToAdd, - clientSitesToRemove, - trx - ); - // Handle subnet proxy target updates for the resource associations await handleSubnetProxyTargetUpdates( siteResource, + sitesList, mergedAllClients, existingResourceClients, clientSiteResourcesToAdd, @@ -623,6 +651,7 @@ export async function updateClientSiteDestinations( async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, + sitesList: Site[], allClients: { clientId: number; pubKey: string | null; @@ -637,131 +666,144 @@ async function handleSubnetProxyTargetUpdates( clientSiteResourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Get the newt for this site - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteResource.siteId)) - .limit(1); + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; - if (!newt) { - logger.warn( - `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` - ); - return; - } + for (const siteData of sitesList) { + const siteId = siteData.siteId; - const proxyJobs = []; - const olmJobs = []; - // Generate targets for added associations - if (clientSiteResourcesToAdd.length > 0) { - const addedClients = allClients.filter((client) => - clientSiteResourcesToAdd.includes(client.clientId) - ); + // Get the newt for this site + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (addedClients.length > 0) { - const targetsToAdd = generateSubnetProxyTargets( - siteResource, - addedClients + if (!newt) { + logger.warn( + `Newt not found for site ${siteId}, skipping subnet proxy target updates` ); - - if (targetsToAdd.length > 0) { - logger.info( - `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targetsToAdd, - newt.version - ) - ); - } - - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, - siteResource.siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + continue; } - } - // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here - - // Generate targets for removed associations - if (clientSiteResourcesToRemove.length > 0) { - const removedClients = existingClients.filter((client) => - clientSiteResourcesToRemove.includes(client.clientId) - ); - - if (removedClients.length > 0) { - const targetsToRemove = generateSubnetProxyTargets( - siteResource, - removedClients + // Generate targets for added associations + if (clientSiteResourcesToAdd.length > 0) { + const addedClients = allClients.filter((client) => + clientSiteResourcesToAdd.includes(client.clientId) ); - if (targetsToRemove.length > 0) { - logger.info( - `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` + if (addedClients.length > 0) { + const targetsToAdd = generateSubnetProxyTargets( + siteResource, + addedClients ); - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targetsToRemove, - newt.version - ) - ); - } - for (const client of removedClients) { - // Check if this client still has access to another resource on this site with the same destination - const destinationStillInUse = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, siteResource.siteId), - eq( - siteResources.destination, - siteResource.destination - ), - ne( - siteResources.siteResourceId, - siteResource.siteResourceId - ) + if (targetsToAdd.length > 0) { + logger.info( + `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}` + ); + proxyJobs.push( + addSubnetProxyTargets( + newt.newtId, + targetsToAdd, + newt.version ) ); + } - // Only remove remote subnet if no other resource uses the same destination - const remoteSubnetsToRemove = - destinationStillInUse.length > 0 - ? [] - : generateRemoteSubnets([siteResource]); + for (const client of addedClients) { + olmJobs.push( + addPeerData( + client.clientId, + siteId, + generateRemoteSubnets([siteResource]), + generateAliasConfig([siteResource]) + ) + ); + } + } + } - olmJobs.push( - removePeerData( - client.clientId, - siteResource.siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) + // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here + + // Generate targets for removed associations + if (clientSiteResourcesToRemove.length > 0) { + const removedClients = existingClients.filter((client) => + clientSiteResourcesToRemove.includes(client.clientId) + ); + + if (removedClients.length > 0) { + const targetsToRemove = generateSubnetProxyTargets( + siteResource, + removedClients ); + + if (targetsToRemove.length > 0) { + logger.info( + `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}` + ); + proxyJobs.push( + removeSubnetProxyTargets( + newt.newtId, + targetsToRemove, + newt.version + ) + ); + } + + for (const client of removedClients) { + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site — a resource on a different + // site sharing the same network should not block removal here. + const destinationStillInUse = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteNetworks.siteId, siteId), + eq( + siteResources.destination, + siteResource.destination + ), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ); + + // Only remove remote subnet if no other resource uses the same destination + const remoteSubnetsToRemove = + destinationStillInUse.length > 0 + ? [] + : generateRemoteSubnets([siteResource]); + + olmJobs.push( + removePeerData( + client.clientId, + siteId, + remoteSubnetsToRemove, + generateAliasConfig([siteResource]) + ) + ); + } } } } @@ -868,10 +910,25 @@ export async function rebuildClientAssociationsFromClient( ) : []; - // Group by siteId for site-level associations - const newSiteIds = Array.from( - new Set(newSiteResources.map((sr) => sr.siteId)) + // Group by siteId for site-level associations — look up via siteNetworks since + // siteResources no longer carries a direct siteId column. + const networkIds = Array.from( + new Set( + newSiteResources + .map((sr) => sr.networkId) + .filter((id): id is number => id !== null) + ) ); + const newSiteIds = + networkIds.length > 0 + ? await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, networkIds)) + .then((rows) => + Array.from(new Set(rows.map((r) => r.siteId))) + ) + : []; /////////// Process client-siteResource associations /////////// @@ -1144,13 +1201,45 @@ async function handleMessagesForClientResources( resourcesToAdd.includes(r.siteResourceId) ); + // Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId + const addedNetworkIds = Array.from( + new Set( + addedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const addedSiteNetworkRows = + addedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, addedNetworkIds)) + : []; + const addedNetworkToSites = new Map(); + for (const row of addedSiteNetworkRows) { + if (!addedNetworkToSites.has(row.networkId)) { + addedNetworkToSites.set(row.networkId, []); + } + addedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const addedBySite = new Map(); for (const resource of addedResources) { - if (!addedBySite.has(resource.siteId)) { - addedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (addedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!addedBySite.has(siteId)) { + addedBySite.set(siteId, []); + } + addedBySite.get(siteId)!.push(resource); } - addedBySite.get(resource.siteId)!.push(resource); } // Add subnet proxy targets for each site @@ -1192,7 +1281,7 @@ async function handleMessagesForClientResources( olmJobs.push( addPeerData( client.clientId, - resource.siteId, + siteId, generateRemoteSubnets([resource]), generateAliasConfig([resource]) ) @@ -1204,7 +1293,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition` ); } else { throw error; @@ -1221,13 +1310,45 @@ async function handleMessagesForClientResources( .from(siteResources) .where(inArray(siteResources.siteResourceId, resourcesToRemove)); + // Build (resource, siteId) pairs via siteNetworks + const removedNetworkIds = Array.from( + new Set( + removedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const removedSiteNetworkRows = + removedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, removedNetworkIds)) + : []; + const removedNetworkToSites = new Map(); + for (const row of removedSiteNetworkRows) { + if (!removedNetworkToSites.has(row.networkId)) { + removedNetworkToSites.set(row.networkId, []); + } + removedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const removedBySite = new Map(); for (const resource of removedResources) { - if (!removedBySite.has(resource.siteId)) { - removedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (removedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!removedBySite.has(siteId)) { + removedBySite.set(siteId, []); + } + removedBySite.get(siteId)!.push(resource); } - removedBySite.get(resource.siteId)!.push(resource); } // Remove subnet proxy targets for each site @@ -1265,7 +1386,11 @@ async function handleMessagesForClientResources( } try { - // Check if this client still has access to another resource on this site with the same destination + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site — a resource on a different + // site sharing the same network should not block removal here. const destinationStillInUse = await trx .select() .from(siteResources) @@ -1276,13 +1401,17 @@ async function handleMessagesForClientResources( siteResources.siteResourceId ) ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteResources.siteId, resource.siteId), + eq(siteNetworks.siteId, siteId), eq( siteResources.destination, resource.destination @@ -1304,7 +1433,7 @@ async function handleMessagesForClientResources( olmJobs.push( removePeerData( client.clientId, - resource.siteId, + siteId, remoteSubnetsToRemove, generateAliasConfig([resource]) ) @@ -1316,7 +1445,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal` ); } else { throw error; From 81eba50c9a3a6b5353e4baa58ba2216ccbd7401b Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 6 Apr 2026 14:03:33 +0100 Subject: [PATCH 14/25] fix: use targetId as row identifier fix: 2797 --- src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 3d6e6186b..a9128b9d3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({ getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => String(row.targetId), state: { pagination: { pageIndex: 0, From 7d3d5b2b22aafa4e0b6bc5584ec65ba405c80878 Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 6 Apr 2026 14:17:04 +0100 Subject: [PATCH 15/25] use targetid also on proxy create as that also has same issue --- src/app/[orgId]/settings/resources/proxy/create/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index f057c07c4..f5c20d8cc 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -999,6 +999,7 @@ export default function Page() { getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => String(row.targetId), state: { pagination: { pageIndex: 0, From 028df8bf27a8c5d8879ce931974f7787acb3083e Mon Sep 17 00:00:00 2001 From: Joshua Belke Date: Tue, 7 Apr 2026 14:58:27 -0400 Subject: [PATCH 16/25] fix: remove encodeURIComponent from invite link email parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @ symbol in email addresses was being encoded as %40 when constructing invite URLs, causing broken or garbled links when copied/shared by users. - Remove encodeURIComponent(email) from server-side invite link construction in inviteUser.ts (both new invite and regenerate paths) - Remove encodeURIComponent(email) from client-side redirect URLs in InviteStatusCard.tsx (login, signup, and useEffect redirect paths) - Valid Zod-validated email addresses do not contain characters that require URL encoding for safe query parameter use (@ is permitted in query strings per RFC 3986 §3.4) --- server/routers/user/inviteUser.ts | 22 ++++++++++++++-------- src/components/InviteStatusCard.tsx | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7ac1849b9..b11586e69 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,7 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { + orgs, + roles, + userInviteRoles, + userInvites, + userOrgs, + users +} from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -37,8 +44,7 @@ const inviteUserBodySchema = z regenerate: z.boolean().optional() }) .refine( - (d) => - (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, { message: "roleIds or roleId is required", path: ["roleIds"] } ) .transform((data) => ({ @@ -265,7 +271,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( @@ -314,12 +320,12 @@ export async function inviteUser( expiresAt, tokenHash }); - await trx.insert(userInviteRoles).values( - uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) - ); + await trx + .insert(userInviteRoles) + .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 417fa9892..5de8f25fd 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -39,7 +39,11 @@ export default function InviteStatusCard({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [type, setType] = useState< - "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded" + | "rejected" + | "wrong_user" + | "user_does_not_exist" + | "not_logged_in" + | "user_limit_exceeded" >("rejected"); useEffect(() => { @@ -90,12 +94,12 @@ export default function InviteStatusCard({ if (!user && type === "user_does_not_exist") { const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else if (!user && type === "not_logged_in") { const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else { @@ -109,7 +113,7 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -117,7 +121,7 @@ export default function InviteStatusCard({ async function goToSignup() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -157,7 +161,9 @@ export default function InviteStatusCard({ Cannot Accept Invite

- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite. + This organization has reached its user limit. Please + contact the organization administrator to upgrade their + plan before accepting this invite.

); From eaa70da4ddd9d50b4966f4e3d6710e4490cab1d6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 16:14:46 -0400 Subject: [PATCH 17/25] add pluto --- src/components/SitesTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index cc02e5d37..6cca706a6 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -342,7 +342,8 @@ export default function SitesTable({ "jupiter", "saturn", "uranus", - "neptune" + "neptune", + "pluto" ].includes(originalRow.exitNodeName.toLowerCase()); if (isCloudNode) { From 80f5914fdd7a280bdb82ddec01527f0fc02f42b7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 16:14:46 -0400 Subject: [PATCH 18/25] add pluto --- src/components/PendingSitesTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 12abcf7c4..c65cb218e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -333,7 +333,8 @@ export default function PendingSitesTable({ "jupiter", "saturn", "uranus", - "neptune" + "neptune", + "pluto" ].includes(originalRow.exitNodeName.toLowerCase()); if (isCloudNode) { From 34387d9859623a63639c3497c12bc97ed61d3256 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:04:28 -0400 Subject: [PATCH 19/25] simplify wildcard domain on non pangolin-dns --- .../settings/domains/[domainId]/page.tsx | 15 +++++---- src/components/CreateDomainForm.tsx | 33 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index cf23e81be..23a79737d 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { GetDNSRecordsResponse } from "@server/routers/domain"; import DNSRecordsTable from "@app/components/DNSRecordTable"; import DomainCertForm from "@app/components/DomainCertForm"; +import { build } from "@server/build"; interface DomainSettingsPageProps { params: Promise<{ domainId: string; orgId: string }>; @@ -65,12 +66,14 @@ export default async function DomainSettingsPage({ )}
- + {build != "oss" && env.flags.usePangolinDns ? ( + + ) : null} diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index 9a187f1ed..f11038d26 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -239,21 +239,24 @@ export default function CreateDomainForm({ className="space-y-4" id="create-domain-form" > - ( - - - - - )} - /> + {build != "oss" && env.flags.usePangolinDns ? ( + ( + + + + + )} + /> + ) : null} + Date: Thu, 9 Apr 2026 17:06:04 -0400 Subject: [PATCH 20/25] dont show international domain warning when capital letter present --- src/components/CreateDomainForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index f11038d26..8840d2f93 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -154,7 +154,7 @@ export default function CreateDomainForm({ const punycodePreview = useMemo(() => { if (!baseDomain) return ""; - const punycode = toPunycode(baseDomain); + const punycode = toPunycode(baseDomain.toLowerCase()); return punycode !== baseDomain.toLowerCase() ? punycode : ""; }, [baseDomain]); From 840684aebab3289a0c4b04b6a48b9747cd05aa79 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:54:08 -0400 Subject: [PATCH 21/25] dont show wildcard in domain picker --- messages/en-US.json | 1 + src/components/DomainPicker.tsx | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6..9fa4e730d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2116,6 +2116,7 @@ "domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", + "domainPickerManual": "Manual", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 44446763b..afb273b5c 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -509,9 +509,11 @@ export default function DomainPicker({ {selectedBaseDomain.domain} - {selectedBaseDomain.verified && ( - - )} + {selectedBaseDomain.verified && + selectedBaseDomain.domainType !== + "wildcard" && ( + + )}
) : ( t("domainPickerSelectBaseDomain") @@ -574,14 +576,23 @@ export default function DomainPicker({ } - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified + {orgDomain.type === + "wildcard" ? t( - "domainPickerVerified" + "domainPickerManual" ) - : t( - "domainPickerUnverified" + : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + )} From 1aedf9da0ac258e58735da61a26ab1dc00aa08d8 Mon Sep 17 00:00:00 2001 From: Adnan Silajdzic Date: Fri, 10 Apr 2026 13:37:15 +0000 Subject: [PATCH 22/25] fix(worldmap): avoid stuck country hover state --- src/components/WorldMap.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index c64c3f430..9dbf6de80 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -164,7 +164,7 @@ const countryClass = cn( const highlightedCountryClass = cn( sharedCountryClass, - "stroke-2", + "stroke-[3]", "fill-[#f4f4f5]", "stroke-[#f36117]", "dark:fill-[#3f3f46]" @@ -194,11 +194,20 @@ function drawInteractiveCountries( const path = setupProjetionPath(); const data = parseWorldTopoJsonToGeoJsonFeatures(); const svg = d3.select(element); + const countriesLayer = svg.append("g"); + const hoverLayer = svg.append("g").style("pointer-events", "none"); + const hoverPath = hoverLayer + .append("path") + .datum(null) + .attr("class", highlightedCountryClass) + .style("display", "none"); - svg.selectAll("path") + countriesLayer + .selectAll("path") .data(data) .enter() .append("path") + .attr("data-country-path", "true") .attr("class", countryClass) .attr("d", path as never) @@ -209,9 +218,10 @@ function drawInteractiveCountries( y, hoveredCountryAlpha3Code: country.properties.a3 }); - // brings country to front - this.parentNode?.appendChild(this); - d3.select(this).attr("class", highlightedCountryClass); + hoverPath + .datum(country) + .attr("d", path(country) as string) + .style("display", null); }) .on("mousemove", function (event) { @@ -221,7 +231,7 @@ function drawInteractiveCountries( .on("mouseout", function () { setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); - d3.select(this).attr("class", countryClass); + hoverPath.style("display", "none"); }); return svg; @@ -257,7 +267,7 @@ function colorInCountriesWithValues( const svg = d3.select(element); return svg - .selectAll("path") + .selectAll('path[data-country-path="true"]') .style("fill", (countryPath) => { const country = getCountryByCountryPath(countryPath); if (!country?.count) { From eac747849b6085be809b5fd1d48711f75ae01c47 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 14:12:18 -0700 Subject: [PATCH 23/25] Restrict namespaces to paid plans due to abuse --- messages/en-US.json | 3 ++- server/lib/billing/tierMatrix.ts | 6 +++-- .../checkDomainNamespaceAvailability.ts | 23 +++++++++++++++- .../routers/domain/listDomainNamespaces.ts | 26 ++++++++++++++++++- server/routers/resource/createResource.ts | 25 +++++++++++++++++- server/routers/resource/updateResource.ts | 26 +++++++++++++++++-- src/components/DomainPicker.tsx | 22 ++++++++++++++++ 7 files changed, 123 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9fa4e730d..5c86aabec 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2113,7 +2113,8 @@ "addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", - "domainPickerFreeProvidedDomain": "Free Provided Domain", + "domainPickerFreeProvidedDomain": "Provided Domain", + "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", "domainPickerManual": "Manual", diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c76dcd95b..0756ea665 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -19,7 +19,8 @@ export enum TierFeature { SshPam = "sshPam", FullRbac = "fullRbac", SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed - SIEM = "siem" // handle downgrade by disabling SIEM integrations + SIEM = "siem", // handle downgrade by disabling SIEM integrations + DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces } export const tierMatrix: Record = { @@ -56,5 +57,6 @@ export const tierMatrix: Record = { [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], - [TierFeature.SIEM]: ["enterprise"] + [TierFeature.SIEM]: ["enterprise"], + [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/private/routers/domain/checkDomainNamespaceAvailability.ts b/server/private/routers/domain/checkDomainNamespaceAvailability.ts index db9a4b46a..0bb7f8704 100644 --- a/server/private/routers/domain/checkDomainNamespaceAvailability.ts +++ b/server/private/routers/domain/checkDomainNamespaceAvailability.ts @@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi"; import { db, domainNamespaces, resources } from "@server/db"; import { inArray } from "drizzle-orm"; import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; +import { build } from "@server/build"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z.strictObject({}); const querySchema = z.strictObject({ - subdomain: z.string() + subdomain: z.string(), + // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise }); registry.registerPath({ @@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability( } const { subdomain } = parsedQuery.data; + // if ( + // build == "saas" && + // !isSubscribed(orgId!, tierMatrix.domainNamespaces) + // ) { + // // return not available + // return response(res, { + // data: { + // available: false, + // options: [] + // }, + // success: true, + // error: false, + // message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.", + // status: HttpCode.OK + // }); + // } + const namespaces = await db.select().from(domainNamespaces); let possibleDomains = namespaces.map((ns) => { const desired = `${subdomain}.${ns.domainNamespaceId}`; diff --git a/server/private/routers/domain/listDomainNamespaces.ts b/server/private/routers/domain/listDomainNamespaces.ts index 180613a85..5bbd25b1a 100644 --- a/server/private/routers/domain/listDomainNamespaces.ts +++ b/server/private/routers/domain/listDomainNamespaces.ts @@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z.strictObject({}); @@ -37,7 +40,8 @@ const querySchema = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise }); async function query(limit: number, offset: number) { @@ -99,6 +103,26 @@ export async function listDomainNamespaces( ); } + // if ( + // build == "saas" && + // !isSubscribed(orgId!, tierMatrix.domainNamespaces) + // ) { + // return response(res, { + // data: { + // domainNamespaces: [], + // pagination: { + // total: 0, + // limit, + // offset + // } + // }, + // success: true, + // error: false, + // message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.", + // status: HttpCode.OK + // }); + // } + const domainNamespacesList = await query(limit, offset); const [{ count }] = await db diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 6cff4d23a..e94a5fc10 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, loginPage } from "@server/db"; +import { db, domainNamespaces, loginPage } from "@server/db"; import { domains, orgDomains, @@ -24,6 +24,8 @@ import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -193,6 +195,27 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + if ( + build == "saas" && + !isSubscribed(orgId!, tierMatrix.domainNamespaces) + ) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } + } + // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 01f3e79ff..8a2df18c3 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, loginPage } from "@server/db"; +import { db, domainNamespaces, loginPage } from "@server/db"; import { domains, Org, @@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -318,6 +319,27 @@ async function updateHttpResource( if (updateData.domainId) { const domainId = updateData.domainId; + if ( + build == "saas" && + !isSubscribed(resource.orgId, tierMatrix.domainNamespaces) + ) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } + } + // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, @@ -366,7 +388,7 @@ async function updateHttpResource( ); } } - + if (build != "oss") { const existingLoginPages = await db .select() diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index afb273b5c..e1ec1062e 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -2,6 +2,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { Command, CommandEmpty, @@ -40,9 +41,12 @@ import { Check, CheckCircle2, ChevronsUpDown, + KeyRound, Zap } from "lucide-react"; import { useTranslations } from "next-intl"; +import { usePaidStatus } from "@/hooks/usePaidStatus"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { toUnicode } from "punycode"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -95,6 +99,7 @@ export default function DomainPicker({ const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); + const { hasSaasSubscription } = usePaidStatus(); const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) @@ -691,6 +696,23 @@ export default function DomainPicker({ + {build === "saas" && + !hasSaasSubscription( + tierMatrix[TierFeature.DomainNamespaces] + ) && + !hideFreeDomain && ( + + +
+ + + {t("domainPickerFreeDomainsPaidFeature")} + +
+
+
+ )} + {/*showProvidedDomainSearch && build === "saas" && ( From f4ea572f6b8a06f4ce0f1be91b31a1772357a843 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 16:50:16 -0700 Subject: [PATCH 24/25] Fix #2828 --- src/components/DeviceLoginForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 16e7f2e1f..0a05e46d3 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -319,6 +319,7 @@ export default function DeviceLoginForm({
Date: Sat, 11 Apr 2026 16:59:43 -0700 Subject: [PATCH 25/25] Grandfather in old users --- server/routers/resource/createResource.ts | 41 +++++++++++++---------- server/routers/resource/updateResource.ts | 37 ++++++++++++-------- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e94a5fc10..d8820de79 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -114,7 +114,10 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { + if ( + req.user && + (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0) + ) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -195,24 +198,26 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; - if ( - build == "saas" && - !isSubscribed(orgId!, tierMatrix.domainNamespaces) - ) { - // check if this domain id is a namespace domain and if so, reject - const domain = await db - .select() - .from(domainNamespaces) - .where(eq(domainNamespaces.domainId, domainId)) - .limit(1); + if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { + // grandfather in existing users + const lastAllowedDate = new Date("2026-04-12"); + const userCreatedDate = new Date(req.user?.dateCreated || new Date()); + if (userCreatedDate > lastAllowedDate) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); - if (domain.length > 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." - ) - ); + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } } } diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 8a2df18c3..07e566194 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -121,7 +121,9 @@ const updateHttpResourceBodySchema = z if (data.headers) { // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) const validHeaderValue = /^[\t\x20-\x7E]*$/; - return data.headers.every((h) => validHeaderValue.test(h.value)); + return data.headers.every((h) => + validHeaderValue.test(h.value) + ); } return true; }, @@ -323,20 +325,27 @@ async function updateHttpResource( build == "saas" && !isSubscribed(resource.orgId, tierMatrix.domainNamespaces) ) { - // check if this domain id is a namespace domain and if so, reject - const domain = await db - .select() - .from(domainNamespaces) - .where(eq(domainNamespaces.domainId, domainId)) - .limit(1); + // grandfather in existing users + const lastAllowedDate = new Date("2026-04-12"); + const userCreatedDate = new Date( + req.user?.dateCreated || new Date() + ); + if (userCreatedDate > lastAllowedDate) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); - if (domain.length > 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." - ) - ); + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } } }