From c9cb28af45deec8a2a9abdb3517092561cac4ee0 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 14:30:36 -0700 Subject: [PATCH 01/12] Rename to public-policies --- server/lib/blueprints/resourcePolicies.ts | 2 +- server/lib/blueprints/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index 7a794c55a..f8d8d1269 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -37,7 +37,7 @@ export async function updateResourcePolicies( const results: ResourcePoliciesResults = []; for (const [policyNiceId, policyData] of Object.entries( - config["resource-policies"] + config["public-policies"] )) { const isLicensed = await isLicensedOrSubscribed( orgId, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ad3676c4b..fc73d83a0 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -573,7 +573,7 @@ export const ConfigSchema = z .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), - "resource-policies": z + "public-policies": z .record(z.string(), ResourcePolicySchema) .optional() .prefault({}), @@ -607,7 +607,7 @@ export const ConfigSchema = z string, z.infer >; - "resource-policies": Record< + "public-policies": Record< string, z.infer >; From 8e5d9e94a99bf20710e48f8e0547821feb4f66b3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 14:37:44 -0700 Subject: [PATCH 02/12] Fix delete site only working on newt site --- server/routers/site/deleteSite.ts | 3 +-- src/components/SitesTable.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 47efba910..bac56c6ba 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -93,10 +93,9 @@ export async function deleteSite( // Clean up all client associations and send peer/proxy removal // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); - - await trx.delete(sites).where(eq(sites.siteId, siteId)); } + await trx.delete(sites).where(eq(sites.siteId, siteId)); await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 3e234bf79..8c3036c4a 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -514,6 +514,16 @@ export default function SitesTable({ )} + { + setSelectedSite(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + Date: Fri, 5 Jun 2026 15:30:42 -0700 Subject: [PATCH 03/12] Remove browser gateway targets for regular targets --- server/db/pg/schema/privateSchema.ts | 21 -- server/db/pg/schema/schema.ts | 7 +- server/db/sqlite/schema/privateSchema.ts | 23 --- server/db/sqlite/schema/schema.ts | 7 +- .../private/lib/traefik/getTraefikConfig.ts | 78 ++------ .../createBrowserGatewayTarget.ts | 187 ------------------ .../deleteBrowserGatewayTarget.ts | 130 ------------ .../getBrowserGatewayTarget.ts | 109 ---------- .../browserGatewayTarget/getBrowserTarget.ts | 53 ++--- .../routers/browserGatewayTarget/index.ts | 5 - .../listBrowserGatewayTargets.ts | 159 --------------- .../updateBrowserGatewayTarget.ts | 180 ----------------- server/private/routers/external.ts | 46 ----- server/private/routers/integration.ts | 41 ---- server/private/routers/internal.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 16 +- server/routers/newt/buildConfiguration.ts | 46 +++-- server/routers/newt/targets.ts | 20 +- server/routers/resource/listResources.ts | 49 +---- server/routers/site/listSites.ts | 5 - server/routers/target/createTarget.ts | 17 ++ server/routers/target/listTargets.ts | 1 + server/routers/target/updateTarget.ts | 7 + .../public/ProxyResourceTargetsForm.tsx | 43 +--- .../resources/public/[niceId]/rdp/page.tsx | 69 +++---- .../resources/public/[niceId]/ssh/page.tsx | 95 +++++---- .../resources/public/[niceId]/vnc/page.tsx | 69 +++---- .../settings/resources/public/create/page.tsx | 28 +-- 28 files changed, 259 insertions(+), 1254 deletions(-) delete mode 100644 server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts delete mode 100644 server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 5040808a9..229fc9ff0 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", { sentAt: bigint("sentAt", { mode: "number" }).notNull() }); -export const browserGatewayTarget = pgTable("browserGatewayTarget", { - browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - authToken: varchar("authToken").notNull(), - type: varchar("type").notNull(), // "ssh", "rdp", "vnc" - destination: varchar("destination").notNull(), - destinationPort: integer("destinationPort").notNull() -}); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel< >; export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6b4ce32b8..b7b34a5d7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -290,7 +290,12 @@ export const targets = pgTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + priority: integer("priority").notNull().default(100), + mode: varchar("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: varchar("authToken") }); export const targetHealthCheck = pgTable("targetHealthCheck", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b235d26d5..ae7360780 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", { sentAt: integer("sentAt").notNull() }); -export const browserGatewayTarget = sqliteTable("browserGatewayTarget", { - browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({ - autoIncrement: true - }), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - authToken: text("authToken").notNull(), - type: text("type").notNull(), // "ssh", "rdp", "vnc" - destination: text("destination").notNull(), - destinationPort: integer("destinationPort").notNull() -}); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel; export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 492576cc6..639e3cf4f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + priority: integer("priority").notNull().default(100), + mode: text("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: text("authToken") }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 7ff452880..901c88f49 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,7 +12,6 @@ */ import { - browserGatewayTarget, certificates, db, domainNamespaces, @@ -182,6 +181,9 @@ export async function getTraefikConfig( const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { + if (!["http", "tcp", "udp"].includes(row.mode)) { + return; + } const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") @@ -295,13 +297,12 @@ export async function getTraefikConfig( maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; targets: { - browserGatewayTargetId: number; + targetId: number; bgType: string; siteId: number; siteType: string; siteOnline: boolean | null; subnet: string | null; - siteExitNodeId: number | null; }[]; }; const browserGatewayResourcesMap = new Map< @@ -310,66 +311,10 @@ export async function getTraefikConfig( >(); if (allowBrowserGatewayResources) { - // Query browser gateway targets for this exit node - const browserGatewayRows = await db - .select({ - // Resource fields - resourceId: resources.resourceId, - resourceName: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - subdomain: resources.subdomain, - domainId: resources.domainId, - enabled: resources.enabled, - wildcard: resources.wildcard, - domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert, - domainNamespaceId: domainNamespaces.domainNamespaceId, - // Maintenance fields - maintenanceModeEnabled: resources.maintenanceModeEnabled, - maintenanceModeType: resources.maintenanceModeType, - maintenanceTitle: resources.maintenanceTitle, - maintenanceMessage: resources.maintenanceMessage, - maintenanceEstimatedTime: resources.maintenanceEstimatedTime, - // Browser gateway target fields - browserGatewayTargetId: - browserGatewayTarget.browserGatewayTargetId, - bgType: browserGatewayTarget.type, - // Site fields - siteId: sites.siteId, - siteType: sites.type, - siteOnline: sites.online, - subnet: sites.subnet, - siteExitNodeId: sites.exitNodeId - }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .innerJoin( - resources, - eq(resources.resourceId, browserGatewayTarget.resourceId) - ) - .leftJoin(domains, eq(domains.domainId, resources.domainId)) - .leftJoin( - domainNamespaces, - eq(domainNamespaces.domainId, resources.domainId) - ) - .where( - and( - eq(resources.enabled, true), - 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) - ) - ); - - for (const row of browserGatewayRows) { + for (const row of resourcesWithTargetsAndSites) { + if (!["ssh", "vnc", "rdp"].includes(row.mode)) { + return; + } if (filterOutNamespaceDomains && row.domainNamespaceId) { continue; } @@ -394,13 +339,12 @@ export async function getTraefikConfig( }); } browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - browserGatewayTargetId: row.browserGatewayTargetId, - bgType: row.bgType, + targetId: row.targetId, + bgType: row.mode, siteId: row.siteId, siteType: row.siteType, siteOnline: row.siteOnline, - subnet: row.subnet, - siteExitNodeId: row.siteExitNodeId + subnet: row.subnet }); } } diff --git a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts deleted file mode 100644 index b26a1a8b6..000000000 --- a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - newts, - resources, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { encrypt } from "@server/lib/crypto"; -import config from "@server/lib/config"; -import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; -import { generateId } from "@server/auth/sessions/app"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - resourceId: z.string().transform(Number).pipe(z.number().int().positive()) -}); - -const bodySchema = z.strictObject({ - siteId: z.number().int().positive(), - type: z.enum(["ssh", "rdp", "vnc"]), - destination: z.string().nonempty(), - destinationPort: z.number().int().min(1).max(65535) -}); - -export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "put", - path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target", - description: "Create a browser gateway target for a resource.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - body: { - content: { - "application/json": { - schema: bodySchema - } - } - } - }, - responses: {} -}); - -export async function createBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, resourceId } = parsedParams.data; - - const parsedBody = bodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { siteId, type, destination, destinationPort } = parsedBody.data; - - const [resource] = await db - .select() - .from(resources) - .where( - and( - eq(resources.resourceId, resourceId), - eq(resources.orgId, orgId) - ) - ) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found in organization ${orgId}` - ) - ); - } - - const [site] = await db - .select() - .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${siteId} not found in organization ${orgId}` - ) - ); - } - - const plainToken = generateId(48); - const encryptedToken = encrypt( - plainToken, - config.getRawConfig().server.secret! - ); - - const [record] = await db - .insert(browserGatewayTarget) - .values({ - resourceId, - siteId, - type, - destination, - destinationPort, - authToken: encryptedToken - }) - .returning(); - - if (site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, siteId)) - .limit(1); - - if (newt) { - await sendBrowserGatewayTargets( - newt.newtId, - [record], - newt.version - ); - } - } - - logger.info( - `Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}` - ); - - return response(res, { - data: record, - success: true, - error: false, - message: "Browser gateway target created successfully", - status: HttpCode.CREATED - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts deleted file mode 100644 index 850944b29..000000000 --- a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { browserGatewayTarget, db, newts, sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { removeBrowserGatewayTarget } from "@server/routers/newt/targets"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -registry.registerPath({ - method: "delete", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Delete a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema - }, - responses: {} -}); - -export async function deleteBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const [existing] = await db - .select({ bgt: browserGatewayTarget, site: sites }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!existing) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - await db - .delete(browserGatewayTarget) - .where( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ) - ); - - if (existing.site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, existing.bgt.siteId)) - .limit(1); - - if (newt) { - await removeBrowserGatewayTarget( - newt.newtId, - browserGatewayTargetId, - newt.version - ); - } - } - - logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`); - - return response(res, { - data: null, - success: true, - error: false, - message: "Browser gateway target deleted successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to delete browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts deleted file mode 100644 index 0ac7a8ce9..000000000 --- a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "get", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Get a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema - }, - responses: {} -}); - -export async function getBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const [result] = await db - .select({ bgt: browserGatewayTarget }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!result) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - return response(res, { - data: result.bgt, - success: true, - error: false, - message: "Browser gateway target retrieved successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to retrieve browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 51e16de75..b8e32d836 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -13,9 +13,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { browserGatewayTarget, db } from "@server/db"; -import { resources, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, resources, targets } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -51,11 +50,11 @@ export async function getBrowserTarget( logger.info(`Retrieving browser target for domain: ${fullDomain}`); - const [browserTarget] = await db + const [row] = await db .select({ - destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort, - authToken: browserGatewayTarget.authToken, + ip: targets.ip, + port: targets.port, + authToken: targets.authToken, resourceId: resources.resourceId, niceId: resources.niceId, name: resources.name, @@ -63,20 +62,18 @@ export async function getBrowserTarget( pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode }) - .from(browserGatewayTarget) - .innerJoin( - resources, - eq(browserGatewayTarget.resourceId, resources.resourceId) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(resources.fullDomain, fullDomain), + eq(targets.enabled, true), + inArray(targets.mode, ["ssh", "rdp", "vnc"]) + ) ) - .where(eq(resources.fullDomain, fullDomain)) .limit(1); - const decryptedAuthToken = decrypt( - browserTarget.authToken, - config.getRawConfig().server.secret! - ); - - if (!browserTarget) { + if (!row) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -85,17 +82,21 @@ export async function getBrowserTarget( ); } + const decryptedAuthToken = row.authToken + ? decrypt(row.authToken, config.getRawConfig().server.secret!) + : ""; + return response(res, { data: { - ip: browserTarget.destination, - port: browserTarget.destinationPort, + ip: row.ip, + port: row.port, authToken: decryptedAuthToken, - pamMode: browserTarget.pamMode, - authDaemonMode: browserTarget.authDaemonMode, - orgId: browserTarget.orgId, - resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId, - name: browserTarget.name + pamMode: row.pamMode, + authDaemonMode: row.authDaemonMode, + orgId: row.orgId, + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name ?? "" }, success: true, error: false, diff --git a/server/private/routers/browserGatewayTarget/index.ts b/server/private/routers/browserGatewayTarget/index.ts index c9cd15dff..3c1b3d6f9 100644 --- a/server/private/routers/browserGatewayTarget/index.ts +++ b/server/private/routers/browserGatewayTarget/index.ts @@ -11,9 +11,4 @@ * This file is not licensed under the AGPLv3. */ -export * from "./createBrowserGatewayTarget"; -export * from "./updateBrowserGatewayTarget"; -export * from "./deleteBrowserGatewayTarget"; -export * from "./getBrowserGatewayTarget"; -export * from "./listBrowserGatewayTargets"; export * from "./getBrowserTarget"; diff --git a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts deleted file mode 100644 index 5b3d1e5d0..000000000 --- a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - resources, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - resourceId: z.string().transform(Number).pipe(z.number().int().positive()) -}); - -const querySchema = z.object({ - limit: z - .string() - .optional() - .default("1000") - .transform(Number) - .pipe(z.number().int().positive()), - offset: z - .string() - .optional() - .default("0") - .transform(Number) - .pipe(z.number().int().nonnegative()) -}); - -export type ListBrowserGatewayTargetsResponse = { - targets: BrowserGatewayTarget[]; - total: number; - limit: number; - offset: number; -}; - -registry.registerPath({ - method: "get", - path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets", - description: "List browser gateway targets for a resource.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - query: querySchema - }, - responses: {} -}); - -export async function listBrowserGatewayTargets( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, resourceId } = parsedParams.data; - - const parsedQuery = querySchema.safeParse(req.query); - if (!parsedQuery.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedQuery.error).toString() - ) - ); - } - - const { limit, offset } = parsedQuery.data; - - const [resource] = await db - .select() - .from(resources) - .where( - and( - eq(resources.resourceId, resourceId), - eq(resources.orgId, orgId) - ) - ) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found in organization ${orgId}` - ) - ); - } - - const rows = await db - .select({ - browserGatewayTargetId: - browserGatewayTarget.browserGatewayTargetId, - resourceId: browserGatewayTarget.resourceId, - siteId: browserGatewayTarget.siteId, - authToken: browserGatewayTarget.authToken, - type: browserGatewayTarget.type, - destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort, - siteName: sites.name - }) - .from(browserGatewayTarget) - .leftJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where(eq(browserGatewayTarget.resourceId, resourceId)) - .limit(limit) - .offset(offset); - - return response(res, { - data: { - targets: rows as any, - total: rows.length, - limit, - offset - }, - success: true, - error: false, - message: "Browser gateway targets retrieved successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to list browser gateway targets" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts deleted file mode 100644 index 825407dc3..000000000 --- a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - newts, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -const bodySchema = z.strictObject({ - siteId: z.number().int().positive().optional(), - type: z.enum(["ssh", "rdp", "vnc"]).optional(), - destination: z.string().nonempty().optional(), - destinationPort: z.number().int().min(1).max(65535).optional() -}); - -export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "post", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Update a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - body: { - content: { - "application/json": { - schema: bodySchema - } - } - } - }, - responses: {} -}); - -export async function updateBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const parsedBody = bodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { siteId, type, destination, destinationPort } = parsedBody.data; - - const [existing] = await db - .select({ bgt: browserGatewayTarget, site: sites }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!existing) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - const updateValues: Partial = {}; - if (siteId !== undefined) updateValues.siteId = siteId; - if (type !== undefined) updateValues.type = type; - if (destination !== undefined) updateValues.destination = destination; - if (destinationPort !== undefined) - updateValues.destinationPort = destinationPort; - - const [updated] = await db - .update(browserGatewayTarget) - .set(updateValues) - .where( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ) - ) - .returning(); - - const targetSiteId = siteId ?? existing.bgt.siteId; - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, targetSiteId)) - .limit(1); - - if (site && site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, targetSiteId)) - .limit(1); - - if (newt) { - await sendBrowserGatewayTargets( - newt.newtId, - [updated], - newt.version - ); - } - } - - logger.info(`Updated browser gateway target ${browserGatewayTargetId}`); - - return response(res, { - data: updated, - success: true, - error: false, - message: "Browser gateway target updated successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to update browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0598a1514..881ba2277 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; import * as resource from "#private/routers/resource"; @@ -879,48 +878,3 @@ authenticated.post( verifyClientAccess, client.rebuildClientAssociationsCacheRoute ); - -authenticated.put( - "/org/:orgId/resource/:resourceId/browser-gateway-target", - verifyValidLicense, - verifyOrgAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget), - logActionAudit(ActionsEnum.createBrowserGatewayTarget), - browserGatewayTarget.createBrowserGatewayTarget -); - -authenticated.get( - "/org/:orgId/resource/:resourceId/browser-gateway-targets", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets), - browserGatewayTarget.listBrowserGatewayTargets -); - -authenticated.get( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget), - browserGatewayTarget.getBrowserGatewayTarget -); - -authenticated.post( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget), - logActionAudit(ActionsEnum.updateBrowserGatewayTarget), - browserGatewayTarget.updateBrowserGatewayTarget -); - -authenticated.delete( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget), - logActionAudit(ActionsEnum.deleteBrowserGatewayTarget), - browserGatewayTarget.deleteBrowserGatewayTarget -); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 542c806f4..820a843f0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,7 +16,6 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import { verifyApiKeyHasAction, @@ -216,43 +215,3 @@ authenticated.delete( logActionAudit(ActionsEnum.removeUserRole), user.removeUserRole ); - -authenticated.put( - "/org/:orgId/resource/:resourceId/browser-gateway-target", - verifyApiKeyOrgAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget), - logActionAudit(ActionsEnum.createBrowserGatewayTarget), - browserGatewayTarget.createBrowserGatewayTarget -); - -authenticated.get( - "/org/:orgId/resource/:resourceId/browser-gateway-targets", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets), - browserGatewayTarget.listBrowserGatewayTargets -); - -authenticated.get( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget), - browserGatewayTarget.getBrowserGatewayTarget -); - -authenticated.post( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget), - logActionAudit(ActionsEnum.updateBrowserGatewayTarget), - browserGatewayTarget.updateBrowserGatewayTarget -); - -authenticated.delete( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget), - logActionAudit(ActionsEnum.deleteBrowserGatewayTarget), - browserGatewayTarget.deleteBrowserGatewayTarget -); diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index f78acb48e..c45fe36b9 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; -import * as browserTarget from "#private/routers/browserGatewayTarget"; import * as ssh from "#private/routers/ssh"; import * as ws from "@server/routers/ws"; +import * as browserTarget from "#private/routers/browserGatewayTarget"; import { verifySessionUserMiddleware, diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index dac4ae62a..bcf8beab7 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -30,8 +30,7 @@ import { userOrgs, sites, Resource, - SiteResource, - browserGatewayTarget + SiteResource } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -291,16 +290,15 @@ export async function signSshKey( const publicResource = resource as Resource; const targetRows = await db .select({ - siteId: browserGatewayTarget.siteId, - ip: browserGatewayTarget.destination + siteId: targets.siteId, + ip: targets.ip }) - .from(browserGatewayTarget) + .from(targets) .where( and( - eq( - browserGatewayTarget.resourceId, - publicResource.resourceId - ) + eq(targets.resourceId, publicResource.resourceId), + eq(targets.enabled, true), + eq(targets.mode, "ssh") ) ); diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 135920d6f..73bf2c630 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,6 +1,4 @@ import { - browserGatewayTarget, - BrowserGatewayTarget, clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, @@ -16,7 +14,7 @@ import { } from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { @@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient( }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + .where( + and( + eq(targets.siteId, siteId), + eq(targets.enabled, true), + inArray(targets.mode, ["http", "udp", "tcp"]) + ) + ); const allHealthChecks = await db .select({ @@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + // Get all enabled targets with their resource mode information const allBrowserGatewayTargets = await db - .select() - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + enabled: targets.enabled, + mode: resources.mode, + authToken: targets.authToken + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(targets.siteId, siteId), + eq(targets.enabled, true), + inArray(targets.mode, ["ssh", "rdp", "vnc"]) + ) + ); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient( const serverSecret = config.getRawConfig().server.secret!; const browserGatewayTargets = allBrowserGatewayTargets.map((t) => { + if (!t.ip || !t.port || !t.authToken) { + return null; + } const decryptAuthToken = decrypt(t.authToken, serverSecret); return { - id: t.browserGatewayTargetId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + id: t.targetId, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 6d8212b12..44aa34637 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,4 +1,4 @@ -import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; +import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -244,23 +244,27 @@ export async function removeTargets( export async function sendBrowserGatewayTargets( newtId: string, - targets: BrowserGatewayTarget[], + targets: Target[], version?: string | null ) { if (targets.length === 0) return; - const payload = targets.map((t) => { + // filter out the ones without auth tokens + const filteredTargets = targets.filter((t) => t.authToken); + if (filteredTargets.length === 0) return; + + const payload = filteredTargets.map((t) => { const decryptAuthToken = decrypt( - t.authToken, + t.authToken!, config.getRawConfig().server.secret! ); return { - id: t.browserGatewayTargetId, + id: t.targetId, resourceId: t.resourceId, siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c982cab9f..8e0a03384 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,6 +1,5 @@ import { alias, - browserGatewayTarget, db, labels, resourceHeaderAuth, @@ -639,15 +638,8 @@ export async function listResources( .from(targets) .innerJoin(sites, eq(targets.siteId, sites.siteId)) .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); - const resourcesWithBrowserGateway = db - .select({ resourceId: browserGatewayTarget.resourceId }) - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); conditions.push( - or( - inArray(resources.resourceId, resourcesWithSite), - inArray(resources.resourceId, resourcesWithBrowserGateway) - ) + or(inArray(resources.resourceId, resourcesWithSite)) ); } @@ -770,30 +762,6 @@ export async function listResources( ) .leftJoin(sites, eq(targets.siteId, sites.siteId)); - const allBgTargetSites = - resourceIdList.length === 0 - ? [] - : await db - .select({ - resourceId: browserGatewayTarget.resourceId, - siteId: browserGatewayTarget.siteId, - siteName: sites.name, - siteNiceId: sites.niceId, - siteOnline: sites.online, - siteType: sites.type - }) - .from(browserGatewayTarget) - .where( - inArray( - browserGatewayTarget.resourceId, - resourceIdList - ) - ) - .leftJoin( - sites, - eq(sites.siteId, browserGatewayTarget.siteId) - ); - // avoids TS issues with reduce/never[] const map = new Map(); @@ -856,21 +824,6 @@ export async function listResources( online: isLocal ? undefined : Boolean(t.siteOnline) }); } - const bgRaw = allBgTargetSites.filter( - (t) => t.resourceId === entry.resourceId - ); - for (const t of bgRaw) { - if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { - continue; - } - const isLocal = t.siteType === "local"; - siteById.set(t.siteId, { - siteId: t.siteId, - siteName: t.siteName ?? "", - siteNiceId: t.siteNiceId ?? "", - online: isLocal ? undefined : Boolean(t.siteOnline) - }); - } entry.sites = Array.from(siteById.values()); } diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c217da489..c6abace5f 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -12,7 +12,6 @@ import { userSites, labels, siteLabels, - browserGatewayTarget, type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; @@ -241,10 +240,6 @@ function querySitesBase() { ON ${siteResources.networkId} = ${siteNetworks.networkId} WHERE ${siteNetworks.siteId} = ${sites.siteId} AND ${siteResources.orgId} = ${sites.orgId} - ) + ( - SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId}) - FROM ${browserGatewayTarget} - WHERE ${browserGatewayTarget.siteId} = ${sites.siteId} )`, status: sites.status }) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 53488e2b7..bb880b045 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -24,6 +24,9 @@ import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "@server/lib/alerts"; +import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -32,6 +35,7 @@ const createTargetParamsSchema = z.strictObject({ const createTargetSchema = z.strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), method: z.string().optional().nullable(), port: z.int().min(1).max(65535), enabled: z.boolean().default(true), @@ -161,6 +165,12 @@ export async function createTarget( ); } + const plainToken = generateId(48); + const encryptedToken = encrypt( + plainToken, + config.getRawConfig().server.secret! + ); + let newTarget: Target[] = []; let targetIps: string[] = []; let healthCheck: TargetHealthCheck[] = []; @@ -191,6 +201,9 @@ export async function createTarget( .values({ resourceId, ...targetData, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], priority: targetData.priority || 100 }) .returning(); @@ -226,6 +239,10 @@ export async function createTarget( resourceId, siteId: site.siteId, ip: targetData.ip, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], + authToken: encryptedToken, method: targetData.method, port: targetData.port, internalPort, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 47e9cdea5..1b2eb0ed5 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -34,6 +34,7 @@ function queryTargets(resourceId: number) { .select({ targetId: targets.targetId, ip: targets.ip, + mode: targets.mode, method: targets.method, port: targets.port, enabled: targets.enabled, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4b667d086..a5bb5fef3 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -27,6 +27,10 @@ const updateTargetBodySchema = z .strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), + mode: z + .enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]) + .optional() + .nullable(), method: z.string().min(1).max(10).optional().nullable(), port: z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), @@ -184,6 +188,8 @@ export async function updateTarget( } const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; + const nextMode = + parsedBody.data.mode === null ? undefined : parsedBody.data.mode; let updatedTarget: any; let updatedHc: any; @@ -193,6 +199,7 @@ export async function updateTarget( .set({ siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, + mode: nextMode, method: parsedBody.data.method, port: parsedBody.data.port, internalPort, diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 7289c2767..881d46b7b 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -138,11 +138,6 @@ export function ProxyResourceTargetsForm({ const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState(""); - const [bgSiteId, setBgSiteId] = useState(null); - const [bgTargetId, setBgTargetId] = useState(null); - const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { return; @@ -207,42 +202,6 @@ export function ProxyResourceTargetsForm({ }) ); - // Browser-gateway targets (edit mode only) - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource?.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - type: string; - destination: string; - destinationPort: number; - }>; - }; - }, - enabled: !!resource - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const bgt = bgTargetsResponse.targets[0]; - setBgDestination(bgt.destination); - setBgDestinationPort(String(bgt.destinationPort)); - setBgSiteId(bgt.siteId); - setBgTargetId(bgt.browserGatewayTargetId); - }, [bgTargetsResponse]); - - useEffect(() => { - if (sites.length > 0 && bgSiteId === null) { - setBgSiteId(sites[0].siteId); - } - }, [sites, bgSiteId]); - const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { @@ -603,6 +562,8 @@ export function ProxyResourceTargetsForm({ const newTarget: LocalTarget = { targetId: -Date.now(), ip: "", + mode: ((resource?.mode as LocalTarget["mode"]) ?? + (isHttp ? "http" : "tcp")) as LocalTarget["mode"], method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index ee564156a..d2ed89601 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -31,21 +31,10 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); - export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; }) { @@ -61,7 +50,7 @@ export default function SshSettingsPage(props: { - (null); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "rdp"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; }>; }; } @@ -122,14 +109,17 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "rdp" + ); + if (!targets.length) return; const first = targets[0]; - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, + targetId: t.targetId, siteId: t.siteId })) ); @@ -159,9 +149,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -171,12 +159,13 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "rdp", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + hcEnabled: false } ) ) @@ -188,20 +177,20 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "rdp", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) ); const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, + targetId: res.data.data.targetId, siteId: toCreate[i].siteId })); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index c769d28e0..7a85be60c 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -54,8 +54,9 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; + authToken?: string | null; }; const sshFormSchema = z.object({ @@ -154,20 +155,19 @@ function SshServerForm({ const [nativeSiteOpen, setNativeSiteOpen] = useState(false); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "ssh"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; + authToken?: string | null; }>; }; } @@ -175,7 +175,10 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "ssh" + ); + if (!targets.length) return; const first = targets[0]; if (isNativeInitially) { setSelectedNativeSite({ @@ -184,16 +187,18 @@ function SshServerForm({ type: "newt" as const }); setNativeExistingTarget({ - browserGatewayTargetId: first.browserGatewayTargetId, - siteId: first.siteId + targetId: first.targetId, + siteId: first.siteId, + authToken: first.authToken }); } else { - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + targetId: t.targetId, + siteId: t.siteId, + authToken: t.authToken })) ); setSelectedSites( @@ -236,28 +241,31 @@ function SshServerForm({ if (selectedNativeSite) { if (nativeExistingTarget) { await api.post( - `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, + `/target/${nativeExistingTarget.targetId}`, { - type: "ssh", - destination: "localhost", - destinationPort: 22, - siteId: selectedNativeSite.siteId + mode: "ssh", + ip: "localhost", + port: 22, + siteId: selectedNativeSite.siteId, + authToken: nativeExistingTarget.authToken, + hcEnabled: false } ); } else { const res = await api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: selectedNativeSite.siteId, - type: "ssh", - destination: "localhost", - destinationPort: 22 + mode: "ssh", + ip: "localhost", + port: 22, + hcEnabled: false } ); setNativeExistingTarget({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: selectedNativeSite.siteId + targetId: res.data.data.targetId, + siteId: selectedNativeSite.siteId, + authToken: res.data.data.authToken }); } } @@ -275,9 +283,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -287,12 +293,14 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + authToken: t.authToken, + hcEnabled: false } ) ) @@ -304,12 +312,13 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) @@ -317,9 +326,9 @@ function SshServerForm({ const newTargets: ExistingTarget[] = created.map( (res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId, + authToken: res.data.data.authToken }) ); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 51efd0311..ecac78b6f 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -29,21 +29,10 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); - export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; }) { @@ -59,7 +48,7 @@ export default function SshSettingsPage(props: { - (null); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "vnc"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; }>; }; } @@ -120,14 +107,17 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "vnc" + ); + if (!targets.length) return; const first = targets[0]; - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, + targetId: t.targetId, siteId: t.siteId })) ); @@ -157,9 +147,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -169,12 +157,13 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "vnc", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + hcEnabled: false } ) ) @@ -186,20 +175,20 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "vnc", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) ); const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, + targetId: res.data.data.targetId, siteId: toCreate[i].siteId })); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 407196769..f3715cba0 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -498,12 +498,13 @@ export default function Page() { if (isNative) { if (nativeSelectedSite) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: nativeSelectedSite.siteId, - type: "ssh", - destination: "localhost", - destinationPort: 22 + mode: "ssh", + ip: "localhost", + port: 22, + hcEnabled: false } ); } @@ -516,12 +517,13 @@ export default function Page() { : []; for (const site of sitesToCreate) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: site.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ); } @@ -533,12 +535,14 @@ export default function Page() { } else if (resourceType === "rdp" || resourceType === "vnc") { for (const site of bgSelectedSites) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: site.siteId, - type: resourceType, - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: resourceType, + ip: bgDestination, + port: Number(bgDestinationPort), + authToken: null, + hcEnabled: false } ); } From 7b7ff51289043aba69a3be33060baeb260697cf5 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 15:37:21 -0700 Subject: [PATCH 04/12] Add target mode and auth token --- server/lib/blueprints/proxyResources.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6c37d17b8..0fb3861e6 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -48,6 +48,9 @@ import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { build } from "@server/build"; +import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; +import serverConfig from "@server/lib/config"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -80,7 +83,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and( @@ -92,7 +95,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) @@ -119,6 +122,15 @@ export async function updateProxyResources( internalPortToCreate = targetData["internal-port"]; } + let authToken: string | undefined; + if (site.type !== "local") { + const plainToken = generateId(48); + authToken = encrypt( + plainToken, + serverConfig.getRawConfig().server.secret! + ); + } + // Create target const [newTarget] = await trx .insert(targets) @@ -126,10 +138,12 @@ export async function updateProxyResources( resourceId: resourceId, siteId: site.siteId, ip: targetData.hostname, + mode: resourceData.mode as Target["mode"], method: targetData.method, port: targetData.port, enabled: targetData.enabled, internalPort: internalPortToCreate, + authToken: authToken, path: targetData.path, pathMatchType: targetData["path-match"], rewritePath: @@ -707,7 +721,8 @@ export async function updateProxyResources( ? "/" : undefined), rewritePathType: targetData["rewrite-match"], - priority: targetData.priority + priority: targetData.priority, + mode: resourceData.mode }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); From 69bd61c308663473cbb526159f4725db3c2bf3b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 16:02:28 -0700 Subject: [PATCH 05/12] Update migrations --- server/setup/scriptsPg/1.19.0.ts | 22 ++++------------------ server/setup/scriptsSqlite/1.19.0.ts | 26 ++++++++++---------------- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/server/setup/scriptsPg/1.19.0.ts b/server/setup/scriptsPg/1.19.0.ts index f8685e80b..d5b52edb1 100644 --- a/server/setup/scriptsPg/1.19.0.ts +++ b/server/setup/scriptsPg/1.19.0.ts @@ -39,18 +39,6 @@ export default async function migration() { try { await db.execute(sql`BEGIN`); - await db.execute(sql` - CREATE TABLE "browserGatewayTarget" ( - "browserGatewayTargetId" serial PRIMARY KEY NOT NULL, - "resourceId" integer NOT NULL, - "siteId" integer NOT NULL, - "authToken" varchar NOT NULL, - "type" varchar NOT NULL, - "destination" varchar NOT NULL, - "destinationPort" integer NOT NULL - ); - `); - await db.execute(sql` CREATE TABLE "clientLabels" ( "clientLabelId" serial PRIMARY KEY NOT NULL, @@ -215,12 +203,6 @@ export default async function migration() { await db.execute( sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;` ); - await db.execute( - sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` - ); - await db.execute( - sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;` - ); await db.execute( sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); @@ -289,6 +271,10 @@ export default async function migration() { ); await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`); await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`); + await db.execute( + sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;` + ); + await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`); await db.execute(sql`COMMIT`); console.log("Migrated database"); diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index 9ea84261b..4540ed4b8 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -40,22 +40,6 @@ export default async function migration() { try { db.transaction(() => { - db.prepare( - ` - CREATE TABLE 'browserGatewayTarget' ( - 'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, - 'resourceId' integer NOT NULL, - 'siteId' integer NOT NULL, - 'authToken' text NOT NULL, - 'type' text NOT NULL, - 'destination' text NOT NULL, - 'destinationPort' integer NOT NULL, - FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade, - FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade - ); - ` - ).run(); - db.prepare( ` CREATE TABLE 'clientLabels' ( @@ -350,6 +334,16 @@ export default async function migration() { ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id); ` ).run(); + db.prepare( + ` + ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'targets' ADD 'authToken' text; + ` + ).run(); })(); const existingResources = db From d1af7a153fa6dd72e8fb4fd93a994c614b7b246f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 16:57:53 -0700 Subject: [PATCH 06/12] Enforece some more things on the types --- server/lib/blueprints/proxyResources.ts | 7 + server/lib/blueprints/types.ts | 74 ++++++++++- .../resources/public/[niceId]/ssh/page.tsx | 121 +++++++++--------- 3 files changed, 137 insertions(+), 65 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 0fb3861e6..b17878974 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -579,6 +579,13 @@ export async function updateProxyResources( ? (resourceData["proxy-protocol-version"] ?? 1) : 1, + pamMode: + resourceData["auth-daemon"]?.pam || + "passthrough", + authDaemonMode: + resourceData["auth-daemon"]?.mode || "native", + authDaemonPort: + resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, defaultResourcePolicyId: inlinePolicyId }) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index fc73d83a0..a98843a99 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -268,8 +268,37 @@ export const PublicResourceSchema = z return true; } - // If protocol/mode is http, it must have a full-domain - if ((resource.mode ?? resource.protocol) === "http") { + const effectiveProtocol = resource.mode ?? resource.protocol; + if (effectiveProtocol !== "ssh") { + return true; + } + + const authDaemonMode = resource["auth-daemon"]?.mode; + if (authDaemonMode !== "native" && authDaemonMode !== "site") { + return true; + } + + return ( + resource.targets.filter((target) => target != null).length <= 1 + ); + }, + { + path: ["targets"], + error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain + const effectiveProtocol = resource.mode ?? resource.protocol; + if ( + effectiveProtocol !== undefined && + ["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol) + ) { return ( resource["full-domain"] !== undefined && resource["full-domain"].length > 0 @@ -279,7 +308,7 @@ export const PublicResourceSchema = z }, { path: ["full-domain"], - error: "When protocol is 'http', a 'full-domain' must be provided" + error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided" } ) .refine( @@ -506,7 +535,44 @@ export const PrivateResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } - ); + ) + .refine( + (data) => { + if (data.mode !== "ssh") { + return true; + } + + const authDaemonMode = data["auth-daemon"]?.mode; + if (authDaemonMode !== "native" && authDaemonMode !== "site") { + return true; + } + + const uniqueSites = new Set(); + if (data.site) { + uniqueSites.add(data.site); + } + for (const site of data.sites) { + uniqueSites.add(site); + } + + return uniqueSites.size <= 1; + }, + { + path: ["sites"], + message: + "When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed" + } + ) + .transform((data) => { + if ( + data.mode === "ssh" && + data.destination !== undefined && + data["destination-port"] === undefined + ) { + data["destination-port"] = 22; + } + return data; + }); export const ResourcePolicyRuleSchema = RuleSchema; diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index 0187a6c62..c6487ad68 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -205,7 +205,8 @@ function SshServerForm({ ? [] : targets.map((target) => ({ targetId: target.targetId, - siteId: target.siteId + siteId: target.siteId, + authToken: target.authToken })) ); @@ -253,7 +254,8 @@ function SshServerForm({ }); if (isNative) { - if (values.selectedNativeSite) { + const nativeSite = values.selectedNativeSite; + if (nativeSite) { if (nativeExistingTarget) { await api.post( `/target/${nativeExistingTarget.targetId}`, @@ -261,16 +263,20 @@ function SshServerForm({ mode: "ssh", ip: "localhost", port: 22, - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, authToken: nativeExistingTarget.authToken, hcEnabled: false } ); + setNativeExistingTarget({ + ...nativeExistingTarget, + siteId: nativeSite.siteId + }); } else { const res = await api.put( `/resource/${resource.resourceId}/target`, { - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, mode: "ssh", ip: "localhost", port: 22, @@ -279,7 +285,7 @@ function SshServerForm({ ); setNativeExistingTarget({ targetId: res.data.data.targetId, - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, authToken: res.data.data.authToken }); } @@ -304,71 +310,64 @@ function SshServerForm({ (t) => !selectedSiteIds.has(t.siteId) ); await Promise.all( - toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` ->>>>>>> 8ee520dbb58f6bd4009581c79322f77b17ff6757 - ) + toDelete.map((t) => api.delete(`/target/${t.targetId}`)) + ); + + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post(`/target/${t.targetId}`, { + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + siteId: t.siteId, + authToken: t.authToken, + hcEnabled: false + }) ) ); -<<<<<<< HEAD - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/target/${t.targetId}`, - { - mode: "ssh", - ip: bgDestination, - api.delete(`/target/${t.targetId}`) - } - ) + const toCreate = activeSites.filter( + (s) => !existingSiteIds.has(s.siteId) ); - -<<<<<<< HEAD - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - `/target/${t.targetId}`, - toCreate.map((s) => - mode: "ssh", - ip: values.destination, - port: Number(values.destinationPort), - siteId: t.siteId, - authToken: t.authToken, - hcEnabled: false - port: Number(bgDestinationPort), - hcEnabled: false - ) - ); - + const created = await Promise.all( + toCreate.map((s) => + api.put(`/resource/${resource.resourceId}/target`, { + siteId: s.siteId, + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + hcEnabled: false + }) ) ); -<<<<<<< HEAD - const newTargets: ExistingTarget[] = created.map( - (res, i) => ({ - `/resource/${resource.resourceId}/target`, - siteId: toCreate[i].siteId, - authToken: res.data.data.authToken - mode: "ssh", - ip: values.destination, - port: Number(values.destinationPort), - hcEnabled: false const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - ) - ); + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId, + authToken: res.data.data.authToken + })); + setExistingTargets([...toUpdate, ...newTargets]); + } + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") }); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - targetId: res.data.data.targetId, - siteId: toCreate[i].siteId, - authToken: res.data.data.authToken - })); - setExistingTargets([...toUpdate, ...newTargets]); + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } } const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ From dc8243cb51ec692a89e6050f2205e69162d64381 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 12:27:14 -0700 Subject: [PATCH 07/12] Fix form rendering issue --- src/components/BrowserGatewayTargetForm.tsx | 53 +++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/components/BrowserGatewayTargetForm.tsx b/src/components/BrowserGatewayTargetForm.tsx index 768ac2738..09e6fd6fa 100644 --- a/src/components/BrowserGatewayTargetForm.tsx +++ b/src/components/BrowserGatewayTargetForm.tsx @@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps = export function BrowserGatewayTargetForm( props: BrowserGatewayTargetFormProps ) { + // IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES + // Opt out of the React Compiler for this component. + // + // The parent (create page) shares a single `bgTargetForm` instance across + // multiple conditionally-rendered Form sections (SSH passthrough/push, RDP, + // VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the + // resource type changes. react-hook-form's Controller uses an external + // subscription that the React Compiler cannot statically reason about, so + // with `reactCompiler: true` (see next.config.ts) the Compiler can memoize + // the render prop and skip re-rendering the elements when their + // bound form values change. The visible symptom is that typing into the + // destination/port inputs updates form state but the input itself never + // visually updates. The escape hatch is the canonical fix here. + "use no memo"; const t = useTranslations(); const [siteOpen, setSiteOpen] = useState(false); const sitesFieldName = props.multiSite === true ? props.sitesField : props.siteField; + // Subscribe to field values via useWatch and drive the controlled + // elements from these values rather than from the `field.value` returned + // by the Controller render prop. Combined with the "use no memo" directive + // above, this makes the inputs reliably re-render when their bound form + // values change. const watchedSites = useWatch({ control: props.control, name: sitesFieldName }); + const watchedDestination = useWatch({ + control: props.control, + name: props.destinationField + }); + + const watchedDestinationPort = useWatch({ + control: props.control, + name: props.destinationPortField + }); + const showMultiSiteDisclaimer = props.multiSite === true && ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; @@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm( {t("destination")} - + @@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm( type="number" min={1} max={65535} - {...field} - value={field.value ?? ""} + name={field.name} + ref={field.ref} + onBlur={field.onBlur} + onChange={field.onChange} + value={ + (watchedDestinationPort as + | string + | number + | undefined) ?? "" + } /> From 4b770d138501843306ad16a6d38d2ab6ebe560e7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 13:34:24 -0700 Subject: [PATCH 08/12] Fix issues --- .../resource-policy/PolicyAuthStackSectionCreate.tsx | 4 ++-- src/components/resource-policy/PolicyAuthStackSectionEdit.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx index c462cd9ae..9deb74998 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -123,7 +123,7 @@ export function PolicyAuthStackSectionCreate({ } allIdps={allIdps} rolesEditor={ - control={parentForm.control} name="roles" render={({ field }) => ( @@ -146,7 +146,7 @@ export function PolicyAuthStackSectionCreate({ /> } usersEditor={ - control={parentForm.control} name="users" render={({ field }) => ( diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index f24e4360d..2140ad445 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -662,7 +662,8 @@ export function PolicyAuthStackSectionEdit({ user: headerAuth.user, password: headerAuth.password, extendedCompatibility: - headerAuth.extendedCompatibility + headerAuth.extendedCompatibility ?? + true } : undefined } From 8658198a9329e6b5aca30c91a38449f576d186a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 13:47:23 -0700 Subject: [PATCH 09/12] Remove unnessicary auth token --- .../settings/resources/public/[niceId]/rdp/page.tsx | 1 - .../settings/resources/public/[niceId]/ssh/page.tsx | 8 -------- .../settings/resources/public/[niceId]/vnc/page.tsx | 1 - src/app/[orgId]/settings/resources/public/create/page.tsx | 1 - 4 files changed, 11 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index 756561bf5..fbf18ecc7 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -178,7 +178,6 @@ function RdpServerForm({ mode: "rdp", ip: destination, port: Number(destinationPort), - authToken: null, hcEnabled: false }) ) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index c6487ad68..f0e856f69 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -54,7 +54,6 @@ import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { targetId: number; siteId: number; - authToken?: string | null; }; type TargetRow = { @@ -65,7 +64,6 @@ type TargetRow = { mode: string | null; ip: string; port: number; - authToken?: string | null; }; type ResourceTargetsResponse = { @@ -206,7 +204,6 @@ function SshServerForm({ : targets.map((target) => ({ targetId: target.targetId, siteId: target.siteId, - authToken: target.authToken })) ); @@ -216,7 +213,6 @@ function SshServerForm({ ? { targetId: firstTarget.targetId, siteId: firstTarget.siteId, - authToken: firstTarget.authToken } : null ); @@ -264,7 +260,6 @@ function SshServerForm({ ip: "localhost", port: 22, siteId: nativeSite.siteId, - authToken: nativeExistingTarget.authToken, hcEnabled: false } ); @@ -286,7 +281,6 @@ function SshServerForm({ setNativeExistingTarget({ targetId: res.data.data.targetId, siteId: nativeSite.siteId, - authToken: res.data.data.authToken }); } } @@ -323,7 +317,6 @@ function SshServerForm({ ip: values.destination, port: Number(values.destinationPort), siteId: t.siteId, - authToken: t.authToken, hcEnabled: false }) ) @@ -347,7 +340,6 @@ function SshServerForm({ const newTargets: ExistingTarget[] = created.map((res, i) => ({ targetId: res.data.data.targetId, siteId: toCreate[i].siteId, - authToken: res.data.data.authToken })); setExistingTargets([...toUpdate, ...newTargets]); } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 7b2c38399..3efe29ee4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -178,7 +178,6 @@ function VncServerForm({ mode: "vnc", ip: destination, port: Number(destinationPort), - authToken: null, hcEnabled: false }) ) diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 322330d63..1662ee560 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -638,7 +638,6 @@ export default function Page() { mode: resourceType, ip: bgValues.destination, port: Number(bgValues.destinationPort), - authToken: null, hcEnabled: false } ); From 3b6b78b3e1ee855147a322f185c151bfc48d3820 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 16:14:20 -0700 Subject: [PATCH 10/12] Update traefik config --- .../private/lib/traefik/getTraefikConfig.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 901c88f49..e81715d3b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -171,8 +171,15 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three - : eq(resources.mode, "http") + ? inArray(resources.mode, [ + "http", + "udp", + "tcp", + "vnc", + "ssh", + "rdp" + ]) // allow all three + : inArray(resources.mode, ["http", "vnc", "ssh", "rdp"]) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering @@ -180,9 +187,9 @@ export async function getTraefikConfig( // Group by resource and include targets with their unique site data const resourcesMap = new Map(); - resourcesWithTargetsAndSites.forEach((row) => { + for (const row of resourcesWithTargetsAndSites) { if (!["http", "tcp", "udp"].includes(row.mode)) { - return; + continue; } const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; @@ -193,7 +200,7 @@ export async function getTraefikConfig( const priority = row.priority ?? 100; if (filterOutNamespaceDomains && row.domainNamespaceId) { - return; + continue; } // Create a unique key combining resourceId, path config, and rewrite config @@ -220,7 +227,7 @@ export async function getTraefikConfig( logger.debug( `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` ); - return; + continue; } resourcesMap.set(mapKey, { @@ -277,7 +284,7 @@ export async function getTraefikConfig( online: row.siteOnline } }); - }); + } // Group browser gateway targets by resource type BrowserGatewayResourceEntry = { @@ -313,7 +320,7 @@ export async function getTraefikConfig( if (allowBrowserGatewayResources) { for (const row of resourcesWithTargetsAndSites) { if (!["ssh", "vnc", "rdp"].includes(row.mode)) { - return; + continue; } if (filterOutNamespaceDomains && row.domainNamespaceId) { continue; From c39449047357b21783b64747fdf1d991ea598047 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Jun 2026 10:43:16 -0700 Subject: [PATCH 11/12] Update browser targets --- server/routers/target/createTarget.ts | 23 ++++++++++++++++------- server/routers/target/deleteTarget.ts | 25 +++++++++++++++++-------- server/routers/target/updateTarget.ts | 23 ++++++++++++++++------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index bb880b045..48ed1f5d9 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -27,6 +27,7 @@ import { import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; +import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -342,13 +343,21 @@ export async function createTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - newTarget, - healthCheck, - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(newTarget[0].mode)) { + await addTargets( + newt.newtId, + newTarget, + healthCheck, + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + newTarget, + newt.version + ); + } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 61d748f8c..77614b1cd 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { OpenAPITags, registry } from "@server/openApi"; import { targetHealthCheck } from "@server/db"; +import { removeBrowserGatewayTarget } from "@server/routers/newt/targets"; const deleteTargetSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -136,14 +137,22 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await removeTargets( - newt.newtId, - // [deletedTarget], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - [deletedHealthCheck], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { + await removeTargets( + newt.newtId, + // [deletedTarget], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this + [deletedHealthCheck], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { + await removeBrowserGatewayTarget( + newt.newtId, + deletedTarget.targetId, + newt.version + ); + } } } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index a5bb5fef3..c40ffa18b 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -18,6 +18,7 @@ import { import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; const updateTargetParamsSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -350,13 +351,21 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - [updatedTarget], - [updatedHc], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { + await addTargets( + newt.newtId, + [updatedTarget], + [updatedHc], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + [updatedTarget], + newt.version + ); + } } } From 8daf7c287222c665e7a5b9cca17cec37594e29c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Jun 2026 12:07:08 -0700 Subject: [PATCH 12/12] Rename and add browser target update --- server/lib/blueprints/applyBlueprint.ts | 48 +++++-- ...clientResources.ts => privateResources.ts} | 2 +- .../{proxyResources.ts => publicResources.ts} | 8 +- src/app/rdp/RdpClient.tsx | 56 ++++---- src/app/ssh/SshClient.tsx | 53 +++++--- src/app/vnc/VncClient.tsx | 44 ++++--- src/lib/secureLocalStorage.ts | 124 ++++++++++++++++++ 7 files changed, 259 insertions(+), 76 deletions(-) rename server/lib/blueprints/{clientResources.ts => privateResources.ts} (99%) rename server/lib/blueprints/{proxyResources.ts => publicResources.ts} (99%) create mode 100644 src/lib/secureLocalStorage.ts diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 5296bb4d2..f2bb9b0c8 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -10,16 +10,22 @@ import { clientSiteResources } from "@server/db"; import { Config, ConfigSchema } from "./types"; -import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; +import { + PublicResourcesResults, + updatePublicResources +} from "./publicResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sites } from "@server/db"; import { eq, and, isNotNull } from "drizzle-orm"; -import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; +import { + addTargets as addProxyTargets, + sendBrowserGatewayTargets +} from "@server/routers/newt/targets"; import { ClientResourcesResults, - updateClientResources -} from "./clientResources"; + updatePrivateResources +} from "./privateResources"; import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; @@ -54,18 +60,18 @@ export async function applyBlueprint({ let error: any | null = null; try { - let proxyResourcesResults: ProxyResourcesResults = []; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); - proxyResourcesResults = await updateProxyResources( + proxyResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updateClientResources( + clientResourcesResults = await updatePrivateResources( orgId, config, trx, @@ -104,13 +110,27 @@ export async function applyBlueprint({ (hc) => hc.targetId === target.targetId ); - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck ? [matchingHealthcheck] : [], - result.proxyResource.mode === "udp" ? "udp" : "tcp", - site.newt.version - ); + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); + } } } } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/privateResources.ts similarity index 99% rename from server/lib/blueprints/clientResources.ts rename to server/lib/blueprints/privateResources.ts index 34e668984..3e6a784e0 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -105,7 +105,7 @@ export type ClientResourcesResults = { oldSites: { siteId: number }[]; }[]; -export async function updateClientResources( +export async function updatePrivateResources( orgId: string, config: Config, trx: Transaction, diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/publicResources.ts similarity index 99% rename from server/lib/blueprints/proxyResources.ts rename to server/lib/blueprints/publicResources.ts index b17878974..2bc1a6d7f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -52,19 +52,19 @@ import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; -export type ProxyResourcesResults = { +export type PublicResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; healthchecksToUpdate: TargetHealthCheck[]; }[]; -export async function updateProxyResources( +export async function updatePublicResources( orgId: string, config: Config, trx: Transaction, siteId?: number -): Promise { - const results: ProxyResourcesResults = []; +): Promise { + const results: PublicResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index f5ad0dc1d..4dd90242f 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; declare module "react" { namespace JSX { @@ -63,22 +67,14 @@ type RdpCredentialsForm = { enableClipboard: boolean; }; -function loadStoredCredentials(key: string): RdpCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as RdpCredentialsForm; - } catch { - // ignore - } - return { - username: "", - password: "", - domain: "", - kdcProxyUrl: "", - pcb: "", - enableClipboard: true - }; -} +const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = { + username: "", + password: "", + domain: "", + kdcProxyUrl: "", + pcb: "", + enableClipboard: true +}; const isIronError = (error: unknown): error is IronError => { return ( @@ -113,9 +109,25 @@ export default function RdpClient({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_RDP_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); @@ -293,11 +305,11 @@ export default function RdpClient({ try { const sessionInfo = await userInteraction.connect(builder.build()); - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 8d97b970b..932b6336b 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -32,6 +32,10 @@ import { useTranslations } from "next-intl"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; type AuthTab = "password" | "privateKey"; @@ -48,15 +52,11 @@ type ConnectCredentials = { certificate?: string; }; -function loadStoredCredentials(key: string): SshCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as SshCredentialsForm; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; -} +const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = { + username: "", + password: "", + privateKey: "" +}; export default function SshClient({ target, @@ -86,9 +86,25 @@ export default function SshClient({ }); const form = useForm({ - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_SSH_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -252,14 +268,11 @@ export default function SshClient({ }) ); if (!override) { - try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify(form.getValues()) - ); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + form.getValues(), + target.authToken + ); } }; @@ -625,7 +638,7 @@ export default function SshClient({ {connected && (
-
+ {/*
-
+
*/}
({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_VNC_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [connected, setConnected] = useState(false); const [connectError, setConnectError] = useState(null); const rfbRef = useRef(null); @@ -132,11 +146,11 @@ export default function VncClient({ rfb.resizeSession = true; rfb.addEventListener("connect", () => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnected(true); }); diff --git a/src/lib/secureLocalStorage.ts b/src/lib/secureLocalStorage.ts new file mode 100644 index 000000000..c8ceb601a --- /dev/null +++ b/src/lib/secureLocalStorage.ts @@ -0,0 +1,124 @@ +type EncryptedStorageEnvelope = { + v: 1; + s: string; + i: string; + d: string; +}; + +const PBKDF2_ITERATIONS = 120000; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function deriveKey(authToken: string, salt: ArrayBuffer) { + const subtle = window.crypto?.subtle; + if (!subtle) { + throw new Error("Web Crypto is unavailable"); + } + + const tokenKey = await subtle.importKey( + "raw", + toArrayBuffer(new TextEncoder().encode(authToken)), + "PBKDF2", + false, + ["deriveKey"] + ); + + return subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256" + }, + tokenKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function saveEncryptedLocalStorage( + storageKey: string, + value: T, + authToken: string | null | undefined +) { + if (typeof window === "undefined") return; + if (!authToken) { + window.localStorage.removeItem(storageKey); + return; + } + + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const encrypted = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(plaintext) + ); + + const payload: EncryptedStorageEnvelope = { + v: 1, + s: bytesToBase64(salt), + i: bytesToBase64(iv), + d: bytesToBase64(new Uint8Array(encrypted)) + }; + + window.localStorage.setItem(storageKey, JSON.stringify(payload)); +} + +export async function loadEncryptedLocalStorage( + storageKey: string, + authToken: string | null | undefined +): Promise { + if (typeof window === "undefined") return null; + if (!authToken) return null; + + const raw = window.localStorage.getItem(storageKey); + if (!raw) return null; + + try { + const payload = JSON.parse(raw) as EncryptedStorageEnvelope; + if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) { + throw new Error("Invalid encrypted payload"); + } + + const salt = base64ToBytes(payload.s); + const iv = base64ToBytes(payload.i); + const data = base64ToBytes(payload.d); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const decrypted = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(data) + ); + const json = new TextDecoder().decode(decrypted); + return JSON.parse(json) as T; + } catch { + window.localStorage.removeItem(storageKey); + return null; + } +}