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()
});
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 Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
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
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").notNull().default(100),
mode: varchar("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: varchar("authToken")
});
export const targetHealthCheck = pgTable("targetHealthCheck", {

View File

@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
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
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {

View File

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

View File

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

View File

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

View File

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

View File

@@ -268,8 +268,37 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is http, it must have a full-domain
if ((resource.mode ?? resource.protocol) === "http") {
const effectiveProtocol = resource.mode ?? resource.protocol;
if (effectiveProtocol !== "ssh") {
return true;
}
const authDaemonMode = resource["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
return (
resource.targets.filter((target) => target != null).length <= 1
);
},
{
path: ["targets"],
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
) {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
@@ -279,7 +308,7 @@ export const PublicResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', a 'full-domain' must be provided"
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
}
)
.refine(
@@ -506,7 +535,44 @@ export const PrivateResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
);
)
.refine(
(data) => {
if (data.mode !== "ssh") {
return true;
}
const authDaemonMode = data["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
const uniqueSites = new Set<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;
@@ -573,7 +639,7 @@ export const ConfigSchema = z
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
"resource-policies": z
"public-policies": z
.record(z.string(), ResourcePolicySchema)
.optional()
.prefault({}),
@@ -607,7 +673,7 @@ export const ConfigSchema = z
string,
z.infer<typeof PrivateResourceSchema>
>;
"resource-policies": Record<
"public-policies": Record<
string,
z.infer<typeof ResourcePolicySchema>
>;

View File

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

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

View File

@@ -11,9 +11,4 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";
export * from "./getBrowserTarget";

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 alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource";
@@ -879,48 +878,3 @@ authenticated.post(
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import {
verifyApiKeyHasAction,
@@ -216,43 +215,3 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

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

View File

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

View File

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

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 logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
@@ -244,23 +244,27 @@ export async function removeTargets(
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
targets: Target[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => {
// filter out the ones without auth tokens
const filteredTargets = targets.filter((t) => t.authToken);
if (filteredTargets.length === 0) return;
const payload = filteredTargets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken,
t.authToken!,
config.getRawConfig().server.secret!
);
return {
id: t.browserGatewayTargetId,
id: t.targetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
type: t.mode,
destination: t.ip,
destinationPort: t.port,
authToken: decryptAuthToken
};
});

View File

@@ -1,6 +1,5 @@
import {
alias,
browserGatewayTarget,
db,
labels,
resourceHeaderAuth,
@@ -639,15 +638,8 @@ export async function listResources(
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
const resourcesWithBrowserGateway = db
.select({ resourceId: browserGatewayTarget.resourceId })
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
conditions.push(
or(
inArray(resources.resourceId, resourcesWithSite),
inArray(resources.resourceId, resourcesWithBrowserGateway)
)
or(inArray(resources.resourceId, resourcesWithSite))
);
}
@@ -770,30 +762,6 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -856,21 +824,6 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}

View File

@@ -93,10 +93,9 @@ export async function deleteSite(
// Clean up all client associations and send peer/proxy removal
// messages in a single efficient pass before deleting the row.
await cleanupSiteAssociations(site, trx);
await trx.delete(sites).where(eq(sites.siteId, siteId));
}
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
});

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const deleteTargetSchema = z.strictObject({
targetId: z.coerce.number().int().positive()
@@ -136,14 +137,22 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
// [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck],
resource.mode === "udp" ? "udp" : "tcp",
newt.version
);
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
await removeTargets(
newt.newtId,
// [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck],
resource.mode === "udp" ? "udp" : "tcp",
newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
await removeBrowserGatewayTarget(
newt.newtId,
deletedTarget.targetId,
newt.version
);
}
}
}

View File

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

View File

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

View File

@@ -39,18 +39,6 @@ export default async function migration() {
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE "browserGatewayTarget" (
"browserGatewayTargetId" serial PRIMARY KEY NOT NULL,
"resourceId" integer NOT NULL,
"siteId" integer NOT NULL,
"authToken" varchar NOT NULL,
"type" varchar NOT NULL,
"destination" varchar NOT NULL,
"destinationPort" integer NOT NULL
);
`);
await db.execute(sql`
CREATE TABLE "clientLabels" (
"clientLabelId" serial PRIMARY KEY NOT NULL,
@@ -215,12 +203,6 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
);
@@ -289,6 +271,10 @@ export default async function migration() {
);
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
await db.execute(
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
);
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");

View File

@@ -40,22 +40,6 @@ export default async function migration() {
try {
db.transaction(() => {
db.prepare(
`
CREATE TABLE 'browserGatewayTarget' (
'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'resourceId' integer NOT NULL,
'siteId' integer NOT NULL,
'authToken' text NOT NULL,
'type' text NOT NULL,
'destination' text NOT NULL,
'destinationPort' integer NOT NULL,
FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE TABLE 'clientLabels' (
@@ -350,6 +334,16 @@ export default async function migration() {
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
`
).run();
db.prepare(
`
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
`
).run();
db.prepare(
`
ALTER TABLE 'targets' ADD 'authToken' text;
`
).run();
})();
const existingResources = db

View File

@@ -143,11 +143,6 @@ export function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
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) => {
if (dockerStates.has(siteId)) {
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(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -624,6 +583,8 @@ export function ProxyResourceTargetsForm({
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -725,7 +725,8 @@ export function PolicyAuthStackSectionEdit({
user: headerAuth.user,
password: headerAuth.password,
extendedCompatibility:
headerAuth.extendedCompatibility
headerAuth.extendedCompatibility ??
true
}
: 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;
}
}