From 75b5afd54484e56aad776d9786cb1f9cf1fb7b17 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 14:07:48 -0700 Subject: [PATCH] Add crud for browser targets --- server/auth/actions.ts | 7 +- server/db/pg/schema/privateSchema.ts | 1 + server/db/sqlite/schema/privateSchema.ts | 1 + .../createBrowserGatewayTarget.ts | 187 ++++++++++++++++++ .../deleteBrowserGatewayTarget.ts | 130 ++++++++++++ .../getBrowserGatewayTarget.ts | 109 ++++++++++ .../routers/browserGatewayTarget/index.ts | 18 ++ .../listBrowserGatewayTargets.ts | 148 ++++++++++++++ .../updateBrowserGatewayTarget.ts | 180 +++++++++++++++++ server/private/routers/external.ts | 46 +++++ server/private/routers/integration.ts | 41 ++++ server/routers/newt/buildConfiguration.ts | 18 +- server/routers/newt/targets.ts | 25 ++- server/routers/resource/getBrowserTarget.ts | 14 +- src/app/rdp/RdpClient.tsx | 14 +- src/app/rdp/page.tsx | 2 +- src/app/ssh/SshClient.tsx | 13 +- src/app/ssh/page.tsx | 2 +- src/app/vnc/VncClient.tsx | 3 +- src/app/vnc/page.tsx | 2 +- 20 files changed, 934 insertions(+), 27 deletions(-) create mode 100644 server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts create mode 100644 server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts create mode 100644 server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts create mode 100644 server/private/routers/browserGatewayTarget/index.ts create mode 100644 server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts create mode 100644 server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 9ba1b5bce..6ae49df8b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -152,7 +152,12 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks" + listHealthChecks = "listHealthChecks", + createBrowserGatewayTarget = "createBrowserGatewayTarget", + updateBrowserGatewayTarget = "updateBrowserGatewayTarget", + deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget", + getBrowserGatewayTarget = "getBrowserGatewayTarget", + listBrowserGatewayTargets = "listBrowserGatewayTargets" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 3b4f459f3..5040808a9 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -592,6 +592,7 @@ export const browserGatewayTarget = pgTable("browserGatewayTarget", { onDelete: "cascade" }) .notNull(), + authToken: varchar("authToken").notNull(), type: varchar("type").notNull(), // "ssh", "rdp", "vnc" destination: varchar("destination").notNull(), destinationPort: integer("destinationPort").notNull() diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 1fdace69b..b235d26d5 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -602,6 +602,7 @@ export const browserGatewayTarget = sqliteTable("browserGatewayTarget", { onDelete: "cascade" }) .notNull(), + authToken: text("authToken").notNull(), type: text("type").notNull(), // "ssh", "rdp", "vnc" destination: text("destination").notNull(), destinationPort: integer("destinationPort").notNull() diff --git a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts new file mode 100644 index 000000000..b26a1a8b6 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts @@ -0,0 +1,187 @@ +/* + * 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 new file mode 100644 index 000000000..850944b29 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts @@ -0,0 +1,130 @@ +/* + * 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 new file mode 100644 index 000000000..0ac7a8ce9 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts @@ -0,0 +1,109 @@ +/* + * 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/index.ts b/server/private/routers/browserGatewayTarget/index.ts new file mode 100644 index 000000000..d080510f8 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from "./createBrowserGatewayTarget"; +export * from "./updateBrowserGatewayTarget"; +export * from "./deleteBrowserGatewayTarget"; +export * from "./getBrowserGatewayTarget"; +export * from "./listBrowserGatewayTargets"; diff --git a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts new file mode 100644 index 000000000..12e4aed69 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts @@ -0,0 +1,148 @@ +/* + * 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 targets = await db + .select() + .from(browserGatewayTarget) + .where(eq(browserGatewayTarget.resourceId, resourceId)) + .limit(limit) + .offset(offset); + + return response(res, { + data: { + targets: targets, + total: targets.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 new file mode 100644 index 000000000..825407dc3 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts @@ -0,0 +1,180 @@ +/* + * 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 a2667daa1..fc71c72c4 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ 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 { verifyOrgAccess, @@ -775,3 +776,48 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getTarget), healthChecks.getHealthCheckStatusHistory ); + +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 820a843f0..542c806f4 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,6 +16,7 @@ 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, @@ -215,3 +216,43 @@ 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/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index fb398236a..cc2bd782b 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -18,6 +18,7 @@ import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; import { formatEndpoint, generateSubnetProxyTargetV2, @@ -311,12 +312,17 @@ export async function buildTargetConfigurationForNewtClient( (target) => target !== null ); - const browserGatewayTargets = allBrowserGatewayTargets.map((t) => ({ - id: t.browserGatewayTargetId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort - })); + const serverSecret = config.getRawConfig().server.secret!; + const browserGatewayTargets = allBrowserGatewayTargets.map((t) => { + const decryptAuthToken = decrypt(t.authToken, serverSecret); + return { + id: t.browserGatewayTargetId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort, + authToken: decryptAuthToken + }; + }); return { validHealthCheckTargets, diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index ca15e50cc..6d8212b12 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -2,6 +2,8 @@ import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; export async function addTargets( newtId: string, @@ -247,14 +249,21 @@ export async function sendBrowserGatewayTargets( ) { if (targets.length === 0) return; - const payload = targets.map((t) => ({ - id: t.browserGatewayTargetId, - resourceId: t.resourceId, - siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort - })); + const payload = targets.map((t) => { + const decryptAuthToken = decrypt( + t.authToken, + config.getRawConfig().server.secret! + ); + return { + id: t.browserGatewayTargetId, + resourceId: t.resourceId, + siteId: t.siteId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort, + authToken: decryptAuthToken + }; + }); await sendToClient( newtId, diff --git a/server/routers/resource/getBrowserTarget.ts b/server/routers/resource/getBrowserTarget.ts index b8ddd43dc..3ea1c4aa2 100644 --- a/server/routers/resource/getBrowserTarget.ts +++ b/server/routers/resource/getBrowserTarget.ts @@ -8,6 +8,8 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const getBrowserTargetSchema = z .object({ @@ -18,6 +20,7 @@ const getBrowserTargetSchema = z export type GetBrowserTargetResponse = { ip: string; port: number; + authToken: string; }; export async function getBrowserTarget( @@ -43,7 +46,8 @@ export async function getBrowserTarget( const [browserTarget] = await db .select({ destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort + destinationPort: browserGatewayTarget.destinationPort, + authToken: browserGatewayTarget.authToken }) .from(browserGatewayTarget) .innerJoin( @@ -53,6 +57,11 @@ export async function getBrowserTarget( .where(eq(resources.fullDomain, fullDomain)) .limit(1); + const decryptAuthToken = decrypt( + browserTarget.authToken, + config.getRawConfig().server.secret! + ); + if (!browserTarget) { return next( createHttpError( @@ -65,7 +74,8 @@ export async function getBrowserTarget( return response(res, { data: { ip: browserTarget.destination, - port: browserTarget.destinationPort + port: browserTarget.destinationPort, + authToken: decryptAuthToken }, success: true, error: false, diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 75f351548..141ec80a0 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -35,6 +35,7 @@ declare module "react" { type Target = { ip: string; port: number; + authToken: string; }; type FormState = { @@ -219,9 +220,16 @@ export default function RdpClient({ ); } - const destination = target ? `${target.ip}:${target.port}` : ""; + if (!target) { + toast({ + variant: "destructive", + title: "No target", + description: "No connection target available" + }); + return; + } - console.log("Starting RDP session with destination:", destination); + const destination = `${target.ip}:${target.port}`; const builder = userInteraction .configBuilder() @@ -232,7 +240,7 @@ export default function RdpClient({ `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) .withServerDomain(form.domain) - .withAuthToken("test-token") + .withAuthToken(target.authToken) .withDesktopSize({ width: window.innerWidth, height: window.innerHeight diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 351aa0092..d68a2ee89 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -15,7 +15,7 @@ export default async function RdpPage() { const host = headersList.get("host") || ""; const hostname = host.split(":")[0]; - let target: { ip: string; port: number } | null = null; + let target: { ip: string; port: number; authToken: string } | null = null; let error: string | null = null; try { diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 86f5bdda4..0a414dd2a 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; type Target = { ip: string; port: number; + authToken: string; }; type FormState = { @@ -125,12 +126,18 @@ export default function SshClient({ setConnectError(null); setConnecting(true); + if (!target) { + setConnectError("No target specified"); + setConnecting(false); + return; + } + const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; const url = new URL(proxyAddress); - url.searchParams.set("host", target?.ip ?? ""); - url.searchParams.set("port", String(target?.port ?? 22)); + url.searchParams.set("host", target.ip ?? ""); + url.searchParams.set("port", String(target.port ?? 22)); url.searchParams.set("username", form.username); - url.searchParams.set("authToken", "test-token"); + url.searchParams.set("authToken", target.authToken ?? ""); const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 30459db42..f54f121c6 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -15,7 +15,7 @@ export default async function SshPage() { const host = headersList.get("host") || ""; const hostname = host.split(":")[0]; - let target: { ip: string; port: number } | null = null; + let target: { ip: string; port: number; authToken: string } | null = null; let error: string | null = null; try { diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 174921eed..7718fd665 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -9,6 +9,7 @@ import { toast } from "@app/hooks/useToast"; type Target = { ip: string; port: number; + authToken: string; }; type FormState = { @@ -91,7 +92,7 @@ export default function VncClient({ const params = new URLSearchParams({ host: target.ip, port: String(target.port), - authToken: "test-token" + authToken: target.authToken }); const wsUrl = `${base}?${params.toString()}`; diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index 026fae98e..32fbdeea5 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -15,7 +15,7 @@ export default async function VncPage() { const host = headersList.get("host") || ""; const hostname = host.split(":")[0]; - let target: { ip: string; port: number } | null = null; + let target: { ip: string; port: number; authToken: string } | null = null; let error: string | null = null; try {