Merge branch 'rdp-ssh' into dev

This commit is contained in:
Owen
2026-05-28 13:59:14 -07:00
104 changed files with 8491 additions and 3528 deletions

View File

@@ -23,7 +23,10 @@ import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -32,15 +35,58 @@ const createResourceParamsSchema = z.strictObject({
orgId: z.string()
});
function resolveModeFromLegacyFields(data: {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
http?: boolean;
protocol?: "tcp" | "udp";
}): {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
error?: string;
} {
if (data.mode) {
return { mode: data.mode };
}
if (typeof data.http === "boolean" && data.protocol) {
if (data.http && data.protocol === "tcp") {
return { mode: "http" };
}
if (!data.http && data.protocol === "tcp") {
return { mode: "tcp" };
}
if (!data.http && data.protocol === "udp") {
return { mode: "udp" };
}
return {
error: "Invalid deprecated http/protocol combination"
};
}
return { mode: undefined };
}
const createHttpResourceSchema = z
.strictObject({
name: z.string().min(1).max(255),
subdomain: z.string().nullable().optional(),
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
http: z.boolean().optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
protocol: z.enum(["tcp", "udp"]).optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
domainId: z.string(),
stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional()
postAuthPath: z.string().nullable().optional(),
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
// SSH Settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
})
.refine(
(data) => {
@@ -60,13 +106,27 @@ const createHttpResourceSchema = z
const createRawResourceSchema = z
.strictObject({
name: z.string().min(1).max(255),
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
http: z.boolean().optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
protocol: z.enum(["tcp", "udp"]).optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
mode: z.enum(["tcp", "udp"]).optional(),
proxyPort: z.int().min(1).max(65535)
// enableProxy: z.boolean().default(true) // always true now
})
.refine(
(data) => {
const resolved = resolveModeFromLegacyFields(data);
if (resolved.error || !resolved.mode) {
return false;
}
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
@@ -143,17 +203,18 @@ export async function createResource(
);
}
if (typeof req.body.http !== "boolean") {
const resolvedMode = resolveModeFromLegacyFields(req.body);
if (resolvedMode.error) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
createHttpError(HttpCode.BAD_REQUEST, resolvedMode.error)
);
}
const { http } = req.body;
if (resolvedMode.mode) {
req.body.mode = resolvedMode.mode;
}
if (http) {
return await createHttpResource({ req, res, next }, { orgId });
} else {
if (typeof req.body.proxyPort === "number") {
if (
!config.getRawConfig().flags?.allow_raw_resources &&
build == "oss"
@@ -167,6 +228,17 @@ export async function createResource(
}
return await createRawResource({ req, res, next }, { orgId });
}
if (req.body.mode) {
return await createHttpResource({ req, res, next }, { orgId });
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mode is required when deprecated fields are not provided"
)
);
}
} catch (error) {
logger.error(error);
return next(
@@ -198,7 +270,15 @@ async function createHttpResource(
);
}
const { name, domainId, postAuthPath } = parsedBody.data;
const {
name,
domainId,
postAuthPath,
mode,
authDaemonPort,
authDaemonMode,
pamMode
} = parsedBody.data;
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
@@ -322,8 +402,10 @@ async function createHttpResource(
orgId,
name,
subdomain: finalSubdomain,
http: true,
protocol: "tcp",
mode: mode,
pamMode: pamMode,
authDaemonMode: authDaemonMode,
authDaemonPort: authDaemonPort,
ssl: true,
stickySession: stickySession,
postAuthPath: postAuthPath,
@@ -405,7 +487,17 @@ async function createRawResource(
);
}
const { name, http, protocol, proxyPort } = parsedBody.data;
const { name, proxyPort } = parsedBody.data;
const resolvedMode = resolveModeFromLegacyFields(parsedBody.data);
if (resolvedMode.error || !resolvedMode.mode) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
resolvedMode.error ||
"mode is required when deprecated fields are not provided"
)
);
}
let resource: Resource | undefined;
@@ -418,9 +510,8 @@ async function createRawResource(
niceId,
orgId,
name,
http,
protocol,
proxyPort
proxyPort,
mode: resolvedMode.mode
// enableProxy
})
.returning();

View File

@@ -94,7 +94,7 @@ export async function createResourceRule(
);
}
if (!resource.http) {
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -106,7 +106,7 @@ export async function deleteResource(
// [target],
[], // 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
healthChecksToBeRemoved,
deletedResource.protocol,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}

View File

@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
orgId: string;
resourceId: number;
niceId: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}

View File

@@ -127,7 +127,7 @@ export async function getUserResources(
ssl: boolean;
enabled: boolean;
sso: boolean;
protocol: string;
mode: string;
emailWhitelistEnabled: boolean;
}> = [];
if (accessibleResourceIds.length > 0) {
@@ -139,7 +139,7 @@ export async function getUserResources(
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
mode: resources.mode,
emailWhitelistEnabled: resources.emailWhitelistEnabled
})
.from(resources)
@@ -323,7 +323,7 @@ export async function getUserResources(
hasPincode ||
hasWhitelist
),
protocol: resource.protocol,
mode: resource.mode,
sso: resource.sso,
password: hasPassword,
pincode: hasPincode,
@@ -339,7 +339,6 @@ export async function getUserResources(
name: siteResource.name,
destination: siteResource.destination,
mode: siteResource.mode,
protocol: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain,
enabled: siteResource.enabled,
@@ -387,14 +386,13 @@ export type GetUserResourcesResponse = {
domain: string;
enabled: boolean;
protected: boolean;
protocol: string;
mode: string;
}>;
siteResources: Array<{
siteResourceId: number;
name: string;
destination: string;
mode: string;
protocol: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;

View File

@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBrowserTarget";

View File

@@ -1,4 +1,5 @@
import {
browserGatewayTarget,
db,
labels,
resourceHeaderAuth,
@@ -156,8 +157,6 @@ export type ResourceWithTargets = {
sso: boolean;
pincodeId: number | null;
whitelist: boolean;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId: string | null;
@@ -165,6 +164,7 @@ export type ResourceWithTargets = {
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
mode: string | null;
targets: Array<{
targetId: number;
ip: string;
@@ -193,8 +193,6 @@ function queryResourcesBase() {
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
domainId: resources.domainId,
@@ -203,7 +201,8 @@ function queryResourcesBase() {
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
health: resources.health,
mode: resources.mode
})
.from(resources)
.leftJoin(
@@ -364,7 +363,9 @@ export async function listResources(
if (typeof authState !== "undefined") {
switch (authState) {
case "none":
conditions.push(eq(resources.http, false));
conditions.push(
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
);
break;
case "protected":
conditions.push(
@@ -520,6 +521,30 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -536,10 +561,9 @@ export async function listResources(
sso: row.sso,
pincodeId: row.pincodeId,
whitelist: row.whitelist,
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
mode: row.mode,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
@@ -583,6 +607,21 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}

View File

@@ -24,7 +24,10 @@ import {
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -68,7 +71,11 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
postAuthPath: z.string().nullable().optional(),
// SSH settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -240,7 +247,7 @@ export async function updateResource(
);
}
if (resource.http) {
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
// HANDLE UPDATING HTTP RESOURCES
return await updateHttpResource(
{

View File

@@ -26,7 +26,9 @@ const updateResourceRuleParamsSchema = z.strictObject({
const updateResourceRuleSchema = z
.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(),
match: z
.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"])
.optional(),
value: z.string().min(1).optional(),
priority: z.int(),
enabled: z.boolean().optional()
@@ -102,7 +104,7 @@ export async function updateResourceRule(
);
}
if (!resource.http) {
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,