Merge branch 'dev' into resource-policies-restyle

This commit is contained in:
miloschwartz
2026-06-08 12:00:08 -07:00
45 changed files with 781 additions and 1468 deletions

View File

@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull() 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<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
>; >;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -290,7 +290,12 @@ export const targets = pgTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix 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", { export const targetHealthCheck = pgTable("targetHealthCheck", {

View File

@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull() 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<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>; export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix 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", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {

View File

@@ -10,16 +10,22 @@ import {
clientSiteResources clientSiteResources
} from "@server/db"; } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import {
PublicResourcesResults,
updatePublicResources
} from "./publicResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, isNotNull } from "drizzle-orm"; 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 { import {
ClientResourcesResults, ClientResourcesResults,
updateClientResources updatePrivateResources
} from "./clientResources"; } from "./privateResources";
import { updateResourcePolicies } from "./resourcePolicies"; import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types"; import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
@@ -54,18 +60,18 @@ export async function applyBlueprint({
let error: any | null = null; let error: any | null = null;
try { try {
let proxyResourcesResults: ProxyResourcesResults = []; let proxyResourcesResults: PublicResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = []; let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx); await updateResourcePolicies(orgId, config, trx);
proxyResourcesResults = await updateProxyResources( proxyResourcesResults = await updatePublicResources(
orgId, orgId,
config, config,
trx, trx,
siteId siteId
); );
clientResourcesResults = await updateClientResources( clientResourcesResults = await updatePrivateResources(
orgId, orgId,
config, config,
trx, trx,
@@ -104,13 +110,27 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
await addProxyTargets( if (["http", "tcp", "udp"].includes(target.mode)) {
site.newt.newtId, await addProxyTargets(
[target], site.newt.newtId,
matchingHealthcheck ? [matchingHealthcheck] : [], [target],
result.proxyResource.mode === "udp" ? "udp" : "tcp", matchingHealthcheck
site.newt.version ? [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
);
}
} }
} }
} }

View File

@@ -105,7 +105,7 @@ export type ClientResourcesResults = {
oldSites: { siteId: number }[]; oldSites: { siteId: number }[];
}[]; }[];
export async function updateClientResources( export async function updatePrivateResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,

View File

@@ -48,20 +48,23 @@ import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { build } from "@server/build"; 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 = { export type PublicResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
targetsToUpdate: Target[]; targetsToUpdate: Target[];
healthchecksToUpdate: TargetHealthCheck[]; healthchecksToUpdate: TargetHealthCheck[];
}[]; }[];
export async function updateProxyResources( export async function updatePublicResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,
siteId?: number siteId?: number
): Promise<ProxyResourcesResults> { ): Promise<PublicResourcesResults> {
const results: ProxyResourcesResults = []; const results: PublicResourcesResults = [];
for (const [resourceNiceId, resourceData] of Object.entries( for (const [resourceNiceId, resourceData] of Object.entries(
config["proxy-resources"] config["proxy-resources"]
@@ -80,7 +83,7 @@ export async function updateProxyResources(
if (targetSiteId) { if (targetSiteId) {
// Look up site by niceId // Look up site by niceId
[site] = await trx [site] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId, type: sites.type })
.from(sites) .from(sites)
.where( .where(
and( and(
@@ -92,7 +95,7 @@ export async function updateProxyResources(
} else if (siteId) { } else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
[site] = await trx [site] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId, type: sites.type })
.from(sites) .from(sites)
.where( .where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
@@ -119,6 +122,15 @@ export async function updateProxyResources(
internalPortToCreate = targetData["internal-port"]; 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 // Create target
const [newTarget] = await trx const [newTarget] = await trx
.insert(targets) .insert(targets)
@@ -126,10 +138,12 @@ export async function updateProxyResources(
resourceId: resourceId, resourceId: resourceId,
siteId: site.siteId, siteId: site.siteId,
ip: targetData.hostname, ip: targetData.hostname,
mode: resourceData.mode as Target["mode"],
method: targetData.method, method: targetData.method,
port: targetData.port, port: targetData.port,
enabled: targetData.enabled, enabled: targetData.enabled,
internalPort: internalPortToCreate, internalPort: internalPortToCreate,
authToken: authToken,
path: targetData.path, path: targetData.path,
pathMatchType: targetData["path-match"], pathMatchType: targetData["path-match"],
rewritePath: rewritePath:
@@ -565,6 +579,13 @@ export async function updateProxyResources(
? (resourceData["proxy-protocol-version"] ?? ? (resourceData["proxy-protocol-version"] ??
1) 1)
: 1, : 1,
pamMode:
resourceData["auth-daemon"]?.pam ||
"passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null, resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId defaultResourcePolicyId: inlinePolicyId
}) })
@@ -707,7 +728,8 @@ export async function updateProxyResources(
? "/" ? "/"
: undefined), : undefined),
rewritePathType: targetData["rewrite-match"], rewritePathType: targetData["rewrite-match"],
priority: targetData.priority priority: targetData.priority,
mode: resourceData.mode
}) })
.where(eq(targets.targetId, existingTarget.targetId)) .where(eq(targets.targetId, existingTarget.targetId))
.returning(); .returning();

View File

@@ -37,7 +37,7 @@ export async function updateResourcePolicies(
const results: ResourcePoliciesResults = []; const results: ResourcePoliciesResults = [];
for (const [policyNiceId, policyData] of Object.entries( for (const [policyNiceId, policyData] of Object.entries(
config["resource-policies"] config["public-policies"]
)) { )) {
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(
orgId, orgId,

View File

@@ -268,8 +268,37 @@ export const PublicResourceSchema = z
return true; return true;
} }
// If protocol/mode is http, it must have a full-domain const effectiveProtocol = resource.mode ?? resource.protocol;
if ((resource.mode ?? resource.protocol) === "http") { 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 ( return (
resource["full-domain"] !== undefined && resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0 resource["full-domain"].length > 0
@@ -279,7 +308,7 @@ export const PublicResourceSchema = z
}, },
{ {
path: ["full-domain"], 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( .refine(
@@ -506,7 +535,44 @@ export const PrivateResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" 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<string>();
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; export const ResourcePolicyRuleSchema = RuleSchema;
@@ -573,7 +639,7 @@ export const ConfigSchema = z
.record(z.string(), PrivateResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"resource-policies": z "public-policies": z
.record(z.string(), ResourcePolicySchema) .record(z.string(), ResourcePolicySchema)
.optional() .optional()
.prefault({}), .prefault({}),
@@ -607,7 +673,7 @@ export const ConfigSchema = z
string, string,
z.infer<typeof PrivateResourceSchema> z.infer<typeof PrivateResourceSchema>
>; >;
"resource-policies": Record< "public-policies": Record<
string, string,
z.infer<typeof ResourcePolicySchema> z.infer<typeof ResourcePolicySchema>
>; >;

View File

@@ -12,7 +12,6 @@
*/ */
import { import {
browserGatewayTarget,
certificates, certificates,
db, db,
domainNamespaces, domainNamespaces,
@@ -172,8 +171,15 @@ export async function getTraefikConfig(
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
allowRawResources allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three ? inArray(resources.mode, [
: eq(resources.mode, "http") "http",
"udp",
"tcp",
"vnc",
"ssh",
"rdp"
]) // allow all three
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
) )
) )
.orderBy(desc(targets.priority), targets.targetId); // stable ordering .orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -181,7 +187,10 @@ export async function getTraefikConfig(
// Group by resource and include targets with their unique site data // Group by resource and include targets with their unique site data
const resourcesMap = new Map(); const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => { for (const row of resourcesWithTargetsAndSites) {
if (!["http", "tcp", "udp"].includes(row.mode)) {
continue;
}
const resourceId = row.resourceId; const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || ""; const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
@@ -191,7 +200,7 @@ export async function getTraefikConfig(
const priority = row.priority ?? 100; const priority = row.priority ?? 100;
if (filterOutNamespaceDomains && row.domainNamespaceId) { if (filterOutNamespaceDomains && row.domainNamespaceId) {
return; continue;
} }
// Create a unique key combining resourceId, path config, and rewrite config // Create a unique key combining resourceId, path config, and rewrite config
@@ -218,7 +227,7 @@ export async function getTraefikConfig(
logger.debug( logger.debug(
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
); );
return; continue;
} }
resourcesMap.set(mapKey, { resourcesMap.set(mapKey, {
@@ -275,7 +284,7 @@ export async function getTraefikConfig(
online: row.siteOnline online: row.siteOnline
} }
}); });
}); }
// Group browser gateway targets by resource // Group browser gateway targets by resource
type BrowserGatewayResourceEntry = { type BrowserGatewayResourceEntry = {
@@ -295,13 +304,12 @@ export async function getTraefikConfig(
maintenanceMessage: string | null; maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null; maintenanceEstimatedTime: string | null;
targets: { targets: {
browserGatewayTargetId: number; targetId: number;
bgType: string; bgType: string;
siteId: number; siteId: number;
siteType: string; siteType: string;
siteOnline: boolean | null; siteOnline: boolean | null;
subnet: string | null; subnet: string | null;
siteExitNodeId: number | null;
}[]; }[];
}; };
const browserGatewayResourcesMap = new Map< const browserGatewayResourcesMap = new Map<
@@ -310,66 +318,10 @@ export async function getTraefikConfig(
>(); >();
if (allowBrowserGatewayResources) { if (allowBrowserGatewayResources) {
// Query browser gateway targets for this exit node for (const row of resourcesWithTargetsAndSites) {
const browserGatewayRows = await db if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
.select({ continue;
// 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) {
if (filterOutNamespaceDomains && row.domainNamespaceId) { if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue; continue;
} }
@@ -394,13 +346,12 @@ export async function getTraefikConfig(
}); });
} }
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId, targetId: row.targetId,
bgType: row.bgType, bgType: row.mode,
siteId: row.siteId, siteId: row.siteId,
siteType: row.siteType, siteType: row.siteType,
siteOnline: row.siteOnline, siteOnline: row.siteOnline,
subnet: row.subnet, subnet: row.subnet
siteExitNodeId: row.siteExitNodeId
}); });
} }
} }

