Merge branch 'dev' into refactor/standardize-dropdowns

This commit is contained in:
Fred KISSIE
2026-06-03 19:34:22 +02:00
30 changed files with 808 additions and 271 deletions
+51
View File
@@ -0,0 +1,51 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
type SetServerAdminArgs = {
email: string;
};
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
command: "set-server-admin",
describe: "Mark any user as a server admin by email address",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const email = argv.email.trim().toLowerCase();
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (user.serverAdmin) {
console.log(`User '${email}' is already a server admin`);
process.exit(0);
}
await db
.update(users)
.set({ serverAdmin: true })
.where(eq(users.userId, user.userId));
console.log(`User '${email}' has been marked as a server admin`);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};
+2
View File
@@ -11,6 +11,7 @@ import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates"; import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa"; import { disableUser2fa } from "./commands/disableUser2fa";
import { setServerAdmin } from "./commands/setServerAdmin";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -23,5 +24,6 @@ yargs(hideBin(process.argv))
.command(generateOrgCaKeys) .command(generateOrgCaKeys)
.command(clearCertificates) .command(clearCertificates)
.command(disableUser2fa) .command(disableUser2fa)
.command(setServerAdmin)
.demandCommand() .demandCommand()
.help().argv; .help().argv;
+48 -9
View File
@@ -26,15 +26,22 @@ import {
userPolicies, userPolicies,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility resourceHeaderAuthExtendedCompatibility,
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, or, sql } from "drizzle-orm"; import { and, eq, inArray, or, sql } from "drizzle-orm";
export type ResourceWithAuth = { export type ResourceWithAuth = {
resource: Resource | null; resource: Resource | null;
pincode: ResourcePincode | null; pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | null; password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org;
}; };
@@ -82,6 +89,31 @@ export async function getResourceByDomain(
resources.resourceId resources.resourceId
) )
) )
.leftJoin(
resourcePolicies,
eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPassword,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where( .where(
or( or(
@@ -113,11 +145,18 @@ export async function getResourceByDomain(
return { return {
resource: result.resources, resource: result.resources,
pincode: result.resourcePincode, pincode: result.resourcePolicyPincode ?? result.resourcePincode,
password: result.resourcePassword, password: result.resourcePolicyPassword ?? result.resourcePassword,
headerAuth: result.resourceHeaderAuth, headerAuth:
headerAuthExtendedCompatibility: result.resourcePolicyHeaderAuth ?? result.resourceHeaderAuth,
result.resourceHeaderAuthExtendedCompatibility, headerAuthExtendedCompatibility: result.resourcePolicyHeaderAuth
? ({
headerAuthExtendedCompatibilityId: 0,
resourceId: result.resources.resourceId,
extendedCompatibilityIsActivated:
result.resourcePolicyHeaderAuth.extendedCompatibility
} as ResourceHeaderAuthExtendedCompatibility)
: result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs org: result.orgs
}; };
} }
+9
View File
@@ -1545,5 +1545,14 @@ export type RoundTripMessageTracker = InferSelectModel<
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>; export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>; export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;
+16 -6
View File
@@ -16,18 +16,18 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain", WildcardSubdomain = "wildcardSubdomain",
Labels = "labels", Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate", NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies" ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -62,15 +62,25 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise" "enterprise"
], ],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"], [TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"] [TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.AdvancedPrivateResources]: [
"tier1",
"tier2",
"tier3",
"enterprise"
]
}; };
+29 -1
View File
@@ -337,6 +337,15 @@ export async function updateProxyResources(
resourceData.maintenance?.message, resourceData.maintenance?.message,
maintenanceEstimatedTime: maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"], resourceData.maintenance?.["estimated-time"],
proxyProtocol:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol"] ?? false)
: false,
proxyProtocolVersion:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
resourcePolicyId: sharedPolicy.resourcePolicyId resourcePolicyId: sharedPolicy.resourcePolicyId
}) })
.where( .where(
@@ -504,6 +513,15 @@ export async function updateProxyResources(
resourceData.maintenance?.message, resourceData.maintenance?.message,
maintenanceEstimatedTime: maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"], resourceData.maintenance?.["estimated-time"],
proxyProtocol:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol"] ?? false)
: false,
proxyProtocolVersion:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
resourcePolicyId: null, resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId defaultResourcePolicyId: inlinePolicyId
}) })
@@ -994,6 +1012,14 @@ export async function updateProxyResources(
maintenanceMessage: resourceData.maintenance?.message, maintenanceMessage: resourceData.maintenance?.message,
maintenanceEstimatedTime: maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"], resourceData.maintenance?.["estimated-time"],
proxyProtocol:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol"] ?? false)
: false,
proxyProtocolVersion:
resourceData.mode === "tcp"
? (resourceData["proxy-protocol-version"] ?? 1)
: 1,
defaultResourcePolicyId: inlinePolicy.resourcePolicyId, defaultResourcePolicyId: inlinePolicy.resourcePolicyId,
resourcePolicyId: sharedPolicyId, resourcePolicyId: sharedPolicyId,
// Only set these resource-level fields when using a shared policy // Only set these resource-level fields when using a shared policy
@@ -1231,7 +1257,9 @@ async function syncRoleResources(
})) }))
); );
role = created; role = created;
logger.info(`Auto-created role "${roleName}" in org ${orgId} from blueprint`); logger.info(
`Auto-created role "${roleName}" in org ${orgId} from blueprint`
);
} }
if (role.isAdmin) { if (role.isAdmin) {
+20 -1
View File
@@ -201,7 +201,9 @@ export const PublicResourceSchema = z
headers: z.array(HeaderSchema).optional(), headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(), rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional(), maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional() "auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional()
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -378,6 +380,23 @@ export const PublicResourceSchema = z
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
} }
) )
.refine(
(resource) => {
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode !== "tcp") {
return (
resource["proxy-protocol"] === undefined &&
resource["proxy-protocol-version"] === undefined
);
}
return true;
},
{
path: ["proxy-protocol"],
message:
"'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'"
}
)
.transform((resource) => { .transform((resource) => {
// Normalize: prefer mode, fall back to protocol for backwards compatibility // Normalize: prefer mode, fall back to protocol for backwards compatibility
if (resource.mode === undefined && resource.protocol !== undefined) { if (resource.mode === undefined && resource.protocol !== undefined) {
@@ -308,8 +308,8 @@ async function disableFeature(
await disableAutoProvisioning(orgId); await disableAutoProvisioning(orgId);
break; break;
case TierFeature.SshPam: case TierFeature.AdvancedPrivateResources:
await disableSshPam(orgId); await disableAdvancedPrivateResources(orgId);
break; break;
case TierFeature.FullRbac: case TierFeature.FullRbac:
@@ -357,10 +357,11 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`); logger.info(`Disabled device approvals on all roles for org ${orgId}`);
} }
async function disableSshPam(orgId: string): Promise<void> { async function disableAdvancedPrivateResources(orgId: string): Promise<void> {
logger.info( // TODO: implement logic to disable advanced private resourcs like ssh and ssh pam
`Disabled SSH PAM options on all roles and site resources for org ${orgId}` // logger.info(
); // `Disabled advanced private resources on all roles and site resources for org ${orgId}`
// );
} }
async function disableFullRbac(orgId: string): Promise<void> { async function disableFullRbac(orgId: string): Promise<void> {
+1 -1
View File
@@ -610,7 +610,7 @@ authenticated.put(
authenticated.post( authenticated.post(
"/org/:orgId/ssh/sign-key", "/org/:orgId/ssh/sign-key",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.sshPam), verifyValidSubscription(tierMatrix.advancedPrivateResources),
verifyOrgAccess, verifyOrgAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.signSshKey), verifyUserHasAction(ActionsEnum.signSshKey),
+54 -9
View File
@@ -35,7 +35,14 @@ import {
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
orgs, orgs,
requestAuditLog, requestAuditLog,
Org Org,
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
} from "@server/db"; } from "@server/db";
import { import {
resources, resources,
@@ -204,9 +211,9 @@ export type ValidateResourceSessionTokenBody = z.infer<
// Type definitions for API responses // Type definitions for API responses
export type ResourceWithAuth = { export type ResourceWithAuth = {
resource: Resource | null; resource: Resource | null;
pincode: ResourcePincode | null; pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | null; password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org;
}; };
@@ -529,6 +536,34 @@ hybridRouter.get(
resources.resourceId resources.resourceId
) )
) )
.leftJoin(
resourcePolicies,
eq(
resourcePolicies.resourcePolicyId,
resources.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPassword,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where( .where(
or( or(
@@ -581,11 +616,21 @@ hybridRouter.get(
const resourceWithAuth: ResourceWithAuth = { const resourceWithAuth: ResourceWithAuth = {
resource: result.resources, resource: result.resources,
pincode: result.resourcePincode, pincode: result.resourcePolicyPincode ?? result.resourcePincode,
password: result.resourcePassword, password:
headerAuth: result.resourceHeaderAuth, result.resourcePolicyPassword ?? result.resourcePassword,
headerAuthExtendedCompatibility: headerAuth:
result.resourceHeaderAuthExtendedCompatibility, result.resourcePolicyHeaderAuth ??
result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourcePolicyHeaderAuth
? ({
headerAuthExtendedCompatibilityId: 0,
resourceId: result.resources.resourceId,
extendedCompatibilityIsActivated:
result.resourcePolicyHeaderAuth
.extendedCompatibility
} as ResourceHeaderAuthExtendedCompatibility)
: result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs org: result.orgs
}; };
+3 -34
View File
@@ -78,41 +78,9 @@ export type SignSshKeyResponse = {
validAfter?: string; validAfter?: string;
validBefore?: string; validBefore?: string;
expiresIn?: number; expiresIn?: number;
authDaemonMode: "site" | "remote" | "native" | null;
}; };
// registry.registerPath({
// method: "post",
// path: "/org/{orgId}/ssh/sign-key",
// description: "Sign an SSH public key for access to a resource.",
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
// request: {
// params: paramsSchema,
// body: {
// content: {
// "application/json": {
// schema: bodySchema
// }
// }
// }
// },
// responses: {
// 200: {
// description: "Successful response",
// content: {
// "application/json": {
// schema: z.object({
// data: z.unknown().nullable(),
// success: z.boolean(),
// error: z.boolean(),
// message: z.string(),
// status: z.number()
// })
// }
// }
// }
// }
// });
export async function signSshKey( export async function signSshKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -181,7 +149,7 @@ export async function signSshKey(
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.sshPam tierMatrix.advancedPrivateResources
); );
if (!isLicensed) { if (!isLicensed) {
return next( return next(
@@ -654,6 +622,7 @@ export async function signSshKey(
siteIds: siteIds, siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
keyId: cert?.keyId, keyId: cert?.keyId,
authDaemonMode: resource.authDaemonMode,
validPrincipals: cert?.validPrincipals, validPrincipals: cert?.validPrincipals,
validAfter: cert?.validAfter.toISOString(), validAfter: cert?.validAfter.toISOString(),
validBefore: cert?.validBefore.toISOString(), validBefore: cert?.validBefore.toISOString(),
+21 -6
View File
@@ -17,6 +17,9 @@ import {
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
ResourcePassword, ResourcePassword,
ResourcePincode, ResourcePincode,
ResourcePolicyPincode,
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule ResourceRule
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -134,9 +137,12 @@ export async function verifyResourceSession(
let resourceData: let resourceData:
| { | {
resource: Resource | null; resource: Resource | null;
pincode: ResourcePincode | null; pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | null; password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth:
| ResourceHeaderAuth
| ResourcePolicyHeaderAuth
| null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org;
} }
@@ -577,7 +583,11 @@ export async function verifyResourceSession(
return notAllowed(res, redirectPath, resource.orgId); return notAllowed(res, redirectPath, resource.orgId);
} }
if (pincode && resourceSession.pincodeId) { if (
pincode &&
(resourceSession.pincodeId ||
resourceSession.policyPincodeId)
) {
logger.debug( logger.debug(
"Resource allowed because pincode session is valid" "Resource allowed because pincode session is valid"
); );
@@ -596,7 +606,11 @@ export async function verifyResourceSession(
return allowed(res, undefined, dontStripSession); return allowed(res, undefined, dontStripSession);
} }
if (password && resourceSession.passwordId) { if (
password &&
(resourceSession.passwordId ||
resourceSession.policyPasswordId)
) {
logger.debug( logger.debug(
"Resource allowed because password session is valid" "Resource allowed because password session is valid"
); );
@@ -617,7 +631,8 @@ export async function verifyResourceSession(
if ( if (
resource.emailWhitelistEnabled && resource.emailWhitelistEnabled &&
resourceSession.whitelistId (resourceSession.whitelistId ||
resourceSession.policyWhitelistId)
) { ) {
logger.debug( logger.debug(
"Resource allowed because whitelist session is valid" "Resource allowed because whitelist session is valid"
+16 -1
View File
@@ -31,7 +31,7 @@ import {
} from "@server/lib/domainUtils"; } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
getUniqueResourceName, getUniqueResourceName,
getUniqueResourcePolicyName getUniqueResourcePolicyName
@@ -342,6 +342,21 @@ async function createHttpResource(
} }
} }
if (
["ssh", "rdp", "vnc"].includes(mode!) &&
!isLicensedOrSubscribed(
orgId!,
tierMatrix[TierFeature.AdvancedPublicResources]
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
)
);
}
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,
@@ -438,6 +438,7 @@ export async function getUserResources(
return { return {
siteResourceId: siteResource.siteResourceId, siteResourceId: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
niceId: siteResource.niceId,
destination: siteResource.destination, destination: siteResource.destination,
mode: siteResource.mode, mode: siteResource.mode,
ssl: siteResource.ssl, ssl: siteResource.ssl,
@@ -492,6 +493,7 @@ export type GetUserResourcesResponse = {
siteResources: Array<{ siteResources: Array<{
siteResourceId: number; siteResourceId: number;
name: string; name: string;
niceId: string;
destination: string; destination: string;
mode: string; mode: string;
tcpPortRangeString: string | null; tcpPortRangeString: string | null;
+25 -8
View File
@@ -123,23 +123,40 @@ export async function createRole(
); );
} }
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); const isLicensedDeviceApprovals = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!isLicensedDeviceApprovals) { if (!isLicensedDeviceApprovals) {
roleData.requireDeviceApproval = undefined; roleData.requireDeviceApproval = undefined;
} }
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
const roleInsertValues: Record<string, unknown> = { const roleInsertValues: Record<string, unknown> = {
name: roleData.name, name: roleData.name,
orgId orgId
}; };
if (roleData.description !== undefined) roleInsertValues.description = roleData.description; if (roleData.description !== undefined)
if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval; roleInsertValues.description = roleData.description;
if (roleData.requireDeviceApproval !== undefined)
roleInsertValues.requireDeviceApproval =
roleData.requireDeviceApproval;
if (isLicensedSshPam) { if (isLicensedSshPam) {
if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode; if (roleData.sshSudoMode !== undefined)
if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands); roleInsertValues.sshSudoMode = roleData.sshSudoMode;
if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; if (roleData.sshSudoCommands !== undefined)
if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups); roleInsertValues.sshSudoCommands = JSON.stringify(
roleData.sshSudoCommands
);
if (roleData.sshCreateHomeDir !== undefined)
roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir;
if (roleData.sshUnixGroups !== undefined)
roleInsertValues.sshUnixGroups = JSON.stringify(
roleData.sshUnixGroups
);
} }
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
+14 -4
View File
@@ -134,12 +134,18 @@ export async function updateRole(
); );
} }
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); const isLicensedDeviceApprovals = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!isLicensedDeviceApprovals) { if (!isLicensedDeviceApprovals) {
updateData.requireDeviceApproval = undefined; updateData.requireDeviceApproval = undefined;
} }
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!isLicensedSshPam) { if (!isLicensedSshPam) {
delete updateData.sshSudoMode; delete updateData.sshSudoMode;
delete updateData.sshSudoCommands; delete updateData.sshSudoCommands;
@@ -147,10 +153,14 @@ export async function updateRole(
delete updateData.sshUnixGroups; delete updateData.sshUnixGroups;
} else { } else {
if (Array.isArray(updateData.sshSudoCommands)) { if (Array.isArray(updateData.sshSudoCommands)) {
updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands); updateData.sshSudoCommands = JSON.stringify(
updateData.sshSudoCommands
);
} }
if (Array.isArray(updateData.sshUnixGroups)) { if (Array.isArray(updateData.sshUnixGroups)) {
updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups); updateData.sshUnixGroups = JSON.stringify(
updateData.sshUnixGroups
);
} }
} }
@@ -49,7 +49,6 @@ const createSiteResourceSchema = z
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()).optional(), siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
@@ -174,6 +173,25 @@ const createSiteResourceSchema = z
{ {
message: "At least one of siteIds or siteId must be provided" message: "At least one of siteIds or siteId must be provided"
} }
)
.refine(
(data) => {
if (data.mode !== "ssh") return true;
const isSingleSiteMode =
data.authDaemonMode === "native" ||
(data.pamMode === "push" && data.authDaemonMode === "site") ||
(data.pamMode === "push" && data.authDaemonMode === undefined);
if (!isSingleSiteMode) return true;
const effectiveSiteIds = [
...(data.siteIds ?? []),
...(data.siteId !== undefined ? [data.siteId] : [])
];
const uniqueSiteIds = new Set(effectiveSiteIds);
return uniqueSiteIds.size <= 1;
},
{
message: "Only one site is allowed for this SSH daemon mode"
}
); );
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>; export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -248,7 +266,6 @@ export async function createSiteResource(
siteId, siteId,
mode, mode,
scheme, scheme,
// proxyPort,
destinationPort, destinationPort,
destination, destination,
enabled, enabled,
@@ -276,7 +293,7 @@ export async function createSiteResource(
if (mode == "http") { if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed( const hasHttpFeature = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix[TierFeature.HTTPPrivateResources] tierMatrix[TierFeature.AdvancedPrivateResources]
); );
if (!hasHttpFeature) { if (!hasHttpFeature) {
return next( return next(
@@ -408,9 +425,18 @@ export async function createSiteResource(
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.sshPam tierMatrix.advancedPrivateResources
); );
if (mode == "ssh" && !isLicensedSshPam) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH private resources are not included in your current plan. Please upgrade."
)
);
}
let updatedNiceId = niceId; let updatedNiceId = niceId;
if (!niceId) { if (!niceId) {
updatedNiceId = await getUniqueSiteResourceName(orgId); updatedNiceId = await getUniqueSiteResourceName(orgId);
@@ -59,7 +59,6 @@ const updateSiteResourceSchema = z
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(), mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(), scheme: z.enum(["http", "https"]).nullish(),
// proxyPort: z.int().positive().nullish(),
destinationPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -182,6 +181,25 @@ const updateSiteResourceSchema = z
{ {
message: "At least one of siteIds or siteId must be provided" message: "At least one of siteIds or siteId must be provided"
} }
)
.refine(
(data) => {
if (data.mode !== "ssh") return true;
const isSingleSiteMode =
data.authDaemonMode === "native" ||
(data.pamMode === "push" && data.authDaemonMode === "site") ||
(data.pamMode === "push" && data.authDaemonMode === undefined);
if (!isSingleSiteMode) return true;
const effectiveSiteIds = [
...(data.siteIds ?? []),
...(data.siteId !== undefined ? [data.siteId] : [])
];
const uniqueSiteIds = new Set(effectiveSiteIds);
return uniqueSiteIds.size <= 1;
},
{
message: "Only one site is allowed for this SSH daemon mode"
}
); );
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>; export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -296,7 +314,7 @@ export async function updateSiteResource(
if (mode == "http") { if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed( const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId, existingSiteResource.orgId,
tierMatrix[TierFeature.HTTPPrivateResources] tierMatrix[TierFeature.AdvancedPrivateResources]
); );
if (!hasHttpFeature) { if (!hasHttpFeature) {
return next( return next(
@@ -310,7 +328,7 @@ export async function updateSiteResource(
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId, existingSiteResource.orgId,
tierMatrix.sshPam tierMatrix.advancedPrivateResources
); );
const [org] = await db const [org] = await db
@@ -632,6 +650,15 @@ export async function updateSiteResource(
}) })
} }
: {}; : {};
let tcpPortRangeStringAdjusted = tcpPortRangeString;
if (mode === "http") {
tcpPortRangeStringAdjusted = "443,80";
} else if (mode === "ssh") {
tcpPortRangeStringAdjusted = destinationPort
? destinationPort.toString()
: "22";
}
[updatedSiteResource] = await trx [updatedSiteResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
@@ -644,9 +671,14 @@ export async function updateSiteResource(
destinationPort: destinationPort, destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias ? alias.trim() : null, alias: alias ? alias.trim() : null,
tcpPortRangeString: tcpPortRangeString, tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString: udpPortRangeString, udpPortRangeString:
disableIcmp: disableIcmp, mode == "http" || mode == "ssh"
? ""
: udpPortRangeString,
disableIcmp:
disableIcmp ||
(mode == "http" || mode == "ssh" ? true : false),
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
-2
View File
@@ -32,8 +32,6 @@ export function generateName(): string {
return name.replace(/[^a-z0-9-]/g, ""); return name.replace(/[^a-z0-9-]/g, "");
} }
await migration();
export default async function migration() { export default async function migration() {
console.log(`Running setup script ${version}...`); console.log(`Running setup script ${version}...`);
@@ -10,11 +10,14 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector"; import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -48,13 +51,21 @@ export default function SshSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm <SshServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -63,11 +74,13 @@ export default function SshSettingsPage(props: {
function SshServerForm({ function SshServerForm({
orgId, orgId,
resource, resource,
updateResource updateResource,
disabled
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -220,31 +233,36 @@ function SshServerForm({
{t("rdpServerDescription")} {t("rdpServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <fieldset
<SettingsSectionForm variant="half"> disabled={disabled}
<BrowserGatewayTargetForm className={disabled ? "opacity-50 pointer-events-none" : ""}
orgId={orgId} >
multiSite={true} <SettingsSectionBody>
selectedSites={selectedSites} <SettingsSectionForm variant="half">
onSitesChange={setSelectedSites} <BrowserGatewayTargetForm
destination={bgDestination} orgId={orgId}
destinationPort={bgDestinationPort} multiSite={true}
onDestinationChange={setBgDestination} selectedSites={selectedSites}
onDestinationPortChange={setBgDestinationPort} onSitesChange={setSelectedSites}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp" destination={bgDestination}
defaultPort={3389} destinationPort={bgDestinationPort}
/> onDestinationChange={setBgDestination}
</SettingsSectionForm> onDestinationPortChange={setBgDestinationPort}
</SettingsSectionBody> learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
<form action={formAction} className="flex justify-end mt-4"> defaultPort={3389}
<Button />
disabled={isSubmitting} </SettingsSectionForm>
loading={isSubmitting} </SettingsSectionBody>
type="submit" <form action={formAction} className="flex justify-end mt-4">
> <Button
{t("saveSettings")} disabled={isSubmitting}
</Button> loading={isSubmitting}
</form> type="submit"
>
{t("saveSettings")}
</Button>
</form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -11,10 +11,13 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
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 { import {
SitesSelector, SitesSelector,
type Selectedsite type Selectedsite
} from "@app/components/site-selector"; } from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
@@ -68,13 +71,21 @@ export default function SshSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm <SshServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -83,11 +94,13 @@ export default function SshSettingsPage(props: {
function SshServerForm({ function SshServerForm({
orgId, orgId,
resource, resource,
updateResource updateResource,
disabled
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -366,6 +379,10 @@ function SshServerForm({
{t("sshServerDescription")} {t("sshServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<div className="space-y-3"> <div className="space-y-3">
@@ -480,7 +497,8 @@ function SshServerForm({
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) : standardDaemonLocation !== "site" ? ( ) : standardDaemonLocation !== "site" ||
pamMode === "passthrough" ? (
<BrowserGatewayTargetForm <BrowserGatewayTargetForm
orgId={orgId} orgId={orgId}
multiSite={true} multiSite={true}
@@ -519,6 +537,7 @@ function SshServerForm({
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
</form> </form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -10,11 +10,14 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector"; import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -46,13 +49,21 @@ export default function SshSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm <SshServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
/> />
</SettingsContainer> </SettingsContainer>
); );
@@ -61,11 +72,13 @@ export default function SshSettingsPage(props: {
function SshServerForm({ function SshServerForm({
orgId, orgId,
resource, resource,
updateResource updateResource,
disabled
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -218,31 +231,36 @@ function SshServerForm({
{t("vncServerDescription")} {t("vncServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <fieldset
<SettingsSectionForm variant="half"> disabled={disabled}
<BrowserGatewayTargetForm className={disabled ? "opacity-50 pointer-events-none" : ""}
orgId={orgId} >
multiSite={true} <SettingsSectionBody>
selectedSites={selectedSites} <SettingsSectionForm variant="half">
onSitesChange={setSelectedSites} <BrowserGatewayTargetForm
destination={bgDestination} orgId={orgId}
destinationPort={bgDestinationPort} multiSite={true}
onDestinationChange={setBgDestination} selectedSites={selectedSites}
onDestinationPortChange={setBgDestinationPort} onSitesChange={setSelectedSites}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc" destination={bgDestination}
defaultPort={5900} destinationPort={bgDestinationPort}
/> onDestinationChange={setBgDestination}
</SettingsSectionForm> onDestinationPortChange={setBgDestinationPort}
</SettingsSectionBody> learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
<form action={formAction} className="flex justify-end mt-4"> defaultPort={5900}
<Button />
disabled={isSubmitting} </SettingsSectionForm>
loading={isSubmitting} </SettingsSectionBody>
type="submit" <form action={formAction} className="flex justify-end mt-4">
> <Button
{t("saveSettings")} disabled={isSubmitting}
</Button> loading={isSubmitting}
</form> type="submit"
>
{t("saveSettings")}
</Button>
</form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -27,7 +27,6 @@ import {
StrategySelect, StrategySelect,
type StrategyOption type StrategyOption
} from "@app/components/StrategySelect"; } from "@app/components/StrategySelect";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { import {
SitesSelector, SitesSelector,
@@ -73,7 +72,10 @@ import {
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
@@ -227,6 +229,8 @@ export default function Page() {
orgQueries.sites({ orgId: orgId as string }) orgQueries.sites({ orgId: orgId as string })
); );
const { isPaidUser } = usePaidStatus();
const [remoteExitNodes, setRemoteExitNodes] = useState< const [remoteExitNodes, setRemoteExitNodes] = useState<
ListRemoteExitNodesResponse["remoteExitNodes"] ListRemoteExitNodesResponse["remoteExitNodes"]
>([]); >([]);
@@ -239,6 +243,14 @@ export default function Page() {
// Resource type state // Resource type state
const [resourceType, setResourceType] = useState<NewResourceType>("http"); const [resourceType, setResourceType] = useState<NewResourceType>("http");
const isBrowserGatewayType =
resourceType === "ssh" ||
resourceType === "rdp" ||
resourceType === "vnc";
const browserGatewayDisabled =
isBrowserGatewayType &&
!isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]);
// Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit)
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
@@ -871,6 +883,14 @@ export default function Page() {
{/* SSH Server Section */} {/* SSH Server Section */}
{resourceType === "ssh" && ( {resourceType === "ssh" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("sshServer")} {t("sshServer")}
@@ -879,6 +899,14 @@ export default function Page() {
{t("sshServerDescription")} {t("sshServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
{/* Mode */} {/* Mode */}
@@ -896,26 +924,23 @@ export default function Page() {
/> />
</div> </div>
{/* Auth Method (standard only) */} <div className="space-y-3">
{!isNative && ( <p className="text-sm font-semibold">
<div className="space-y-3"> {t(
<p className="text-sm font-semibold"> "sshAuthenticationMethod"
{t( )}
"sshAuthenticationMethod" </p>
)} <StrategySelect<
</p> "passthrough" | "push"
<StrategySelect< >
"passthrough" | "push" value={pamMode}
> options={
value={pamMode} authMethodOptions
options={ }
authMethodOptions onChange={setPamMode}
} cols={2}
onChange={setPamMode} />
cols={2} </div>
/>
</div>
)}
{/* Daemon Location (standard + push) */} {/* Daemon Location (standard + push) */}
{showDaemonLocation && ( {showDaemonLocation && (
@@ -1046,7 +1071,9 @@ export default function Page() {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) : standardDaemonLocation !== ) : standardDaemonLocation !==
"site" ? ( "site" ||
pamMode ===
"passthrough" ? (
<BrowserGatewayTargetForm <BrowserGatewayTargetForm
orgId={orgId as string} orgId={orgId as string}
multiSite={true} multiSite={true}
@@ -1100,12 +1127,21 @@ export default function Page() {
</div> </div>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
{/* RDP Server Section */} {/* RDP Server Section */}
{resourceType === "rdp" && ( {resourceType === "rdp" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("rdpServer")} {t("rdpServer")}
@@ -1114,6 +1150,14 @@ export default function Page() {
{t("rdpServerDescription")} {t("rdpServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<BrowserGatewayTargetForm <BrowserGatewayTargetForm
@@ -1138,12 +1182,21 @@ export default function Page() {
/> />
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
{/* VNC Server Section */} {/* VNC Server Section */}
{resourceType === "vnc" && ( {resourceType === "vnc" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("vncServer")} {t("vncServer")}
@@ -1152,6 +1205,14 @@ export default function Page() {
{t("vncServerDescription")} {t("vncServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<BrowserGatewayTargetForm <BrowserGatewayTargetForm
@@ -1176,6 +1237,7 @@ export default function Page() {
/> />
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
@@ -1227,7 +1289,7 @@ export default function Page() {
} }
}} }}
loading={createLoading} loading={createLoading}
disabled={!areAllTargetsValid()} disabled={!areAllTargetsValid() || browserGatewayDisabled}
> >
{t("resourceCreate")} {t("resourceCreate")}
</Button> </Button>
@@ -103,6 +103,7 @@ export default function CreatePrivateResourceDialog({
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: undefined, : undefined,
destinationPort: data.destinationPort ?? undefined,
pamMode: data.pamMode ?? undefined, pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && { ...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode authDaemonMode: data.authDaemonMode
@@ -112,13 +113,14 @@ export default function CreatePrivateResourceDialog({
authDaemonPort: data.authDaemonPort authDaemonPort: data.authDaemonPort
}) })
}), }),
...((data.mode === "host" || ...((data.mode === "host" || data.mode === "cidr") && {
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false disableIcmp: data.disableIcmp ?? false
}), }),
...(data.mode === "ssh" && {
disableIcmp: data.disableIcmp ?? false
}),
roleIds: data.roles roleIds: data.roles
? data.roles.map((r) => parseInt(r.id)) ? data.roles.map((r) => parseInt(r.id))
: [], : [],
+5 -9
View File
@@ -16,10 +16,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
CreateRoleBody,
CreateRoleResponse
} from "@server/routers/role";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; import { useTransition } from "react";
@@ -50,7 +47,7 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval, requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh allowSsh: values.allowSsh
}; };
if (isPaidUser(tierMatrix.sshPam)) { if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode; payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands = payload.sshSudoCommands =
@@ -69,10 +66,9 @@ export default function CreateRoleForm({
} }
} }
const res = await api const res = await api
.put<AxiosResponse<CreateRoleResponse>>( .put<
`/org/${org?.org.orgId}/role`, AxiosResponse<CreateRoleResponse>
payload >(`/org/${org?.org.orgId}/role`, payload)
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
+5 -3
View File
@@ -104,6 +104,7 @@ export default function EditPrivateResourceDialog({
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: null, : null,
destinationPort: data.destinationPort ?? null,
pamMode: data.pamMode ?? undefined, pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && { ...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode authDaemonMode: data.authDaemonMode
@@ -112,13 +113,14 @@ export default function EditPrivateResourceDialog({
authDaemonPort: data.authDaemonPort || null authDaemonPort: data.authDaemonPort || null
}) })
}), }),
...((data.mode === "host" || ...((data.mode === "host" || data.mode === "cidr") && {
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false disableIcmp: data.disableIcmp ?? false
}), }),
...(data.mode === "ssh" && {
disableIcmp: data.disableIcmp ?? false
}),
roleIds: (data.roles || []).map((r) => parseInt(r.id)), roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id), userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id)) clientIds: (data.clients || []).map((c) => parseInt(c.id))
+5 -9
View File
@@ -16,10 +16,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { Role } from "@server/db"; import type { Role } from "@server/db";
import type { import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
UpdateRoleBody,
UpdateRoleResponse
} from "@server/routers/role";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; import { useTransition } from "react";
@@ -53,7 +50,7 @@ export default function EditRoleForm({
payload.name = values.name; payload.name = values.name;
payload.description = values.description || undefined; payload.description = values.description || undefined;
} }
if (isPaidUser(tierMatrix.sshPam)) { if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode; payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands = payload.sshSudoCommands =
@@ -72,10 +69,9 @@ export default function EditRoleForm({
} }
} }
const res = await api const res = await api
.post<AxiosResponse<UpdateRoleResponse>>( .post<
`/role/${role.roleId}`, AxiosResponse<UpdateRoleResponse>
payload >(`/role/${role.roleId}`, payload)
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
+92 -57
View File
@@ -41,6 +41,7 @@ import {
TooltipTrigger TooltipTrigger
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@/components/ui/badge";
// Update Resource type to include site information // Update Resource type to include site information
type Resource = { type Resource = {
@@ -49,7 +50,7 @@ type Resource = {
domain: string; domain: string;
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
// mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh" mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh"
// Auth method fields // Auth method fields
sso?: boolean; sso?: boolean;
password?: boolean; password?: boolean;
@@ -62,6 +63,7 @@ type Resource = {
type SiteResource = { type SiteResource = {
siteResourceId: number; siteResourceId: number;
name: string; name: string;
niceId: string;
destination: string; destination: string;
mode: string; mode: string;
ssl: boolean; ssl: boolean;
@@ -754,7 +756,13 @@ export default function MemberResourcesPortal({
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0 flex items-center gap-2">
<Badge
variant="secondary"
className="text-xs"
>
{resource.mode.toUpperCase()}
</Badge>
<ResourceInfo <ResourceInfo
resource={resource} resource={resource}
/> />
@@ -860,7 +868,13 @@ export default function MemberResourcesPortal({
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0 flex items-center gap-2">
<Badge
variant="secondary"
className="text-xs"
>
{siteResource.mode.toUpperCase()}
</Badge>
<InfoPopup> <InfoPopup>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">
@@ -876,24 +890,24 @@ export default function MemberResourcesPortal({
: :
</span> </span>
<span className="ml-2 text-muted-foreground capitalize"> <span className="ml-2 text-muted-foreground capitalize">
{ {siteResource.mode.toUpperCase()}
siteResource.mode
}
</span>
</div>
<div>
<span className="font-medium">
{t(
"memberPortalDestination"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
siteResource.destination
}
</span> </span>
</div> </div>
{siteResource.destination && (
<div>
<span className="font-medium">
{t(
"memberPortalDestination"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
siteResource.destination
}
</span>
</div>
)}
{siteResource.alias && ( {siteResource.alias && (
<div> <div>
<span className="font-medium"> <span className="font-medium">
@@ -942,45 +956,35 @@ export default function MemberResourcesPortal({
isLink={true} isLink={true}
/> />
) : siteResource.alias ? ( ) : siteResource.alias ? (
<> /* Alias as primary */
{/* Alias as primary */} <div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-1"> <div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
<div className="text-base font-semibold text-foreground text-left truncate flex-1"> {siteResource.alias}
{ </div>
siteResource.alias <Button
} variant="ghost"
</div> size="icon"
<Button className="h-8 w-8 text-muted-foreground"
variant="ghost" onClick={() => {
size="icon" navigator.clipboard.writeText(
className="h-8 w-8 text-muted-foreground" siteResource.alias!
onClick={() => { );
navigator.clipboard.writeText( toast({
siteResource.alias! title: t(
); "memberPortalCopiedToClipboard"
toast({ ),
title: t( description:
"memberPortalCopiedToClipboard" t(
"memberPortalCopiedAliasDescription"
), ),
description: duration: 2000
t( });
"memberPortalCopiedAliasDescription" }}
), >
duration: 2000 <Copy className="h-4 w-4" />
}); </Button>
}} </div>
> ) : siteResource.destination ? (
<Copy className="h-4 w-4" />
</Button>
</div>
{/* Destination as secondary */}
<div className="text-xs text-muted-foreground truncate">
{
siteResource.destination
}
</div>
</>
) : (
/* Destination as primary when no alias */ /* Destination as primary when no alias */
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1"> <div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
@@ -1011,6 +1015,37 @@ export default function MemberResourcesPortal({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
) : (
/* niceId fallback when no alias and no destination */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{
siteResource.niceId
}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.niceId
);
toast({
title: t(
"memberPortalCopiedToClipboard"
),
description:
t(
"memberPortalCopiedDestinationDescription"
),
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
)} )}
</div> </div>
</div> </div>
+112 -13
View File
@@ -224,8 +224,10 @@ export function PrivateResourceForm({
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources); const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const nameRequiredKey = const nameRequiredKey =
variant === "create" variant === "create"
@@ -365,6 +367,19 @@ export function PrivateResourceForm({
path: ["destination"] path: ["destination"]
}); });
} }
if (data.mode === "ssh" && !isNativeSsh) {
if (
data.destinationPort == null ||
!Number.isFinite(data.destinationPort) ||
data.destinationPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["destinationPort"]
});
}
}
if (data.mode !== "http") return; if (data.mode !== "http") return;
if (!data.scheme) { if (!data.scheme) {
ctx.addIssue({ ctx.addIssue({
@@ -548,7 +563,7 @@ export function PrivateResourceForm({
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
destinationPort: null, destinationPort: 22,
scheme: "http", scheme: "http",
ssl: true, ssl: true,
httpConfigSubdomain: null, httpConfigSubdomain: null,
@@ -581,6 +596,7 @@ export function PrivateResourceForm({
const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain"); const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpMode = mode === "http"; const isHttpMode = mode === "http";
const isSshMode = mode === "ssh";
const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const pamMode = form.watch("pamMode") ?? "passthrough"; const pamMode = form.watch("pamMode") ?? "passthrough";
const isNative = sshServerMode === "native"; const isNative = sshServerMode === "native";
@@ -726,8 +742,17 @@ export function PrivateResourceForm({
]); ]);
useEffect(() => { useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled); onSubmitDisabledChange?.(
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]); (isHttpMode && httpSectionDisabled) ||
(isSshMode && sshSectionDisabled)
);
}, [
isHttpMode,
httpSectionDisabled,
isSshMode,
sshSectionDisabled,
onSubmitDisabledChange
]);
return ( return (
<Form {...form}> <Form {...form}>
@@ -735,6 +760,7 @@ export function PrivateResourceForm({
onSubmit={form.handleSubmit((values) => { onSubmit={form.handleSubmit((values) => {
const siteIds = values.siteIds; const siteIds = values.siteIds;
const trimmedDestination = values.destination?.trim(); const trimmedDestination = values.destination?.trim();
const isSshMode = values.mode === "ssh";
onSubmit({ onSubmit({
...values, ...values,
siteIds, siteIds,
@@ -742,6 +768,12 @@ export function PrivateResourceForm({
trimmedDestination && trimmedDestination.length > 0 trimmedDestination && trimmedDestination.length > 0
? trimmedDestination ? trimmedDestination
: null, : null,
tcpPortRangeString: isSshMode
? undefined
: values.tcpPortRangeString,
udpPortRangeString: isSshMode
? undefined
: values.udpPortRangeString,
clients: (values.clients ?? []).map((c) => ({ clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(), id: c.clientId.toString(),
text: c.name text: c.name
@@ -826,8 +858,11 @@ export function PrivateResourceForm({
{t("sites")} {t("sites")}
</FormLabel> </FormLabel>
{mode === "ssh" && {mode === "ssh" &&
sshServerMode === (sshServerMode ===
"native" ? ( "native" ||
(pamMode === "push" &&
authDaemonMode ===
"site")) ? (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
asChild asChild
@@ -1106,8 +1141,10 @@ export function PrivateResourceForm({
"" ""
} }
disabled={ disabled={
isHttpMode && (isHttpMode &&
httpSectionDisabled httpSectionDisabled) ||
(isSshMode &&
sshSectionDisabled)
} }
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
@@ -1146,6 +1183,10 @@ export function PrivateResourceForm({
field.value ?? field.value ??
"" ""
} }
disabled={
isSshMode &&
sshSectionDisabled
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1179,7 +1220,10 @@ export function PrivateResourceForm({
"" ""
} }
disabled={ disabled={
httpSectionDisabled (isHttpMode &&
httpSectionDisabled) ||
(isSshMode &&
sshSectionDisabled)
} }
onChange={(e) => { onChange={(e) => {
const raw = const raw =
@@ -1214,9 +1258,9 @@ export function PrivateResourceForm({
</div> </div>
</div> </div>
{isHttpMode && ( {(isHttpMode || isSshMode) && (
<PaidFeaturesAlert <PaidFeaturesAlert
tiers={tierMatrix.httpPrivateResources} tiers={tierMatrix.advancedPrivateResources}
/> />
)} )}
@@ -1750,7 +1794,9 @@ export function PrivateResourceForm({
{/* SSH Access tab (ssh mode only) */} {/* SSH Access tab (ssh mode only) */}
{!disableEnterpriseFeatures && mode === "ssh" && ( {!disableEnterpriseFeatures && mode === "ssh" && (
<div className="space-y-4 mt-4 p-1"> <div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
{/* Mode */} {/* Mode */}
<div className="space-y-3"> <div className="space-y-3">
@@ -1862,6 +1908,36 @@ export function PrivateResourceForm({
"authDaemonPort", "authDaemonPort",
null null
); );
} else if (
v === "push"
) {
// push + site (default) = single site
const curAuthMode =
form.getValues(
"authDaemonMode"
);
if (
curAuthMode !==
"remote" &&
selectedSites.length >
1
) {
const first =
selectedSites.slice(
0,
1
);
setSelectedSites(
first
);
form.setValue(
"siteIds",
first.map(
(s) =>
s.siteId
)
);
}
} }
}} }}
cols={2} cols={2}
@@ -1929,6 +2005,29 @@ export function PrivateResourceForm({
"authDaemonPort", "authDaemonPort",
null null
); );
// site daemon = single site
if (
selectedSites.length >
1
) {
const first =
selectedSites.slice(
0,
1
);
setSelectedSites(
first
);
form.setValue(
"siteIds",
first.map(
(
s
) =>
s.siteId
)
);
}
} }
}} }}
cols={2} cols={2}
+4 -2
View File
@@ -164,7 +164,7 @@ export function RoleForm({
} }
}, [variant, role, form]); }, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.sshPam); const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshSudoMode = form.watch("sshSudoMode"); const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true; const isAdminRole = variant === "edit" && role?.isAdmin === true;
@@ -319,7 +319,9 @@ export function RoleForm({
{/* SSH tab - hidden when enterprise features are disabled */} {/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && ( {!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<FormField <FormField
control={form.control} control={form.control}
name="allowSsh" name="allowSsh"