mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-25 08:41:55 +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()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: varchar("authToken").notNull(),
|
|
||||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: varchar("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -290,7 +290,12 @@ export const targets = pgTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100)
|
priority: integer("priority").notNull().default(100),
|
||||||
|
mode: varchar("mode")
|
||||||
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
|
.notNull()
|
||||||
|
.default("http"),
|
||||||
|
authToken: varchar("authToken")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||||
|
|||||||
@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: text("authToken").notNull(),
|
|
||||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: text("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100)
|
priority: integer("priority").notNull().default(100),
|
||||||
|
mode: text("mode")
|
||||||
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
|
.notNull()
|
||||||
|
.default("http"),
|
||||||
|
authToken: text("authToken")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
|
|||||||
@@ -10,16 +10,22 @@ import {
|
|||||||
clientSiteResources
|
clientSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { Config, ConfigSchema } from "./types";
|
import { Config, ConfigSchema } from "./types";
|
||||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
import {
|
||||||
|
PublicResourcesResults,
|
||||||
|
updatePublicResources
|
||||||
|
} from "./publicResources";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, isNotNull } from "drizzle-orm";
|
import { eq, and, isNotNull } from "drizzle-orm";
|
||||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
import {
|
||||||
|
addTargets as addProxyTargets,
|
||||||
|
sendBrowserGatewayTargets
|
||||||
|
} from "@server/routers/newt/targets";
|
||||||
import {
|
import {
|
||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updateClientResources
|
updatePrivateResources
|
||||||
} from "./clientResources";
|
} from "./privateResources";
|
||||||
import { updateResourcePolicies } from "./resourcePolicies";
|
import { updateResourcePolicies } from "./resourcePolicies";
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
@@ -54,18 +60,18 @@ export async function applyBlueprint({
|
|||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
let proxyResourcesResults: PublicResourcesResults = [];
|
||||||
let clientResourcesResults: ClientResourcesResults = [];
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await updateResourcePolicies(orgId, config, trx);
|
await updateResourcePolicies(orgId, config, trx);
|
||||||
|
|
||||||
proxyResourcesResults = await updateProxyResources(
|
proxyResourcesResults = await updatePublicResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
clientResourcesResults = await updateClientResources(
|
clientResourcesResults = await updatePrivateResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
@@ -104,13 +110,27 @@ export async function applyBlueprint({
|
|||||||
(hc) => hc.targetId === target.targetId
|
(hc) => hc.targetId === target.targetId
|
||||||
);
|
);
|
||||||
|
|
||||||
await addProxyTargets(
|
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||||
site.newt.newtId,
|
await addProxyTargets(
|
||||||
[target],
|
site.newt.newtId,
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
[target],
|
||||||
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
matchingHealthcheck
|
||||||
site.newt.version
|
? [matchingHealthcheck]
|
||||||
);
|
: [],
|
||||||
|
result.proxyResource.mode === "udp"
|
||||||
|
? "udp"
|
||||||
|
: "tcp",
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||||
|
) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
site.newt.newtId,
|
||||||
|
[target],
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export type ClientResourcesResults = {
|
|||||||
oldSites: { siteId: number }[];
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateClientResources(
|
export async function updatePrivateResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
@@ -48,20 +48,23 @@ import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
|||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import serverConfig from "@server/lib/config";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type PublicResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
targetsToUpdate: Target[];
|
targetsToUpdate: Target[];
|
||||||
healthchecksToUpdate: TargetHealthCheck[];
|
healthchecksToUpdate: TargetHealthCheck[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateProxyResources(
|
export async function updatePublicResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
siteId?: number
|
siteId?: number
|
||||||
): Promise<ProxyResourcesResults> {
|
): Promise<PublicResourcesResults> {
|
||||||
const results: ProxyResourcesResults = [];
|
const results: PublicResourcesResults = [];
|
||||||
|
|
||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["proxy-resources"]
|
config["proxy-resources"]
|
||||||
@@ -80,7 +83,7 @@ export async function updateProxyResources(
|
|||||||
if (targetSiteId) {
|
if (targetSiteId) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, type: sites.type })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -92,7 +95,7 @@ export async function updateProxyResources(
|
|||||||
} else if (siteId) {
|
} else if (siteId) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, type: sites.type })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
@@ -119,6 +122,15 @@ export async function updateProxyResources(
|
|||||||
internalPortToCreate = targetData["internal-port"];
|
internalPortToCreate = targetData["internal-port"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authToken: string | undefined;
|
||||||
|
if (site.type !== "local") {
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
authToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
serverConfig.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create target
|
// Create target
|
||||||
const [newTarget] = await trx
|
const [newTarget] = await trx
|
||||||
.insert(targets)
|
.insert(targets)
|
||||||
@@ -126,10 +138,12 @@ export async function updateProxyResources(
|
|||||||
resourceId: resourceId,
|
resourceId: resourceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
ip: targetData.hostname,
|
ip: targetData.hostname,
|
||||||
|
mode: resourceData.mode as Target["mode"],
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
enabled: targetData.enabled,
|
enabled: targetData.enabled,
|
||||||
internalPort: internalPortToCreate,
|
internalPort: internalPortToCreate,
|
||||||
|
authToken: authToken,
|
||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData["path-match"],
|
pathMatchType: targetData["path-match"],
|
||||||
rewritePath:
|
rewritePath:
|
||||||
@@ -565,6 +579,13 @@ export async function updateProxyResources(
|
|||||||
? (resourceData["proxy-protocol-version"] ??
|
? (resourceData["proxy-protocol-version"] ??
|
||||||
1)
|
1)
|
||||||
: 1,
|
: 1,
|
||||||
|
pamMode:
|
||||||
|
resourceData["auth-daemon"]?.pam ||
|
||||||
|
"passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort:
|
||||||
|
resourceData["auth-daemon"]?.port || 22123,
|
||||||
resourcePolicyId: null,
|
resourcePolicyId: null,
|
||||||
defaultResourcePolicyId: inlinePolicyId
|
defaultResourcePolicyId: inlinePolicyId
|
||||||
})
|
})
|
||||||
@@ -707,7 +728,8 @@ export async function updateProxyResources(
|
|||||||
? "/"
|
? "/"
|
||||||
: undefined),
|
: undefined),
|
||||||
rewritePathType: targetData["rewrite-match"],
|
rewritePathType: targetData["rewrite-match"],
|
||||||
priority: targetData.priority
|
priority: targetData.priority,
|
||||||
|
mode: resourceData.mode
|
||||||
})
|
})
|
||||||
.where(eq(targets.targetId, existingTarget.targetId))
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -37,7 +37,7 @@ export async function updateResourcePolicies(
|
|||||||
const results: ResourcePoliciesResults = [];
|
const results: ResourcePoliciesResults = [];
|
||||||
|
|
||||||
for (const [policyNiceId, policyData] of Object.entries(
|
for (const [policyNiceId, policyData] of Object.entries(
|
||||||
config["resource-policies"]
|
config["public-policies"]
|
||||||
)) {
|
)) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
orgId,
|
orgId,
|
||||||
|
|||||||
@@ -268,8 +268,37 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is http, it must have a full-domain
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
if ((resource.mode ?? resource.protocol) === "http") {
|
if (effectiveProtocol !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = resource["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
resource.targets.filter((target) => target != null).length <= 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["targets"],
|
||||||
|
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||||
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
|
if (
|
||||||
|
effectiveProtocol !== undefined &&
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -279,7 +308,7 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', a 'full-domain' must be provided"
|
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -506,7 +535,44 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = data["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueSites = new Set<string>();
|
||||||
|
if (data.site) {
|
||||||
|
uniqueSites.add(data.site);
|
||||||
|
}
|
||||||
|
for (const site of data.sites) {
|
||||||
|
uniqueSites.add(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSites.size <= 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["sites"],
|
||||||
|
message:
|
||||||
|
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transform((data) => {
|
||||||
|
if (
|
||||||
|
data.mode === "ssh" &&
|
||||||
|
data.destination !== undefined &&
|
||||||
|
data["destination-port"] === undefined
|
||||||
|
) {
|
||||||
|
data["destination-port"] = 22;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
export const ResourcePolicyRuleSchema = RuleSchema;
|
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||||
|
|
||||||
@@ -573,7 +639,7 @@ export const ConfigSchema = z
|
|||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"resource-policies": z
|
"public-policies": z
|
||||||
.record(z.string(), ResourcePolicySchema)
|
.record(z.string(), ResourcePolicySchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
@@ -607,7 +673,7 @@ export const ConfigSchema = z
|
|||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
"resource-policies": Record<
|
"public-policies": Record<
|
||||||
string,
|
string,
|
||||||
z.infer<typeof ResourcePolicySchema>
|
z.infer<typeof ResourcePolicySchema>
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
@@ -172,8 +171,15 @@ export async function getTraefikConfig(
|
|||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
allowRawResources
|
allowRawResources
|
||||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
? inArray(resources.mode, [
|
||||||
: eq(resources.mode, "http")
|
"http",
|
||||||
|
"udp",
|
||||||
|
"tcp",
|
||||||
|
"vnc",
|
||||||
|
"ssh",
|
||||||
|
"rdp"
|
||||||
|
]) // allow all three
|
||||||
|
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
@@ -181,7 +187,10 @@ export async function getTraefikConfig(
|
|||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
for (const row of resourcesWithTargetsAndSites) {
|
||||||
|
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
@@ -191,7 +200,7 @@ export async function getTraefikConfig(
|
|||||||
const priority = row.priority ?? 100;
|
const priority = row.priority ?? 100;
|
||||||
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique key combining resourceId, path config, and rewrite config
|
// Create a unique key combining resourceId, path config, and rewrite config
|
||||||
@@ -218,7 +227,7 @@ export async function getTraefikConfig(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
|
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
|
||||||
);
|
);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(mapKey, {
|
||||||
@@ -275,7 +284,7 @@ export async function getTraefikConfig(
|
|||||||
online: row.siteOnline
|
online: row.siteOnline
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Group browser gateway targets by resource
|
// Group browser gateway targets by resource
|
||||||
type BrowserGatewayResourceEntry = {
|
type BrowserGatewayResourceEntry = {
|
||||||
@@ -295,13 +304,12 @@ export async function getTraefikConfig(
|
|||||||
maintenanceMessage: string | null;
|
maintenanceMessage: string | null;
|
||||||
maintenanceEstimatedTime: string | null;
|
maintenanceEstimatedTime: string | null;
|
||||||
targets: {
|
targets: {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
bgType: string;
|
bgType: string;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteType: string;
|
siteType: string;
|
||||||
siteOnline: boolean | null;
|
siteOnline: boolean | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
siteExitNodeId: number | null;
|
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
const browserGatewayResourcesMap = new Map<
|
const browserGatewayResourcesMap = new Map<
|
||||||
@@ -310,66 +318,10 @@ export async function getTraefikConfig(
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
if (allowBrowserGatewayResources) {
|
if (allowBrowserGatewayResources) {
|
||||||
// Query browser gateway targets for this exit node
|
for (const row of resourcesWithTargetsAndSites) {
|
||||||
const browserGatewayRows = await db
|
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
|
||||||
.select({
|
continue;
|
||||||
// Resource fields
|
}
|
||||||
resourceId: resources.resourceId,
|
|
||||||
resourceName: resources.name,
|
|
||||||
fullDomain: resources.fullDomain,
|
|
||||||
ssl: resources.ssl,
|
|
||||||
subdomain: resources.subdomain,
|
|
||||||
domainId: resources.domainId,
|
|
||||||
enabled: resources.enabled,
|
|
||||||
wildcard: resources.wildcard,
|
|
||||||
domainCertResolver: domains.certResolver,
|
|
||||||
preferWildcardCert: domains.preferWildcardCert,
|
|
||||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
|
||||||
// Maintenance fields
|
|
||||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
|
||||||
maintenanceModeType: resources.maintenanceModeType,
|
|
||||||
maintenanceTitle: resources.maintenanceTitle,
|
|
||||||
maintenanceMessage: resources.maintenanceMessage,
|
|
||||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
|
||||||
// Browser gateway target fields
|
|
||||||
browserGatewayTargetId:
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
bgType: browserGatewayTarget.type,
|
|
||||||
// Site fields
|
|
||||||
siteId: sites.siteId,
|
|
||||||
siteType: sites.type,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
subnet: sites.subnet,
|
|
||||||
siteExitNodeId: sites.exitNodeId
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
|
||||||
.leftJoin(
|
|
||||||
domainNamespaces,
|
|
||||||
eq(domainNamespaces.domainId, resources.domainId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.enabled, true),
|
|
||||||
or(
|
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
|
||||||
and(
|
|
||||||
isNull(sites.exitNodeId),
|
|
||||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
|
||||||
eq(sites.type, "local"),
|
|
||||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
inArray(sites.type, siteTypes)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of browserGatewayRows) {
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -394,13 +346,12 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
targetId: row.targetId,
|
||||||
bgType: row.bgType,
|
bgType: row.mode,
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
siteType: row.siteType,
|
siteType: row.siteType,
|
||||||
siteOnline: row.siteOnline,
|
siteOnline: row.siteOnline,
|
||||||
subnet: row.subnet,
|
subnet: row.subnet
|
||||||
siteExitNodeId: row.siteExitNodeId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { browserGatewayTarget, db } from "@server/db";
|
import { db, resources, targets } from "@server/db";
|
||||||
import { resources, targets } from "@server/db";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -51,11 +50,11 @@ export async function getBrowserTarget(
|
|||||||
|
|
||||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||||
|
|
||||||
const [browserTarget] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
destination: browserGatewayTarget.destination,
|
ip: targets.ip,
|
||||||
destinationPort: browserGatewayTarget.destinationPort,
|
port: targets.port,
|
||||||
authToken: browserGatewayTarget.authToken,
|
authToken: targets.authToken,
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
@@ -63,20 +62,18 @@ export async function getBrowserTarget(
|
|||||||
pamMode: resources.pamMode,
|
pamMode: resources.pamMode,
|
||||||
authDaemonMode: resources.authDaemonMode
|
authDaemonMode: resources.authDaemonMode
|
||||||
})
|
})
|
||||||
.from(browserGatewayTarget)
|
.from(targets)
|
||||||
.innerJoin(
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
resources,
|
.where(
|
||||||
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
and(
|
||||||
|
eq(resources.fullDomain, fullDomain),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.where(eq(resources.fullDomain, fullDomain))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const decryptedAuthToken = decrypt(
|
if (!row) {
|
||||||
browserTarget.authToken,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!browserTarget) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -85,17 +82,21 @@ export async function getBrowserTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decryptedAuthToken = row.authToken
|
||||||
|
? decrypt(row.authToken, config.getRawConfig().server.secret!)
|
||||||
|
: "";
|
||||||
|
|
||||||
return response<GetBrowserTargetResponse>(res, {
|
return response<GetBrowserTargetResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
ip: browserTarget.destination,
|
ip: row.ip,
|
||||||
port: browserTarget.destinationPort,
|
port: row.port,
|
||||||
authToken: decryptedAuthToken,
|
authToken: decryptedAuthToken,
|
||||||
pamMode: browserTarget.pamMode,
|
pamMode: row.pamMode,
|
||||||
authDaemonMode: browserTarget.authDaemonMode,
|
authDaemonMode: row.authDaemonMode,
|
||||||
orgId: browserTarget.orgId,
|
orgId: row.orgId,
|
||||||
resourceId: browserTarget.resourceId,
|
resourceId: row.resourceId,
|
||||||
niceId: browserTarget.niceId,
|
niceId: row.niceId,
|
||||||
name: browserTarget.name
|
name: row.name ?? ""
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -11,9 +11,4 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./createBrowserGatewayTarget";
|
|
||||||
export * from "./updateBrowserGatewayTarget";
|
|
||||||
export * from "./deleteBrowserGatewayTarget";
|
|
||||||
export * from "./getBrowserGatewayTarget";
|
|
||||||
export * from "./listBrowserGatewayTargets";
|
|
||||||
export * from "./getBrowserTarget";
|
export * from "./getBrowserTarget";
|
||||||
|
|||||||
@@ -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 eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
@@ -879,48 +878,3 @@ authenticated.post(
|
|||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
client.rebuildClientAssociationsCacheRoute
|
client.rebuildClientAssociationsCacheRoute
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.updateBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as alertEvents from "#private/routers/alertEvents";
|
import * as alertEvents from "#private/routers/alertEvents";
|
||||||
import * as certificates from "#private/routers/certificates";
|
import * as certificates from "#private/routers/certificates";
|
||||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -216,43 +215,3 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.removeUserRole),
|
logActionAudit(ActionsEnum.removeUserRole),
|
||||||
user.removeUserRole
|
user.removeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.updateBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp";
|
|||||||
import * as billing from "#private/routers/billing";
|
import * as billing from "#private/routers/billing";
|
||||||
import * as license from "#private/routers/license";
|
import * as license from "#private/routers/license";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
import * as ssh from "#private/routers/ssh";
|
import * as ssh from "#private/routers/ssh";
|
||||||
import * as ws from "@server/routers/ws";
|
import * as ws from "@server/routers/ws";
|
||||||
|
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifySessionUserMiddleware,
|
verifySessionUserMiddleware,
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ import {
|
|||||||
userOrgs,
|
userOrgs,
|
||||||
sites,
|
sites,
|
||||||
Resource,
|
Resource,
|
||||||
SiteResource,
|
SiteResource
|
||||||
browserGatewayTarget
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||||
@@ -291,16 +290,15 @@ export async function signSshKey(
|
|||||||
const publicResource = resource as Resource;
|
const publicResource = resource as Resource;
|
||||||
const targetRows = await db
|
const targetRows = await db
|
||||||
.select({
|
.select({
|
||||||
siteId: browserGatewayTarget.siteId,
|
siteId: targets.siteId,
|
||||||
ip: browserGatewayTarget.destination
|
ip: targets.ip
|
||||||
})
|
})
|
||||||
.from(browserGatewayTarget)
|
.from(targets)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(targets.resourceId, publicResource.resourceId),
|
||||||
browserGatewayTarget.resourceId,
|
eq(targets.enabled, true),
|
||||||
publicResource.resourceId
|
eq(targets.mode, "ssh")
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
clients,
|
clients,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
@@ -16,7 +14,7 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
@@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targets.siteId, siteId),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["http", "udp", "tcp"])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const allHealthChecks = await db
|
const allHealthChecks = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(eq(targetHealthCheck.siteId, siteId));
|
.where(eq(targetHealthCheck.siteId, siteId));
|
||||||
|
|
||||||
|
// Get all enabled targets with their resource mode information
|
||||||
const allBrowserGatewayTargets = await db
|
const allBrowserGatewayTargets = await db
|
||||||
.select()
|
.select({
|
||||||
.from(browserGatewayTarget)
|
resourceId: targets.resourceId,
|
||||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
targetId: targets.targetId,
|
||||||
|
ip: targets.ip,
|
||||||
|
method: targets.method,
|
||||||
|
port: targets.port,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
mode: resources.mode,
|
||||||
|
authToken: targets.authToken
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targets.siteId, siteId),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
(acc, target) => {
|
(acc, target) => {
|
||||||
@@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
|
|
||||||
const serverSecret = config.getRawConfig().server.secret!;
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||||
|
if (!t.ip || !t.port || !t.authToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||||
return {
|
return {
|
||||||
id: t.browserGatewayTargetId,
|
id: t.targetId,
|
||||||
type: t.type,
|
type: t.mode,
|
||||||
destination: t.destination,
|
destination: t.ip,
|
||||||
destinationPort: t.destinationPort,
|
destinationPort: t.port,
|
||||||
authToken: decryptAuthToken
|
authToken: decryptAuthToken
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
import { Target, TargetHealthCheck } from "@server/db";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
@@ -244,23 +244,27 @@ export async function removeTargets(
|
|||||||
|
|
||||||
export async function sendBrowserGatewayTargets(
|
export async function sendBrowserGatewayTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: BrowserGatewayTarget[],
|
targets: Target[],
|
||||||
version?: string | null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (targets.length === 0) return;
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
const payload = targets.map((t) => {
|
// filter out the ones without auth tokens
|
||||||
|
const filteredTargets = targets.filter((t) => t.authToken);
|
||||||
|
if (filteredTargets.length === 0) return;
|
||||||
|
|
||||||
|
const payload = filteredTargets.map((t) => {
|
||||||
const decryptAuthToken = decrypt(
|
const decryptAuthToken = decrypt(
|
||||||
t.authToken,
|
t.authToken!,
|
||||||
config.getRawConfig().server.secret!
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: t.browserGatewayTargetId,
|
id: t.targetId,
|
||||||
resourceId: t.resourceId,
|
resourceId: t.resourceId,
|
||||||
siteId: t.siteId,
|
siteId: t.siteId,
|
||||||
type: t.type,
|
type: t.mode,
|
||||||
destination: t.destination,
|
destination: t.ip,
|
||||||
destinationPort: t.destinationPort,
|
destinationPort: t.port,
|
||||||
authToken: decryptAuthToken
|
authToken: decryptAuthToken
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
alias,
|
alias,
|
||||||
browserGatewayTarget,
|
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
@@ -639,15 +638,8 @@ export async function listResources(
|
|||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
const resourcesWithBrowserGateway = db
|
|
||||||
.select({ resourceId: browserGatewayTarget.resourceId })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(inArray(resources.resourceId, resourcesWithSite))
|
||||||
inArray(resources.resourceId, resourcesWithSite),
|
|
||||||
inArray(resources.resourceId, resourcesWithBrowserGateway)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,30 +762,6 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||||
|
|
||||||
const allBgTargetSites =
|
|
||||||
resourceIdList.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
resourceId: browserGatewayTarget.resourceId,
|
|
||||||
siteId: browserGatewayTarget.siteId,
|
|
||||||
siteName: sites.name,
|
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
siteType: sites.type
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
browserGatewayTarget.resourceId,
|
|
||||||
resourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sites,
|
|
||||||
eq(sites.siteId, browserGatewayTarget.siteId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
|
|
||||||
@@ -856,21 +824,6 @@ export async function listResources(
|
|||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const bgRaw = allBgTargetSites.filter(
|
|
||||||
(t) => t.resourceId === entry.resourceId
|
|
||||||
);
|
|
||||||
for (const t of bgRaw) {
|
|
||||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isLocal = t.siteType === "local";
|
|
||||||
siteById.set(t.siteId, {
|
|
||||||
siteId: t.siteId,
|
|
||||||
siteName: t.siteName ?? "",
|
|
||||||
siteNiceId: t.siteNiceId ?? "",
|
|
||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,9 @@ export async function deleteSite(
|
|||||||
// Clean up all client associations and send peer/proxy removal
|
// Clean up all client associations and send peer/proxy removal
|
||||||
// messages in a single efficient pass before deleting the row.
|
// messages in a single efficient pass before deleting the row.
|
||||||
await cleanupSiteAssociations(site, trx);
|
await cleanupSiteAssociations(site, trx);
|
||||||
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
userSites,
|
userSites,
|
||||||
labels,
|
labels,
|
||||||
siteLabels,
|
siteLabels,
|
||||||
browserGatewayTarget,
|
|
||||||
type Label
|
type Label
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
@@ -241,10 +240,6 @@ function querySitesBase() {
|
|||||||
ON ${siteResources.networkId} = ${siteNetworks.networkId}
|
ON ${siteResources.networkId} = ${siteNetworks.networkId}
|
||||||
WHERE ${siteNetworks.siteId} = ${sites.siteId}
|
WHERE ${siteNetworks.siteId} = ${sites.siteId}
|
||||||
AND ${siteResources.orgId} = ${sites.orgId}
|
AND ${siteResources.orgId} = ${sites.orgId}
|
||||||
) + (
|
|
||||||
SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId})
|
|
||||||
FROM ${browserGatewayTarget}
|
|
||||||
WHERE ${browserGatewayTarget.siteId} = ${sites.siteId}
|
|
||||||
)`,
|
)`,
|
||||||
status: sites.status
|
status: sites.status
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
fireHealthCheckUnhealthyAlert,
|
fireHealthCheckUnhealthyAlert,
|
||||||
fireHealthCheckUnknownAlert
|
fireHealthCheckUnknownAlert
|
||||||
} from "@server/lib/alerts";
|
} from "@server/lib/alerts";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const createTargetParamsSchema = z.strictObject({
|
const createTargetParamsSchema = z.strictObject({
|
||||||
resourceId: z.coerce.number().int().positive()
|
resourceId: z.coerce.number().int().positive()
|
||||||
@@ -32,6 +36,7 @@ const createTargetParamsSchema = z.strictObject({
|
|||||||
const createTargetSchema = z.strictObject({
|
const createTargetSchema = z.strictObject({
|
||||||
siteId: z.int().positive(),
|
siteId: z.int().positive(),
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
|
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||||
method: z.string().optional().nullable(),
|
method: z.string().optional().nullable(),
|
||||||
port: z.int().min(1).max(65535),
|
port: z.int().min(1).max(65535),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
@@ -161,6 +166,12 @@ export async function createTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
const encryptedToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
let newTarget: Target[] = [];
|
let newTarget: Target[] = [];
|
||||||
let targetIps: string[] = [];
|
let targetIps: string[] = [];
|
||||||
let healthCheck: TargetHealthCheck[] = [];
|
let healthCheck: TargetHealthCheck[] = [];
|
||||||
@@ -191,6 +202,9 @@ export async function createTarget(
|
|||||||
.values({
|
.values({
|
||||||
resourceId,
|
resourceId,
|
||||||
...targetData,
|
...targetData,
|
||||||
|
mode: (targetData.mode ??
|
||||||
|
resource.mode ??
|
||||||
|
"http") as Target["mode"],
|
||||||
priority: targetData.priority || 100
|
priority: targetData.priority || 100
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -226,6 +240,10 @@ export async function createTarget(
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
ip: targetData.ip,
|
ip: targetData.ip,
|
||||||
|
mode: (targetData.mode ??
|
||||||
|
resource.mode ??
|
||||||
|
"http") as Target["mode"],
|
||||||
|
authToken: encryptedToken,
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
internalPort,
|
internalPort,
|
||||||
@@ -325,13 +343,21 @@ export async function createTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await addTargets(
|
if (["http", "tcp", "udp"].includes(newTarget[0].mode)) {
|
||||||
newt.newtId,
|
await addTargets(
|
||||||
newTarget,
|
newt.newtId,
|
||||||
healthCheck,
|
newTarget,
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
healthCheck,
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
newTarget,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { removeTargets } from "../newt/targets";
|
import { removeTargets } from "../newt/targets";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { targetHealthCheck } from "@server/db";
|
import { targetHealthCheck } from "@server/db";
|
||||||
|
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const deleteTargetSchema = z.strictObject({
|
const deleteTargetSchema = z.strictObject({
|
||||||
targetId: z.coerce.number().int().positive()
|
targetId: z.coerce.number().int().positive()
|
||||||
@@ -136,14 +137,22 @@ export async function deleteTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await removeTargets(
|
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
|
||||||
newt.newtId,
|
await removeTargets(
|
||||||
// [deletedTarget],
|
newt.newtId,
|
||||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
// [deletedTarget],
|
||||||
[deletedHealthCheck],
|
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
[deletedHealthCheck],
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
|
||||||
|
await removeBrowserGatewayTarget(
|
||||||
|
newt.newtId,
|
||||||
|
deletedTarget.targetId,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function queryTargets(resourceId: number) {
|
|||||||
.select({
|
.select({
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
ip: targets.ip,
|
ip: targets.ip,
|
||||||
|
mode: targets.mode,
|
||||||
method: targets.method,
|
method: targets.method,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const updateTargetParamsSchema = z.strictObject({
|
const updateTargetParamsSchema = z.strictObject({
|
||||||
targetId: z.coerce.number().int().positive()
|
targetId: z.coerce.number().int().positive()
|
||||||
@@ -27,6 +28,10 @@ const updateTargetBodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
siteId: z.int().positive(),
|
siteId: z.int().positive(),
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
|
mode: z
|
||||||
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
method: z.string().min(1).max(10).optional().nullable(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.int().min(1).max(65535).optional(),
|
port: z.int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -184,6 +189,8 @@ export async function updateTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
||||||
|
const nextMode =
|
||||||
|
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
|
||||||
|
|
||||||
let updatedTarget: any;
|
let updatedTarget: any;
|
||||||
let updatedHc: any;
|
let updatedHc: any;
|
||||||
@@ -193,6 +200,7 @@ export async function updateTarget(
|
|||||||
.set({
|
.set({
|
||||||
siteId: parsedBody.data.siteId,
|
siteId: parsedBody.data.siteId,
|
||||||
ip: parsedBody.data.ip,
|
ip: parsedBody.data.ip,
|
||||||
|
mode: nextMode,
|
||||||
method: parsedBody.data.method,
|
method: parsedBody.data.method,
|
||||||
port: parsedBody.data.port,
|
port: parsedBody.data.port,
|
||||||
internalPort,
|
internalPort,
|
||||||
@@ -343,13 +351,21 @@ export async function updateTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await addTargets(
|
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
|
||||||
newt.newtId,
|
await addTargets(
|
||||||
[updatedTarget],
|
newt.newtId,
|
||||||
[updatedHc],
|
[updatedTarget],
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
[updatedHc],
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[updatedTarget],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ export default async function migration() {
|
|||||||
try {
|
try {
|
||||||
await db.execute(sql`BEGIN`);
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TABLE "browserGatewayTarget" (
|
|
||||||
"browserGatewayTargetId" serial PRIMARY KEY NOT NULL,
|
|
||||||
"resourceId" integer NOT NULL,
|
|
||||||
"siteId" integer NOT NULL,
|
|
||||||
"authToken" varchar NOT NULL,
|
|
||||||
"type" varchar NOT NULL,
|
|
||||||
"destination" varchar NOT NULL,
|
|
||||||
"destinationPort" integer NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TABLE "clientLabels" (
|
CREATE TABLE "clientLabels" (
|
||||||
"clientLabelId" serial PRIMARY KEY NOT NULL,
|
"clientLabelId" serial PRIMARY KEY NOT NULL,
|
||||||
@@ -215,12 +203,6 @@ export default async function migration() {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
|
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
|
||||||
);
|
);
|
||||||
await db.execute(
|
|
||||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
|
|
||||||
);
|
|
||||||
await db.execute(
|
|
||||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;`
|
|
||||||
);
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
|
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
|
||||||
);
|
);
|
||||||
@@ -289,6 +271,10 @@ export default async function migration() {
|
|||||||
);
|
);
|
||||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
|
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
|
||||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
|
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
|
||||||
|
);
|
||||||
|
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
|
||||||
|
|
||||||
await db.execute(sql`COMMIT`);
|
await db.execute(sql`COMMIT`);
|
||||||
console.log("Migrated database");
|
console.log("Migrated database");
|
||||||
|
|||||||
@@ -40,22 +40,6 @@ export default async function migration() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(
|
|
||||||
`
|
|
||||||
CREATE TABLE 'browserGatewayTarget' (
|
|
||||||
'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
'resourceId' integer NOT NULL,
|
|
||||||
'siteId' integer NOT NULL,
|
|
||||||
'authToken' text NOT NULL,
|
|
||||||
'type' text NOT NULL,
|
|
||||||
'destination' text NOT NULL,
|
|
||||||
'destinationPort' integer NOT NULL,
|
|
||||||
FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
CREATE TABLE 'clientLabels' (
|
CREATE TABLE 'clientLabels' (
|
||||||
@@ -350,6 +334,16 @@ export default async function migration() {
|
|||||||
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
||||||
`
|
`
|
||||||
).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'targets' ADD 'authToken' text;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const existingResources = db
|
const existingResources = db
|
||||||
|
|||||||
@@ -143,11 +143,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
|
|
||||||
const [bgDestination, setBgDestination] = useState("");
|
|
||||||
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
|
||||||
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
|
||||||
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
return;
|
return;
|
||||||
@@ -212,42 +207,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Browser-gateway targets (edit mode only)
|
|
||||||
const { data: bgTargetsResponse } = useQuery({
|
|
||||||
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await api.get(
|
|
||||||
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
|
|
||||||
);
|
|
||||||
return res.data.data as {
|
|
||||||
targets: Array<{
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
resourceId: number;
|
|
||||||
siteId: number;
|
|
||||||
type: string;
|
|
||||||
destination: string;
|
|
||||||
destinationPort: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!resource
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bgTargetsResponse?.targets?.length) return;
|
|
||||||
const bgt = bgTargetsResponse.targets[0];
|
|
||||||
setBgDestination(bgt.destination);
|
|
||||||
setBgDestinationPort(String(bgt.destinationPort));
|
|
||||||
setBgSiteId(bgt.siteId);
|
|
||||||
setBgTargetId(bgt.browserGatewayTargetId);
|
|
||||||
}, [bgTargetsResponse]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sites.length > 0 && bgSiteId === null) {
|
|
||||||
setBgSiteId(sites[0].siteId);
|
|
||||||
}
|
|
||||||
}, [sites, bgSiteId]);
|
|
||||||
|
|
||||||
const updateTarget = useCallback(
|
const updateTarget = useCallback(
|
||||||
(targetId: number, data: Partial<LocalTarget>) => {
|
(targetId: number, data: Partial<LocalTarget>) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
@@ -624,6 +583,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
targetId: -Date.now(),
|
targetId: -Date.now(),
|
||||||
ip: "",
|
ip: "",
|
||||||
|
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
||||||
|
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
||||||
method: isHttp ? "http" : null,
|
method: isHttp ? "http" : null,
|
||||||
port: 0,
|
port: 0,
|
||||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||||
|
|||||||
@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
|
|||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
type ExistingTarget = {
|
type ExistingTarget = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTarget = {
|
type TargetRow = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
type: string;
|
mode: string | null;
|
||||||
destination: string;
|
ip: string;
|
||||||
destinationPort: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTargetsResponse = {
|
type ResourceTargetsResponse = {
|
||||||
targets: BgTarget[];
|
targets: TargetRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RdpSettingsPage(props: {
|
export default function RdpSettingsPage(props: {
|
||||||
@@ -61,13 +61,11 @@ export default function RdpSettingsPage(props: {
|
|||||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
|
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||||
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
|
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get(
|
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||||
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
return res.data.data as ResourceTargetsResponse;
|
||||||
);
|
|
||||||
return res.data.data as BgTargetsResponse;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ export default function RdpSettingsPage(props: {
|
|||||||
resource={resource}
|
resource={resource}
|
||||||
updateResource={updateResource}
|
updateResource={updateResource}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
|
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||||
/>
|
/>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
@@ -95,18 +93,18 @@ function RdpServerForm({
|
|||||||
orgId,
|
orgId,
|
||||||
resource,
|
resource,
|
||||||
disabled,
|
disabled,
|
||||||
bgTargetsResponse
|
targetsResponse
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
updateResource: ResourceContextType["updateResource"];
|
updateResource: ResourceContextType["updateResource"];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
bgTargetsResponse: BgTargetsResponse;
|
targetsResponse: ResourceTargetsResponse;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const targets = bgTargetsResponse.targets;
|
const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
|
||||||
const firstTarget = targets[0];
|
const firstTarget = targets[0];
|
||||||
|
|
||||||
const formSchema = useMemo(
|
const formSchema = useMemo(
|
||||||
@@ -122,17 +120,15 @@ function RdpServerForm({
|
|||||||
name: target.siteName ?? String(target.siteId),
|
name: target.siteName ?? String(target.siteId),
|
||||||
type: "newt" as const
|
type: "newt" as const
|
||||||
})),
|
})),
|
||||||
destination: firstTarget?.destination ?? "",
|
destination: firstTarget?.ip ?? "",
|
||||||
destinationPort: firstTarget
|
destinationPort: firstTarget ? String(firstTarget.port) : "3389"
|
||||||
? String(firstTarget.destinationPort)
|
|
||||||
: "3389"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
() =>
|
() =>
|
||||||
targets.map((target) => ({
|
targets.map((target) => ({
|
||||||
browserGatewayTargetId: target.browserGatewayTargetId,
|
targetId: target.targetId,
|
||||||
siteId: target.siteId
|
siteId: target.siteId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -155,28 +151,20 @@ function RdpServerForm({
|
|||||||
const toDelete = existingTargets.filter(
|
const toDelete = existingTargets.filter(
|
||||||
(t) => !selectedSiteIds.has(t.siteId)
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
|
||||||
toDelete.map((t) =>
|
|
||||||
api.delete(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toUpdate = existingTargets.filter((t) =>
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
selectedSiteIds.has(t.siteId)
|
selectedSiteIds.has(t.siteId)
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toUpdate.map((t) =>
|
toUpdate.map((t) =>
|
||||||
api.post(
|
api.post(`/target/${t.targetId}`, {
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
mode: "rdp",
|
||||||
{
|
ip: destination,
|
||||||
type: "rdp",
|
port: Number(destinationPort),
|
||||||
destination,
|
siteId: t.siteId,
|
||||||
destinationPort: Number(destinationPort),
|
hcEnabled: false
|
||||||
siteId: t.siteId
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,20 +173,18 @@ function RdpServerForm({
|
|||||||
);
|
);
|
||||||
const created = await Promise.all(
|
const created = await Promise.all(
|
||||||
toCreate.map((s) =>
|
toCreate.map((s) =>
|
||||||
api.put(
|
api.put(`/resource/${resource.resourceId}/target`, {
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
siteId: s.siteId,
|
||||||
{
|
mode: "rdp",
|
||||||
siteId: s.siteId,
|
ip: destination,
|
||||||
type: "rdp",
|
port: Number(destinationPort),
|
||||||
destination,
|
hcEnabled: false
|
||||||
destinationPort: Number(destinationPort)
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
browserGatewayTargetId: res.data.data.browserGatewayTargetId,
|
targetId: res.data.data.targetId,
|
||||||
siteId: toCreate[i].siteId
|
siteId: toCreate[i].siteId
|
||||||
}));
|
}));
|
||||||
setExistingTargets([...toUpdate, ...newTargets]);
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import {
|
|||||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import {
|
import { SitesSelector } from "@app/components/site-selector";
|
||||||
SitesSelector
|
|
||||||
} from "@app/components/site-selector";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -54,22 +52,22 @@ import { GetResourceResponse } from "@server/routers/resource";
|
|||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
type ExistingTarget = {
|
type ExistingTarget = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTarget = {
|
type TargetRow = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
type: string;
|
mode: string | null;
|
||||||
destination: string;
|
ip: string;
|
||||||
destinationPort: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTargetsResponse = {
|
type ResourceTargetsResponse = {
|
||||||
targets: BgTarget[];
|
targets: TargetRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SshSettingsPage(props: {
|
export default function SshSettingsPage(props: {
|
||||||
@@ -83,13 +81,11 @@ export default function SshSettingsPage(props: {
|
|||||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
|
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||||
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
|
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get(
|
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||||
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
return res.data.data as ResourceTargetsResponse;
|
||||||
);
|
|
||||||
return res.data.data as BgTargetsResponse;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +103,7 @@ export default function SshSettingsPage(props: {
|
|||||||
resource={resource}
|
resource={resource}
|
||||||
updateResource={updateResource}
|
updateResource={updateResource}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
|
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||||
/>
|
/>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
@@ -118,20 +114,20 @@ function SshServerForm({
|
|||||||
resource,
|
resource,
|
||||||
updateResource,
|
updateResource,
|
||||||
disabled,
|
disabled,
|
||||||
bgTargetsResponse
|
targetsResponse
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
updateResource: ResourceContextType["updateResource"];
|
updateResource: ResourceContextType["updateResource"];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
bgTargetsResponse: BgTargetsResponse;
|
targetsResponse: ResourceTargetsResponse;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const isNativeInitially = resource.authDaemonMode === "native";
|
const isNativeInitially = resource.authDaemonMode === "native";
|
||||||
const targets = bgTargetsResponse.targets;
|
const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
|
||||||
const firstTarget = targets[0];
|
const firstTarget = targets[0];
|
||||||
const initialPamMode =
|
const initialPamMode =
|
||||||
(resource.pamMode as "passthrough" | "push") || "passthrough";
|
(resource.pamMode as "passthrough" | "push") || "passthrough";
|
||||||
@@ -192,11 +188,11 @@ function SshServerForm({
|
|||||||
: null,
|
: null,
|
||||||
destination: isNativeInitially
|
destination: isNativeInitially
|
||||||
? ""
|
? ""
|
||||||
: (firstTarget?.destination ?? ""),
|
: (firstTarget?.ip ?? ""),
|
||||||
destinationPort: isNativeInitially
|
destinationPort: isNativeInitially
|
||||||
? "22"
|
? "22"
|
||||||
: firstTarget
|
: firstTarget
|
||||||
? String(firstTarget.destinationPort)
|
? String(firstTarget.port)
|
||||||
: "22"
|
: "22"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -206,8 +202,8 @@ function SshServerForm({
|
|||||||
isNativeInitially
|
isNativeInitially
|
||||||
? []
|
? []
|
||||||
: targets.map((target) => ({
|
: targets.map((target) => ({
|
||||||
browserGatewayTargetId: target.browserGatewayTargetId,
|
targetId: target.targetId,
|
||||||
siteId: target.siteId
|
siteId: target.siteId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,14 +211,12 @@ function SshServerForm({
|
|||||||
useState<ExistingTarget | null>(() =>
|
useState<ExistingTarget | null>(() =>
|
||||||
isNativeInitially && firstTarget
|
isNativeInitially && firstTarget
|
||||||
? {
|
? {
|
||||||
browserGatewayTargetId:
|
targetId: firstTarget.targetId,
|
||||||
firstTarget.browserGatewayTargetId,
|
siteId: firstTarget.siteId,
|
||||||
siteId: firstTarget.siteId
|
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
const pamMode = form.watch("pamMode");
|
const pamMode = form.watch("pamMode");
|
||||||
@@ -256,31 +250,37 @@ function SshServerForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
if (values.selectedNativeSite) {
|
const nativeSite = values.selectedNativeSite;
|
||||||
|
if (nativeSite) {
|
||||||
if (nativeExistingTarget) {
|
if (nativeExistingTarget) {
|
||||||
await api.post(
|
await api.post(
|
||||||
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
|
`/target/${nativeExistingTarget.targetId}`,
|
||||||
{
|
{
|
||||||
type: "ssh",
|
mode: "ssh",
|
||||||
destination: "localhost",
|
ip: "localhost",
|
||||||
destinationPort: 22,
|
port: 22,
|
||||||
siteId: values.selectedNativeSite.siteId
|
siteId: nativeSite.siteId,
|
||||||
}
|
hcEnabled: false
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const res = await api.put(
|
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
|
||||||
{
|
|
||||||
siteId: values.selectedNativeSite.siteId,
|
|
||||||
type: "ssh",
|
|
||||||
destination: "localhost",
|
|
||||||
destinationPort: 22
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
setNativeExistingTarget({
|
setNativeExistingTarget({
|
||||||
browserGatewayTargetId:
|
...nativeExistingTarget,
|
||||||
res.data.data.browserGatewayTargetId,
|
siteId: nativeSite.siteId
|
||||||
siteId: values.selectedNativeSite.siteId
|
});
|
||||||
|
} else {
|
||||||
|
const res = await api.put(
|
||||||
|
`/resource/${resource.resourceId}/target`,
|
||||||
|
{
|
||||||
|
siteId: nativeSite.siteId,
|
||||||
|
mode: "ssh",
|
||||||
|
ip: "localhost",
|
||||||
|
port: 22,
|
||||||
|
hcEnabled: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setNativeExistingTarget({
|
||||||
|
targetId: res.data.data.targetId,
|
||||||
|
siteId: nativeSite.siteId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,6 @@ function SshServerForm({
|
|||||||
: values.selectedSite
|
: values.selectedSite
|
||||||
? [values.selectedSite]
|
? [values.selectedSite]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const selectedSiteIds = new Set(
|
const selectedSiteIds = new Set(
|
||||||
activeSites.map((s) => s.siteId)
|
activeSites.map((s) => s.siteId)
|
||||||
);
|
);
|
||||||
@@ -305,11 +304,7 @@ function SshServerForm({
|
|||||||
(t) => !selectedSiteIds.has(t.siteId)
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toDelete.map((t) =>
|
toDelete.map((t) => api.delete(`/target/${t.targetId}`))
|
||||||
api.delete(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const toUpdate = existingTargets.filter((t) =>
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
@@ -317,17 +312,13 @@ function SshServerForm({
|
|||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toUpdate.map((t) =>
|
toUpdate.map((t) =>
|
||||||
api.post(
|
api.post(`/target/${t.targetId}`, {
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
mode: "ssh",
|
||||||
{
|
ip: values.destination,
|
||||||
type: "ssh",
|
port: Number(values.destinationPort),
|
||||||
destination: values.destination,
|
siteId: t.siteId,
|
||||||
destinationPort: Number(
|
hcEnabled: false
|
||||||
values.destinationPort
|
})
|
||||||
),
|
|
||||||
siteId: t.siteId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,24 +327,19 @@ function SshServerForm({
|
|||||||
);
|
);
|
||||||
const created = await Promise.all(
|
const created = await Promise.all(
|
||||||
toCreate.map((s) =>
|
toCreate.map((s) =>
|
||||||
api.put(
|
api.put(`/resource/${resource.resourceId}/target`, {
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
siteId: s.siteId,
|
||||||
{
|
mode: "ssh",
|
||||||
siteId: s.siteId,
|
ip: values.destination,
|
||||||
type: "ssh",
|
port: Number(values.destinationPort),
|
||||||
destination: values.destination,
|
hcEnabled: false
|
||||||
destinationPort: Number(
|
})
|
||||||
values.destinationPort
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
browserGatewayTargetId:
|
targetId: res.data.data.targetId,
|
||||||
res.data.data.browserGatewayTargetId,
|
siteId: toCreate[i].siteId,
|
||||||
siteId: toCreate[i].siteId
|
|
||||||
}));
|
}));
|
||||||
setExistingTargets([...toUpdate, ...newTargets]);
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
|
|||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
type ExistingTarget = {
|
type ExistingTarget = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTarget = {
|
type TargetRow = {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
type: string;
|
mode: string | null;
|
||||||
destination: string;
|
ip: string;
|
||||||
destinationPort: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BgTargetsResponse = {
|
type ResourceTargetsResponse = {
|
||||||
targets: BgTarget[];
|
targets: TargetRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VncSettingsPage(props: {
|
export default function VncSettingsPage(props: {
|
||||||
@@ -61,13 +61,11 @@ export default function VncSettingsPage(props: {
|
|||||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
|
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||||
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
|
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get(
|
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||||
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
return res.data.data as ResourceTargetsResponse;
|
||||||
);
|
|
||||||
return res.data.data as BgTargetsResponse;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ export default function VncSettingsPage(props: {
|
|||||||
resource={resource}
|
resource={resource}
|
||||||
updateResource={updateResource}
|
updateResource={updateResource}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
|
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||||
/>
|
/>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
@@ -95,18 +93,18 @@ function VncServerForm({
|
|||||||
orgId,
|
orgId,
|
||||||
resource,
|
resource,
|
||||||
disabled,
|
disabled,
|
||||||
bgTargetsResponse
|
targetsResponse
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
updateResource: ResourceContextType["updateResource"];
|
updateResource: ResourceContextType["updateResource"];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
bgTargetsResponse: BgTargetsResponse;
|
targetsResponse: ResourceTargetsResponse;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const targets = bgTargetsResponse.targets;
|
const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
|
||||||
const firstTarget = targets[0];
|
const firstTarget = targets[0];
|
||||||
|
|
||||||
const formSchema = useMemo(
|
const formSchema = useMemo(
|
||||||
@@ -122,17 +120,15 @@ function VncServerForm({
|
|||||||
name: target.siteName ?? String(target.siteId),
|
name: target.siteName ?? String(target.siteId),
|
||||||
type: "newt" as const
|
type: "newt" as const
|
||||||
})),
|
})),
|
||||||
destination: firstTarget?.destination ?? "",
|
destination: firstTarget?.ip ?? "",
|
||||||
destinationPort: firstTarget
|
destinationPort: firstTarget ? String(firstTarget.port) : "5900"
|
||||||
? String(firstTarget.destinationPort)
|
|
||||||
: "5900"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
() =>
|
() =>
|
||||||
targets.map((target) => ({
|
targets.map((target) => ({
|
||||||
browserGatewayTargetId: target.browserGatewayTargetId,
|
targetId: target.targetId,
|
||||||
siteId: target.siteId
|
siteId: target.siteId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -155,28 +151,20 @@ function VncServerForm({
|
|||||||
const toDelete = existingTargets.filter(
|
const toDelete = existingTargets.filter(
|
||||||
(t) => !selectedSiteIds.has(t.siteId)
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
|
||||||
toDelete.map((t) =>
|
|
||||||
api.delete(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toUpdate = existingTargets.filter((t) =>
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
selectedSiteIds.has(t.siteId)
|
selectedSiteIds.has(t.siteId)
|
||||||
);
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toUpdate.map((t) =>
|
toUpdate.map((t) =>
|
||||||
api.post(
|
api.post(`/target/${t.targetId}`, {
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
mode: "vnc",
|
||||||
{
|
ip: destination,
|
||||||
type: "vnc",
|
port: Number(destinationPort),
|
||||||
destination,
|
siteId: t.siteId,
|
||||||
destinationPort: Number(destinationPort),
|
hcEnabled: false
|
||||||
siteId: t.siteId
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,20 +173,18 @@ function VncServerForm({
|
|||||||
);
|
);
|
||||||
const created = await Promise.all(
|
const created = await Promise.all(
|
||||||
toCreate.map((s) =>
|
toCreate.map((s) =>
|
||||||
api.put(
|
api.put(`/resource/${resource.resourceId}/target`, {
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
siteId: s.siteId,
|
||||||
{
|
mode: "vnc",
|
||||||
siteId: s.siteId,
|
ip: destination,
|
||||||
type: "vnc",
|
port: Number(destinationPort),
|
||||||
destination,
|
hcEnabled: false
|
||||||
destinationPort: Number(destinationPort)
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
browserGatewayTargetId: res.data.data.browserGatewayTargetId,
|
targetId: res.data.data.targetId,
|
||||||
siteId: toCreate[i].siteId
|
siteId: toCreate[i].siteId
|
||||||
}));
|
}));
|
||||||
setExistingTargets([...toUpdate, ...newTargets]);
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
|||||||
@@ -591,12 +591,13 @@ export default function Page() {
|
|||||||
if (isNative) {
|
if (isNative) {
|
||||||
if (nativeSelectedSite) {
|
if (nativeSelectedSite) {
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
`/resource/${id}/target`,
|
||||||
{
|
{
|
||||||
siteId: nativeSelectedSite.siteId,
|
siteId: nativeSelectedSite.siteId,
|
||||||
type: "ssh",
|
mode: "ssh",
|
||||||
destination: "localhost",
|
ip: "localhost",
|
||||||
destinationPort: 22
|
port: 22,
|
||||||
|
hcEnabled: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -612,14 +613,13 @@ export default function Page() {
|
|||||||
: [];
|
: [];
|
||||||
for (const site of sitesToCreate) {
|
for (const site of sitesToCreate) {
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
`/resource/${id}/target`,
|
||||||
{
|
{
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
type: "ssh",
|
mode: "ssh",
|
||||||
destination: bgValues.destination,
|
ip: bgValues.destination,
|
||||||
destinationPort: Number(
|
port: Number(bgValues.destinationPort),
|
||||||
bgValues.destinationPort
|
hcEnabled: false
|
||||||
)
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -632,14 +632,13 @@ export default function Page() {
|
|||||||
const bgValues = bgTargetForm.getValues();
|
const bgValues = bgTargetForm.getValues();
|
||||||
for (const site of bgValues.selectedSites) {
|
for (const site of bgValues.selectedSites) {
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
`/resource/${id}/target`,
|
||||||
{
|
{
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
type: resourceType,
|
mode: resourceType,
|
||||||
destination: bgValues.destination,
|
ip: bgValues.destination,
|
||||||
destinationPort: Number(
|
port: Number(bgValues.destinationPort),
|
||||||
bgValues.destinationPort
|
hcEnabled: false
|
||||||
)
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
|||||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
loadEncryptedLocalStorage,
|
||||||
|
saveEncryptedLocalStorage
|
||||||
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
|
||||||
declare module "react" {
|
declare module "react" {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
@@ -63,22 +67,14 @@ type RdpCredentialsForm = {
|
|||||||
enableClipboard: boolean;
|
enableClipboard: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadStoredCredentials(key: string): RdpCredentialsForm {
|
const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
|
||||||
try {
|
username: "",
|
||||||
const saved = localStorage.getItem(key);
|
password: "",
|
||||||
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
|
domain: "",
|
||||||
} catch {
|
kdcProxyUrl: "",
|
||||||
// ignore
|
pcb: "",
|
||||||
}
|
enableClipboard: true
|
||||||
return {
|
};
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
domain: "",
|
|
||||||
kdcProxyUrl: "",
|
|
||||||
pcb: "",
|
|
||||||
enableClipboard: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const isIronError = (error: unknown): error is IronError => {
|
const isIronError = (error: unknown): error is IronError => {
|
||||||
return (
|
return (
|
||||||
@@ -113,9 +109,25 @@ export default function RdpClient({
|
|||||||
|
|
||||||
const form = useForm<RdpCredentialsForm>({
|
const form = useForm<RdpCredentialsForm>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
defaultValues: DEFAULT_RDP_CREDENTIALS
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void loadEncryptedLocalStorage<RdpCredentialsForm>(
|
||||||
|
STORAGE_KEY,
|
||||||
|
target?.authToken
|
||||||
|
).then((saved) => {
|
||||||
|
if (cancelled || !saved) return;
|
||||||
|
form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [form, target?.authToken]);
|
||||||
|
|
||||||
const [showLogin, setShowLogin] = useState(true);
|
const [showLogin, setShowLogin] = useState(true);
|
||||||
const [moduleReady, setModuleReady] = useState(false);
|
const [moduleReady, setModuleReady] = useState(false);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
@@ -293,11 +305,11 @@ export default function RdpClient({
|
|||||||
try {
|
try {
|
||||||
const sessionInfo = await userInteraction.connect(builder.build());
|
const sessionInfo = await userInteraction.connect(builder.build());
|
||||||
|
|
||||||
try {
|
void saveEncryptedLocalStorage(
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
STORAGE_KEY,
|
||||||
} catch {
|
values,
|
||||||
// ignore
|
target.authToken
|
||||||
}
|
);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
userInteraction.setVisibility(true);
|
userInteraction.setVisibility(true);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import { useTranslations } from "next-intl";
|
|||||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||||
|
import {
|
||||||
|
loadEncryptedLocalStorage,
|
||||||
|
saveEncryptedLocalStorage
|
||||||
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
|
||||||
type AuthTab = "password" | "privateKey";
|
type AuthTab = "password" | "privateKey";
|
||||||
|
|
||||||
@@ -48,15 +52,11 @@ type ConnectCredentials = {
|
|||||||
certificate?: string;
|
certificate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadStoredCredentials(key: string): SshCredentialsForm {
|
const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
|
||||||
try {
|
username: "",
|
||||||
const saved = localStorage.getItem(key);
|
password: "",
|
||||||
if (saved) return JSON.parse(saved) as SshCredentialsForm;
|
privateKey: ""
|
||||||
} catch {
|
};
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return { username: "", password: "", privateKey: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SshClient({
|
export default function SshClient({
|
||||||
target,
|
target,
|
||||||
@@ -86,9 +86,25 @@ export default function SshClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<SshCredentialsForm>({
|
const form = useForm<SshCredentialsForm>({
|
||||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
defaultValues: DEFAULT_SSH_CREDENTIALS
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void loadEncryptedLocalStorage<SshCredentialsForm>(
|
||||||
|
STORAGE_KEY,
|
||||||
|
target?.authToken
|
||||||
|
).then((saved) => {
|
||||||
|
if (cancelled || !saved) return;
|
||||||
|
form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [form, target?.authToken]);
|
||||||
|
|
||||||
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -252,14 +268,11 @@ export default function SshClient({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!override) {
|
if (!override) {
|
||||||
try {
|
void saveEncryptedLocalStorage(
|
||||||
localStorage.setItem(
|
STORAGE_KEY,
|
||||||
STORAGE_KEY,
|
form.getValues(),
|
||||||
JSON.stringify(form.getValues())
|
target.authToken
|
||||||
);
|
);
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -625,7 +638,7 @@ export default function SshClient({
|
|||||||
|
|
||||||
{connected && (
|
{connected && (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
|
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
|
||||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
{/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -633,7 +646,7 @@ export default function SshClient({
|
|||||||
>
|
>
|
||||||
{t("sshTerminate")}
|
{t("sshTerminate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div> */}
|
||||||
<div
|
<div
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
className="flex-1 overflow-hidden"
|
className="flex-1 overflow-hidden"
|
||||||
|
|||||||
@@ -28,20 +28,18 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
|||||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
loadEncryptedLocalStorage,
|
||||||
|
saveEncryptedLocalStorage
|
||||||
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
|
||||||
type VncCredentialsForm = {
|
type VncCredentialsForm = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadStoredCredentials(key: string): VncCredentialsForm {
|
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
|
||||||
try {
|
password: ""
|
||||||
const saved = localStorage.getItem(key);
|
};
|
||||||
if (saved) return JSON.parse(saved) as VncCredentialsForm;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return { password: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VncClient({
|
export default function VncClient({
|
||||||
target,
|
target,
|
||||||
@@ -62,9 +60,25 @@ export default function VncClient({
|
|||||||
|
|
||||||
const form = useForm<VncCredentialsForm>({
|
const form = useForm<VncCredentialsForm>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
defaultValues: DEFAULT_VNC_CREDENTIALS
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void loadEncryptedLocalStorage<VncCredentialsForm>(
|
||||||
|
STORAGE_KEY,
|
||||||
|
target?.authToken
|
||||||
|
).then((saved) => {
|
||||||
|
if (cancelled || !saved) return;
|
||||||
|
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [form, target?.authToken]);
|
||||||
|
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const rfbRef = useRef<any>(null);
|
const rfbRef = useRef<any>(null);
|
||||||
@@ -132,11 +146,11 @@ export default function VncClient({
|
|||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
|
|
||||||
rfb.addEventListener("connect", () => {
|
rfb.addEventListener("connect", () => {
|
||||||
try {
|
void saveEncryptedLocalStorage(
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
STORAGE_KEY,
|
||||||
} catch {
|
values,
|
||||||
// ignore
|
target.authToken
|
||||||
}
|
);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
|
|||||||
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||||
props: BrowserGatewayTargetFormProps<T>
|
props: BrowserGatewayTargetFormProps<T>
|
||||||
) {
|
) {
|
||||||
|
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
|
||||||
|
// Opt out of the React Compiler for this component.
|
||||||
|
//
|
||||||
|
// The parent (create page) shares a single `bgTargetForm` instance across
|
||||||
|
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
|
||||||
|
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
|
||||||
|
// resource type changes. react-hook-form's Controller uses an external
|
||||||
|
// subscription that the React Compiler cannot statically reason about, so
|
||||||
|
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
|
||||||
|
// the render prop and skip re-rendering the <Input> elements when their
|
||||||
|
// bound form values change. The visible symptom is that typing into the
|
||||||
|
// destination/port inputs updates form state but the input itself never
|
||||||
|
// visually updates. The escape hatch is the canonical fix here.
|
||||||
|
"use no memo";
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteOpen, setSiteOpen] = useState(false);
|
const [siteOpen, setSiteOpen] = useState(false);
|
||||||
|
|
||||||
const sitesFieldName =
|
const sitesFieldName =
|
||||||
props.multiSite === true ? props.sitesField : props.siteField;
|
props.multiSite === true ? props.sitesField : props.siteField;
|
||||||
|
|
||||||
|
// Subscribe to field values via useWatch and drive the controlled <Input>
|
||||||
|
// elements from these values rather than from the `field.value` returned
|
||||||
|
// by the Controller render prop. Combined with the "use no memo" directive
|
||||||
|
// above, this makes the inputs reliably re-render when their bound form
|
||||||
|
// values change.
|
||||||
const watchedSites = useWatch({
|
const watchedSites = useWatch({
|
||||||
control: props.control,
|
control: props.control,
|
||||||
name: sitesFieldName
|
name: sitesFieldName
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedDestination = useWatch({
|
||||||
|
control: props.control,
|
||||||
|
name: props.destinationField
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedDestinationPort = useWatch({
|
||||||
|
control: props.control,
|
||||||
|
name: props.destinationPortField
|
||||||
|
});
|
||||||
|
|
||||||
const showMultiSiteDisclaimer =
|
const showMultiSiteDisclaimer =
|
||||||
props.multiSite === true &&
|
props.multiSite === true &&
|
||||||
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
||||||
@@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("destination")}</FormLabel>
|
<FormLabel>{t("destination")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} value={field.value ?? ""} />
|
<Input
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
onChange={field.onChange}
|
||||||
|
value={
|
||||||
|
(watchedDestination as
|
||||||
|
| string
|
||||||
|
| undefined) ?? ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
{...field}
|
name={field.name}
|
||||||
value={field.value ?? ""}
|
ref={field.ref}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
onChange={field.onChange}
|
||||||
|
value={
|
||||||
|
(watchedDestinationPort as
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| undefined) ?? ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -514,6 +514,16 @@ export default function SitesTable({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSite(siteRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function PolicyAuthStackSectionCreate({
|
|||||||
}
|
}
|
||||||
allIdps={allIdps}
|
allIdps={allIdps}
|
||||||
rolesEditor={
|
rolesEditor={
|
||||||
<FormField
|
<FormField<PolicyFormValues, "roles">
|
||||||
control={parentForm.control}
|
control={parentForm.control}
|
||||||
name="roles"
|
name="roles"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
@@ -146,7 +146,7 @@ export function PolicyAuthStackSectionCreate({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
usersEditor={
|
usersEditor={
|
||||||
<FormField
|
<FormField<PolicyFormValues, "users">
|
||||||
control={parentForm.control}
|
control={parentForm.control}
|
||||||
name="users"
|
name="users"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
|
|||||||
@@ -725,7 +725,8 @@ export function PolicyAuthStackSectionEdit({
|
|||||||
user: headerAuth.user,
|
user: headerAuth.user,
|
||||||
password: headerAuth.password,
|
password: headerAuth.password,
|
||||||
extendedCompatibility:
|
extendedCompatibility:
|
||||||
headerAuth.extendedCompatibility
|
headerAuth.extendedCompatibility ??
|
||||||
|
true
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
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