View File

@@ -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<any> {
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<CreateBrowserGatewayTargetResponse>(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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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<GetBrowserGatewayTargetResponse>(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"
)
);
}
}

View File

@@ -13,9 +13,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db"; import { db, resources, targets } from "@server/db";
import { resources, targets } from "@server/db"; import { eq, and, inArray } from "drizzle-orm";
import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -51,11 +50,11 @@ export async function getBrowserTarget(
logger.info(`Retrieving browser target for domain: ${fullDomain}`); logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db const [row] = await db
.select({ .select({
destination: browserGatewayTarget.destination, ip: targets.ip,
destinationPort: browserGatewayTarget.destinationPort, port: targets.port,
authToken: browserGatewayTarget.authToken, authToken: targets.authToken,
resourceId: resources.resourceId, resourceId: resources.resourceId,
niceId: resources.niceId, niceId: resources.niceId,
name: resources.name, name: resources.name,
@@ -63,20 +62,18 @@ export async function getBrowserTarget(
pamMode: resources.pamMode, pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode authDaemonMode: resources.authDaemonMode
}) })
.from(browserGatewayTarget) .from(targets)
.innerJoin( .innerJoin(resources, eq(targets.resourceId, resources.resourceId))
resources, .where(
eq(browserGatewayTarget.resourceId, resources.resourceId) and(
eq(resources.fullDomain, fullDomain),
eq(targets.enabled, true),
inArray(targets.mode, ["ssh", "rdp", "vnc"])
)
) )
.where(eq(resources.fullDomain, fullDomain))
.limit(1); .limit(1);
const decryptedAuthToken = decrypt( if (!row) {
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
@@ -85,17 +82,21 @@ export async function getBrowserTarget(
); );
} }
const decryptedAuthToken = row.authToken
? decrypt(row.authToken, config.getRawConfig().server.secret!)
: "";
return response<GetBrowserTargetResponse>(res, { return response<GetBrowserTargetResponse>(res, {
data: { data: {
ip: browserTarget.destination, ip: row.ip,
port: browserTarget.destinationPort, port: row.port,
authToken: decryptedAuthToken, authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode, pamMode: row.pamMode,
authDaemonMode: browserTarget.authDaemonMode, authDaemonMode: row.authDaemonMode,
orgId: browserTarget.orgId, orgId: row.orgId,
resourceId: browserTarget.resourceId, resourceId: row.resourceId,
niceId: browserTarget.niceId, niceId: row.niceId,
name: browserTarget.name name: row.name ?? ""
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -11,9 +11,4 @@
* This file is not licensed under the AGPLv3. * 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"; export * from "./getBrowserTarget";

View File

@@ -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<any> {
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<ListBrowserGatewayTargetsResponse>(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"
)
);
}
}

View File

@@ -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<any> {
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<BrowserGatewayTarget> = {};
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<UpdateBrowserGatewayTargetResponse>(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"
)
);
}
}

View File

@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels"; import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client"; import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource"; import * as resource from "#private/routers/resource";
@@ -879,48 +878,3 @@ authenticated.post(
verifyClientAccess, verifyClientAccess,
client.rebuildClientAssociationsCacheRoute 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
);

View File

@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents"; import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates"; import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import { import {
verifyApiKeyHasAction, verifyApiKeyHasAction,
@@ -216,43 +215,3 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole), logActionAudit(ActionsEnum.removeUserRole),
user.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
);

View File

@@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing"; import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license"; import * as license from "#private/routers/license";
import * as resource from "#private/routers/resource"; import * as resource from "#private/routers/resource";
import * as browserTarget from "#private/routers/browserGatewayTarget";
import * as ssh from "#private/routers/ssh"; import * as ssh from "#private/routers/ssh";
import * as ws from "@server/routers/ws"; import * as ws from "@server/routers/ws";
import * as browserTarget from "#private/routers/browserGatewayTarget";
import { import {
verifySessionUserMiddleware, verifySessionUserMiddleware,

View File

@@ -30,8 +30,7 @@ import {
userOrgs, userOrgs,
sites, sites,
Resource, Resource,
SiteResource, SiteResource
browserGatewayTarget
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -291,16 +290,15 @@ export async function signSshKey(
const publicResource = resource as Resource; const publicResource = resource as Resource;
const targetRows = await db const targetRows = await db
.select({ .select({
siteId: browserGatewayTarget.siteId, siteId: targets.siteId,
ip: browserGatewayTarget.destination ip: targets.ip
}) })
.from(browserGatewayTarget) .from(targets)
.where( .where(
and( and(
eq( eq(targets.resourceId, publicResource.resourceId),
browserGatewayTarget.resourceId, eq(targets.enabled, true),
publicResource.resourceId eq(targets.mode, "ssh")
)
) )
); );

View File

@@ -1,6 +1,4 @@
import { import {
browserGatewayTarget,
BrowserGatewayTarget,
clients, clients,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache, clientSitesAssociationsCache,
@@ -16,7 +14,7 @@ import {
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers"; 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 config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import { import {
@@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient(
}) })
.from(targets) .from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .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 const allHealthChecks = await db
.select({ .select({
@@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck) .from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId)); .where(eq(targetHealthCheck.siteId, siteId));
// Get all enabled targets with their resource mode information
const allBrowserGatewayTargets = await db const allBrowserGatewayTargets = await db
.select() .select({
.from(browserGatewayTarget) resourceId: targets.resourceId,
.where(eq(browserGatewayTarget.siteId, siteId)); 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( const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => { (acc, target) => {
@@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient(
const serverSecret = config.getRawConfig().server.secret!; const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => { const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
if (!t.ip || !t.port || !t.authToken) {
return null;
}
const decryptAuthToken = decrypt(t.authToken, serverSecret); const decryptAuthToken = decrypt(t.authToken, serverSecret);
return { return {
id: t.browserGatewayTargetId, id: t.targetId,
type: t.type, type: t.mode,
destination: t.destination, destination: t.ip,
destinationPort: t.destinationPort, destinationPort: t.port,
authToken: decryptAuthToken authToken: decryptAuthToken
}; };
}); });

View File

@@ -1,4 +1,4 @@
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
@@ -244,23 +244,27 @@ export async function removeTargets(
export async function sendBrowserGatewayTargets( export async function sendBrowserGatewayTargets(
newtId: string, newtId: string,
targets: BrowserGatewayTarget[], targets: Target[],
version?: string | null version?: string | null
) { ) {
if (targets.length === 0) return; 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( const decryptAuthToken = decrypt(
t.authToken, t.authToken!,
config.getRawConfig().server.secret! config.getRawConfig().server.secret!
); );
return { return {
id: t.browserGatewayTargetId, id: t.targetId,
resourceId: t.resourceId, resourceId: t.resourceId,
siteId: t.siteId, siteId: t.siteId,
type: t.type, type: t.mode,
destination: t.destination, destination: t.ip,
destinationPort: t.destinationPort, destinationPort: t.port,
authToken: decryptAuthToken authToken: decryptAuthToken
}; };
}); });

View File

@@ -1,6 +1,5 @@
import { import {
alias, alias,
browserGatewayTarget,
db, db,
labels, labels,
resourceHeaderAuth, resourceHeaderAuth,
@@ -639,15 +638,8 @@ export async function listResources(
.from(targets) .from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId)) .innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, 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( conditions.push(
or( or(inArray(resources.resourceId, resourcesWithSite))
inArray(resources.resourceId, resourcesWithSite),
inArray(resources.resourceId, resourcesWithBrowserGateway)
)
); );
} }
@@ -770,30 +762,6 @@ export async function listResources(
) )
.leftJoin(sites, eq(targets.siteId, sites.siteId)); .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[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();
@@ -856,21 +824,6 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline) 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()); entry.sites = Array.from(siteById.values());
} }

View File

@@ -93,10 +93,9 @@ export async function deleteSite(
// Clean up all client associations and send peer/proxy removal // Clean up all client associations and send peer/proxy removal
// messages in a single efficient pass before deleting the row. // messages in a single efficient pass before deleting the row.
await cleanupSiteAssociations(site, trx); 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); await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
}); });

View File

@@ -12,7 +12,6 @@ import {
userSites, userSites,
labels, labels,
siteLabels, siteLabels,
browserGatewayTarget,
type Label type Label
} from "@server/db"; } from "@server/db";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
@@ -241,10 +240,6 @@ function querySitesBase() {
ON ${siteResources.networkId} = ${siteNetworks.networkId} ON ${siteResources.networkId} = ${siteNetworks.networkId}
WHERE ${siteNetworks.siteId} = ${sites.siteId} WHERE ${siteNetworks.siteId} = ${sites.siteId}
AND ${siteResources.orgId} = ${sites.orgId} AND ${siteResources.orgId} = ${sites.orgId}
) + (
SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId})
FROM ${browserGatewayTarget}
WHERE ${browserGatewayTarget.siteId} = ${sites.siteId}
)`, )`,
status: sites.status status: sites.status
}) })

