mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-18 05:12:02 +00:00
Merge branch 'dev' into resource-policies-restyle
This commit is contained in:
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export type ClientResourcesResults = {
|
||||
oldSites: { siteId: number }[];
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
export async function updatePrivateResources(
|
||||
orgId: string,
|
||||
config: Config,
|
||||
trx: Transaction,
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
>;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -725,7 +725,8 @@ export function PolicyAuthStackSectionEdit({
|
||||
user: headerAuth.user,
|
||||
password: headerAuth.password,
|
||||
extendedCompatibility:
|
||||
headerAuth.extendedCompatibility
|
||||
headerAuth.extendedCompatibility ??
|
||||
true
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
124
src/lib/secureLocalStorage.ts
Normal file
124
src/lib/secureLocalStorage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user