From 102a2354075345c4c3b62ec4b64eb2e018be5e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:38 -0700 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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 1efd2af44b7122c6fa7846559b557aa49e9d3397 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 15:32:25 -0400 Subject: [PATCH 14/45] Sync acme certs into the database --- server/index.ts | 2 + server/lib/acmeCertSync.ts | 3 + server/private/lib/acmeCertSync.ts | 277 +++++++++++++++++++++++++++ server/private/lib/readConfigFile.ts | 13 +- 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 server/lib/acmeCertSync.ts create mode 100644 server/private/lib/acmeCertSync.ts diff --git a/server/index.ts b/server/index.ts index 0fc44c279..e3a6ba049 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { @@ -39,6 +40,7 @@ async function startServers() { initTelemetryClient(); initLogCleanupInterval(); + initAcmeCertSync(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/acmeCertSync.ts b/server/lib/acmeCertSync.ts new file mode 100644 index 000000000..d8fbd6368 --- /dev/null +++ b/server/lib/acmeCertSync.ts @@ -0,0 +1,3 @@ +export function initAcmeCertSync(): void { + // stub +} \ No newline at end of file diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts new file mode 100644 index 000000000..04d40809c --- /dev/null +++ b/server/private/lib/acmeCertSync.ts @@ -0,0 +1,277 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import fs from "fs"; +import crypto from "crypto"; +import { certificates, domains, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { encryptData, decryptData } from "@server/lib/encryption"; +import logger from "@server/logger"; +import config from "#private/lib/config"; + +interface AcmeCert { + domain: { main: string; sans?: string[] }; + certificate: string; + key: string; + Store: string; +} + +interface AcmeJson { + [resolver: string]: { + Certificates: AcmeCert[]; + }; +} + +function getEncryptionKey(): Buffer { + const keyHex = config.getRawPrivateConfig().server.encryption_key; + if (!keyHex) { + throw new Error("acmeCertSync: encryption key is not configured"); + } + return Buffer.from(keyHex, "hex"); +} + +async function findDomainId(certDomain: string): Promise { + // Strip wildcard prefix before lookup (*.example.com -> example.com) + const lookupDomain = certDomain.startsWith("*.") + ? certDomain.slice(2) + : certDomain; + + // 1. Exact baseDomain match (any domain type) + const exactMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where(eq(domains.baseDomain, lookupDomain)) + .limit(1); + + if (exactMatch.length > 0) { + return exactMatch[0].domainId; + } + + // 2. Walk up the domain hierarchy looking for a wildcard-type domain whose + // baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com" + // matches a wildcard domain with baseDomain "example.com". + const parts = lookupDomain.split("."); + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(i).join("."); + if (!candidate) continue; + + const wildcardMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where( + and( + eq(domains.baseDomain, candidate), + eq(domains.type, "wildcard") + ) + ) + .limit(1); + + if (wildcardMatch.length > 0) { + return wildcardMatch[0].domainId; + } + } + + return null; +} + +function extractFirstCert(pemBundle: string): string | null { + const match = pemBundle.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ + ); + return match ? match[0] : null; +} + +async function syncAcmeCerts( + acmeJsonPath: string, + resolver: string +): Promise { + let raw: string; + try { + raw = fs.readFileSync(acmeJsonPath, "utf8"); + } catch (err) { + logger.debug( + `acmeCertSync: could not read ${acmeJsonPath}: ${err}` + ); + return; + } + + let acmeJson: AcmeJson; + try { + acmeJson = JSON.parse(raw); + } catch (err) { + logger.debug(`acmeCertSync: could not parse acme.json: ${err}`); + return; + } + + const resolverData = acmeJson[resolver]; + if (!resolverData || !Array.isArray(resolverData.Certificates)) { + logger.debug( + `acmeCertSync: no certificates found for resolver "${resolver}"` + ); + return; + } + + const encryptionKey = getEncryptionKey(); + + for (const cert of resolverData.Certificates) { + const domain = cert.domain?.main; + + if (!domain) { + logger.debug( + `acmeCertSync: skipping cert with missing domain` + ); + continue; + } + + if (!cert.certificate || !cert.key) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - empty certificate or key field` + ); + continue; + } + + const certPem = Buffer.from(cert.certificate, "base64").toString( + "utf8" + ); + const keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + + if (!certPem.trim() || !keyPem.trim()) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode` + ); + continue; + } + + // Check if cert already exists in DB + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decryptData( + existing[0].certFile, + encryptionKey + ); + if (storedCertPem === certPem) { + logger.debug( + `acmeCertSync: cert for ${domain} is unchanged, skipping` + ); + continue; + } + } catch (err) { + // Decryption failure means we should proceed with the update + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + // Parse cert expiry from the first cert in the PEM bundle + let expiresAt: number | null = null; + const firstCertPem = extractFirstCert(certPem); + if (firstCertPem) { + try { + const x509 = new crypto.X509Certificate(firstCertPem); + expiresAt = Math.floor( + new Date(x509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + } + + const wildcard = domain.startsWith("*."); + const encryptedCert = encryptData(certPem, encryptionKey); + const encryptedKey = encryptData(keyPem, encryptionKey); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + logger.info( + `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + } else { + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + logger.info( + `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + } + } +} + +export function initAcmeCertSync(): void { + const privateConfig = config.getRawPrivateConfig(); + + if (!privateConfig.flags?.enable_acme_cert_sync) { + return; + } + + const acmeJsonPath = + privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; + const resolver = privateConfig.acme?.resolver ?? "letsencrypt"; + const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000; + + logger.info( + `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` + ); + + // Run immediately on init, then on the configured interval + syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + logger.error(`acmeCertSync: error during initial sync: ${err}`); + }); + + setInterval(() => { + syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + }, intervalMs); +} \ No newline at end of file diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 54260009b..a755e9fc3 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -95,10 +95,21 @@ export const privateConfigSchema = z.object({ .object({ enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), - use_org_only_idp: z.boolean().optional() + use_org_only_idp: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(false) }) .optional() .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + resolver: z.string().optional().default("letsencrypt"), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), branding: z .object({ app_name: z.string().optional(), From eb771ceda44638acabb89ec4990e5cf94f8fd9c6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:02:08 -0400 Subject: [PATCH 15/45] Add http to mode and put destinationPort back --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/blueprints/clientResources.ts | 2 ++ server/lib/blueprints/types.ts | 4 ++-- server/routers/siteResource/createSiteResource.ts | 7 ++++--- server/routers/siteResource/updateSiteResource.ts | 7 +++++-- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bde3e9aec..96c5b8ae6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -230,7 +230,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1fb04ef14..7dbbaf007 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -258,7 +258,7 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" + mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 80c691c63..4196c67ed 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -84,6 +84,7 @@ export async function updateClientResources( siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, @@ -223,6 +224,7 @@ export async function updateClientResources( name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 6ebc509b8..4a8dc272f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -325,11 +325,11 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr"]), + mode: z.enum(["host", "cidr", "http", "https"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + "destination-port": z.int().positive().optional(), destination: z.string().min(1), // enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1485a4192..f257e7b22 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,11 +36,11 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "port"]), + mode: z.enum(["host", "cidr", "port", "http", "https"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + destinationPort: z.int().positive().optional(), destination: z.string().min(1), enabled: z.boolean().default(true), alias: z @@ -163,7 +163,7 @@ export async function createSiteResource( mode, // protocol, // proxyPort, - // destinationPort, + destinationPort, destination, enabled, alias, @@ -295,6 +295,7 @@ export async function createSiteResource( name, mode: mode as "host" | "cidr", destination, + destinationPort, enabled, alias, aliasAddress, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 8f56ece0f..129375ee8 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,10 +51,10 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr"]).optional(), + mode: z.enum(["host", "cidr", "http", "https"]).optional(), // protocol: z.enum(["tcp", "udp"]).nullish(), // proxyPort: z.int().positive().nullish(), - // destinationPort: z.int().positive().nullish(), + destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), enabled: z.boolean().optional(), alias: z @@ -176,6 +176,7 @@ export async function updateSiteResource( niceId, mode, destination, + destinationPort, alias, enabled, userIds, @@ -347,6 +348,7 @@ export async function updateSiteResource( niceId, mode, destination, + destinationPort, enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString, @@ -450,6 +452,7 @@ export async function updateSiteResource( siteId: siteId, mode: mode, destination: destination, + destinationPort: destinationPort, enabled: enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, From 333ccb84382b4b8f18e484429d9d854a51258962 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:10:48 -0400 Subject: [PATCH 16/45] Restrict to make sure there is an alias --- .../siteResource/createSiteResource.ts | 22 ++++++++++++------- .../siteResource/updateSiteResource.ts | 21 ++++++++++++------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f257e7b22..ca7424bb6 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -62,15 +62,21 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host") { - // Check if it's a valid IP address using zod (v4 or v6) - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if ( + data.mode === "host" || + data.mode == "http" || + data.mode == "https" + ) { + if (data.mode == "host") { + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; + if (isValidIP) { + return true; + } } // Check if it's a valid domain (hostname pattern, TLD not required) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 129375ee8..de4ad3398 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -76,14 +76,21 @@ const updateSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" && data.destination) { - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if ( + (data.mode === "host" || + data.mode == "http" || + data.mode == "https") && + data.destination + ) { + if (data.mode == "host") { + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; + if (isValidIP) { + return true; + } } // Check if it's a valid domain (hostname pattern, TLD not required) From e4cbf088b4d56598dd28185626c2da95dd0575a9 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:23:24 -0400 Subject: [PATCH 17/45] Working on defining the schema to send down --- server/lib/ip.ts | 81 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 7f829bcef..c7d02dc1b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -582,6 +582,16 @@ export type SubnetProxyTargetV2 = { protocol: "tcp" | "udp"; }[]; resourceId?: number; + protocol?: "http" | "https"; // if set, this target only applies to the specified protocol + httpTargets?: HTTPTarget[]; + tlsCert?: string; + tlsKey?: string; +}; + +export type HTTPTarget = { + destAddr: string; // must be an IP or hostname + destPort: number; + scheme: "http" | "https"; }; export function generateSubnetProxyTargetV2( @@ -619,7 +629,7 @@ export function generateSubnetProxyTargetV2( destPrefix: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, + resourceId: siteResource.siteResourceId }; } @@ -631,7 +641,7 @@ export function generateSubnetProxyTargetV2( rewriteTo: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, + resourceId: siteResource.siteResourceId }; } } else if (siteResource.mode == "cidr") { @@ -640,7 +650,34 @@ export function generateSubnetProxyTargetV2( destPrefix: siteResource.destination, portRange, disableIcmp, + resourceId: siteResource.siteResourceId + }; + } else if (siteResource.mode == "http" || siteResource.mode == "https") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + } + + if (!siteResource.alias || !siteResource.aliasAddress) { + logger.debug( + `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address, skipping alias target generation.` + ); + return; + } + // also push a match for the alias address + target = { + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination, + portRange, + disableIcmp, resourceId: siteResource.siteResourceId, + protocol: siteResource.mode, // will be either http or https, + httpTargets: [], + tlsCert: "", + tlsKey: "" }; } @@ -670,33 +707,31 @@ export function generateSubnetProxyTargetV2( return target; } - /** * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * by expanding each source prefix into its own target entry. * @param targetV2 - The v2 target to convert * @returns Array of v1 SubnetProxyTarget objects */ - export function convertSubnetProxyTargetsV2ToV1( - targetsV2: SubnetProxyTargetV2[] - ): SubnetProxyTarget[] { - return targetsV2.flatMap((targetV2) => - targetV2.sourcePrefixes.map((sourcePrefix) => ({ - sourcePrefix, - destPrefix: targetV2.destPrefix, - ...(targetV2.disableIcmp !== undefined && { - disableIcmp: targetV2.disableIcmp - }), - ...(targetV2.rewriteTo !== undefined && { - rewriteTo: targetV2.rewriteTo - }), - ...(targetV2.portRange !== undefined && { - portRange: targetV2.portRange - }) - })) - ); - } - +export function convertSubnetProxyTargetsV2ToV1( + targetsV2: SubnetProxyTargetV2[] +): SubnetProxyTarget[] { + return targetsV2.flatMap((targetV2) => + targetV2.sourcePrefixes.map((sourcePrefix) => ({ + sourcePrefix, + destPrefix: targetV2.destPrefix, + ...(targetV2.disableIcmp !== undefined && { + disableIcmp: targetV2.disableIcmp + }), + ...(targetV2.rewriteTo !== undefined && { + rewriteTo: targetV2.rewriteTo + }), + ...(targetV2.portRange !== undefined && { + portRange: targetV2.portRange + }) + })) + ); +} // Custom schema for validating port range strings // Format: "80,443,8000-9000" or "*" for all ports, or empty string From d73796b92e92d26b205d4756a4519910e9fd37df Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:49:22 -0400 Subject: [PATCH 18/45] add new modes, port input, and domain picker --- messages/en-US.json | 8 + .../siteResource/createSiteResource.ts | 7 +- .../siteResource/listAllSiteResourcesByOrg.ts | 4 +- src/components/ClientResourcesTable.tsx | 19 +- .../CreateInternalResourceDialog.tsx | 7 +- src/components/EditInternalResourceDialog.tsx | 7 +- src/components/InternalResourceForm.tsx | 707 +++++++++++------- 7 files changed, 495 insertions(+), 264 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6..e5b073f6c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1817,6 +1817,8 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", @@ -1860,6 +1862,8 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", @@ -2661,6 +2665,10 @@ "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "createInternalResourceDialogHttpConfiguration": "HTTP configuration", + "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP configuration", + "editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index ca7424bb6..e1b97bdca 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,7 +36,7 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "port", "http", "https"]), + mode: z.enum(["host", "cidr", "http", "https"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), @@ -286,8 +286,7 @@ export async function createSiteResource( const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; - if (mode == "host") { - // we can only have an alias on a host + if (mode === "host" || mode === "http" || mode === "https") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -299,7 +298,7 @@ export async function createSiteResource( niceId, orgId, name, - mode: mode as "host" | "cidr", + mode, destination, destinationPort, enabled, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3320aa3b7..36bc6bee0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr"]) + .enum(["host", "cidr", "http", "https"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr"], + enum: ["host", "cidr", "http", "https"], description: "Filter site resources by mode" }), sort_by: z diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5066f273d..b25409c44 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -46,7 +46,7 @@ export type InternalResourceRow = { siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr"; + mode: "host" | "cidr" | "http" | "https"; // protocol: string | null; // proxyPort: number | null; siteId: number; @@ -215,6 +215,14 @@ export default function ClientResourcesTable({ { value: "cidr", label: t("editInternalResourceDialogModeCidr") + }, + { + value: "http", + label: t("editInternalResourceDialogModeHttp") + }, + { + value: "https", + label: t("editInternalResourceDialogModeHttps") } ]} selectedValue={searchParams.get("mode") ?? undefined} @@ -227,10 +235,15 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - const modeLabels: Record<"host" | "cidr" | "port", string> = { + const modeLabels: Record< + "host" | "cidr" | "port" | "http" | "https", + string + > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort") + port: t("editInternalResourceDialogModePort"), + http: t("editInternalResourceDialogModeHttp"), + https: t("editInternalResourceDialogModeHttps") }; return {modeLabels[resourceRow.mode]}; } diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index d5ca61acc..d37acdc6e 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -50,7 +50,12 @@ export default function CreateInternalResourceDialog({ setIsSubmitting(true); try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || + data.mode === "http" || + data.mode === "https") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 690ad405d..2a4cd35fc 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -54,7 +54,12 @@ export default function EditInternalResourceDialog({ async function handleSubmit(values: InternalResourceFormValues) { try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || + data.mode === "http" || + data.mode === "https") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index a4a793753..fb31d27b8 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -45,6 +45,7 @@ import { z } from "zod"; import { SitesSelector, type Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; +import DomainPicker from "@app/components/DomainPicker"; // --- Helpers (shared) --- @@ -120,12 +121,14 @@ export const cleanForFQDN = (name: string): string => type Site = ListSitesResponse["sites"][0]; +export type InternalResourceMode = "host" | "cidr" | "http" | "https"; + export type InternalResourceData = { id: number; name: string; orgId: string; siteName: string; - mode: "host" | "cidr"; + mode: InternalResourceMode; siteId: number; niceId: string; destination: string; @@ -135,6 +138,10 @@ export type InternalResourceData = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + httpConfigSubdomain?: string | null; + httpConfigDomainId?: string | null; + httpConfigFullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); @@ -142,7 +149,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); export type InternalResourceFormValues = { name: string; siteId: number; - mode: "host" | "cidr"; + mode: InternalResourceMode; destination: string; alias?: string | null; niceId?: string; @@ -151,6 +158,10 @@ export type InternalResourceFormValues = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + httpConfigSubdomain?: string | null; + httpConfigDomainId?: string | null; + httpConfigFullDomain?: string | null; roles?: z.infer[]; users?: z.infer[]; clients?: z.infer[]; @@ -211,6 +222,14 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogModeCidr" : "editInternalResourceDialogModeCidr"; + const modeHttpKey = + variant === "create" + ? "createInternalResourceDialogModeHttp" + : "editInternalResourceDialogModeHttp"; + const modeHttpsKey = + variant === "create" + ? "createInternalResourceDialogModeHttps" + : "editInternalResourceDialogModeHttps"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" @@ -223,6 +242,18 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; + const httpHttpsPortLabelKey = + variant === "create" + ? "createInternalResourceDialogModePort" + : "editInternalResourceDialogModePort"; + const httpConfigurationTitleKey = + variant === "create" + ? "createInternalResourceDialogHttpConfiguration" + : "editInternalResourceDialogHttpConfiguration"; + const httpConfigurationDescriptionKey = + variant === "create" + ? "createInternalResourceDialogHttpConfigurationDescription" + : "editInternalResourceDialogHttpConfigurationDescription"; const formSchema = z.object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), @@ -230,7 +261,7 @@ export function InternalResourceForm({ .number() .int() .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), - mode: z.enum(["host", "cidr"]), + mode: z.enum(["host", "cidr", "http", "https"]), destination: z .string() .min( @@ -240,6 +271,10 @@ export function InternalResourceForm({ : undefined ), alias: z.string().nullish(), + httpHttpsPort: z.number().int().min(1).max(65535).optional().nullable(), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish(), niceId: z .string() .min(1) @@ -394,6 +429,10 @@ export function InternalResourceForm({ disableIcmp: resource.disableIcmp ?? false, authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + httpConfigSubdomain: resource.httpConfigSubdomain ?? null, + httpConfigDomainId: resource.httpConfigDomainId ?? null, + httpConfigFullDomain: resource.httpConfigFullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -405,6 +444,10 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, + httpHttpsPort: null, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -425,6 +468,10 @@ export function InternalResourceForm({ }); const mode = form.watch("mode"); + const httpConfigSubdomain = form.watch("httpConfigSubdomain"); + const httpConfigDomainId = form.watch("httpConfigDomainId"); + const httpConfigFullDomain = form.watch("httpConfigFullDomain"); + const isHttpOrHttps = mode === "http" || mode === "https"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); @@ -448,6 +495,10 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, + httpHttpsPort: null, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -475,6 +526,10 @@ export function InternalResourceForm({ mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + httpConfigSubdomain: resource.httpConfigSubdomain ?? null, + httpConfigDomainId: resource.httpConfigDomainId ?? null, + httpConfigFullDomain: resource.httpConfigFullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -701,6 +756,12 @@ export function InternalResourceForm({ {t(modeCidrKey)} + + {t(modeHttpKey)} + + + {t(modeHttpsKey)} + @@ -731,7 +792,7 @@ export function InternalResourceForm({ )} /> - {mode !== "cidr" && ( + {mode === "host" && (
)} + {(mode === "http" || mode === "https") && ( +
+ ( + + + {t( + httpHttpsPortLabelKey + )} + + + { + const raw = + e.target + .value; + if ( + raw === "" + ) { + field.onChange( + null + ); + return; + } + const n = + Number(raw); + field.onChange( + Number.isFinite( + n + ) + ? n + : null + ); + }} + /> + + + + )} + /> +
+ )} -
-
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" + {isHttpOrHttps ? ( +
+
+ +
+ {t(httpConfigurationDescriptionKey)} +
+
+ { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } + form.setValue( + "httpConfigSubdomain", + res.subdomain ?? null + ); + form.setValue( + "httpConfigDomainId", + res.domainId + ); + form.setValue( + "httpConfigFullDomain", + res.fullDomain + ); + }} + /> +
+ ) : ( +
+
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
-
-
-
- - {t("editInternalResourceDialogTcp")} - -
-
- ( - -
- - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t("editInternalResourceDialogUdp")} - -
-
- ( - -
- - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t("editInternalResourceDialogIcmp")} - -
-
- ( - -
- - + + {t("editInternalResourceDialogTcp")} + +
+
+ ( + +
+ + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t("editInternalResourceDialogUdp")} + +
+
+ ( + +
+ + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t( + "editInternalResourceDialogIcmp" + )} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t("blocked") + : t("allowed")} + +
+ +
+ )} + /> +
-
+ )}
From a730f4da1d77e4b5db92bfabcf3cb045bcc89d47 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:54:08 -0400 Subject: [PATCH 19/45] 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 e5b073f6c..7fd13b583 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2120,6 +2120,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 c027c8958b57475c10d92db87fe87a58bf968694 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:54:07 -0400 Subject: [PATCH 20/45] Add scheme --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/ip.ts | 21 ++++++++++---- .../siteResource/createSiteResource.ts | 29 ++----------------- .../siteResource/updateSiteResource.ts | 5 +++- 5 files changed, 25 insertions(+), 34 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 96c5b8ae6..8966bc0e4 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -231,7 +231,7 @@ export const siteResources = pgTable("siteResources", { niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" - protocol: varchar("protocol"), // only for port mode + scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 7dbbaf007..6205d0179 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -259,7 +259,7 @@ export const siteResources = sqliteTable("siteResources", { niceId: text("niceId").notNull(), name: text("name").notNull(), mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" - protocol: text("protocol"), // only for port mode + scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: text("destination").notNull(), // ip, cidr, hostname diff --git a/server/lib/ip.ts b/server/lib/ip.ts index c7d02dc1b..96ea04873 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -660,9 +660,14 @@ export function generateSubnetProxyTargetV2( destination = `${destination}/32`; } - if (!siteResource.alias || !siteResource.aliasAddress) { + if ( + !siteResource.alias || + !siteResource.aliasAddress || + !siteResource.destinationPort || + !siteResource.scheme + ) { logger.debug( - `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address, skipping alias target generation.` + `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address or destinationPort, skipping alias target generation.` ); return; } @@ -675,9 +680,15 @@ export function generateSubnetProxyTargetV2( disableIcmp, resourceId: siteResource.siteResourceId, protocol: siteResource.mode, // will be either http or https, - httpTargets: [], - tlsCert: "", - tlsKey: "" + httpTargets: [ + { + destAddr: siteResource.destination, + destPort: siteResource.destinationPort, + scheme: siteResource.scheme + } + ], + // tlsCert: "", + // tlsKey: "" }; } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e1b97bdca..437643be4 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -38,7 +38,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "http", "https"]), siteId: z.int(), - // protocol: z.enum(["tcp", "udp"]).optional(), + scheme: z.enum(["http", "https"]).optional(), // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), destination: z.string().min(1), @@ -167,7 +167,7 @@ export async function createSiteResource( name, siteId, mode, - // protocol, + scheme, // proxyPort, destinationPort, destination, @@ -232,30 +232,6 @@ export async function createSiteResource( ); } - // // check if resource with same protocol and proxy port already exists (only for port mode) - // if (mode === "port" && protocol && proxyPort) { - // const [existingResource] = await db - // .select() - // .from(siteResources) - // .where( - // and( - // eq(siteResources.siteId, siteId), - // eq(siteResources.orgId, orgId), - // eq(siteResources.protocol, protocol), - // eq(siteResources.proxyPort, proxyPort) - // ) - // ) - // .limit(1); - // if (existingResource && existingResource.siteResourceId) { - // return next( - // createHttpError( - // HttpCode.CONFLICT, - // "A resource with the same protocol and proxy port already exists" - // ) - // ); - // } - // } - // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db @@ -300,6 +276,7 @@ export async function createSiteResource( name, mode, destination, + scheme, destinationPort, enabled, alias, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index de4ad3398..22e57383c 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -52,7 +52,7 @@ const updateSiteResourceSchema = z .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr", "http", "https"]).optional(), - // protocol: z.enum(["tcp", "udp"]).nullish(), + scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), @@ -182,6 +182,7 @@ export async function updateSiteResource( siteId, // because it can change niceId, mode, + scheme, destination, destinationPort, alias, @@ -354,6 +355,7 @@ export async function updateSiteResource( siteId, niceId, mode, + scheme, destination, destinationPort, enabled, @@ -458,6 +460,7 @@ export async function updateSiteResource( name: name, siteId: siteId, mode: mode, + scheme, destination: destination, destinationPort: destinationPort, enabled: enabled, From a74378e1d39de58c76ad94249c52b3ea5e47d9ec Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 18:17:08 -0400 Subject: [PATCH 21/45] show domain and destination with port in table --- server/routers/resource/getUserResources.ts | 8 +- .../siteResource/listAllSiteResourcesByOrg.ts | 2 +- .../settings/resources/client/page.tsx | 2 +- src/components/ClientResourcesTable.tsx | 79 ++++++++++++++++--- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 9afd6b4f3..b6469fa77 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -144,7 +144,7 @@ export async function getUserResources( name: string; destination: string; mode: string; - protocol: string | null; + scheme: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; @@ -156,7 +156,7 @@ export async function getUserResources( name: siteResources.name, destination: siteResources.destination, mode: siteResources.mode, - protocol: siteResources.protocol, + scheme: siteResources.scheme, enabled: siteResources.enabled, alias: siteResources.alias, aliasAddress: siteResources.aliasAddress @@ -240,7 +240,7 @@ export async function getUserResources( name: siteResource.name, destination: siteResource.destination, mode: siteResource.mode, - protocol: siteResource.protocol, + protocol: siteResource.scheme, enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, @@ -289,7 +289,7 @@ export type GetUserResourcesResponse = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }>; }; }; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 36bc6bee0..7376fd6ec 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -88,7 +88,7 @@ function querySiteResourcesBase() { niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, - protocol: siteResources.protocol, + scheme: siteResources.scheme, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, destination: siteResources.destination, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f0f582f0f..95477949d 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -67,7 +67,7 @@ export default async function ClientResourcesPage( // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, destination: siteResource.destination, - // destinationPort: siteResource.destinationPort, + httpHttpsPort: siteResource.destinationPort ?? null, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, siteNiceId: siteResource.siteNiceId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b25409c44..20b968ea3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -52,7 +52,7 @@ export type InternalResourceRow = { siteId: number; siteNiceId: string; destination: string; - // destinationPort: number | null; + httpHttpsPort: number | null; alias: string | null; aliasAddress: string | null; niceId: string; @@ -63,6 +63,42 @@ export type InternalResourceRow = { authDaemonPort?: number | null; }; +function resolveHttpHttpsDisplayPort( + mode: "http" | "https", + httpHttpsPort: number | null +): number { + if (httpHttpsPort != null) { + return httpHttpsPort; + } + return mode === "https" ? 443 : 80; +} + +function formatDestinationDisplay(row: InternalResourceRow): string { + const { mode, destination, httpHttpsPort } = row; + if (mode !== "http" && mode !== "https") { + return destination; + } + const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); + const hostPart = + destination.includes(":") && !destination.startsWith("[") + ? `[${destination}]` + : destination; + return `${hostPart}:${port}`; +} + +function formatHttpHttpsAliasUrl(mode: "http" | "https", alias: string): string { + return `${mode}://${alias}`; +} + +function isSafeUrlForLink(href: string): boolean { + try { + void new URL(href); + return true; + } catch { + return false; + } +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -256,11 +292,12 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; + const display = formatDestinationDisplay(resourceRow); return ( ); } @@ -273,15 +310,33 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - return resourceRow.mode === "host" && resourceRow.alias ? ( - - ) : ( - - - ); + if (resourceRow.mode === "host" && resourceRow.alias) { + return ( + + ); + } + if ( + (resourceRow.mode === "http" || + resourceRow.mode === "https") && + resourceRow.alias + ) { + const url = formatHttpHttpsAliasUrl( + resourceRow.mode, + resourceRow.alias + ); + return ( + + ); + } + return -; } }, { From 584a8e7d1d84773c0a2f52b5c12e1e11bbfe4a68 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 20:52:37 -0400 Subject: [PATCH 22/45] Generate certs and add placeholder screen --- messages/en-US.json | 2 + .../private/lib/traefik/getTraefikConfig.ts | 157 +++++++++++++++++- src/app/private-maintenance-screen/page.tsx | 32 ++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/app/private-maintenance-screen/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7fd13b583..40b66fc6e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2708,6 +2708,8 @@ "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", + "privateMaintenanceScreenTitle": "Private Placeholder Screen", + "privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index adc3d965b..2487574e8 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -33,7 +33,7 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { orgs, resources, sites, Target, targets } from "@server/db"; +import { orgs, resources, sites, siteResources, Target, targets } from "@server/db"; import { sanitize, encodePath, @@ -267,6 +267,33 @@ export async function getTraefikConfig( }); }); + // Query siteResources in http/https mode that have aliases - needed for cert generation + const siteResourcesWithAliases = await db + .select({ + siteResourceId: siteResources.siteResourceId, + alias: siteResources.alias, + mode: siteResources.mode + }) + .from(siteResources) + .innerJoin(sites, eq(sites.siteId, siteResources.siteId)) + .where( + and( + eq(siteResources.enabled, true), + isNotNull(siteResources.alias), + inArray(siteResources.mode, ["http", "https"]), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` + ) + ), + inArray(sites.type, siteTypes) + ) + ); + let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for @@ -276,6 +303,12 @@ export async function getTraefikConfig( domains.add(resource.fullDomain); } } + // Include siteResource aliases so pangolin-dns also fetches certs for them + for (const sr of siteResourcesWithAliases) { + if (sr.alias) { + domains.add(sr.alias); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -867,6 +900,128 @@ export async function getTraefikConfig( } } + // Add Traefik routes for siteResource aliases in http/https mode so that + // Traefik generates TLS certificates for those domains even when no + // matching resource exists yet. + if (siteResourcesWithAliases.length > 0) { + // Build a set of domains already covered by normal resources + const existingFullDomains = new Set(); + for (const resource of resourcesMap.values()) { + if (resource.fullDomain) { + existingFullDomains.add(resource.fullDomain); + } + } + + for (const sr of siteResourcesWithAliases) { + if (!sr.alias) continue; + + // Skip if this alias is already handled by a resource router + if (existingFullDomains.has(sr.alias)) continue; + + const alias = sr.alias; + const srKey = `site-resource-cert-${sr.siteResourceId}`; + const siteResourceServiceName = `${srKey}-service`; + const siteResourceRouterName = `${srKey}-router`; + const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + if (!config_output.http.services) { + config_output.http.services = {}; + } + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // Service pointing at the internal maintenance/Next.js page + config_output.http.services[siteResourceServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${maintenanceHost}:${maintenancePort}` + } + ], + passHostHeader: true + } + }; + + // Middleware that rewrites any path to /maintenance-screen + config_output.http.middlewares[ + siteResourceRewriteMiddlewareName + ] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; + + // HTTP -> HTTPS redirect so the ACME challenge can be served + config_output.http.routers[ + `${siteResourceRouterName}-redirect` + ] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: siteResourceServiceName, + rule: `Host(\`${alias}\`)`, + priority: 100 + }; + + // Determine TLS / cert-resolver configuration + let tls: any = {}; + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + const domainParts = alias.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + // pangolin-dns: only add route if we already have a valid cert + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === alias + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for siteResource alias: ${alias}` + ); + continue; + } + } + + // HTTPS router — presence of this entry triggers cert generation + config_output.http.routers[siteResourceRouterName] = { + entryPoints: [ + config.getRawConfig().traefik.https_entrypoint + ], + service: siteResourceServiceName, + middlewares: [siteResourceRewriteMiddlewareName], + rule: `Host(\`${alias}\`)`, + priority: 100, + tls + }; + } + } + if (generateLoginPageRouters) { const exitNodeLoginPages = await db .select({ diff --git a/src/app/private-maintenance-screen/page.tsx b/src/app/private-maintenance-screen/page.tsx new file mode 100644 index 000000000..21417b6f4 --- /dev/null +++ b/src/app/private-maintenance-screen/page.tsx @@ -0,0 +1,32 @@ +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Private Placeholder" +}; + +export default async function MaintenanceScreen() { + const t = await getTranslations(); + + let title = t("privateMaintenanceScreenTitle"); + let message = t("privateMaintenanceScreenMessage"); + + return ( +
+ + + {title} + + {message} + +
+ ); +} From 510931e7d693292bdc57fd2833a0a5b250a386dd Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 21:02:20 -0400 Subject: [PATCH 23/45] Add ssl to schema --- server/db/pg/schema/schema.ts | 11 ++++++++--- server/db/sqlite/schema/schema.ts | 7 +++++-- server/routers/siteResource/createSiteResource.ts | 10 ++++++---- server/routers/siteResource/updateSiteResource.ts | 9 ++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 8966bc0e4..4885eec98 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -101,7 +103,9 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - status: varchar("status").$type<"pending" | "approved">().default("approved") + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }); export const resources = pgTable("resources", { @@ -230,7 +234,8 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" + ssl: boolean("ssl").notNull().default(false), + mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6205d0179..7b31460f6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -258,7 +260,8 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" + ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), + mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 437643be4..99db6810e 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,7 +36,8 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "https"]), + mode: z.enum(["host", "cidr", "http"]), + ssl: z.boolean().optional(), // only used for http mode siteId: z.int(), scheme: z.enum(["http", "https"]).optional(), // proxyPort: z.int().positive().optional(), @@ -64,8 +65,7 @@ const createSiteResourceSchema = z (data) => { if ( data.mode === "host" || - data.mode == "http" || - data.mode == "https" + data.mode == "http" ) { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) @@ -172,6 +172,7 @@ export async function createSiteResource( destinationPort, destination, enabled, + ssl, alias, userIds, roleIds, @@ -262,7 +263,7 @@ export async function createSiteResource( const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; - if (mode === "host" || mode === "http" || mode === "https") { + if (mode === "host" || mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -275,6 +276,7 @@ export async function createSiteResource( orgId, name, mode, + ssl, destination, scheme, destinationPort, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 22e57383c..bb0239478 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,7 +51,8 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http", "https"]).optional(), + mode: z.enum(["host", "cidr", "http"]).optional(), + ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(), @@ -78,8 +79,7 @@ const updateSiteResourceSchema = z (data) => { if ( (data.mode === "host" || - data.mode == "http" || - data.mode == "https") && + data.mode == "http") && data.destination ) { if (data.mode == "host") { @@ -186,6 +186,7 @@ export async function updateSiteResource( destination, destinationPort, alias, + ssl, enabled, userIds, roleIds, @@ -356,6 +357,7 @@ export async function updateSiteResource( niceId, mode, scheme, + ssl, destination, destinationPort, enabled, @@ -461,6 +463,7 @@ export async function updateSiteResource( siteId: siteId, mode: mode, scheme, + ssl, destination: destination, destinationPort: destinationPort, enabled: enabled, From 79751c208d8c6566a11a5ef2e2c16c3d66472bee Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:24:39 -0400 Subject: [PATCH 24/45] basic ui working --- messages/en-US.json | 9 + server/lib/blueprints/clientResources.ts | 27 +- server/lib/ip.ts | 9 +- .../private/lib/traefik/getTraefikConfig.ts | 7 +- .../siteResource/createSiteResource.ts | 15 + .../siteResource/listAllSiteResourcesByOrg.ts | 9 +- .../siteResource/updateSiteResource.ts | 17 + .../settings/resources/client/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 39 +-- .../CreateInternalResourceDialog.tsx | 55 ++- src/components/EditInternalResourceDialog.tsx | 9 +- src/components/InternalResourceForm.tsx | 322 ++++++++++++------ 12 files changed, 365 insertions(+), 167 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 40b66fc6e..ba22ea0d1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1819,6 +1819,9 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Scheme", + "editInternalResourceDialogEnableSsl": "Enable SSL", + "editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", @@ -1864,11 +1867,17 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Scheme", + "createInternalResourceDialogScheme": "Scheme", + "createInternalResourceDialogEnableSsl": "Enable SSL", + "createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", + "internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources", + "internalResourceHttpPortRequired": "Destination port is required for HTTP resources", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 4196c67ed..281f4f7dd 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -16,6 +16,20 @@ import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): { + mode: "host" | "cidr" | "http"; + ssl: boolean; + scheme: "http" | "https" | null; +} { + if (mode === "https") { + return { mode: "http", ssl: true, scheme: "https" }; + } + if (mode === "http") { + return { mode: "http", ssl: false, scheme: "http" }; + } + return { mode, ssl: false, scheme: null }; +} + export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; @@ -76,13 +90,16 @@ export async function updateClientResources( } if (existingResource) { + const mappedMode = siteResourceModeForDb(resourceData.mode); // Update existing resource const [updatedResource] = await trx .update(siteResources) .set({ name: resourceData.name || resourceNiceId, siteId: site.siteId, - mode: resourceData.mode, + mode: mappedMode.mode, + ssl: mappedMode.ssl, + scheme: mappedMode.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now @@ -208,9 +225,9 @@ export async function updateClientResources( oldSiteResource: existingResource }); } else { + const mappedMode = siteResourceModeForDb(resourceData.mode); let aliasAddress: string | null = null; - if (resourceData.mode == "host") { - // we can only have an alias on a host + if (mappedMode.mode === "host" || mappedMode.mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -222,7 +239,9 @@ export async function updateClientResources( siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, - mode: resourceData.mode, + mode: mappedMode.mode, + ssl: mappedMode.ssl, + scheme: mappedMode.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 96ea04873..b4be4285f 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -652,7 +652,7 @@ export function generateSubnetProxyTargetV2( disableIcmp, resourceId: siteResource.siteResourceId }; - } else if (siteResource.mode == "http" || siteResource.mode == "https") { + } else if (siteResource.mode == "http") { let destination = siteResource.destination; // check if this is a valid ip const ipSchema = z.union([z.ipv4(), z.ipv6()]); @@ -667,10 +667,11 @@ export function generateSubnetProxyTargetV2( !siteResource.scheme ) { logger.debug( - `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address or destinationPort, skipping alias target generation.` + `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.` ); return; } + const publicProtocol = siteResource.ssl ? "https" : "http"; // also push a match for the alias address target = { sourcePrefixes: [], @@ -679,14 +680,14 @@ export function generateSubnetProxyTargetV2( portRange, disableIcmp, resourceId: siteResource.siteResourceId, - protocol: siteResource.mode, // will be either http or https, + protocol: publicProtocol, httpTargets: [ { destAddr: siteResource.destination, destPort: siteResource.destinationPort, scheme: siteResource.scheme } - ], + ] // tlsCert: "", // tlsKey: "" }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 2487574e8..e82f0bdc7 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -267,7 +267,7 @@ export async function getTraefikConfig( }); }); - // Query siteResources in http/https mode that have aliases - needed for cert generation + // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge const siteResourcesWithAliases = await db .select({ siteResourceId: siteResources.siteResourceId, @@ -280,7 +280,8 @@ export async function getTraefikConfig( and( eq(siteResources.enabled, true), isNotNull(siteResources.alias), - inArray(siteResources.mode, ["http", "https"]), + eq(siteResources.mode, "http"), + eq(siteResources.ssl, true), or( eq(sites.exitNodeId, exitNodeId), and( @@ -900,7 +901,7 @@ export async function getTraefikConfig( } } - // Add Traefik routes for siteResource aliases in http/https mode so that + // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that // Traefik generates TLS certificates for those domains even when no // matching resource exists yet. if (siteResourcesWithAliases.length > 0) { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 99db6810e..6fbe50d59 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -111,6 +111,21 @@ const createSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.destinationPort !== undefined && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } ); export type CreateSiteResourceBody = z.infer; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 7376fd6ec..896dc77f5 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr", "http", "https"]) + .enum(["host", "cidr", "http"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr", "http", "https"], + enum: ["host", "cidr", "http"], description: "Filter site resources by mode" }), sort_by: z @@ -88,6 +88,7 @@ function querySiteResourcesBase() { niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, + ssl: siteResources.ssl, scheme: siteResources.scheme, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, @@ -193,7 +194,9 @@ export async function listAllSiteResourcesByOrg( const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( - querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") + querySiteResourcesBase() + .where(and(...conditions)) + .as("filtered_site_resources") ); const [siteResourcesList, totalCount] = await Promise.all([ diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index bb0239478..81283e353 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -125,6 +125,23 @@ const updateSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } ); export type UpdateSiteResourceBody = z.infer; diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 95477949d..46dfeb9cc 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -56,13 +56,25 @@ export default async function ClientResourcesPage( const internalResourceRows: InternalResourceRow[] = siteResources.map( (siteResource) => { + const rawMode = siteResource.mode as string | undefined; + const normalizedMode = + rawMode === "https" + ? ("http" as const) + : rawMode === "host" || rawMode === "cidr" || rawMode === "http" + ? rawMode + : ("host" as const); return { id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, - mode: siteResource.mode || ("port" as any), + mode: normalizedMode, + scheme: + siteResource.scheme ?? + (rawMode === "https" ? ("https" as const) : null), + ssl: + siteResource.ssl === true || rawMode === "https", // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 20b968ea3..6adce8fd9 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -46,7 +46,9 @@ export type InternalResourceRow = { siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr" | "http" | "https"; + mode: "host" | "cidr" | "http"; + scheme: "http" | "https" | null; + ssl: boolean; // protocol: string | null; // proxyPort: number | null; siteId: number; @@ -64,30 +66,27 @@ export type InternalResourceRow = { }; function resolveHttpHttpsDisplayPort( - mode: "http" | "https", + mode: "http", httpHttpsPort: number | null ): number { if (httpHttpsPort != null) { return httpHttpsPort; } - return mode === "https" ? 443 : 80; + return 80; } function formatDestinationDisplay(row: InternalResourceRow): string { - const { mode, destination, httpHttpsPort } = row; - if (mode !== "http" && mode !== "https") { + const { mode, destination, httpHttpsPort, scheme } = row; + if (mode !== "http") { return destination; } const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); + const downstreamScheme = scheme ?? "http"; const hostPart = destination.includes(":") && !destination.startsWith("[") ? `[${destination}]` : destination; - return `${hostPart}:${port}`; -} - -function formatHttpHttpsAliasUrl(mode: "http" | "https", alias: string): string { - return `${mode}://${alias}`; + return `${downstreamScheme}://${hostPart}:${port}`; } function isSafeUrlForLink(href: string): boolean { @@ -255,10 +254,6 @@ export default function ClientResourcesTable({ { value: "http", label: t("editInternalResourceDialogModeHttp") - }, - { - value: "https", - label: t("editInternalResourceDialogModeHttps") } ]} selectedValue={searchParams.get("mode") ?? undefined} @@ -272,14 +267,13 @@ export default function ClientResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; const modeLabels: Record< - "host" | "cidr" | "port" | "http" | "https", + "host" | "cidr" | "port" | "http", string > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), port: t("editInternalResourceDialogModePort"), - http: t("editInternalResourceDialogModeHttp"), - https: t("editInternalResourceDialogModeHttps") + http: t("editInternalResourceDialogModeHttp") }; return {modeLabels[resourceRow.mode]}; } @@ -319,15 +313,8 @@ export default function ClientResourcesTable({ /> ); } - if ( - (resourceRow.mode === "http" || - resourceRow.mode === "https") && - resourceRow.alias - ) { - const url = formatHttpHttpsAliasUrl( - resourceRow.mode, - resourceRow.alias - ); + if (resourceRow.mode === "http" && resourceRow.alias) { + const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`; return ( parseInt(r.id)) : [], + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && + data.authDaemonPort != null && { + authDaemonPort: data.authDaemonPort + }), + roleIds: data.roles + ? data.roles.map((r) => parseInt(r.id)) + : [], userIds: data.users ? data.users.map((u) => u.id) : [], - clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] + clientIds: data.clients + ? data.clients.map((c) => parseInt(c.id)) + : [] } ); toast({ title: t("createInternalResourceDialogSuccess"), - description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ), variant: "default" }); setOpen(false); @@ -98,7 +117,9 @@ export default function CreateInternalResourceDialog({ title: t("createInternalResourceDialogError"), description: formatAxiosError( error, - t("createInternalResourceDialogFailedToCreateInternalResource") + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) ), variant: "destructive" }); @@ -111,9 +132,13 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResource")} + - {t("createInternalResourceDialogCreateClientResourceDescription")} + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} @@ -128,7 +153,11 @@ export default function CreateInternalResourceDialog({ - diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 2a4cd35fc..5f20dd458 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -55,9 +55,7 @@ export default function EditInternalResourceDialog({ try { let data = { ...values }; if ( - (data.mode === "host" || - data.mode === "http" || - data.mode === "https") && + (data.mode === "host" || data.mode === "http") && isHostname(data.destination) ) { const currentAlias = data.alias?.trim() || ""; @@ -76,6 +74,11 @@ export default function EditInternalResourceDialog({ mode: data.mode, niceId: data.niceId, destination: data.destination, + ...(data.mode === "http" && { + scheme: data.scheme, + ssl: data.ssl ?? false, + destinationPort: data.httpHttpsPort ?? null + }), alias: data.alias && typeof data.alias === "string" && diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index fb31d27b8..9e1390f09 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -46,6 +46,7 @@ import { SitesSelector, type Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; +import { SwitchInput } from "@app/components/SwitchInput"; // --- Helpers (shared) --- @@ -121,7 +122,7 @@ export const cleanForFQDN = (name: string): string => type Site = ListSitesResponse["sites"][0]; -export type InternalResourceMode = "host" | "cidr" | "http" | "https"; +export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { id: number; @@ -139,6 +140,8 @@ export type InternalResourceData = { authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; + scheme?: "http" | "https" | null; + ssl?: boolean; httpConfigSubdomain?: string | null; httpConfigDomainId?: string | null; httpConfigFullDomain?: string | null; @@ -159,6 +162,8 @@ export type InternalResourceFormValues = { authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; + scheme?: "http" | "https"; + ssl?: boolean; httpConfigSubdomain?: string | null; httpConfigDomainId?: string | null; httpConfigFullDomain?: string | null; @@ -226,10 +231,18 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogModeHttp" : "editInternalResourceDialogModeHttp"; - const modeHttpsKey = + const schemeLabelKey = variant === "create" - ? "createInternalResourceDialogModeHttps" - : "editInternalResourceDialogModeHttps"; + ? "createInternalResourceDialogScheme" + : "editInternalResourceDialogScheme"; + const enableSslLabelKey = + variant === "create" + ? "createInternalResourceDialogEnableSsl" + : "editInternalResourceDialogEnableSsl"; + const enableSslDescriptionKey = + variant === "create" + ? "createInternalResourceDialogEnableSslDescription" + : "editInternalResourceDialogEnableSslDescription"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" @@ -255,48 +268,78 @@ export function InternalResourceForm({ ? "createInternalResourceDialogHttpConfigurationDescription" : "editInternalResourceDialogHttpConfigurationDescription"; - const formSchema = z.object({ - name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteId: z - .number() - .int() - .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), - mode: z.enum(["host", "cidr", "http", "https"]), - destination: z - .string() - .min( - 1, - destinationRequiredKey - ? { message: t(destinationRequiredKey) } - : undefined - ), - alias: z.string().nullish(), - httpHttpsPort: z.number().int().min(1).max(65535).optional().nullable(), - httpConfigSubdomain: z.string().nullish(), - httpConfigDomainId: z.string().nullish(), - httpConfigFullDomain: z.string().nullish(), - niceId: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/) - .optional(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), - authDaemonPort: z.number().int().positive().optional().nullable(), - roles: z.array(tagSchema).optional(), - users: z.array(tagSchema).optional(), - clients: z - .array( - z.object({ - clientId: z.number(), - name: z.string() - }) - ) - .optional() - }); + const formSchema = z + .object({ + name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), + siteId: z + .number() + .int() + .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), + mode: z.enum(["host", "cidr", "http"]), + destination: z + .string() + .min( + 1, + destinationRequiredKey + ? { message: t(destinationRequiredKey) } + : undefined + ), + alias: z.string().nullish(), + httpHttpsPort: z + .number() + .int() + .min(1) + .max(65535) + .optional() + .nullable(), + scheme: z.enum(["http", "https"]).optional(), + ssl: z.boolean().optional(), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish(), + niceId: z + .string() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/) + .optional(), + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), + disableIcmp: z.boolean().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), + authDaemonPort: z.number().int().positive().optional().nullable(), + roles: z.array(tagSchema).optional(), + users: z.array(tagSchema).optional(), + clients: z + .array( + z.object({ + clientId: z.number(), + name: z.string() + }) + ) + .optional() + }) + .superRefine((data, ctx) => { + if (data.mode !== "http") return; + if (!data.scheme) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceDownstreamSchemeRequired"), + path: ["scheme"] + }); + } + if ( + data.httpHttpsPort == null || + !Number.isFinite(data.httpHttpsPort) || + data.httpHttpsPort < 1 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceHttpPortRequired"), + path: ["httpHttpsPort"] + }); + } + }); type FormData = z.infer; @@ -430,6 +473,8 @@ export function InternalResourceForm({ authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null, @@ -445,6 +490,8 @@ export function InternalResourceForm({ destination: "", alias: null, httpHttpsPort: null, + scheme: "http", + ssl: false, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -471,7 +518,7 @@ export function InternalResourceForm({ const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); - const isHttpOrHttps = mode === "http" || mode === "https"; + const isHttpMode = mode === "http"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); @@ -496,6 +543,8 @@ export function InternalResourceForm({ destination: "", alias: null, httpHttpsPort: null, + scheme: "http", + ssl: false, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -527,6 +576,8 @@ export function InternalResourceForm({ destination: resource.destination ?? "", alias: resource.alias ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null, @@ -681,6 +732,37 @@ export function InternalResourceForm({ )} /> + ( + + {t(modeLabelKey)} + + + + )} + />
+ {mode === "http" && ( +
+ ( + + + {t(schemeLabelKey)} + + + + + )} + /> +
+ )}
- ( - - - {t(modeLabelKey)} - - - - - )} - /> -
-
- + @@ -793,7 +868,7 @@ export function InternalResourceForm({ />
{mode === "host" && ( -
+
)} - {(mode === "http" || mode === "https") && ( -
+ {mode === "http" && ( +
- {isHttpOrHttps ? ( + {isHttpMode ? (
+ ( + + + + + + )} + />
From 73482c2a059e212a6c03c89937629b5a7bdc5087 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:38:04 -0400 Subject: [PATCH 25/45] disable ssh access tab on http mode --- messages/en-US.json | 2 +- src/components/InternalResourceForm.tsx | 186 ++++++++++++++---------- 2 files changed, 108 insertions(+), 80 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ba22ea0d1..e4bcbd623 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2673,7 +2673,7 @@ "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", - "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 9e1390f09..c6da0eb0e 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1,6 +1,10 @@ "use client"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { StrategySelect } from "@app/components/StrategySelect"; import { Tag, TagInput } from "@app/components/tags/tag-input"; @@ -687,82 +691,6 @@ export function InternalResourceForm({ )} /> )} - ( - - {t("site")} - - - - - - - - { - setSelectedSite(site); - field.onChange(site.siteId); - }} - /> - - - - - )} - /> - ( - - {t(modeLabelKey)} - - - - )} - />
+
+
+ ( + + {t("site")} + + + + + + + + { + setSelectedSite( + site + ); + field.onChange( + site.siteId + ); + }} + /> + + + + + )} + /> +
+
+ { + const modeOptions: OptionSelectOption[] = + [ + { + value: "host", + label: t(modeHostKey) + }, + { + value: "cidr", + label: t(modeCidrKey) + }, + { + value: "http", + label: t(modeHttpKey) + } + ]; + return ( + + + {t(modeLabelKey)} + + + options={modeOptions} + value={field.value} + onChange={ + field.onChange + } + cols={3} + /> + + + ); + }} + /> +
+
- {/* SSH Access tab */} - {!disableEnterpriseFeatures && mode !== "cidr" && ( + {/* SSH Access tab (host mode only) */} + {!disableEnterpriseFeatures && mode === "host" && (
From 8a47d69d0dac3cef54b03ccccb8de50cb85edefc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:48:43 -0400 Subject: [PATCH 26/45] fix domain picker --- src/components/DomainPicker.tsx | 15 +++--- src/components/InternalResourceForm.tsx | 65 +++++++++++++------------ 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index afb273b5c..28bcd0029 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -163,15 +163,18 @@ export default function DomainPicker({ domainId: firstOrExistingDomain.domainId }; + const base = firstOrExistingDomain.baseDomain; + const sub = + firstOrExistingDomain.type !== "cname" + ? defaultSubdomain?.trim() || undefined + : undefined; + onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", - subdomain: - firstOrExistingDomain.type !== "cname" - ? defaultSubdomain || undefined - : undefined, - fullDomain: firstOrExistingDomain.baseDomain, - baseDomain: firstOrExistingDomain.baseDomain + subdomain: sub, + fullDomain: sub ? `${sub}.${base}` : base, + baseDomain: base }); } } diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index c6da0eb0e..f7254d6b7 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -495,7 +495,7 @@ export function InternalResourceForm({ alias: null, httpHttpsPort: null, scheme: "http", - ssl: false, + ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -548,7 +548,7 @@ export function InternalResourceForm({ alias: null, httpHttpsPort: null, scheme: "http", - ssl: false, + ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -732,7 +732,9 @@ export function InternalResourceForm({ name="siteId" render={({ field }) => ( - {t("site")} + + {t("site")} + @@ -888,7 +890,10 @@ export function InternalResourceForm({ {t(destinationLabelKey)} - + @@ -947,16 +952,16 @@ export function InternalResourceForm({ const raw = e.target .value; - if (raw === "") { + if ( + raw === "" + ) { field.onChange( null ); return; } const n = - Number( - raw - ); + Number(raw); field.onChange( Number.isFinite( n @@ -986,29 +991,6 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)}
- ( - - - - - - )} - /> + ( + + + + + + )} + />
) : (
From a19f0acfb92da14351847210c928570619ac1845 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Apr 2026 17:21:54 -0400 Subject: [PATCH 27/45] Working --- server/lib/ip.ts | 34 ++++++++++++++++--- server/lib/rebuildClientAssociations.ts | 8 ++--- server/routers/newt/buildConfiguration.ts | 2 +- .../siteResource/updateSiteResource.ts | 4 +-- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 96ea04873..fce15692c 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -5,6 +5,7 @@ import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; +import { getValidCertificatesForDomains } from "#private/lib/certificates"; interface IPRange { start: bigint; @@ -594,14 +595,14 @@ export type HTTPTarget = { scheme: "http" | "https"; }; -export function generateSubnetProxyTargetV2( +export async function generateSubnetProxyTargetV2( siteResource: SiteResource, clients: { clientId: number; pubKey: string | null; subnet: string | null; }[] -): SubnetProxyTargetV2 | undefined { +): Promise { if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` @@ -672,6 +673,30 @@ export function generateSubnetProxyTargetV2( return; } // also push a match for the alias address + let tlsCert: string | undefined; + let tlsKey: string | undefined; + + if (siteResource.ssl && siteResource.alias) { + try { + const certs = await getValidCertificatesForDomains( + new Set([siteResource.alias]), + true + ); + if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { + tlsCert = certs[0].certFile; + tlsKey = certs[0].keyFile; + } else { + logger.warn( + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.alias}` + ); + } + } catch (err) { + logger.error( + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.alias}: ${err}` + ); + } + } + target = { sourcePrefixes: [], destPrefix: `${siteResource.aliasAddress}/32`, @@ -679,7 +704,7 @@ export function generateSubnetProxyTargetV2( portRange, disableIcmp, resourceId: siteResource.siteResourceId, - protocol: siteResource.mode, // will be either http or https, + protocol: siteResource.ssl ? "https" : "http", httpTargets: [ { destAddr: siteResource.destination, @@ -687,8 +712,7 @@ export function generateSubnetProxyTargetV2( scheme: siteResource.scheme } ], - // tlsCert: "", - // tlsKey: "" + ...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {}) }; } diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8459ce249..7c69ff71c 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -661,7 +661,7 @@ async function handleSubnetProxyTargetUpdates( ); if (addedClients.length > 0) { - const targetToAdd = generateSubnetProxyTargetV2( + const targetToAdd = await generateSubnetProxyTargetV2( siteResource, addedClients ); @@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates( ); if (removedClients.length > 0) { - const targetToRemove = generateSubnetProxyTargetV2( + const targetToRemove = await generateSubnetProxyTargetV2( siteResource, removedClients ); @@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const target = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1241,7 +1241,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const target = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 35d52816e..5e79804b7 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient( ) ); - const resourceTarget = generateSubnetProxyTargetV2( + const resourceTarget = await generateSubnetProxyTargetV2( resource, resourceClients ); diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index bb0239478..89949e9a8 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -634,11 +634,11 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if destination changed if (destinationChanged || portRangesChanged) { - const oldTarget = generateSubnetProxyTargetV2( + const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients ); - const newTarget = generateSubnetProxyTargetV2( + const newTarget = await generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); From fc4633db918bf93cc998833c8faad34868572008 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 17:19:18 -0700 Subject: [PATCH 28/45] Add domain component to the site resource --- server/db/pg/schema/schema.ts | 7 ++- server/db/sqlite/schema/schema.ts | 7 ++- .../siteResource/createSiteResource.ts | 56 +++++++++++++++---- .../siteResource/updateSiteResource.ts | 43 ++++++++++++-- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4885eec98..aac86c1b9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,7 +249,12 @@ export const siteResources = pgTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain") }); export const clientSiteResources = pgTable("clientSiteResources", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 7b31460f6..e58601dc3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -277,7 +277,12 @@ export const siteResources = sqliteTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: text("authDaemonMode") .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: text("subdomain"), + fullDomain: text("fullDomain"), }); export const clientSiteResources = sqliteTable("clientSiteResources", { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 6fbe50d59..d51bf54db 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -28,6 +28,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -58,15 +59,14 @@ const createSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org + subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org }) .strict() .refine( (data) => { - if ( - data.mode === "host" || - data.mode == "http" - ) { + if (data.mode === "host" || data.mode == "http") { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z @@ -196,7 +196,9 @@ export async function createSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; // Verify the site exists and belongs to the org @@ -248,15 +250,47 @@ export async function createSiteResource( ); } + if (domainId && alias) { + // throw an error because we can only have one or the other + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Alias and domain cannot both be set. Please choose one or the other." + ) + ); + } + + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + let finalAlias = alias ? alias.trim() : null; + if (domainId && subdomain) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + } + // make sure the alias is unique within the org if provided - if (alias) { + if (finalAlias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, alias.trim()) + eq(siteResources.alias, finalAlias.trim()) ) ) .limit(1); @@ -296,11 +330,13 @@ export async function createSiteResource( scheme, destinationPort, enabled, - alias, + alias: finalAlias, aliasAddress, tcpPortRangeString, udpPortRangeString, - disableIcmp + disableIcmp, + domainId, + subdomain: finalSubdomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 5b1fac861..b66792c75 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -14,6 +14,7 @@ import { userSiteResources } from "@server/db"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { generateAliasConfig, generateRemoteSubnets, @@ -72,7 +73,9 @@ const updateSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().nullish(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), + subdomain: z.string().optional() }) .strict() .refine( @@ -212,7 +215,9 @@ export async function updateSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; const [site] = await db @@ -302,15 +307,37 @@ export async function updateSiteResource( } } + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + let finalAlias = alias ? alias.trim() : null; + if (domainId && subdomain) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + org.orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + } + // make sure the alias is unique within the org if provided - if (alias) { + if (finalAlias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), - eq(siteResources.alias, alias.trim()), + eq(siteResources.alias, finalAlias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) @@ -378,10 +405,12 @@ export async function updateSiteResource( destination, destinationPort, enabled, - alias: alias && alias.trim() ? alias : null, + alias: finalAlias, tcpPortRangeString, udpPortRangeString, disableIcmp, + domainId, + subdomain: finalSubdomain, ...sshPamSet }) .where( @@ -484,10 +513,12 @@ export async function updateSiteResource( destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: alias && alias.trim() ? alias : null, + alias: finalAlias, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, + domainId, + subdomain: finalSubdomain, ...sshPamSet }) .where( From 5803da48935e01aea90c76999dc44c9afff87f93 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 21:09:12 -0700 Subject: [PATCH 29/45] Crud working --- server/lib/blueprints/clientResources.ts | 113 +++++++++++++++++- server/lib/blueprints/proxyResources.ts | 2 +- server/lib/blueprints/types.ts | 37 +++++- server/lib/ip.ts | 5 +- .../routers/olm/handleOlmRegisterMessage.ts | 1 - .../siteResource/createSiteResource.ts | 28 +++-- .../siteResource/listAllSiteResourcesByOrg.ts | 3 + .../siteResource/updateSiteResource.ts | 33 +++-- .../settings/resources/client/page.tsx | 5 +- src/components/ClientResourcesTable.tsx | 7 +- .../CreateInternalResourceDialog.tsx | 40 ++++--- src/components/EditInternalResourceDialog.tsx | 34 +++--- src/components/InternalResourceForm.tsx | 20 ++-- 13 files changed, 258 insertions(+), 70 deletions(-) diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 281f4f7dd..40c09dd10 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -1,6 +1,8 @@ import { clients, clientSiteResources, + domains, + orgDomains, roles, roleSiteResources, SiteResource, @@ -11,10 +13,83 @@ import { userSiteResources } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray, or } from "drizzle-orm"; +import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; + +async function getDomainForSiteResource( + siteResourceId: number | undefined, + fullDomain: string, + orgId: string, + trx: Transaction +): Promise<{ subdomain: string | null; domainId: string }> { + const [fullDomainExists] = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, fullDomain), + eq(siteResources.orgId, orgId), + siteResourceId + ? ne(siteResources.siteResourceId, siteResourceId) + : isNotNull(siteResources.siteResourceId) + ) + ) + .limit(1); + + if (fullDomainExists) { + throw new Error( + `Site resource already exists with domain: ${fullDomain} in org ${orgId}` + ); + } + + const possibleDomains = await trx + .select() + .from(domains) + .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) + .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) + .execute(); + + if (possibleDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const validDomains = possibleDomains.filter((domain) => { + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { + return ( + fullDomain === domain.domains.baseDomain || + fullDomain.endsWith(`.${domain.domains.baseDomain}`) + ); + } else if (domain.domains.type == "cname") { + return fullDomain === domain.domains.baseDomain; + } + }); + + if (validDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const domainSelection = validDomains[0].domains; + const baseDomain = domainSelection.baseDomain; + + let subdomain: string | null = null; + if (fullDomain !== baseDomain) { + subdomain = fullDomain.replace(`.${baseDomain}`, ""); + } + + await createCertificate(domainSelection.domainId, fullDomain, trx); + + return { + subdomain, + domainId: domainSelection.domainId + }; +} function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): { mode: "host" | "cidr" | "http"; @@ -91,6 +166,19 @@ export async function updateClientResources( if (existingResource) { const mappedMode = siteResourceModeForDb(resourceData.mode); + + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + existingResource.siteResourceId, + resourceData["full-domain"], + orgId, + trx + ); + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) @@ -107,7 +195,10 @@ export async function updateClientResources( alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .where( eq( @@ -118,7 +209,6 @@ export async function updateClientResources( .returning(); const siteResourceId = existingResource.siteResourceId; - const orgId = existingResource.orgId; await trx .delete(clientSiteResources) @@ -231,6 +321,18 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + undefined, + resourceData["full-domain"], + orgId, + trx + ); + } + // Create new resource const [newResource] = await trx .insert(siteResources) @@ -250,7 +352,10 @@ export async function updateClientResources( aliasAddress: aliasAddress, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .returning(); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index e16da2ea5..4d78e946d 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1100,7 +1100,7 @@ function checkIfTargetChanged( return false; } -async function getDomain( +export async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 4a8dc272f..7939e6e24 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -325,7 +325,7 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "https"]), + mode: z.enum(["host", "cidr", "http"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), @@ -335,6 +335,8 @@ export const ClientResourceSchema = z "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), + "full-domain": z.string().optional(), + ssl: z.boolean().optional(), alias: z .string() .regex( @@ -477,6 +479,39 @@ export const ConfigSchema = z }); } + // Enforce the full-domain uniqueness across client-resources in the same stack + const clientFullDomainMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + if (!clientFullDomainMap.has(fullDomain)) { + clientFullDomainMap.set(fullDomain, []); + } + clientFullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + const clientFullDomainDuplicates = Array.from( + clientFullDomainMap.entries() + ) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([fullDomain, resourceKeys]) => + `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + if (clientFullDomainDuplicates.length !== 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["client-resources"], + message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}` + }); + } + // Enforce proxy-port uniqueness within proxy-resources per protocol const protocolPortMap = new Map(); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 252abc1e1..6f04b8170 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -478,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources - .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") + .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .map((sr) => ({ - alias: sr.alias, + alias: sr.alias || sr.fullDomain, aliasAddress: sr.aliasAddress })); } @@ -672,7 +672,6 @@ export async function generateSubnetProxyTargetV2( ); return; } - const publicProtocol = siteResource.ssl ? "https" : "http"; // also push a match for the alias address let tlsCert: string | undefined; let tlsKey: string | undefined; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 01495de3b..a4a62973d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { OlmErrorCodes, sendOlmError } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d51bf54db..f871990fa 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -66,7 +66,7 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" || data.mode == "http") { + if (data.mode === "host") { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z @@ -262,7 +262,6 @@ export async function createSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -279,18 +278,32 @@ export async function createSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, finalAlias.trim()) + eq(siteResources.alias, alias.trim()) ) ) .limit(1); @@ -330,13 +343,14 @@ export async function createSiteResource( scheme, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, - subdomain: finalSubdomain + subdomain: finalSubdomain, + fullDomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 896dc77f5..3495d9767 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -101,6 +101,9 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + subdomain: siteResources.subdomain, + domainId: siteResources.domainId, + fullDomain: siteResources.fullDomain, siteName: sites.name, siteNiceId: sites.niceId, siteAddress: sites.address diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index b66792c75..ef72ebd84 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -81,11 +81,9 @@ const updateSiteResourceSchema = z .refine( (data) => { if ( - (data.mode === "host" || - data.mode == "http") && + data.mode === "host" && data.destination ) { - if (data.mode == "host") { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere @@ -94,7 +92,6 @@ const updateSiteResourceSchema = z if (isValidIP) { return true; } - } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = @@ -309,7 +306,6 @@ export async function updateSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -326,18 +322,32 @@ export async function updateSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), - eq(siteResources.alias, finalAlias.trim()), + eq(siteResources.alias, alias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) @@ -405,12 +415,13 @@ export async function updateSiteResource( destination, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( @@ -507,18 +518,20 @@ export async function updateSiteResource( .set({ name: name, siteId: siteId, + niceId: niceId, mode: mode, scheme, ssl, destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 46dfeb9cc..537124ad1 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -88,7 +88,10 @@ export default async function ClientResourcesPage( udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, - authDaemonPort: siteResource.authDaemonPort ?? null + authDaemonPort: siteResource.authDaemonPort ?? null, + subdomain: siteResource.subdomain ?? null, + domainId: siteResource.domainId ?? null, + fullDomain: siteResource.fullDomain ?? null }; } ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 6adce8fd9..c531d506d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -63,6 +63,9 @@ export type InternalResourceRow = { disableIcmp: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; function resolveHttpHttpsDisplayPort( @@ -313,8 +316,8 @@ export default function ClientResourcesTable({ /> ); } - if (resourceRow.mode === "http" && resourceRow.alias) { - const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`; + if (resourceRow.mode === "http") { + const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`; return ( parseInt(r.id)) : [], diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 5f20dd458..8e8795a0d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -77,22 +77,28 @@ export default function EditInternalResourceDialog({ ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, - destinationPort: data.httpHttpsPort ?? null + destinationPort: data.httpHttpsPort ?? null, + domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, + subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined }), - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode + ...(data.mode === "host" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && { + authDaemonPort: data.authDaemonPort || null + }) }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null + ...((data.mode === "host" || data.mode === "cidr") && { + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index f7254d6b7..d669c3b15 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -146,9 +146,9 @@ export type InternalResourceData = { httpHttpsPort?: number | null; scheme?: "http" | "https" | null; ssl?: boolean; - httpConfigSubdomain?: string | null; - httpConfigDomainId?: string | null; - httpConfigFullDomain?: string | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); @@ -479,9 +479,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -582,9 +582,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -1023,7 +1023,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", null ); - form.setValue("alias", null); return; } form.setValue( @@ -1038,7 +1037,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", res.fullDomain ); - form.setValue("alias", res.fullDomain); }} /> Date: Sat, 11 Apr 2026 21:56:39 -0700 Subject: [PATCH 30/45] Add logging --- .../routers/newt/handleRequestLogMessage.ts | 166 ++++++++++++++++++ server/private/routers/newt/index.ts | 1 + server/private/routers/ws/messageHandlers.ts | 3 +- .../routers/newt/handleRequestLogMessage.ts | 9 + server/routers/newt/index.ts | 1 + 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/newt/handleRequestLogMessage.ts create mode 100644 server/routers/newt/handleRequestLogMessage.ts diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..c11c98950 --- /dev/null +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,166 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { sites, Newt, orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +const zlibInflate = promisify(inflate); + +interface HTTPRequestLogData { + requestId: string; + resourceId: number; // siteResourceId + timestamp: string; // ISO 8601 + method: string; + scheme: string; // "http" or "https" + host: string; + path: string; + rawQuery?: string; + userAgent?: string; + sourceAddr: string; // ip:port + tls: boolean; +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressRequestLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed request log data is not an array"); + } + + return parsed; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Request log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Request log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Request log message missing compressed data"); + return; + } + + // Look up the org for this site and check retention settings + const [site] = await db + .select({ + orgId: sites.orgId, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Request log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + if (site.settingsLogRetentionDaysRequest === 0) { + logger.debug( + `Request log retention is disabled for org ${orgId}, skipping` + ); + return; + } + + let entries: HTTPRequestLogData[]; + try { + entries = await decompressRequestLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress request log data:", error); + return; + } + + if (entries.length === 0) { + return; + } + + logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + + for (const entry of entries) { + if ( + !entry.requestId || + !entry.resourceId || + !entry.method || + !entry.scheme || + !entry.host || + !entry.path || + !entry.sourceAddr + ) { + logger.debug( + `Skipping request log entry with missing required fields: ${JSON.stringify(entry)}` + ); + continue; + } + + const originalRequestURL = + entry.scheme + + "://" + + entry.host + + entry.path + + (entry.rawQuery ? "?" + entry.rawQuery : ""); + + await logRequestAudit( + { + action: true, + reason: 100, + resourceId: entry.resourceId, + orgId + }, + { + path: entry.path, + originalRequestURL, + scheme: entry.scheme, + host: entry.host, + method: entry.method, + tls: entry.tls, + requestIp: entry.sourceAddr + } + ); + } + + logger.debug( + `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` + ); +}; \ No newline at end of file diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts index 256d19cb7..14ba88bea 100644 --- a/server/private/routers/newt/index.ts +++ b/server/private/routers/newt/index.ts @@ -12,3 +12,4 @@ */ export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index 5021cb966..abef4a66b 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -18,12 +18,13 @@ import { } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; -import { handleConnectionLogMessage } from "#private/routers/newt"; +import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, + "newt/request-log": handleRequestLogMessage, }; if (build != "saas") { diff --git a/server/routers/newt/handleRequestLogMessage.ts b/server/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..190020ad1 --- /dev/null +++ b/server/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,9 @@ +import { MessageHandler } from "@server/routers/ws"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + return; +}; \ No newline at end of file diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 33b5caf7c..fa228cd93 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -9,4 +9,5 @@ export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; export * from "./registerNewt"; From 0cf385b718becb5e95b167d0f0047ad8cb29fc31 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 12:15:29 -0700 Subject: [PATCH 31/45] CRUD and newt mode http mostly working --- messages/en-US.json | 1 + server/lib/ip.ts | 12 +++--- .../routers/newt/handleRequestLogMessage.ts | 4 +- server/routers/badger/logRequestAudit.ts | 1 + server/routers/newt/handleGetConfigMessage.ts | 2 +- .../siteResource/createSiteResource.ts | 2 +- .../siteResource/updateSiteResource.ts | 40 +++++++++++-------- .../[orgId]/settings/logs/request/page.tsx | 3 ++ 8 files changed, 38 insertions(+), 27 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e4bcbd623..3a86af49b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2436,6 +2436,7 @@ "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Connected Client", "resourceBlocked": "Resource Blocked", "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 6f04b8170..13d35834b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -662,10 +662,10 @@ export async function generateSubnetProxyTargetV2( } if ( - !siteResource.alias || !siteResource.aliasAddress || !siteResource.destinationPort || - !siteResource.scheme + !siteResource.scheme || + !siteResource.fullDomain ) { logger.debug( `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.` @@ -676,10 +676,10 @@ export async function generateSubnetProxyTargetV2( let tlsCert: string | undefined; let tlsKey: string | undefined; - if (siteResource.ssl && siteResource.alias) { + if (siteResource.ssl && siteResource.fullDomain) { try { const certs = await getValidCertificatesForDomains( - new Set([siteResource.alias]), + new Set([siteResource.fullDomain]), true ); if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { @@ -687,12 +687,12 @@ export async function generateSubnetProxyTargetV2( tlsKey = certs[0].keyFile; } else { logger.warn( - `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.alias}` + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}` ); } } catch (err) { logger.error( - `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.alias}: ${err}` + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}` ); } } diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts index c11c98950..6cbb18b72 100644 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -144,7 +144,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { await logRequestAudit( { action: true, - reason: 100, + reason: 108, resourceId: entry.resourceId, orgId }, @@ -163,4 +163,4 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { logger.debug( `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` ); -}; \ No newline at end of file +}; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 92d01332e..db4c17939 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -18,6 +18,7 @@ Reasons: 105 - Valid Password 106 - Valid email 107 - Valid SSO +108 - Connected Client 201 - Resource Not Found 202 - Resource Blocked diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 9c67f53ee..7d82e96af 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { logger.warn( - `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` + `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); return; } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f871990fa..ec2eda527 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -262,7 +262,7 @@ export async function createSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - if (domainId && subdomain) { + if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index ef72ebd84..6e253d0e3 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -80,18 +80,15 @@ const updateSiteResourceSchema = z .strict() .refine( (data) => { - if ( - data.mode === "host" && - data.destination - ) { - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if (data.mode === "host" && data.destination) { + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; - } + if (isValidIP) { + return true; + } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = @@ -306,7 +303,7 @@ export async function updateSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - if (domainId && subdomain) { + if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, @@ -324,12 +321,16 @@ export async function updateSiteResource( finalSubdomain = domainResult.subdomain; // make sure the full domain is unique - const existingResource = await db + const [existingDomain] = await db .select() .from(siteResources) .where(eq(siteResources.fullDomain, fullDomain)); - if (existingResource.length > 0) { + if ( + existingDomain && + existingDomain.siteResourceId !== + existingSiteResource.siteResourceId + ) { return next( createHttpError( HttpCode.CONFLICT, @@ -666,9 +667,14 @@ export async function handleMessagingForUpdatedSiteResource( const destinationChanged = existingSiteResource && existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; const aliasChanged = existingSiteResource && - existingSiteResource.alias !== updatedSiteResource.alias; + (existingSiteResource.alias !== updatedSiteResource.alias || + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias const portRangesChanged = existingSiteResource && (existingSiteResource.tcpPortRangeString !== @@ -680,7 +686,7 @@ 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) { + if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) { const [newt] = await trx .select() .from(newts) @@ -694,7 +700,7 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged) { + if (destinationChanged || portRangesChanged || destinationPortChanged) { const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 4a1fe3cd9..061995811 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -360,6 +360,7 @@ export default function GeneralPage() { // 105 - Valid Password // 106 - Valid email // 107 - Valid SSO + // 108 - Connected Client // 201 - Resource Not Found // 202 - Resource Blocked @@ -377,6 +378,7 @@ export default function GeneralPage() { 105: t("validPassword"), 106: t("validEmail"), 107: t("validSSO"), + 108: t("connectedClient"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), @@ -634,6 +636,7 @@ export default function GeneralPage() { { value: "105", label: t("validPassword") }, { value: "106", label: t("validEmail") }, { value: "107", label: t("validSSO") }, + { value: "108", label: t("connectedClient") }, { value: "201", label: t("resourceNotFound") }, { value: "202", label: t("resourceBlocked") }, { value: "203", label: t("droppedByRule") }, From 1564c4bee71f29b7998621727a4ca3dd3e9e26ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:17:45 -0700 Subject: [PATCH 32/45] add multi site selector for ha on private resources --- messages/en-US.json | 3 +- src/app/[orgId]/settings/logs/access/page.tsx | 6 +- .../[orgId]/settings/logs/connection/page.tsx | 16 +-- .../[orgId]/settings/logs/request/page.tsx | 6 +- .../settings/resources/client/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 128 ++++++++++++++++-- .../CreateInternalResourceDialog.tsx | 2 +- src/components/EditInternalResourceDialog.tsx | 10 +- src/components/InternalResourceForm.tsx | 123 ++++++++++++----- src/components/LogDataTable.tsx | 6 +- src/components/multi-site-selector.tsx | 117 ++++++++++++++++ src/components/ui/checkbox.tsx | 4 +- 12 files changed, 356 insertions(+), 79 deletions(-) create mode 100644 src/components/multi-site-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e4bcbd623..03cdc3ddb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1837,6 +1837,7 @@ "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -2673,7 +2674,7 @@ "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", - "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", + "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index a0f1b5386..826e11c17 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -471,11 +471,7 @@ export default function GeneralPage() { : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` } > - diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index e15708f8e..6eaedff5a 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -451,11 +451,7 @@ export default function ConnectionLogsPage() { - @@ -497,11 +493,7 @@ export default function ConnectionLogsPage() { - diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 537124ad1..4d3b48c6c 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -60,21 +60,29 @@ export default async function ClientResourcesPage( const normalizedMode = rawMode === "https" ? ("http" as const) - : rawMode === "host" || rawMode === "cidr" || rawMode === "http" + : rawMode === "host" || + rawMode === "cidr" || + rawMode === "http" ? rawMode : ("host" as const); return { id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, + sites: [ + { + siteId: siteResource.siteId, + siteName: siteResource.siteName, + siteNiceId: siteResource.siteNiceId + } + ], siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, mode: normalizedMode, scheme: siteResource.scheme ?? (rawMode === "https" ? ("https" as const) : null), - ssl: - siteResource.ssl === true || rawMode === "https", + ssl: siteResource.ssl === true || rawMode === "https", // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c531d506d..0f7122c7d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -38,11 +38,23 @@ import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; + +export type InternalResourceSiteRow = { + siteId: number; + siteName: string; + siteNiceId: string; +}; export type InternalResourceRow = { id: number; name: string; orgId: string; + sites: InternalResourceSiteRow[]; siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; @@ -101,6 +113,102 @@ function isSafeUrlForLink(href: string): boolean { } } +const MAX_SITE_LINKS = 3; + +function ClientResourceSiteLinks({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + if (sites.length === 0) { + return -; + } + const visible = sites.slice(0, MAX_SITE_LINKS); + const overflow = sites.slice(MAX_SITE_LINKS); + + return ( +
+ {visible.map((site) => ( + + + + ))} + {overflow.length > 0 ? ( + + ) : null} +
+ ); +} + +function OverflowSitesPopover({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > +
    + {sites.map((site) => ( +
  • + + + +
  • + ))} +
+
+
+ ); +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -223,20 +331,18 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", - friendlyName: t("site"), - header: () => {t("site")}, + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", ") || row.siteName, + friendlyName: t("sites"), + header: () => {t("sites")}, cell: ({ row }) => { const resourceRow = row.original; return ( - - - + ); } }, diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 177571dff..1ad7b3632 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -68,7 +68,7 @@ export default function CreateInternalResourceDialog({ `/org/${orgId}/site-resource`, { name: data.name, - siteId: data.siteId, + siteId: data.siteIds[0], mode: data.mode, destination: data.destination, enabled: true, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 8e8795a0d..e7bdfb795 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -70,7 +70,7 @@ export default function EditInternalResourceDialog({ await api.post(`/site-resource/${resource.id}`, { name: data.name, - siteId: data.siteId, + siteId: data.siteIds[0], mode: data.mode, niceId: data.niceId, destination: data.destination, @@ -78,8 +78,12 @@ export default function EditInternalResourceDialog({ scheme: data.scheme, ssl: data.ssl ?? false, destinationPort: data.httpHttpsPort ?? null, - domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, - subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined }), ...(data.mode === "host" && { alias: diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index d669c3b15..6bc807046 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -46,7 +46,11 @@ import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { SitesSelector, type Selectedsite } from "./site-selector"; +import { + MultiSitesSelector, + formatMultiSitesSelectorLabel +} from "./multi-site-selector"; +import type { Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; @@ -153,9 +157,32 @@ export type InternalResourceData = { const tagSchema = z.object({ id: z.string(), text: z.string() }); +function buildSelectedSitesForResource( + resource: InternalResourceData, + catalog: Site[] +): Selectedsite[] { + const fromCatalog = catalog.find((s) => s.siteId === resource.siteId); + if (fromCatalog) { + return [ + { + name: fromCatalog.name, + siteId: fromCatalog.siteId, + type: fromCatalog.type + } + ]; + } + return [ + { + name: resource.siteName, + siteId: resource.siteId, + type: "newt" + } + ]; +} + export type InternalResourceFormValues = { name: string; - siteId: number; + siteIds: number[]; mode: InternalResourceMode; destination: string; alias?: string | null; @@ -272,13 +299,14 @@ export function InternalResourceForm({ ? "createInternalResourceDialogHttpConfigurationDescription" : "editInternalResourceDialogHttpConfigurationDescription"; + const siteIdsSchema = siteRequiredKey + ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) + : z.array(z.number().int().positive()).min(1); + const formSchema = z .object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteId: z - .number() - .int() - .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), + siteIds: siteIdsSchema, mode: z.enum(["host", "cidr", "http"]), destination: z .string() @@ -467,7 +495,7 @@ export function InternalResourceForm({ variant === "edit" && resource ? { name: resource.name, - siteId: resource.siteId, + siteIds: [resource.siteId], mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -489,7 +517,7 @@ export function InternalResourceForm({ } : { name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -509,8 +537,18 @@ export function InternalResourceForm({ clients: [] }; - const [selectedSite, setSelectedSite] = useState( - availableSites[0] + const [selectedSites, setSelectedSites] = useState(() => + variant === "edit" && resource + ? buildSelectedSitesForResource(resource, sites) + : availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] ); const form = useForm({ @@ -542,7 +580,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -561,12 +599,23 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] + ); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open]); + }, [variant, open, form, sites]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -575,7 +624,7 @@ export function InternalResourceForm({ if (resourceChanged) { form.reset({ name: resource.name, - siteId: resource.siteId, + siteIds: [resource.siteId], mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -594,6 +643,9 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + buildSelectedSitesForResource(resource, sites) + ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -615,7 +667,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form]); + }, [variant, resource, form, sites]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { @@ -651,8 +703,10 @@ export function InternalResourceForm({
{ + const siteIds = values.siteIds; onSubmit({ ...values, + siteIds, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name @@ -729,11 +783,11 @@ export function InternalResourceForm({
( - {t("site")} + {t("sites")} @@ -743,40 +797,41 @@ export function InternalResourceForm({ role="combobox" className={cn( "w-full justify-between", - !field.value && + selectedSites.length === + 0 && "text-muted-foreground" )} > - {field.value - ? availableSites.find( - (s) => - s.siteId === - field.value - )?.name - : t( - "selectSite" - )} + + {formatMultiSitesSelectorLabel( + selectedSites, + t + )} + - { - setSelectedSite( - site + setSelectedSites( + sites ); field.onChange( - site.siteId + sites.map( + (s) => + s.siteId + ) ); }} /> diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 3a53a859f..14e87ff75 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -405,7 +405,11 @@ export function LogDataTable({ onClick={() => !disabled && onExport() } - disabled={isExporting || disabled || isExportDisabled} + disabled={ + isExporting || + disabled || + isExportDisabled + } > {isExporting ? ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx new file mode 100644 index 000000000..407e3b3e1 --- /dev/null +++ b/src/components/multi-site-selector.tsx @@ -0,0 +1,117 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { Checkbox } from "./ui/checkbox"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; +import type { Selectedsite } from "./site-selector"; + +export type MultiSitesSelectorProps = { + orgId: string; + selectedSites: Selectedsite[]; + onSelectionChange: (sites: Selectedsite[]) => void; + filterTypes?: string[]; +}; + +export function formatMultiSitesSelectorLabel( + selectedSites: Selectedsite[], + t: (key: string, values?: { count: number }) => string +): string { + if (selectedSites.length === 0) { + return t("selectSites"); + } + if (selectedSites.length === 1) { + return selectedSites[0]!.name; + } + return t("multiSitesSelectorSitesCount", { + count: selectedSites.length + }); +} + +export function MultiSitesSelector({ + orgId, + selectedSites, + onSelectionChange, + filterTypes +}: MultiSitesSelectorProps) { + const t = useTranslations(); + const [siteSearchQuery, setSiteSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(siteSearchQuery, 150); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 10 + }) + ); + + const sitesShown = useMemo(() => { + const base = filterTypes + ? sites.filter((s) => filterTypes.includes(s.type)) + : [...sites]; + if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) { + const selectedNotInBase = selectedSites.filter( + (sel) => !base.some((s) => s.siteId === sel.siteId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, sites, selectedSites, filterTypes]); + + const selectedIds = useMemo( + () => new Set(selectedSites.map((s) => s.siteId)), + [selectedSites] + ); + + const toggleSite = (site: Selectedsite) => { + if (selectedIds.has(site.siteId)) { + onSelectionChange( + selectedSites.filter((s) => s.siteId !== site.siteId) + ); + } else { + onSelectionChange([...selectedSites, site]); + } + }; + + return ( + + setSiteSearchQuery(v)} + /> + + {t("siteNotFound")} + + {sitesShown.map((site) => ( + { + toggleSite(site); + }} + > + {}} + aria-hidden + tabIndex={-1} + /> + {site.name} + + ))} + + + + ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 261655bb0..5cffd8978 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -43,8 +43,8 @@ const Checkbox = React.forwardRef< className={cn(checkboxVariants({ variant }), className)} {...props} > - - + + )); From b5e239d1adb24e494e1892254457182c736a755f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:24:52 -0700 Subject: [PATCH 33/45] adjust button size --- src/components/ClientResourcesTable.tsx | 4 ++-- src/components/PendingSitesTable.tsx | 4 ++-- src/components/ShareLinksTable.tsx | 4 ++-- src/components/SitesTable.tsx | 4 ++-- src/components/UserDevicesTable.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 0f7122c7d..4822f358e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -143,7 +143,7 @@ function ClientResourceSiteLinks({ {site.siteName} - + ))} @@ -198,7 +198,7 @@ function OverflowSitesPopover({ {site.siteName} - + diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 12abcf7c4..f4156603e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -352,9 +352,9 @@ export default function PendingSitesTable({ - ); diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index efac77df3..333cee03f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -144,9 +144,9 @@ export default function ShareLinksTable({ - ); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index cc02e5d37..4f459ffc1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -362,9 +362,9 @@ export default function SitesTable({ - ); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 52f2d1384..58a5ba402 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -373,12 +373,12 @@ export default function UserDevicesTable({ - ) : ( From 0cbcc0c29c0f38b1f0a01c56e0d7ae3569e3f5d3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 14:58:55 -0700 Subject: [PATCH 34/45] remove extra sites query --- .../siteResource/listAllSiteResourcesByOrg.ts | 4 +- .../settings/resources/client/page.tsx | 3 +- src/components/ClientResourcesTable.tsx | 168 +++++++++--------- .../CreateInternalResourceDialog.tsx | 3 - src/components/EditInternalResourceDialog.tsx | 3 - src/components/InternalResourceForm.tsx | 49 +---- 6 files changed, 97 insertions(+), 133 deletions(-) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3495d9767..de9083c2c 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -76,6 +76,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteName: string; siteNiceId: string; siteAddress: string | null; + siteOnline: boolean; })[]; }>; @@ -106,7 +107,8 @@ function querySiteResourcesBase() { fullDomain: siteResources.fullDomain, siteName: sites.name, siteNiceId: sites.niceId, - siteAddress: sites.address + siteAddress: sites.address, + siteOnline: sites.online }) .from(siteResources) .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 4d3b48c6c..f63563cc9 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -73,7 +73,8 @@ export default async function ClientResourcesPage( { siteId: siteResource.siteId, siteName: siteResource.siteName, - siteNiceId: siteResource.siteNiceId + siteNiceId: siteResource.siteNiceId, + online: siteResource.siteOnline } ], siteName: siteResource.siteName, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 4822f358e..fc1a6a6f3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -20,7 +20,7 @@ import { ArrowDown01Icon, ArrowUp10Icon, ArrowUpDown, - ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -38,16 +38,13 @@ import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; export type InternalResourceSiteRow = { siteId: number; siteName: string; siteNiceId: string; + online: boolean; }; export type InternalResourceRow = { @@ -113,99 +110,106 @@ function isSafeUrlForLink(href: string): boolean { } } -const MAX_SITE_LINKS = 3; +type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; -function ClientResourceSiteLinks({ - orgId, - sites -}: { - orgId: string; - sites: InternalResourceSiteRow[]; -}) { - if (sites.length === 0) { - return -; +function aggregateSitesStatus( + resourceSites: InternalResourceSiteRow[] +): AggregateSitesStatus { + if (resourceSites.length === 0) { + return "allOffline"; } - const visible = sites.slice(0, MAX_SITE_LINKS); - const overflow = sites.slice(MAX_SITE_LINKS); - - return ( -
- {visible.map((site) => ( - - - - ))} - {overflow.length > 0 ? ( - - ) : null} -
- ); + const onlineCount = resourceSites.filter((rs) => rs.online).length; + if (onlineCount === resourceSites.length) return "allOnline"; + if (onlineCount > 0) return "partial"; + return "allOffline"; } -function OverflowSitesPopover({ +function aggregateStatusDotClass(status: AggregateSitesStatus): string { + switch (status) { + case "allOnline": + return "bg-green-500"; + case "partial": + return "bg-yellow-500"; + case "allOffline": + default: + return "bg-gray-500"; + } +} + +function ClientResourceSitesStatusCell({ orgId, - sites + resourceSites }: { orgId: string; - sites: InternalResourceSiteRow[]; + resourceSites: InternalResourceSiteRow[]; }) { - const [open, setOpen] = useState(false); + const t = useTranslations(); + + if (resourceSites.length === 0) { + return -; + } + + const aggregate = aggregateSitesStatus(resourceSites); + const countLabel = t("multiSitesSelectorSitesCount", { + count: resourceSites.length + }); return ( - - + + - - setOpen(true)} - onMouseLeave={() => setOpen(false)} - > -
    - {sites.map((site) => ( -
  • + + + {resourceSites.map((site) => { + const isOnline = site.online; + return ( + - +
+ + {isOnline ? t("online") : t("offline")} + - - ))} - - - + + ); + })} + + ); } @@ -243,8 +247,6 @@ export default function ClientResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); - const [isRefreshing, startTransition] = useTransition(); const refreshData = () => { @@ -339,9 +341,9 @@ export default function ClientResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; return ( - ); } @@ -599,7 +601,6 @@ export default function ClientResourcesTable({ setOpen={setIsEditDialogOpen} resource={editingResource} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { @@ -614,7 +615,6 @@ export default function ClientResourcesTable({ open={isCreateDialogOpen} setOpen={setIsCreateDialogOpen} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 1ad7b3632..c0483e35d 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -31,7 +31,6 @@ type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -39,7 +38,6 @@ export default function CreateInternalResourceDialog({ open, setOpen, orgId, - sites, onSuccess }: CreateInternalResourceDialogProps) { const t = useTranslations(); @@ -155,7 +153,6 @@ export default function CreateInternalResourceDialog({ void; resource: InternalResourceData; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -43,7 +42,6 @@ export default function EditInternalResourceDialog({ setOpen, resource, orgId, - sites, onSuccess }: EditInternalResourceDialogProps) { const t = useTranslations(); @@ -174,7 +172,6 @@ export default function EditInternalResourceDialog({ variant="edit" open={open} resource={resource} - sites={sites} orgId={orgId} siteResourceId={resource.id} formId="edit-internal-resource-form" diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 6bc807046..0d98fb30b 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -159,18 +159,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( resource: InternalResourceData, - catalog: Site[] ): Selectedsite[] { - const fromCatalog = catalog.find((s) => s.siteId === resource.siteId); - if (fromCatalog) { - return [ - { - name: fromCatalog.name, - siteId: fromCatalog.siteId, - type: fromCatalog.type - } - ]; - } return [ { name: resource.siteName, @@ -207,7 +196,6 @@ type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; - sites: Site[]; orgId: string; siteResourceId?: number; formId: string; @@ -218,7 +206,6 @@ export function InternalResourceForm({ variant, resource, open, - sites, orgId, siteResourceId, formId, @@ -375,8 +362,6 @@ export function InternalResourceForm({ type FormData = z.infer; - const availableSites = sites.filter((s) => s.type === "newt"); - const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); @@ -517,7 +502,7 @@ export function InternalResourceForm({ } : { name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -539,16 +524,8 @@ export function InternalResourceForm({ const [selectedSites, setSelectedSites] = useState(() => variant === "edit" && resource - ? buildSelectedSitesForResource(resource, sites) - : availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] + ? buildSelectedSitesForResource(resource) + : [] ); const form = useForm({ @@ -580,7 +557,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -599,23 +576,13 @@ export function InternalResourceForm({ users: [], clients: [] }); - setSelectedSites( - availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] - ); + setSelectedSites([]); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open, form, sites]); + }, [variant, open, form]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -644,7 +611,7 @@ export function InternalResourceForm({ clients: [] }); setSelectedSites( - buildSelectedSitesForResource(resource, sites) + buildSelectedSitesForResource(resource) ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) @@ -667,7 +634,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form, sites]); + }, [variant, resource, form]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { From 789b991c569faa7e5513ef3c115fa22540e7c76f Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 15:08:17 -0700 Subject: [PATCH 35/45] Logging and http working --- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + .../newt/handleConnectionLogMessage.ts | 16 +- .../routers/newt/handleRequestLogMessage.ts | 80 +++++++++- .../routers/auditLogs/queryRequestAuditLog.ts | 149 +++++++++++++----- server/routers/auditLogs/types.ts | 1 + server/routers/badger/logRequestAudit.ts | 3 + .../[orgId]/settings/logs/request/page.tsx | 6 +- 8 files changed, 212 insertions(+), 45 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index aac86c1b9..308a69fc7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1004,6 +1004,7 @@ export const requestAuditLog = pgTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e58601dc3..fe192014b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1104,6 +1104,7 @@ export const requestAuditLog = sqliteTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), diff --git a/server/private/routers/newt/handleConnectionLogMessage.ts b/server/private/routers/newt/handleConnectionLogMessage.ts index e980f85c9..60a810ee6 100644 --- a/server/private/routers/newt/handleConnectionLogMessage.ts +++ b/server/private/routers/newt/handleConnectionLogMessage.ts @@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { return; } - // Look up the org for this site + // Look up the org for this site and check retention settings const [site] = await db - .select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) + .select({ + orgId: sites.orgId, + orgSubnet: orgs.subnet, + settingsLogRetentionDaysConnection: + orgs.settingsLogRetentionDaysConnection + }) .from(sites) .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) .where(eq(sites.siteId, newt.siteId)); @@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { const orgId = site.orgId; + if (site.settingsLogRetentionDaysConnection === 0) { + logger.debug( + `Connection log retention is disabled for org ${orgId}, skipping` + ); + return; + } + // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can // reconstruct the exact subnet string stored on each client record. const cidrSuffix = site.orgSubnet?.includes("/") diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts index 6cbb18b72..42f1baf2c 100644 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -13,12 +13,13 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { sites, Newt, orgs } from "@server/db"; -import { eq } from "drizzle-orm"; +import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { inflate } from "zlib"; import { promisify } from "util"; import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; +import { getCountryCodeForIp } from "@server/lib/geoip"; export async function flushRequestLogToDb(): Promise { return; @@ -81,6 +82,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { const [site] = await db .select({ orgId: sites.orgId, + orgSubnet: orgs.subnet, settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest }) @@ -118,6 +120,61 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + // Build a map from sourceIp → external endpoint string by joining clients + // with clientSitesAssociationsCache. The endpoint is the real-world IP:port + // of the client device and is used for GeoIP lookup. + const ipToEndpoint = new Map(); + + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + if (cidrSuffix) { + const uniqueSourceAddrs = new Set(); + for (const entry of entries) { + if (entry.sourceAddr) { + uniqueSourceAddrs.add(entry.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + }); + + const matchedClients = await db + .select({ + subnet: clients.subnet, + endpoint: clientSitesAssociationsCache.endpoint + }) + .from(clients) + .innerJoin( + clientSitesAssociationsCache, + and( + eq( + clientSitesAssociationsCache.clientId, + clients.clientId + ), + eq(clientSitesAssociationsCache.siteId, newt.siteId) + ) + ) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + if (c.endpoint) { + const ip = c.subnet.split("/")[0]; + ipToEndpoint.set(ip, c.endpoint); + } + } + } + } + for (const entry of entries) { if ( !entry.requestId || @@ -141,12 +198,27 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { entry.path + (entry.rawQuery ? "?" + entry.rawQuery : ""); + // Resolve the client's external endpoint for GeoIP lookup. + // sourceAddr is the WireGuard IP (possibly ip:port), so strip the port. + const sourceIp = entry.sourceAddr.includes(":") + ? entry.sourceAddr.split(":")[0] + : entry.sourceAddr; + const endpoint = ipToEndpoint.get(sourceIp); + let location: string | undefined; + if (endpoint) { + const endpointIp = endpoint.includes(":") + ? endpoint.split(":")[0] + : endpoint; + location = await getCountryCodeForIp(endpointIp); + } + await logRequestAudit( { action: true, reason: 108, - resourceId: entry.resourceId, - orgId + siteResourceId: entry.resourceId, + orgId, + location }, { path: entry.path, diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 176a9e5d3..000ec9815 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,8 +1,8 @@ -import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; +import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -92,7 +92,10 @@ function getWhere(data: Q) { lt(requestAuditLog.timestamp, data.timeEnd), eq(requestAuditLog.orgId, data.orgId), data.resourceId - ? eq(requestAuditLog.resourceId, data.resourceId) + ? or( + eq(requestAuditLog.resourceId, data.resourceId), + eq(requestAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined, @@ -110,15 +113,16 @@ export function queryRequest(data: Q) { return primaryLogsDb .select({ id: requestAuditLog.id, - timestamp: requestAuditLog.timestamp, - orgId: requestAuditLog.orgId, - action: requestAuditLog.action, - reason: requestAuditLog.reason, - actorType: requestAuditLog.actorType, - actor: requestAuditLog.actor, - actorId: requestAuditLog.actorId, - resourceId: requestAuditLog.resourceId, - ip: requestAuditLog.ip, + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + siteResourceId: requestAuditLog.siteResourceId, + ip: requestAuditLog.ip, location: requestAuditLog.location, userAgent: requestAuditLog.userAgent, metadata: requestAuditLog.metadata, @@ -137,37 +141,73 @@ export function queryRequest(data: Q) { } async function enrichWithResourceDetails(logs: Awaited>) { - // If logs database is the same as main database, we can do a join - // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); - if (resourceIds.length === 0) { + const siteResourceIds = logs + .filter(log => log.resourceId == null && log.siteResourceId != null) + .map(log => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0 && siteResourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } - // Fetch resource details from main database - const resourceDetails = await primaryDb - .select({ - resourceId: resources.resourceId, - name: resources.name, - niceId: resources.niceId - }) - .from(resources) - .where(inArray(resources.resourceId, resourceIds)); + const resourceMap = new Map(); - // Create a map for quick lookup - const resourceMap = new Map( - resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) - ); + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); + } + } + + const siteResourceMap = new Map(); + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of siteResourceDetails) { + siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); + } + } // Enrich logs with resource details - return logs.map(log => ({ - ...log, - resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, - resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null - })); + return logs.map(log => { + if (log.resourceId != null) { + const details = resourceMap.get(log.resourceId); + return { + ...log, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } else if (log.siteResourceId != null) { + const details = siteResourceMap.get(log.siteResourceId); + return { + ...log, + resourceId: log.siteResourceId, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } + return { ...log, resourceName: null, resourceNiceId: null }; + }); } export function countRequestQuery(data: Q) { @@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes( uniqueLocations, uniqueHosts, uniquePaths, - uniqueResources + uniqueResources, + uniqueSiteResources ] = await Promise.all([ primaryLogsDb .selectDistinct({ actor: requestAuditLog.actor }) @@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes( }) .from(requestAuditLog) .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ + id: requestAuditLog.siteResourceId + }) + .from(requestAuditLog) + .where(and(baseConditions, isNull(requestAuditLog.resourceId))) .limit(DISTINCT_LIMIT + 1) ]); @@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes( .map(row => row.id) .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map(row => row.id) + .filter((id): id is number => id !== null); + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { @@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes( .from(resources) .where(inArray(resources.resourceId, resourceIds)); - resourcesWithNames = resourceDetails.map(r => ({ - id: r.resourceId, - name: r.name - })); + resourcesWithNames = [ + ...resourcesWithNames, + ...resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })) + ]; + } + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...siteResourceDetails.map(r => ({ + id: r.siteResourceId, + name: r.name + })) + ]; } return { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 4c278cba5..972eebfe3 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = { actor: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceNiceId: string | null; resourceName: string | null; ip: string | null; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index db4c17939..884fb7ae4 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -39,6 +39,7 @@ const auditLogBuffer: Array<{ metadata: any; action: boolean; resourceId?: number; + siteResourceId?: number; reason: number; location?: string; originalRequestURL: string; @@ -187,6 +188,7 @@ export async function logRequestAudit( action: boolean; reason: number; resourceId?: number; + siteResourceId?: number; orgId?: string; location?: string; user?: { username: string; userId: string }; @@ -263,6 +265,7 @@ export async function logRequestAudit( metadata: sanitizeString(metadata), action: data.action, resourceId: data.resourceId, + siteResourceId: data.siteResourceId, reason: data.reason, location: sanitizeString(data.location), originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 061995811..c57914c8a 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -512,7 +512,11 @@ export default function GeneralPage() { cell: ({ row }) => { return ( e.stopPropagation()} >
+ {isHttpMode && ( + + )} + {isHttpMode ? (
@@ -991,6 +1005,7 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)}
+
+
From 9b271950d243228168f2b7e4d81f14594337889e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 17:31:51 -0700 Subject: [PATCH 37/45] Push down certs when they are detected --- server/private/lib/acmeCertSync.ts | 169 ++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 052488f0f..cd3e5478f 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -13,12 +13,24 @@ import fs from "fs"; import crypto from "crypto"; -import { certificates, domains, db } from "@server/db"; +import { + certificates, + clients, + clientSiteResourcesAssociationsCache, + db, + domains, + newts, + SiteResource, + siteResources +} from "@server/db"; import { and, eq } from "drizzle-orm"; import { encrypt, decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import privateConfig from "#private/lib/config"; import config from "@server/lib/config"; +import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; +import { updateTargets } from "@server/routers/client/targets"; +import cache from "#private/lib/cache"; interface AcmeCert { domain: { main: string; sans?: string[] }; @@ -33,6 +45,138 @@ interface AcmeJson { }; } +async function pushCertUpdateToAffectedNewts( + domain: string, + domainId: string | null, + oldCertPem: string | null, + oldKeyPem: string | null +): Promise { + // Find all SSL-enabled HTTP site resources that use this cert's domain + let affectedResources: SiteResource[] = []; + + if (domainId) { + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.domainId, domainId), + eq(siteResources.ssl, true) + ) + ); + } else { + // Fallback: match by exact fullDomain when no domainId is available + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, domain), + eq(siteResources.ssl, true) + ) + ); + } + + if (affectedResources.length === 0) { + logger.debug( + `acmeCertSync: no affected site resources for cert domain "${domain}"` + ); + return; + } + + logger.info( + `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` + ); + + for (const resource of affectedResources) { + try { + // Get the newt for this site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, resource.siteId)) + .limit(1); + + if (!newt) { + logger.debug( + `acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}` + ); + continue; + } + + // Get all clients with access to this resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); + + if (resourceClients.length === 0) { + logger.debug( + `acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data + if (resource.fullDomain) { + await cache.del(`cert:${resource.fullDomain}`); + } + + // Generate the new target (will read the freshly updated cert from DB) + const newTarget = await generateSubnetProxyTargetV2( + resource, + resourceClients + ); + + if (!newTarget) { + logger.debug( + `acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Construct the old target — same routing shape but with the previous cert/key. + // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the + // semantics correct so the update message accurately reflects what changed. + const oldTarget: SubnetProxyTargetV2 = { + ...newTarget, + tlsCert: oldCertPem ?? undefined, + tlsKey: oldKeyPem ?? undefined + }; + + await updateTargets( + newt.newtId, + { oldTargets: [oldTarget], newTargets: [newTarget] }, + newt.version + ); + + logger.info( + `acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}` + ); + } catch (err) { + logger.error( + `acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}` + ); + } + } +} + async function findDomainId(certDomain: string): Promise { // Strip wildcard prefix before lookup (*.example.com -> example.com) const lookupDomain = certDomain.startsWith("*.") @@ -148,6 +292,9 @@ async function syncAcmeCerts( .where(eq(certificates.domain, domain)) .limit(1); + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + if (existing.length > 0 && existing[0].certFile) { try { const storedCertPem = decrypt( @@ -160,6 +307,21 @@ async function syncAcmeCerts( ); continue; } + // Cert has changed; capture old values so we can send a correct + // update message to the newt after the DB write. + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } } catch (err) { // Decryption failure means we should proceed with the update logger.debug( @@ -215,6 +377,8 @@ async function syncAcmeCerts( logger.info( `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); + + await pushCertUpdateToAffectedNewts(domain, domainId, oldCertPem, oldKeyPem); } else { await db.insert(certificates).values({ domain, @@ -231,6 +395,9 @@ async function syncAcmeCerts( logger.info( `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); + + // For a brand-new cert, push to any SSL resources that were waiting for it + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); } } } From aa41a63430fdbe03a9281d77cc092b7c9425318c Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 17:50:27 -0700 Subject: [PATCH 38/45] Dont run the acme in saas or when we control dns --- server/private/lib/acmeCertSync.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index cd3e5478f..aff3efaec 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -31,6 +31,7 @@ import config from "@server/lib/config"; import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; import { updateTargets } from "@server/routers/client/targets"; import cache from "#private/lib/cache"; +import { build } from "@server/build"; interface AcmeCert { domain: { main: string; sans?: string[] }; @@ -403,9 +404,20 @@ async function syncAcmeCerts( } export function initAcmeCertSync(): void { + if (build == "saas") { + logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`); + return; + } + const privateConfigData = privateConfig.getRawPrivateConfig(); if (!privateConfigData.flags?.enable_acme_cert_sync) { + logger.debug(`acmeCertSync: ACME cert sync is disabled by config flag, skipping`); + return; + } + + if (!privateConfigData.flags.use_pangolin_dns) { + logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be enabled, skipping`); return; } From 676eacc9cf690cce4eddc3501d1c0eed3741ff51 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 16:06:23 -0700 Subject: [PATCH 39/45] Invert logic for pangolin dns --- server/private/lib/acmeCertSync.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 9bedc6a3e..9e6856ae2 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -434,8 +434,8 @@ export function initAcmeCertSync(): void { return; } - if (!privateConfigData.flags.use_pangolin_dns) { - logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be enabled, skipping`); + if (privateConfigData.flags.use_pangolin_dns) { + logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`); return; } From 173a81ead8f8165b87cbf3c8cea05d880c989483 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 16:22:22 -0700 Subject: [PATCH 40/45] Fixing up the crud for multiple sites --- .../siteResource/createSiteResource.ts | 3 +- .../siteResource/listAllSiteResourcesByOrg.ts | 58 +++++++++++++++---- .../siteResource/updateSiteResource.ts | 16 +++-- src/components/ClientResourcesTable.tsx | 6 +- .../CreateInternalResourceDialog.tsx | 2 +- src/components/EditInternalResourceDialog.tsx | 5 +- src/components/InternalResourceForm.tsx | 20 +++---- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index da5355c9e..9a7d632fd 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -222,8 +222,7 @@ export async function createSiteResource( const sitesToAssign = await db .select() .from(sites) - .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))) - .limit(1); + .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))); if (sitesToAssign.length !== siteIds.length) { return next( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index aa1fe7043..8750e7516 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; +import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -81,6 +81,40 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ })[]; }>; +/** + * Returns an aggregation expression compatible with both SQLite and PostgreSQL. + * - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch + * - PostgreSQL: array_agg(col) → returns a native array + */ +function aggCol(column: any) { + if (DB_TYPE === "sqlite") { + return sql`json_group_array(${column})`; + } + return sql`array_agg(${column})`; +} + +/** + * For SQLite the aggregated columns come back as JSON strings; parse them into + * proper arrays. For PostgreSQL the driver already returns native arrays, so + * the row is returned unchanged. + */ +function transformSiteResourceRow(row: any) { + if (DB_TYPE !== "sqlite") { + return row; + } + return { + ...row, + siteNames: JSON.parse(row.siteNames) as string[], + siteNiceIds: JSON.parse(row.siteNiceIds) as string[], + siteIds: JSON.parse(row.siteIds) as number[], + siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[], + // SQLite stores booleans as 0/1 integers + siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map( + (v) => v === 1 + ) as boolean[] + }; +} + function querySiteResourcesBase() { return db .select({ @@ -107,19 +141,21 @@ function querySiteResourcesBase() { fullDomain: siteResources.fullDomain, networkId: siteResources.networkId, 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})`, - siteOnlines: sql`array_agg(${sites.online})` + siteNames: aggCol(sites.name), + siteNiceIds: aggCol(sites.niceId), + siteIds: aggCol(sites.siteId), + siteAddresses: aggCol<(string | null)[]>(sites.address), + siteOnlines: aggCol(sites.online) }) .from(siteResources) - .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } - registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", @@ -210,7 +246,7 @@ export async function listAllSiteResourcesByOrg( .as("filtered_site_resources") ); - const [siteResourcesList, totalCount] = await Promise.all([ + const [siteResourcesRaw, totalCount] = await Promise.all([ baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) @@ -224,6 +260,8 @@ export async function listAllSiteResourcesByOrg( countQuery ]); + const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow); + return response(res, { data: { siteResources: siteResourcesList, @@ -247,4 +285,4 @@ export async function listAllSiteResourcesByOrg( ) ); } -} +} \ No newline at end of file diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 24b9f45b2..40e0feef9 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -280,8 +280,7 @@ export async function updateSiteResource( inArray(sites.siteId, siteIds), eq(sites.orgId, existingSiteResource.orgId) ) - ) - .limit(1); + ); if (sitesToAssign.length !== siteIds.length) { return next( @@ -727,7 +726,12 @@ 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 || destinationPortChanged) { + if ( + destinationChanged || + aliasChanged || + portRangesChanged || + destinationPortChanged + ) { for (const site of sites) { const [newt] = await trx .select() @@ -742,7 +746,11 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged || destinationPortChanged) { + if ( + destinationChanged || + portRangesChanged || + destinationPortChanged + ) { const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 4fd7f44fe..c32208321 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -653,11 +653,7 @@ export default function ClientResourcesTable({ { // Delay refresh to allow modal to close smoothly diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index b90cae8a6..b9c978b3f 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -67,7 +67,7 @@ export default function CreateInternalResourceDialog({ `/org/${orgId}/site-resource`, { name: data.name, - siteId: data.siteIds[0], + siteIds: data.siteIds, mode: data.mode, destination: data.destination, enabled: true, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 7d1c7e8aa..859981f7d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { resourceQueries } from "@app/lib/queries"; -import { ListSitesResponse } from "@server/routers/site"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; @@ -27,8 +26,6 @@ import { isHostname } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type EditInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; @@ -69,7 +66,7 @@ export default function EditInternalResourceDialog({ await api.post(`/site-resource/${resource.id}`, { name: data.name, - siteId: data.siteIds[0], + siteIds: data.siteIds, mode: data.mode, niceId: data.niceId, destination: data.destination, diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 11abd8919..13d24b6b0 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -136,9 +136,9 @@ export type InternalResourceData = { id: number; name: string; orgId: string; - siteName: string; + siteNames: string[]; mode: InternalResourceMode; - siteId: number; + siteIds: number[]; niceId: string; destination: string; alias?: string | null; @@ -160,13 +160,11 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( resource: InternalResourceData, ): Selectedsite[] { - return [ - { - name: resource.siteName, - siteId: resource.siteId, - type: "newt" - } - ]; + return resource.siteIds.map((siteId, idx) => ({ + name: resource.siteNames[idx] ?? "", + siteId, + type: "newt" as const + })); } export type InternalResourceFormValues = { @@ -483,7 +481,7 @@ export function InternalResourceForm({ variant === "edit" && resource ? { name: resource.name, - siteIds: [resource.siteId], + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -594,7 +592,7 @@ export function InternalResourceForm({ if (resourceChanged) { form.reset({ name: resource.name, - siteIds: [resource.siteId], + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, From 30fd48a14adb28fb7a55893a7e22b629cf968782 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:17:28 -0700 Subject: [PATCH 41/45] HA site crud working --- .../routers/siteResource/updateSiteResource.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 40e0feef9..c48066b4a 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -466,6 +466,23 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq( + siteNetworks.networkId, + updatedSiteResource.networkId! + ) + ); + + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + } + const [adminRole] = await trx .select() .from(roles) From 7a40084bf47f6f52a1d84ff2c2968e87e4b5dbce Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:21:34 -0700 Subject: [PATCH 42/45] Rename for better understanding --- ...andleGetConfigMessage.ts => handleNewtGetConfigMessage.ts} | 2 +- server/routers/newt/index.ts | 2 +- server/routers/ws/messageHandlers.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename server/routers/newt/{handleGetConfigMessage.ts => handleNewtGetConfigMessage.ts} (98%) diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts similarity index 98% rename from server/routers/newt/handleGetConfigMessage.ts rename to server/routers/newt/handleNewtGetConfigMessage.ts index 7d82e96af..e76819ed1 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; -export const handleGetConfigMessage: MessageHandler = async (context) => { +export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index fa228cd93..fe6998722 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -2,7 +2,7 @@ export * from "./createNewt"; export * from "./getNewtToken"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; -export * from "./handleGetConfigMessage"; +export * from "./handleNewtGetConfigMessage"; export * from "./handleSocketMessages"; export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 143e4d516..2dc09eedc 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -2,7 +2,7 @@ import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, - handleGetConfigMessage, + handleNewtGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, handleNewtPingRequestMessage, @@ -37,7 +37,7 @@ export const messageHandlers: Record = { "newt/disconnecting": handleNewtDisconnectingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, - "newt/wg/get-config": handleGetConfigMessage, + "newt/wg/get-config": handleNewtGetConfigMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, From 3996e14e70bbc8f774bd924af288c6ffe7a218b4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:56:51 -0700 Subject: [PATCH 43/45] Add comment --- server/routers/newt/handleNewtGetConfigMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index e76819ed1..787151a5a 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -113,7 +113,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { exitNode ); - const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format return { message: { From 1b9a395432c8d44519e3fe828b8ffbed4ce4ed5a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:56:55 -0700 Subject: [PATCH 44/45] Add logging for debugging --- server/lib/rebuildClientAssociations.ts | 80 ++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 04b16beb8..a570f0f8b 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -21,7 +21,6 @@ import { import { and, eq, inArray, ne } from "drizzle-orm"; import { - addPeer as newtAddPeer, deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { @@ -35,7 +34,6 @@ import { generateRemoteSubnets, generateSubnetProxyTargetV2, parseEndpoint, - formatEndpoint } from "@server/lib/ip"; import { addPeerData, @@ -61,6 +59,10 @@ export async function getClientSiteResourceAccess( .then((rows) => rows.map((row) => row.sites)) : []; + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` + ); + if (sitesList.length === 0) { logger.warn( `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` @@ -144,6 +146,10 @@ export async function getClientSiteResourceAccess( const mergedAllClients = Array.from(allClientsMap.values()); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})` + ); + return { sitesList, mergedAllClients, @@ -161,9 +167,17 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` + ); + const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]` + ); + /////////// process the client-siteResource associations /////////// // get all of the clients associated with other resources in the same network, @@ -223,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource( (row) => row.clientId ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` + ); + // Get full client details for existing resource clients (needed for sending delete messages) const existingResourceClients = existingClientSiteResourceIds.length > 0 @@ -242,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource( (clientId) => !existingClientSiteResourceIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]` + ); + const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( (clientId) => ({ clientId, @@ -250,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource( ); if (clientSiteResourcesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)` + ); await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert` + ); } const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( (clientId) => !mergedAllClientIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]` + ); + if (clientSiteResourcesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)` + ); await trx .delete(clientSiteResourcesAssociationsCache) .where( @@ -279,9 +318,17 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-site associations /////////// + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` + ); + for (const site of sitesList) { const siteId = site.siteId; + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` + ); + const existingClientSites = await trx .select({ clientId: clientSitesAssociationsCache.clientId @@ -293,6 +340,10 @@ export async function rebuildClientAssociationsFromSiteResource( (row) => row.clientId ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` + ); + // Get full client details for existing clients (needed for sending delete messages) const existingClients = existingClientSiteIds.length > 0 @@ -308,6 +359,10 @@ export async function rebuildClientAssociationsFromSiteResource( const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` + ); + const clientSitesToAdd = mergedAllClientIds.filter( (clientId) => !existingClientSiteIds.includes(clientId) && @@ -319,11 +374,25 @@ export async function rebuildClientAssociationsFromSiteResource( siteId })); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` + ); + if (clientSitesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` + ); await trx .insert(clientSitesAssociationsCache) .values(clientSitesToInsert) .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` + ); } // Now remove any client-site associations that should no longer exist @@ -333,7 +402,14 @@ export async function rebuildClientAssociationsFromSiteResource( !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` + ); + if (clientSitesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + ); await trx .delete(clientSitesAssociationsCache) .where( From 49ae5eecb65b676463808b47eab816c32436c58c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 21:56:35 -0700 Subject: [PATCH 45/45] Filter only approved sites --- src/components/CreateInternalResourceDialog.tsx | 3 --- src/components/InternalResourceForm.tsx | 3 --- src/components/resource-target-address-item.tsx | 15 +++++++-------- src/lib/queries.ts | 3 ++- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index b9c978b3f..4d2bc0916 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -25,8 +24,6 @@ import { type InternalResourceFormValues } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 13d24b6b0..e8574b29e 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -38,7 +38,6 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { ListSitesResponse } from "@server/routers/site"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; @@ -128,8 +127,6 @@ export const cleanForFQDN = (name: string): string => // --- Types --- -type Site = ListSitesResponse["sites"][0]; - export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 851b64b54..c801844ce 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -12,14 +12,6 @@ import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { ContainersSelector } from "./ContainersSelector"; import { Button } from "./ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "./ui/command"; import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; @@ -212,6 +204,12 @@ export function ResourceTargetAddressItem({ proxyTarget.port === 0 ? "" : proxyTarget.port } className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs" + type="number" + onKeyDown={(e) => { + if (["e", "E", "+", "-", "."].includes(e.key)) { + e.preventDefault(); + } + }} onBlur={(e) => { const value = parseInt(e.target.value, 10); if (!isNaN(value) && value > 0) { @@ -227,6 +225,7 @@ export function ResourceTargetAddressItem({ } }} /> + ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2fd34e8ac..d7822d6cf 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -155,7 +155,8 @@ export const orgQueries = { queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - pageSize: perPage.toString() + pageSize: perPage.toString(), + status: "approved" }); if (query?.trim()) {