View File

@@ -24,6 +24,10 @@ import {
fireHealthCheckUnhealthyAlert, fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert fireHealthCheckUnknownAlert
} from "@server/lib/alerts"; } from "@server/lib/alerts";
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({ const createTargetParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
@@ -32,6 +36,7 @@ const createTargetParamsSchema = z.strictObject({
const createTargetSchema = z.strictObject({ const createTargetSchema = z.strictObject({
siteId: z.int().positive(), siteId: z.int().positive(),
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
method: z.string().optional().nullable(), method: z.string().optional().nullable(),
port: z.int().min(1).max(65535), port: z.int().min(1).max(65535),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
@@ -161,6 +166,12 @@ export async function createTarget(
); );
} }
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
let newTarget: Target[] = []; let newTarget: Target[] = [];
let targetIps: string[] = []; let targetIps: string[] = [];
let healthCheck: TargetHealthCheck[] = []; let healthCheck: TargetHealthCheck[] = [];
@@ -191,6 +202,9 @@ export async function createTarget(
.values({ .values({
resourceId, resourceId,
...targetData, ...targetData,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
priority: targetData.priority || 100 priority: targetData.priority || 100
}) })
.returning(); .returning();
@@ -226,6 +240,10 @@ export async function createTarget(
resourceId, resourceId,
siteId: site.siteId, siteId: site.siteId,
ip: targetData.ip, ip: targetData.ip,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
authToken: encryptedToken,
method: targetData.method, method: targetData.method,
port: targetData.port, port: targetData.port,
internalPort, internalPort,
@@ -325,13 +343,21 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await addTargets( if (["http", "tcp", "udp"].includes(newTarget[0].mode)) {
newt.newtId, await addTargets(
newTarget, newt.newtId,
healthCheck, newTarget,
resource.mode === "udp" ? "udp" : "tcp", healthCheck,
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
await sendBrowserGatewayTargets(
newt.newtId,
newTarget,
newt.version
);
}
} }
} }

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets"; import { removeTargets } from "../newt/targets";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db"; import { targetHealthCheck } from "@server/db";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const deleteTargetSchema = z.strictObject({ const deleteTargetSchema = z.strictObject({
targetId: z.coerce.number().int().positive() targetId: z.coerce.number().int().positive()
@@ -136,14 +137,22 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await removeTargets( if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
newt.newtId, await removeTargets(
// [deletedTarget], newt.newtId,
[], // 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 // [deletedTarget],
[deletedHealthCheck], [], // 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
resource.mode === "udp" ? "udp" : "tcp", [deletedHealthCheck],
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
await removeBrowserGatewayTarget(
newt.newtId,
deletedTarget.targetId,
newt.version
);
}
} }
} }

View File

@@ -34,6 +34,7 @@ function queryTargets(resourceId: number) {
.select({ .select({
targetId: targets.targetId, targetId: targets.targetId,
ip: targets.ip, ip: targets.ip,
mode: targets.mode,
method: targets.method, method: targets.method,
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,

View File

@@ -18,6 +18,7 @@ import {
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const updateTargetParamsSchema = z.strictObject({ const updateTargetParamsSchema = z.strictObject({
targetId: z.coerce.number().int().positive() targetId: z.coerce.number().int().positive()
@@ -27,6 +28,10 @@ const updateTargetBodySchema = z
.strictObject({ .strictObject({
siteId: z.int().positive(), siteId: z.int().positive(),
ip: z.string().refine(isTargetValid), 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(), method: z.string().min(1).max(10).optional().nullable(),
port: z.int().min(1).max(65535).optional(), port: z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -184,6 +189,8 @@ export async function updateTarget(
} }
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const nextMode =
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
let updatedTarget: any; let updatedTarget: any;
let updatedHc: any; let updatedHc: any;
@@ -193,6 +200,7 @@ export async function updateTarget(
.set({ .set({
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip, ip: parsedBody.data.ip,
mode: nextMode,
method: parsedBody.data.method, method: parsedBody.data.method,
port: parsedBody.data.port, port: parsedBody.data.port,
internalPort, internalPort,
@@ -343,13 +351,21 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await addTargets( if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
newt.newtId, await addTargets(
[updatedTarget], newt.newtId,
[updatedHc], [updatedTarget],
resource.mode === "udp" ? "udp" : "tcp", [updatedHc],
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
await sendBrowserGatewayTargets(
newt.newtId,
[updatedTarget],
newt.version
);
}
} }
} }

View File

@@ -39,18 +39,6 @@ export default async function migration() {
try { try {
await db.execute(sql`BEGIN`); 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` await db.execute(sql`
CREATE TABLE "clientLabels" ( CREATE TABLE "clientLabels" (
"clientLabelId" serial PRIMARY KEY NOT NULL, "clientLabelId" serial PRIMARY KEY NOT NULL,
@@ -215,12 +203,6 @@ export default async function migration() {
await db.execute( await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;` 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( 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;` 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 "http";`);
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`); 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`); await db.execute(sql`COMMIT`);
console.log("Migrated database"); console.log("Migrated database");

View File

@@ -40,22 +40,6 @@ export default async function migration() {
try { try {
db.transaction(() => { 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( db.prepare(
` `
CREATE TABLE 'clientLabels' ( CREATE TABLE 'clientLabels' (
@@ -350,6 +334,16 @@ export default async function migration() {
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id); ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
` `
).run(); ).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 const existingResources = db

View File

@@ -143,11 +143,6 @@ export function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null); useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => { const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) { if (dockerStates.has(siteId)) {
return; return;
@@ -212,42 +207,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( const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => { (targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
@@ -624,6 +583,8 @@ export function ProxyResourceTargetsForm({
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
targetId: -Date.now(), targetId: -Date.now(),
ip: "", ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,

View File

@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = { type ExistingTarget = {
browserGatewayTargetId: number; targetId: number;
siteId: number; siteId: number;
}; };
type BgTarget = { type TargetRow = {
browserGatewayTargetId: number; targetId: number;
resourceId: number; resourceId: number;
siteId: number; siteId: number;
siteName?: string; siteName?: string;
type: string; mode: string | null;
destination: string; ip: string;
destinationPort: number; port: number;
}; };
type BgTargetsResponse = { type ResourceTargetsResponse = {
targets: BgTarget[]; targets: TargetRow[];
}; };
export default function RdpSettingsPage(props: { export default function RdpSettingsPage(props: {
@@ -61,13 +61,11 @@ export default function RdpSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources] tierMatrix[TierFeature.AdvancedPublicResources]
); );
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
queryFn: async () => { queryFn: async () => {
const res = await api.get( const res = await api.get(`/resource/${resource.resourceId}/targets`);
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` return res.data.data as ResourceTargetsResponse;
);
return res.data.data as BgTargetsResponse;
} }
}); });
@@ -85,7 +83,7 @@ export default function RdpSettingsPage(props: {
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled} disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -95,18 +93,18 @@ function RdpServerForm({
orgId, orgId,
resource, resource,
disabled, disabled,
bgTargetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean; disabled: boolean;
bgTargetsResponse: BgTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter(); const router = useRouter();
const targets = bgTargetsResponse.targets; const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
const firstTarget = targets[0]; const firstTarget = targets[0];
const formSchema = useMemo( const formSchema = useMemo(
@@ -122,17 +120,15 @@ function RdpServerForm({
name: target.siteName ?? String(target.siteId), name: target.siteName ?? String(target.siteId),
type: "newt" as const type: "newt" as const
})), })),
destination: firstTarget?.destination ?? "", destination: firstTarget?.ip ?? "",
destinationPort: firstTarget destinationPort: firstTarget ? String(firstTarget.port) : "3389"
? String(firstTarget.destinationPort)
: "3389"
} }
}); });
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>( const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() => () =>
targets.map((target) => ({ targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId, targetId: target.targetId,
siteId: target.siteId siteId: target.siteId
})) }))
); );
@@ -155,28 +151,20 @@ function RdpServerForm({
const toDelete = existingTargets.filter( const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId) (t) => !selectedSiteIds.has(t.siteId)
); );
await Promise.all( await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) => const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId) selectedSiteIds.has(t.siteId)
); );
await Promise.all( await Promise.all(
toUpdate.map((t) => toUpdate.map((t) =>
api.post( api.post(`/target/${t.targetId}`, {
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, mode: "rdp",
{ ip: destination,
type: "rdp", port: Number(destinationPort),
destination, siteId: t.siteId,
destinationPort: Number(destinationPort), hcEnabled: false
siteId: t.siteId })
}
)
) )
); );
@@ -185,20 +173,18 @@ function RdpServerForm({
); );
const created = await Promise.all( const created = await Promise.all(
toCreate.map((s) => toCreate.map((s) =>
api.put( api.put(`/resource/${resource.resourceId}/target`, {
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, siteId: s.siteId,
{ mode: "rdp",
siteId: s.siteId, ip: destination,
type: "rdp", port: Number(destinationPort),
destination, hcEnabled: false
destinationPort: Number(destinationPort) })
}
)
) )
); );
const newTargets: ExistingTarget[] = created.map((res, i) => ({ const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId: res.data.data.browserGatewayTargetId, targetId: res.data.data.targetId,
siteId: toCreate[i].siteId siteId: toCreate[i].siteId
})); }));
setExistingTargets([...toUpdate, ...newTargets]); setExistingTargets([...toUpdate, ...newTargets]);

View File

@@ -15,9 +15,7 @@ import {
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { import { SitesSelector } from "@app/components/site-selector";
SitesSelector
} from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
@@ -54,22 +52,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = { type ExistingTarget = {
browserGatewayTargetId: number; targetId: number;
siteId: number; siteId: number;
}; };
type BgTarget = { type TargetRow = {
browserGatewayTargetId: number; targetId: number;
resourceId: number; resourceId: number;
siteId: number; siteId: number;
siteName?: string; siteName?: string;
type: string; mode: string | null;
destination: string; ip: string;
destinationPort: number; port: number;
}; };
type BgTargetsResponse = { type ResourceTargetsResponse = {
targets: BgTarget[]; targets: TargetRow[];
}; };
export default function SshSettingsPage(props: { export default function SshSettingsPage(props: {
@@ -83,13 +81,11 @@ export default function SshSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources] tierMatrix[TierFeature.AdvancedPublicResources]
); );
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
queryFn: async () => { queryFn: async () => {
const res = await api.get( const res = await api.get(`/resource/${resource.resourceId}/targets`);
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` return res.data.data as ResourceTargetsResponse;
);
return res.data.data as BgTargetsResponse;
} }
}); });
@@ -107,7 +103,7 @@ export default function SshSettingsPage(props: {
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled} disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -118,20 +114,20 @@ function SshServerForm({
resource, resource,
updateResource, updateResource,
disabled, disabled,
bgTargetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean; disabled: boolean;
bgTargetsResponse: BgTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter(); const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native"; const isNativeInitially = resource.authDaemonMode === "native";
const targets = bgTargetsResponse.targets; const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
const firstTarget = targets[0]; const firstTarget = targets[0];
const initialPamMode = const initialPamMode =
(resource.pamMode as "passthrough" | "push") || "passthrough"; (resource.pamMode as "passthrough" | "push") || "passthrough";
@@ -192,11 +188,11 @@ function SshServerForm({
: null, : null,
destination: isNativeInitially destination: isNativeInitially
? "" ? ""
: (firstTarget?.destination ?? ""), : (firstTarget?.ip ?? ""),
destinationPort: isNativeInitially destinationPort: isNativeInitially
? "22" ? "22"
: firstTarget : firstTarget
? String(firstTarget.destinationPort) ? String(firstTarget.port)
: "22" : "22"
} }
}); });
@@ -206,8 +202,8 @@ function SshServerForm({
isNativeInitially isNativeInitially
? [] ? []
: targets.map((target) => ({ : targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId, targetId: target.targetId,
siteId: target.siteId siteId: target.siteId,
})) }))
); );
@@ -215,14 +211,12 @@ function SshServerForm({
useState<ExistingTarget | null>(() => useState<ExistingTarget | null>(() =>
isNativeInitially && firstTarget isNativeInitially && firstTarget
? { ? {
browserGatewayTargetId: targetId: firstTarget.targetId,
firstTarget.browserGatewayTargetId, siteId: firstTarget.siteId,
siteId: firstTarget.siteId
} }
: null : null
); );
const [nativeSiteOpen, setNativeSiteOpen] = useState(false); const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const [, formAction, isSubmitting] = useActionState(save, null); const [, formAction, isSubmitting] = useActionState(save, null);
const pamMode = form.watch("pamMode"); const pamMode = form.watch("pamMode");
@@ -256,31 +250,37 @@ function SshServerForm({
}); });
if (isNative) { if (isNative) {
if (values.selectedNativeSite) { const nativeSite = values.selectedNativeSite;
if (nativeSite) {
if (nativeExistingTarget) { if (nativeExistingTarget) {
await api.post( await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, `/target/${nativeExistingTarget.targetId}`,
{ {
type: "ssh", mode: "ssh",
destination: "localhost", ip: "localhost",
destinationPort: 22, port: 22,
siteId: values.selectedNativeSite.siteId siteId: nativeSite.siteId,
} hcEnabled: false
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: values.selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
} }
); );
setNativeExistingTarget({ setNativeExistingTarget({
browserGatewayTargetId: ...nativeExistingTarget,
res.data.data.browserGatewayTargetId, siteId: nativeSite.siteId
siteId: values.selectedNativeSite.siteId });
} else {
const res = await api.put(
`/resource/${resource.resourceId}/target`,
{
siteId: nativeSite.siteId,
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
setNativeExistingTarget({
targetId: res.data.data.targetId,
siteId: nativeSite.siteId,
}); });
} }
} }
@@ -293,7 +293,6 @@ function SshServerForm({
: values.selectedSite : values.selectedSite
? [values.selectedSite] ? [values.selectedSite]
: []; : [];
const selectedSiteIds = new Set( const selectedSiteIds = new Set(
activeSites.map((s) => s.siteId) activeSites.map((s) => s.siteId)
); );
@@ -305,11 +304,7 @@ function SshServerForm({
(t) => !selectedSiteIds.has(t.siteId) (t) => !selectedSiteIds.has(t.siteId)
); );
await Promise.all( await Promise.all(
toDelete.map((t) => toDelete.map((t) => api.delete(`/target/${t.targetId}`))
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
); );
const toUpdate = existingTargets.filter((t) => const toUpdate = existingTargets.filter((t) =>
@@ -317,17 +312,13 @@ function SshServerForm({
); );
await Promise.all( await Promise.all(
toUpdate.map((t) => toUpdate.map((t) =>
api.post( api.post(`/target/${t.targetId}`, {
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, mode: "ssh",
{ ip: values.destination,
type: "ssh", port: Number(values.destinationPort),
destination: values.destination, siteId: t.siteId,
destinationPort: Number( hcEnabled: false
values.destinationPort })
),
siteId: t.siteId
}
)
) )
); );
@@ -336,24 +327,19 @@ function SshServerForm({
); );
const created = await Promise.all( const created = await Promise.all(
toCreate.map((s) => toCreate.map((s) =>
api.put( api.put(`/resource/${resource.resourceId}/target`, {
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, siteId: s.siteId,
{ mode: "ssh",
siteId: s.siteId, ip: values.destination,
type: "ssh", port: Number(values.destinationPort),
destination: values.destination, hcEnabled: false
destinationPort: Number( })
values.destinationPort
)
}
)
) )
); );
const newTargets: ExistingTarget[] = created.map((res, i) => ({ const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId: targetId: res.data.data.targetId,
res.data.data.browserGatewayTargetId, siteId: toCreate[i].siteId,
siteId: toCreate[i].siteId
})); }));
setExistingTargets([...toUpdate, ...newTargets]); setExistingTargets([...toUpdate, ...newTargets]);
} }

View File

@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = { type ExistingTarget = {
browserGatewayTargetId: number; targetId: number;
siteId: number; siteId: number;
}; };
type BgTarget = { type TargetRow = {
browserGatewayTargetId: number; targetId: number;
resourceId: number; resourceId: number;
siteId: number; siteId: number;
siteName?: string; siteName?: string;
type: string; mode: string | null;
destination: string; ip: string;
destinationPort: number; port: number;
}; };
type BgTargetsResponse = { type ResourceTargetsResponse = {
targets: BgTarget[]; targets: TargetRow[];
}; };
export default function VncSettingsPage(props: { export default function VncSettingsPage(props: {
@@ -61,13 +61,11 @@ export default function VncSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources] tierMatrix[TierFeature.AdvancedPublicResources]
); );
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
queryFn: async () => { queryFn: async () => {
const res = await api.get( const res = await api.get(`/resource/${resource.resourceId}/targets`);
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` return res.data.data as ResourceTargetsResponse;
);
return res.data.data as BgTargetsResponse;
} }
}); });
@@ -85,7 +83,7 @@ export default function VncSettingsPage(props: {
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled} disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -95,18 +93,18 @@ function VncServerForm({
orgId, orgId,
resource, resource,
disabled, disabled,
bgTargetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean; disabled: boolean;
bgTargetsResponse: BgTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter(); const router = useRouter();
const targets = bgTargetsResponse.targets; const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
const firstTarget = targets[0]; const firstTarget = targets[0];
const formSchema = useMemo( const formSchema = useMemo(
@@ -122,17 +120,15 @@ function VncServerForm({
name: target.siteName ?? String(target.siteId), name: target.siteName ?? String(target.siteId),
type: "newt" as const type: "newt" as const
})), })),
destination: firstTarget?.destination ?? "", destination: firstTarget?.ip ?? "",
destinationPort: firstTarget destinationPort: firstTarget ? String(firstTarget.port) : "5900"
? String(firstTarget.destinationPort)
: "5900"
} }
}); });
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>( const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() => () =>
targets.map((target) => ({ targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId, targetId: target.targetId,
siteId: target.siteId siteId: target.siteId
})) }))
); );
@@ -155,28 +151,20 @@ function VncServerForm({
const toDelete = existingTargets.filter( const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId) (t) => !selectedSiteIds.has(t.siteId)
); );
await Promise.all( await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) => const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId) selectedSiteIds.has(t.siteId)
); );
await Promise.all( await Promise.all(
toUpdate.map((t) => toUpdate.map((t) =>
api.post( api.post(`/target/${t.targetId}`, {
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, mode: "vnc",
{ ip: destination,
type: "vnc", port: Number(destinationPort),
destination, siteId: t.siteId,
destinationPort: Number(destinationPort), hcEnabled: false
siteId: t.siteId })
}
)
) )
); );
@@ -185,20 +173,18 @@ function VncServerForm({
); );
const created = await Promise.all( const created = await Promise.all(
toCreate.map((s) => toCreate.map((s) =>
api.put( api.put(`/resource/${resource.resourceId}/target`, {
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, siteId: s.siteId,
{ mode: "vnc",
siteId: s.siteId, ip: destination,
type: "vnc", port: Number(destinationPort),
destination, hcEnabled: false
destinationPort: Number(destinationPort) })
}
)
) )
); );
const newTargets: ExistingTarget[] = created.map((res, i) => ({ const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId: res.data.data.browserGatewayTargetId, targetId: res.data.data.targetId,
siteId: toCreate[i].siteId siteId: toCreate[i].siteId
})); }));
setExistingTargets([...toUpdate, ...newTargets]); setExistingTargets([...toUpdate, ...newTargets]);

View File

@@ -591,12 +591,13 @@ export default function Page() {
if (isNative) { if (isNative) {
if (nativeSelectedSite) { if (nativeSelectedSite) {
await api.put( await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`, `/resource/${id}/target`,
{ {
siteId: nativeSelectedSite.siteId, siteId: nativeSelectedSite.siteId,
type: "ssh", mode: "ssh",
destination: "localhost", ip: "localhost",
destinationPort: 22 port: 22,
hcEnabled: false
} }
); );
} }
@@ -612,14 +613,13 @@ export default function Page() {
: []; : [];
for (const site of sitesToCreate) { for (const site of sitesToCreate) {
await api.put( await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`, `/resource/${id}/target`,
{ {
siteId: site.siteId, siteId: site.siteId,
type: "ssh", mode: "ssh",
destination: bgValues.destination, ip: bgValues.destination,
destinationPort: Number( port: Number(bgValues.destinationPort),
bgValues.destinationPort hcEnabled: false
)
} }
); );
} }
@@ -632,14 +632,13 @@ export default function Page() {
const bgValues = bgTargetForm.getValues(); const bgValues = bgTargetForm.getValues();
for (const site of bgValues.selectedSites) { for (const site of bgValues.selectedSites) {
await api.put( await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`, `/resource/${id}/target`,
{ {
siteId: site.siteId, siteId: site.siteId,
type: resourceType, mode: resourceType,
destination: bgValues.destination, ip: bgValues.destination,
destinationPort: Number( port: Number(bgValues.destinationPort),
bgValues.destinationPort hcEnabled: false
)
} }
); );
} }

View File

@@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
declare module "react" { declare module "react" {
namespace JSX { namespace JSX {
@@ -63,22 +67,14 @@ type RdpCredentialsForm = {
enableClipboard: boolean; enableClipboard: boolean;
}; };
function loadStoredCredentials(key: string): RdpCredentialsForm { const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
try { username: "",
const saved = localStorage.getItem(key); password: "",
if (saved) return JSON.parse(saved) as RdpCredentialsForm; domain: "",
} catch { kdcProxyUrl: "",
// ignore pcb: "",
} enableClipboard: true
return { };
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
}
const isIronError = (error: unknown): error is IronError => { const isIronError = (error: unknown): error is IronError => {
return ( return (
@@ -113,9 +109,25 @@ export default function RdpClient({
const form = useForm<RdpCredentialsForm>({ const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_RDP_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<RdpCredentialsForm>(
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 [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false); const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
@@ -293,11 +305,11 @@ export default function RdpClient({
try { try {
const sessionInfo = await userInteraction.connect(builder.build()); const sessionInfo = await userInteraction.connect(builder.build());
try { void saveEncryptedLocalStorage(
localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); STORAGE_KEY,
} catch { values,
// ignore target.authToken
} );
setConnecting(false); setConnecting(false);
setShowLogin(false); setShowLogin(false);
userInteraction.setVisibility(true); userInteraction.setVisibility(true);

View File

@@ -32,6 +32,10 @@ import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type AuthTab = "password" | "privateKey"; type AuthTab = "password" | "privateKey";
@@ -48,15 +52,11 @@ type ConnectCredentials = {
certificate?: string; certificate?: string;
}; };
function loadStoredCredentials(key: string): SshCredentialsForm { const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
try { username: "",
const saved = localStorage.getItem(key); password: "",
if (saved) return JSON.parse(saved) as SshCredentialsForm; privateKey: ""
} catch { };
// ignore
}
return { username: "", password: "", privateKey: "" };
}
export default function SshClient({ export default function SshClient({
target, target,
@@ -86,9 +86,25 @@ export default function SshClient({
}); });
const form = useForm<SshCredentialsForm>({ const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_SSH_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<SshCredentialsForm>(
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<HTMLInputElement>) { function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -252,14 +268,11 @@ export default function SshClient({
}) })
); );
if (!override) { if (!override) {
try { void saveEncryptedLocalStorage(
localStorage.setItem( STORAGE_KEY,
STORAGE_KEY, form.getValues(),
JSON.stringify(form.getValues()) target.authToken
); );
} catch {
// ignore
}
} }
}; };
@@ -625,7 +638,7 @@ export default function SshClient({
{connected && ( {connected && (
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900"> <div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white"> {/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
@@ -633,7 +646,7 @@ export default function SshClient({
> >
{t("sshTerminate")} {t("sshTerminate")}
</Button> </Button>
</div> </div> */}
<div <div
ref={terminalRef} ref={terminalRef}
className="flex-1 overflow-hidden" className="flex-1 overflow-hidden"

View File

@@ -28,20 +28,18 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type VncCredentialsForm = { type VncCredentialsForm = {
password: string; password: string;
}; };
function loadStoredCredentials(key: string): VncCredentialsForm { const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
try { password: ""
const saved = localStorage.getItem(key); };
if (saved) return JSON.parse(saved) as VncCredentialsForm;
} catch {
// ignore
}
return { password: "" };
}
export default function VncClient({ export default function VncClient({
target, target,
@@ -62,9 +60,25 @@ export default function VncClient({
const form = useForm<VncCredentialsForm>({ const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_VNC_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<VncCredentialsForm>(
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 [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null); const rfbRef = useRef<any>(null);
@@ -132,11 +146,11 @@ export default function VncClient({
rfb.resizeSession = true; rfb.resizeSession = true;
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
try { void saveEncryptedLocalStorage(
localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); STORAGE_KEY,
} catch { values,
// ignore target.authToken
} );
setConnected(true); setConnected(true);
}); });

View File

@@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
export function BrowserGatewayTargetForm<T extends FieldValues>( export function BrowserGatewayTargetForm<T extends FieldValues>(
props: BrowserGatewayTargetFormProps<T> props: BrowserGatewayTargetFormProps<T>
) { ) {
// 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 <Input> 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 t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false); const [siteOpen, setSiteOpen] = useState(false);
const sitesFieldName = const sitesFieldName =
props.multiSite === true ? props.sitesField : props.siteField; props.multiSite === true ? props.sitesField : props.siteField;
// Subscribe to field values via useWatch and drive the controlled <Input>
// 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({ const watchedSites = useWatch({
control: props.control, control: props.control,
name: sitesFieldName name: sitesFieldName
}); });
const watchedDestination = useWatch({
control: props.control,
name: props.destinationField
});
const watchedDestinationPort = useWatch({
control: props.control,
name: props.destinationPortField
});
const showMultiSiteDisclaimer = const showMultiSiteDisclaimer =
props.multiSite === true && props.multiSite === true &&
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
@@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
<FormItem> <FormItem>
<FormLabel>{t("destination")}</FormLabel> <FormLabel>{t("destination")}</FormLabel>
<FormControl> <FormControl>
<Input {...field} value={field.value ?? ""} /> <Input
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestination as
| string
| undefined) ?? ""
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
type="number" type="number"
min={1} min={1}
max={65535} max={65535}
{...field} name={field.name}
value={field.value ?? ""} ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestinationPort as
| string
| number
| undefined) ?? ""
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -514,6 +514,16 @@ export default function SitesTable({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link

View File

@@ -123,7 +123,7 @@ export function PolicyAuthStackSectionCreate({
} }
allIdps={allIdps} allIdps={allIdps}
rolesEditor={ rolesEditor={
<FormField <FormField<PolicyFormValues, "roles">
control={parentForm.control} control={parentForm.control}
name="roles" name="roles"
render={({ field }) => ( render={({ field }) => (
@@ -146,7 +146,7 @@ export function PolicyAuthStackSectionCreate({
/> />
} }
usersEditor={ usersEditor={
<FormField <FormField<PolicyFormValues, "users">
control={parentForm.control} control={parentForm.control}
name="users" name="users"
render={({ field }) => ( render={({ field }) => (

View File

@@ -725,7 +725,8 @@ export function PolicyAuthStackSectionEdit({
user: headerAuth.user, user: headerAuth.user,
password: headerAuth.password, password: headerAuth.password,
extendedCompatibility: extendedCompatibility:
headerAuth.extendedCompatibility headerAuth.extendedCompatibility ??
true
} }
: undefined : undefined
} }

View File

@@ -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<T>(
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<T>(
storageKey: string,
authToken: string | null | undefined
): Promise<T | null> {
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;
}
}