Merge branch 'dev' into refactor/standardize-clear-buttons

This commit is contained in:
Fred KISSIE
2026-06-05 20:21:34 +02:00
80 changed files with 4385 additions and 1973 deletions
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
+7
View File
@@ -0,0 +1,7 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.
+2 -4
View File
@@ -38,7 +38,5 @@ flags:
disable_user_create_org: false
allow_raw_resources: true
{{if .IsPostgreSQL}}
postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
{{end}}
{{if .IsPostgreSQL}}postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
+20 -33
View File
@@ -7,23 +7,17 @@ services:
deploy:
resources:
limits:
memory: 1g
memory: 2g
reservations:
memory: 256m
{{if or .IsPostgreSQL .IsRedis}}
depends_on:
{{if .IsPostgreSQL}}
postgres:
condition: service_healthy
{{end}}
{{if .IsRedis}}
redis:
condition: service_healthy
{{end}}
memory: 512m
{{if or .IsPostgreSQL .IsRedis}}depends_on:
{{if .IsPostgreSQL}}postgres:
condition: service_healthy{{end}}
{{if .IsRedis}}redis:
condition: service_healthy{{end}}
networks:
- default
- backend
{{end}}
- backend{{end}}
volumes:
- ./config:/app/config
healthcheck:
@@ -31,8 +25,8 @@ services:
interval: "10s"
timeout: "10s"
retries: 15
{{if .InstallGerbil}}
gerbil:
{{if .InstallGerbil}}gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil
restart: unless-stopped
@@ -53,17 +47,16 @@ services:
- 21820:21820/udp
- 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80
{{end}}
- 80:80{{end}}
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
{{end}}
- 80:80{{end}}
depends_on:
pangolin:
condition: service_healthy
@@ -74,8 +67,7 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}
postgres:
{{if .IsPostgreSQL}}postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
@@ -91,11 +83,9 @@ services:
timeout: 5s
retries: 5
networks:
- backend
{{end}}
- backend{{end}}
{{if .IsRedis}}
redis:
{{if .IsRedis}}redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
@@ -113,17 +103,14 @@ services:
retries: 3
start_period: 10s
networks:
- backend
{{end}}
- backend{{end}}
networks:
default:
driver: bridge
name: pangolin_frontend
{{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}}
backend:
{{if or .IsPostgreSQL .IsRedis}} backend:
driver: bridge
name: pangolin_backend
internal: true
{{end}}
internal: true{{end}}
+2 -4
View File
@@ -1,6 +1,4 @@
{{if .IsRedis}}
redis:
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"
{{end}}
password: "{{.IsRedisPass}}"{{end}}
+7 -4
View File
@@ -71,9 +71,12 @@ const (
Undefined SupportedContainer = "undefined"
)
var redisFlag *bool
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
@@ -491,13 +494,13 @@ func collectUserInput() Config {
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
if config.IsEnterprise {
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
if config.IsRedis {
if *redisFlag {
config.IsRedis = true
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
if config.IsPostgreSQL {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
@@ -544,7 +547,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
+47 -4
View File
@@ -101,6 +101,8 @@
"sitesTableViewPrivateResources": "View Private Resources",
"siteInstallNewt": "Install Site",
"siteInstallNewtDescription": "Install the site connector for your system",
"siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
"siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
"WgConfiguration": "WireGuard Configuration",
"WgConfigurationDescription": "Use the following configuration to connect to the network",
"operatingSystem": "Operating System",
@@ -1220,8 +1222,10 @@
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelsNotFound": "No labels found.",
"labelsEmptyCreateHint": "Start typing above to create a label.",
"labelSearch": "Search labels",
"labelSearchOrCreate": "Search or create a label",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
@@ -1648,7 +1652,7 @@
"standaloneHcFilterResourceIdFallback": "Resource {id}",
"blueprints": "Blueprints",
"blueprintsLog": "Blueprints Log",
"blueprintsDescription": "View past blueprint applications and their results",
"blueprintsDescription": "View past blueprint applications and their results or apply a new blueprint",
"blueprintAdd": "Add Blueprint",
"blueprintGoBack": "See all Blueprints",
"blueprintCreate": "Create Blueprint",
@@ -2047,6 +2051,7 @@
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings",
"sshAccess": "SSH Access",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
"sshServer": "SSH Server",
@@ -2073,7 +2078,7 @@
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"sshServerDestinationDescription": "Configure the destination of the SSH server",
"destination": "Destination",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"roleAllowSsh": "Allow SSH",
@@ -3455,5 +3460,43 @@
"sshErrorNoTarget": "No target specified",
"sshErrorWebSocket": "WebSocket connection failed",
"sshErrorAuthFailed": "Authentication failed",
"sshErrorConnectionClosed": "Connection closed before authentication completed"
"sshErrorConnectionClosed": "Connection closed before authentication completed",
"sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.",
"browserGatewayNoResourceForDomain": "No resource found for this domain",
"browserGatewayNoTarget": "No target",
"browserGatewayConnect": "Connect",
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
"sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?",
"sshTerminalError": "Error: {error}",
"sshConnectionClosedCode": "Connection closed (code {code})",
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Private key is required",
"vncTitle": "VNC",
"vncSignInDescription": "Enter your VNC password to connect",
"vncPasswordOptional": "Password (optional)",
"vncNoResourceTarget": "No resource target is available",
"vncFailedToLoadNovnc": "Failed to load noVNC",
"vncAuthFailedStatus": "Status {status}",
"vncPasteClipboard": "Paste clipboard",
"rdpTitle": "RDP",
"rdpSignInTitle": "Sign in to Remote Desktop",
"rdpSignInDescription": "Enter Windows credentials to connect",
"rdpLoadingModule": "Loading module...",
"rdpFailedToLoadModule": "Failed to load RDP module",
"rdpNotReady": "Not ready",
"rdpModuleInitializing": "RDP module is still initializing",
"rdpDownloadingFiles": "Downloading {count} file(s) from remote…",
"rdpDownloadFailed": "Download failed: {fileName}",
"rdpUploaded": "Uploaded: {fileName}",
"rdpNoConnectionTarget": "No connection target available",
"rdpConnectionFailed": "Connection failed",
"rdpFit": "Fit",
"rdpFull": "Full",
"rdpReal": "Real",
"rdpMeta": "Meta",
"rdpUploadFiles": "Upload files",
"rdpFilesReadyToPaste": "Files ready to paste",
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
"rdpUploadFailed": "Upload failed",
"rdpUnicodeKeyboardMode": "Unicode keyboard mode"
}
+3 -1
View File
@@ -886,7 +886,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
match: varchar("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
.notNull(),
value: varchar("value").notNull()
});
+3 -1
View File
@@ -1248,7 +1248,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
match: text("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
.notNull(),
value: text("value").notNull()
});
+3
View File
@@ -20,6 +20,7 @@ import {
ClientResourcesResults,
updateClientResources
} from "./clientResources";
import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names";
@@ -56,6 +57,8 @@ export async function applyBlueprint({
let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
proxyResourcesResults = await updateProxyResources(
orgId,
config,
+32 -2
View File
@@ -23,6 +23,8 @@ import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
@@ -114,6 +116,30 @@ export async function updateClientResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
@@ -366,7 +392,9 @@ export async function updateClientResources(
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
@@ -510,7 +538,9 @@ export async function updateClientResources(
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
+92
View File
@@ -47,6 +47,7 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { build } from "@server/build";
export type ProxyResourcesResults = {
proxyResource: Resource;
@@ -222,17 +223,59 @@ export async function updateProxyResources(
headers = JSON.stringify(resourceData.headers);
}
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPublicResources
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
);
}
}
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.resourcePolicies
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
);
}
}
if (existingResource) {
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
throw new Error(
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
);
}
}
domain = await getDomain(
existingResource.resourceId,
resourceData["full-domain"]!,
orgId,
trx
);
await enforceDomainNamespacePaywall(
orgId,
domain.domainId,
trx
);
}
// check if the only key in the resource is targets, if so, skip the update
@@ -906,12 +949,30 @@ export async function updateProxyResources(
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
throw new Error(
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
);
}
}
domain = await getDomain(
undefined,
resourceData["full-domain"]!,
orgId,
trx
);
await enforceDomainNamespacePaywall(
orgId,
domain.domainId,
trx
);
}
const isLicensed = await isLicensedOrSubscribed(
@@ -1866,6 +1927,37 @@ function checkIfTargetChanged(
return false;
}
async function enforceDomainNamespacePaywall(
orgId: string,
domainId: string,
trx: Transaction
) {
if (build !== "saas") {
return;
}
const hasDomainNamespaceAccess = await isLicensedOrSubscribed(
orgId,
tierMatrix.domainNamespaces
);
if (hasDomainNamespaceAccess) {
return;
}
const [namespaceDomain] = await trx
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
throw new Error(
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
);
}
}
export async function getDomain(
resourceId: number | undefined,
fullDomain: string,
+653
View File
@@ -0,0 +1,653 @@
import {
db,
idp,
idpOrg,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
roles,
Transaction,
userOrgs,
userPolicies,
users
} from "@server/db";
import { eq, and, or } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
export type ResourcePoliciesResults = {
resourcePolicyId: number;
niceId: string;
}[];
export async function updateResourcePolicies(
orgId: string,
config: Config,
trx: Transaction
): Promise<ResourcePoliciesResults> {
const results: ResourcePoliciesResults = [];
for (const [policyNiceId, policyData] of Object.entries(
config["resource-policies"]
)) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.resourcePolicies
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
);
}
// Validate rules
for (const rule of policyData.rules) {
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
throw new Error(
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
throw new Error(
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (
rule.match === "path" &&
!isValidUrlGlobPattern(rule.value)
) {
throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
);
}
}
// Validate auto-login-idp if provided
if (policyData["auto-login-idp"]) {
const [provider] = await trx
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(
eq(idp.idpId, policyData["auto-login-idp"]),
eq(idpOrg.orgId, orgId)
)
)
.limit(1);
if (!provider) {
throw new Error(
`Identity provider not found for policy '${policyNiceId}' in this organization`
);
}
}
// Look up the admin role
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error("Admin role not found");
}
// Find existing policy by niceId and orgId
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, policyNiceId),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
let resourcePolicyId: number;
if (existingPolicy) {
// Update the existing policy
await trx
.update(resourcePolicies)
.set({
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] || policyData.rules.length > 0
})
.where(
eq(
resourcePolicies.resourcePolicyId,
existingPolicy.resourcePolicyId
)
);
resourcePolicyId = existingPolicy.resourcePolicyId;
// Sync password
await trx
.delete(resourcePolicyPassword)
.where(
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicyId
)
);
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Sync pincode
await trx
.delete(resourcePolicyPincode)
.where(
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
);
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Sync header auth
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicyId
)
);
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Sync SSO roles
await syncRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Sync SSO users
await syncUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Sync whitelist users
await syncWhitelistPolicyUsers(
resourcePolicyId,
policyData["whitelist-users"],
trx
);
// Sync rules
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
logger.debug(
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
);
} else {
// Create a new policy
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] ||
policyData.rules.length > 0,
scope: "global"
})
.returning();
resourcePolicyId = newPolicy.resourcePolicyId;
// Always add admin role
await trx.insert(rolePolicies).values({
roleId: adminRole.roleId,
resourcePolicyId
});
// Add SSO roles
await addRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Add SSO users
await addUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Add password
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Add pincode
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Add header auth
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Add whitelist users
if (policyData["whitelist-users"].length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
policyData["whitelist-users"].map((email) => ({
email,
resourcePolicyId
}))
);
}
// Add rules
if (policyData.rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
policyData.rules.map((rule, index) => ({
resourcePolicyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: rule.priority ?? index + 1,
enabled: rule.enabled ?? true
}))
);
}
logger.debug(
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
);
}
results.push({ resourcePolicyId, niceId: policyNiceId });
}
return results;
}
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
if (input === "allow") return "ACCEPT";
if (input === "deny") return "DROP";
return "PASS";
}
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION";
}
async function syncRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
const existingRolePolicies = await trx
.select()
.from(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, policyId));
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin role is always included, skip
}
const alreadyExists = existingRolePolicies.some(
(rp) => rp.roleId === role.roleId
);
if (!alreadyExists) {
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
// Remove roles no longer in the list (except admin)
for (const existingRolePolicy of existingRolePolicies) {
if (existingRolePolicy.roleId === adminRoleId) {
continue;
}
const [role] = await trx
.select()
.from(roles)
.where(eq(roles.roleId, existingRolePolicy.roleId))
.limit(1);
if (role?.isAdmin) {
continue;
}
if (role && !ssoRoles.includes(role.name)) {
await trx
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, existingRolePolicy.roleId)
)
);
}
}
}
async function addRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin already added
}
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
async function syncUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
const existingUserPolicies = await trx
.select()
.from(userPolicies)
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.user.userId
);
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
// Remove users no longer in the list
for (const existingUserPolicy of existingUserPolicies) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
eq(users.userId, existingUserPolicy.userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (
user &&
user.user.username &&
!ssoUsers.includes(user.user.username) &&
!ssoUsers.includes(user.user.email ?? "")
) {
await trx
.delete(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, existingUserPolicy.userId)
)
);
}
}
}
async function addUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
async function syncWhitelistPolicyUsers(
policyId: number,
whitelistUsers: string[],
trx: Transaction
) {
const existingWhitelist = await trx
.select()
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
for (const email of whitelistUsers) {
const alreadyExists = existingWhitelist.some((w) => w.email === email);
if (!alreadyExists) {
await trx.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
}
}
for (const existingEntry of existingWhitelist) {
if (!whitelistUsers.includes(existingEntry.email)) {
await trx
.delete(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, existingEntry.email)
)
);
}
}
}
async function syncPolicyRules(
policyId: number,
rules: ResourcePolicyData["rules"],
trx: Transaction
) {
const existingRules = await trx
.select()
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
.orderBy(resourcePolicyRules.priority);
for (const [index, rule] of rules.entries()) {
const intendedPriority = rule.priority ?? index + 1;
const existingRule = existingRules[index];
if (existingRule) {
await trx
.update(resourcePolicyRules)
.set({
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
})
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
} else {
await trx.insert(resourcePolicyRules).values({
resourcePolicyId: policyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
});
}
}
// Remove extra rules
if (existingRules.length > rules.length) {
const rulesToDelete = existingRules.slice(rules.length);
for (const rule of rulesToDelete) {
await trx
.delete(resourcePolicyRules)
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
}
}
}
+57 -1
View File
@@ -83,7 +83,8 @@ export const RuleSchema = z
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.coerce.string(),
priority: z.int().optional()
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
})
.refine(
(rule) => {
@@ -507,6 +508,52 @@ export const PrivateResourceSchema = z
}
);
export const ResourcePolicyRuleSchema = RuleSchema;
export const ResourcePolicySchema = z.object({
name: z.string().min(1).max(255),
sso: z.boolean().optional().default(true),
"auto-login-idp": z.int().positive().optional().nullable(),
"sso-roles": z
.array(z.string())
.optional()
.default([])
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles"
}),
"sso-users": z.array(z.string()).optional().default([]),
password: z.string().min(4).max(100).optional().nullable(),
pincode: z
.string()
.regex(/^\d{6}$/)
.optional()
.nullable(),
"basic-auth": z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
"extended-compatibility": z.boolean().default(true)
})
.optional()
.nullable(),
"email-whitelist-enabled": z.boolean().optional().default(false),
"whitelist-users": z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
"apply-rules": z.boolean().optional().default(false),
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
});
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
// Schema for the entire configuration object
export const ConfigSchema = z
.object({
@@ -526,6 +573,10 @@ export const ConfigSchema = z
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
"resource-policies": z
.record(z.string(), ResourcePolicySchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
})
.transform((data) => {
@@ -556,6 +607,10 @@ export const ConfigSchema = z
string,
z.infer<typeof PrivateResourceSchema>
>;
"resource-policies": Record<
string,
z.infer<typeof ResourcePolicySchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
})
@@ -695,3 +750,4 @@ export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
+1 -1
View File
@@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2(
return;
}
let targets: SubnetProxyTargetV2[] = [];
const targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
+4 -3
View File
@@ -44,7 +44,8 @@ export async function getTraefikConfig(
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
allowBrowserGatewayResources = true
): Promise<any> {
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
@@ -240,7 +241,7 @@ export async function getTraefikConfig(
continue;
}
if (resource.http) {
if (resource.mode === "http") {
if (!resource.domainId || !resource.fullDomain) {
continue;
}
@@ -572,7 +573,7 @@ export async function getTraefikConfig(
serviceName
].loadBalancer.serversTransport = transportName;
}
} else {
} else if (resource.mode === "tcp" || resource.mode === "udp") {
// Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy || !resource.proxyPort) {
continue;
+322
View File
@@ -0,0 +1,322 @@
import { assertEquals } from "@test/assert";
/**
* Tests for the cross-organization site binding prevention in verifySiteAccess.
*
* verifySiteAccess now includes a check: if req.userOrgId is already set by a
* previous middleware (e.g. verifyResourceAccess or verifyTargetAccess), and the
* loaded site's orgId differs from req.userOrgId, the request is rejected with
* 403 Forbidden.
*
* Route stacks after fix:
* PUT /resource/:resourceId/target
* verifyResourceAccess verifySiteAccess verifyLimits ...
* POST /target/:targetId
* verifyTargetAccess verifySiteAccess verifyLimits ...
*
* verifyResourceAccess sets req.userOrgId to the resource's org.
* verifyTargetAccess sets req.userOrgId to the target's resource org.
* verifySiteAccess then checks site.orgId against req.userOrgId before
* overwriting it with the site's org.
*/
// --- Core org-matching logic (mirrors the check in verifySiteAccess) ---
function siteOrgMatchesExpectedOrg(
siteOrgId: string | null | undefined,
expectedOrgId: string | null | undefined
): boolean {
if (!siteOrgId || !expectedOrgId) {
return false;
}
return siteOrgId === expectedOrgId;
}
// Simulates the condition check in verifySiteAccess:
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
function shouldRejectCrossOrgSite(
siteOrgId: string,
reqUserOrgId: string | undefined
): boolean {
// The actual check in verifySiteAccess is:
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
return !!(reqUserOrgId && siteOrgId !== reqUserOrgId);
}
// --- Tests ---
function testSiteOrgMatchLogic() {
console.log("Running verifySiteAccess org-match logic tests...");
// Test 1: Same org — should match
{
const result = siteOrgMatchesExpectedOrg(
"org-attacker",
"org-attacker"
);
assertEquals(result, true, "Same org should match");
}
// Test 2: Different org — should NOT match (cross-org bypass scenario)
{
const result = siteOrgMatchesExpectedOrg("org-victim", "org-attacker");
assertEquals(
result,
false,
"Cross-org site should NOT match expected org"
);
}
// Test 3: Site orgId is null — should NOT match
{
const result = siteOrgMatchesExpectedOrg(null, "org-attacker");
assertEquals(result, false, "Null site orgId should NOT match");
}
// Test 4: Expected orgId is null — should NOT match
{
const result = siteOrgMatchesExpectedOrg("org-attacker", null);
assertEquals(result, false, "Null expected orgId should NOT match");
}
// Test 5: Both null — should NOT match
{
const result = siteOrgMatchesExpectedOrg(null, null);
assertEquals(result, false, "Both null should NOT match");
}
// Test 6: Empty string orgIds — should NOT match (empty string is falsy)
{
const result = siteOrgMatchesExpectedOrg("", "org-attacker");
assertEquals(result, false, "Empty site orgId should NOT match");
}
// Test 7: Undefined orgIds — should NOT match
{
const result = siteOrgMatchesExpectedOrg(undefined, "org-attacker");
assertEquals(result, false, "Undefined site orgId should NOT match");
}
console.log("All verifySiteAccess org-match logic tests passed.");
}
function testShouldRejectCrossOrgSite() {
console.log(
"Running shouldRejectCrossOrgSite tests (mirrors verifySiteAccess check)..."
);
// Test: No prior org context (undefined) — should NOT reject
// This is the normal case for site-only routes (e.g. PUT /site/:siteId)
// where verifySiteAccess runs without a prior verifyResourceAccess.
{
const shouldReject = shouldRejectCrossOrgSite("org-victim", undefined);
assertEquals(
shouldReject,
false,
"No prior org context should NOT reject (normal site routes)"
);
}
// Test: Same org — should NOT reject
{
const shouldReject = shouldRejectCrossOrgSite(
"org-attacker",
"org-attacker"
);
assertEquals(shouldReject, false, "Same org should NOT reject");
}
// Test: Different org — should reject
{
const shouldReject = shouldRejectCrossOrgSite(
"org-victim",
"org-attacker"
);
assertEquals(shouldReject, true, "Cross-org site should be rejected");
}
// Test: Empty string userOrgId — should NOT reject (falsy, check is skipped)
{
const shouldReject = shouldRejectCrossOrgSite("org-victim", "");
assertEquals(
shouldReject,
false,
"Empty string userOrgId should NOT reject (check is skipped)"
);
}
console.log("All shouldRejectCrossOrgSite tests passed.");
}
// --- Route stack validation tests ---
function testRouteStackOrdering() {
console.log("Running route stack ordering tests...");
const createTargetStack = [
"verifyResourceAccess",
"verifySiteAccess",
"verifyLimits",
"verifyUserHasAction",
"logActionAudit",
"createTarget"
];
const updateTargetStack = [
"verifyTargetAccess",
"verifySiteAccess",
"verifyLimits",
"verifyUserHasAction",
"logActionAudit",
"updateTarget"
];
// Verify verifySiteAccess comes after resource/target access middleware
{
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
const resourceAccessIndex = createTargetStack.indexOf(
"verifyResourceAccess"
);
assertEquals(
siteAccessIndex > resourceAccessIndex,
true,
"verifySiteAccess must come after verifyResourceAccess in create target stack"
);
}
{
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
const targetAccessIndex =
updateTargetStack.indexOf("verifyTargetAccess");
assertEquals(
siteAccessIndex > targetAccessIndex,
true,
"verifySiteAccess must come after verifyTargetAccess in update target stack"
);
}
// Verify verifySiteAccess comes before the handler
{
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
const handlerIndex = createTargetStack.indexOf("createTarget");
assertEquals(
siteAccessIndex < handlerIndex,
true,
"verifySiteAccess must come before createTarget handler"
);
}
{
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
const handlerIndex = updateTargetStack.indexOf("updateTarget");
assertEquals(
siteAccessIndex < handlerIndex,
true,
"verifySiteAccess must come before updateTarget handler"
);
}
console.log("All route stack ordering tests passed.");
}
// --- Security scenario tests ---
function testSecurityScenarios() {
console.log("Running security scenario tests...");
// Scenario 1: Attacker has resource access in org_attacker, but tries to
// bind target to a site in org_victim.
// verifyResourceAccess passes (sets req.userOrgId = "org_attacker").
// verifySiteAccess loads site (org_victim), checks site.orgId !== req.userOrgId.
// Expected: 403 Forbidden.
{
const shouldReject = shouldRejectCrossOrgSite(
"org_victim",
"org_attacker"
);
assertEquals(
shouldReject,
true,
"Scenario 1: Cross-org site binding must be rejected"
);
}
// Scenario 2: Attacker has resource access AND site access in another org.
// Even though the user has site access, verifySiteAccess rejects because
// the org-match check runs before the site access check.
// Expected: 403 Forbidden (org mismatch caught before site access check).
{
const shouldReject = shouldRejectCrossOrgSite(
"org_victim",
"org_attacker"
);
assertEquals(
shouldReject,
true,
"Scenario 2: Cross-org site must be rejected even if user has site access"
);
}
// Scenario 3: Legitimate user creates target with site in same org.
// verifyResourceAccess passes, verifySiteAccess org-match passes (same org),
// verifySiteAccess site access passes.
// Expected: 201 Created.
{
const shouldReject = shouldRejectCrossOrgSite(
"org_attacker",
"org_attacker"
);
assertEquals(
shouldReject,
false,
"Scenario 3: Same-org site must be allowed"
);
}
// Scenario 4: WireGuard site in victim org — org mismatch is caught before
// any DB write, pickPort, addPeer, or addTargets side effect.
{
const shouldReject = shouldRejectCrossOrgSite(
"org_victim",
"org_attacker"
);
assertEquals(
shouldReject,
true,
"Scenario 4: WireGuard cross-org site must be rejected before addPeer"
);
}
// Scenario 5: Newt site in victim org — same as scenario 4 but for newt.
{
const shouldReject = shouldRejectCrossOrgSite(
"org_victim",
"org_attacker"
);
assertEquals(
shouldReject,
true,
"Scenario 5: Newt cross-org site must be rejected before addTargets"
);
}
// Scenario 6: Normal site-only route (e.g. PUT /site/:siteId) where
// verifySiteAccess runs without a prior verifyResourceAccess.
// req.userOrgId is undefined, so the org-match check is skipped.
// Normal site access verification proceeds.
{
const shouldReject = shouldRejectCrossOrgSite("org_victim", undefined);
assertEquals(
shouldReject,
false,
"Scenario 6: Site-only routes should skip org-match check"
);
}
console.log("All security scenario tests passed.");
}
// Run all tests
testSiteOrgMatchLogic();
testShouldRejectCrossOrgSite();
testRouteStackOrdering();
testSecurityScenarios();
+10 -4
View File
@@ -71,6 +71,15 @@ export async function verifySiteAccess(
);
}
if (req.userOrgId && site.orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this site"
)
);
}
if (!req.userOrg) {
// Get user's role ID in the organization
const userOrgRole = await db
@@ -128,10 +137,7 @@ export async function verifySiteAccess(
.where(
and(
eq(roleSites.siteId, site.siteId),
inArray(
roleSites.roleId,
req.userOrgRoleIds!
)
inArray(roleSites.roleId, req.userOrgRoleIds!)
)
)
.limit(1)
+5 -1
View File
@@ -109,7 +109,11 @@ export const privateConfigSchema = z
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
enable_acme_cert_sync: z.boolean().optional().default(true),
disable_private_http_placeholder: z
.boolean()
.optional()
.default(false)
})
.optional()
.prefault({}),
+20 -3
View File
@@ -410,7 +410,11 @@ export async function getTraefikConfig(
fullDomain: string | null;
mode: "http" | "host" | "cidr" | "ssh";
}[] = [];
if (build == "enterprise") {
if (
build == "enterprise" &&
!privateConfig.getRawPrivateConfig().flags
.disable_private_http_placeholder
) {
// we dont want to do this on the cloud
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
siteResourcesWithFullDomain = await db
@@ -493,16 +497,29 @@ export async function getTraefikConfig(
const transportName = `${key}-transport`;
const headersMiddlewareName = `${key}-headers-middleware`;
logger.debug(
`Processing resource ${resource.name} with domain ${fullDomain} and ${targets.length} targets`
);
if (!resource.enabled) {
logger.debug(
`Resource ${resource.name} is disabled, skipping Traefik config`
);
continue;
}
if (resource.http) {
if (resource.mode == "http") {
if (!resource.domainId) {
logger.debug(
`Resource ${resource.name} does not have a domainId, skipping Traefik config`
);
continue;
}
if (!resource.fullDomain) {
logger.debug(
`Resource ${resource.name} does not have a fullDomain, skipping Traefik config`
);
continue;
}
@@ -958,7 +975,7 @@ export async function getTraefikConfig(
serviceName
].loadBalancer.serversTransport = transportName;
}
} else {
} else if (resource.mode == "tcp" || resource.mode == "udp") {
// Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) {
continue;
@@ -58,6 +58,7 @@ export async function getBrowserTarget(
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
name: resources.name,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
@@ -93,7 +94,8 @@ export async function getBrowserTarget(
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
niceId: browserTarget.niceId,
name: browserTarget.name
},
success: true,
error: false,
+2 -1
View File
@@ -12,6 +12,7 @@
*/
import { Request, Response, NextFunction } from "express";
import { randomInt } from "crypto";
import { z } from "zod";
import {
actionAuditLog,
@@ -392,7 +393,7 @@ export async function signSshKey(
if (existingUserWithSameName) {
let foundUniqueUsername = false;
for (let attempt = 0; attempt < 20; attempt++) {
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
const randomNum = randomInt(0, 101); // 0 to 100
const candidateUsername = `${usernameToUse}${randomNum}`;
const [existingUser] = await db
@@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = {
orgId: string;
resourceId: number;
niceId: string;
name: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
+4 -1
View File
@@ -561,6 +561,7 @@ authenticated.delete(
authenticated.put(
"/resource/:resourceId/target",
verifyResourceAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
@@ -612,6 +613,7 @@ authenticated.get(
authenticated.post(
"/target/:targetId",
verifyTargetAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateTarget),
logActionAudit(ActionsEnum.updateTarget),
@@ -1234,7 +1236,8 @@ export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
authRouter.use(
rateLimit({
windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
windowMs:
config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
max: config.getRawConfig().rate_limits.auth.max_requests,
keyGenerator: (req) =>
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
@@ -332,17 +332,6 @@ export async function validateOidcCallback(
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
for (const org of allOrgs) {
const subscribed = await isSubscribed(
org.orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
// filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
}
}
} else {
allOrgs = await db.select().from(orgs);
}
+56 -23
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { roleResources, roles } from "@server/db";
import { roleResources, roles, rolePolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -131,31 +131,64 @@ export async function addRoleToResource(
);
}
// Check if role already exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Role already assigned to resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// Check if role already exists in the inline policy
const existingEntry = await db
.select()
.from(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, roleId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Role already assigned to resource"
)
);
}
await db.insert(rolePolicies).values({
roleId,
resourcePolicyId: policyId
});
} else {
// Check if role already exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Role already assigned to resource"
)
);
}
await db.insert(roleResources).values({
roleId,
resourceId
});
}
await db.insert(roleResources).values({
roleId,
resourceId
});
return response(res, {
data: {},
success: true,
+56 -23
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,31 +103,64 @@ export async function addUserToResource(
);
}
// Check if user already exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"User already assigned to resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// Check if user already exists in the inline policy
const existingEntry = await db
.select()
.from(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, userId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"User already assigned to resource"
)
);
}
await db.insert(userPolicies).values({
userId,
resourcePolicyId: policyId
});
} else {
// Check if user already exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"User already assigned to resource"
)
);
}
await db.insert(userResources).values({
userId,
resourceId
});
}
await db.insert(userResources).values({
userId,
resourceId
});
return response(res, {
data: {},
success: true,
+29 -1
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db";
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -153,6 +153,34 @@ export async function createResourceRule(
}
}
// Create the new resource rule
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
const [newRule] = await db
.insert(resourcePolicyRules)
.values({
resourcePolicyId: policyId,
action,
match,
value,
priority,
enabled
})
.returning();
return response(res, {
data: newRule,
success: true,
error: false,
message: "Resource rule created successfully",
status: HttpCode.CREATED
});
}
// Create the new resource rule
const [newRule] = await db
.insert(resourceRules)
+43 -1
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db";
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -59,6 +59,48 @@ export async function deleteResourceRule(
const { ruleId } = parsedParams.data;
// Look up resource to determine which table to use
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (isInlinePolicy) {
const [deletedRule] = await db
.delete(resourcePolicyRules)
.where(eq(resourcePolicyRules.ruleId, ruleId))
.returning();
if (!deletedRule) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource rule with ID ${ruleId} not found`
)
);
}
return response(res, {
data: null,
success: true,
error: false,
message: "Resource rule deleted successfully",
status: HttpCode.OK
});
}
// Delete the rule and return the deleted record
const [deletedRule] = await db
.delete(resourceRules)
@@ -1,7 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceWhitelist, users } from "@server/db"; // Assuming these are the correct tables
import {
resourceWhitelist,
resourcePolicyWhiteList,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) {
.where(eq(resourceWhitelist.resourceId, resourceId));
}
async function queryPolicyWhitelist(policyId: number) {
return await db
.select({
email: resourcePolicyWhiteList.email
})
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
}
export type GetResourceWhitelistResponse = {
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
};
@@ -71,7 +84,25 @@ export async function getResourceWhitelist(
const { resourceId } = parsedParams.data;
const whitelist = await queryWhitelist(resourceId);
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const whitelist = isInlinePolicy
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
: await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, {
data: {
+33 -2
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roleResources, roles } from "@server/db";
import { roleResources, roles, rolePolicies, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -27,6 +27,19 @@ async function query(resourceId: number) {
.where(eq(roleResources.resourceId, resourceId));
}
async function queryInlinePolicy(policyId: number) {
return await db
.select({
roleId: roles.roleId,
name: roles.name,
description: roles.description,
isAdmin: roles.isAdmin
})
.from(rolePolicies)
.innerJoin(roles, eq(rolePolicies.roleId, roles.roleId))
.where(eq(rolePolicies.resourcePolicyId, policyId));
}
export type ListResourceRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
};
@@ -75,7 +88,25 @@ export async function listResourceRoles(
const { resourceId } = parsedParams.data;
const resourceRolesList = await query(resourceId);
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const resourceRolesList = isInlinePolicy
? await queryInlinePolicy(resource.defaultResourcePolicyId!)
: await query(resourceId);
return response<ListResourceRolesResponse>(res, {
data: {
+42 -9
View File
@@ -1,5 +1,5 @@
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db";
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, sql } from "drizzle-orm";
@@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) {
return baseQuery;
}
function queryPolicyRules(policyId: number) {
return db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number | null>`null`,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value,
priority: resourcePolicyRules.priority,
enabled: resourcePolicyRules.enabled
})
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
}
export type ListResourceRulesResponse = {
rules: Awaited<ReturnType<typeof queryResourceRules>>;
pagination: { total: number; limit: number; offset: number };
@@ -125,16 +140,34 @@ export async function listResourceRules(
);
}
const baseQuery = queryResourceRules(resourceId);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
let rulesList: Awaited<ReturnType<typeof queryResourceRules>>;
let totalCount: number;
let rulesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
const policyRules = await queryPolicyRules(policyId)
.limit(limit)
.offset(offset);
const countResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
rulesList = policyRules as typeof rulesList;
totalCount = countResult[0].count;
} else {
const baseQuery = queryResourceRules(resourceId);
const countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
rulesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
totalCount = totalCountResult[0].count;
}
// sort rules list by the priority in ascending order
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { roleResources, roles } from "@server/db";
import { roleResources, roles, rolePolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -130,35 +130,71 @@ export async function removeRoleFromResource(
);
}
// Check if role exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Role not found in resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
const existingEntry = await db
.select()
.from(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, roleId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Role not found in resource"
)
);
}
await db
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, roleId)
)
);
} else {
// Check if role exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Role not found in resource"
)
);
}
await db
.delete(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
}
await db
.delete(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
return response(res, {
data: {},
success: true,
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,35 +103,71 @@ export async function removeUserFromResource(
);
}
// Check if user exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
const existingEntry = await db
.select()
.from(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, userId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in resource"
)
);
}
await db
.delete(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, userId)
)
);
} else {
// Check if user exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in resource"
)
);
}
await db
.delete(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
}
await db
.delete(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
return response(res, {
data: {},
success: true,
@@ -3,7 +3,9 @@ import { z } from "zod";
import {
db,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility
resourceHeaderAuthExtendedCompatibility,
resourcePolicyHeaderAuth,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -89,36 +91,73 @@ export async function setResourceHeaderAuth(
const { resourceId } = parsedParams.data;
const { user, password, extendedCompatibility } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
await trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId));
await trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resourceId
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
await Promise.all([
trx
.insert(resourceHeaderAuth)
.values({ resourceId, headerAuthHash }),
trx
.insert(resourceHeaderAuthExtendedCompatibility)
.values({
resourceId,
extendedCompatibilityIsActivated:
extendedCompatibility
})
]);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: policyId,
headerAuthHash,
extendedCompatibility: extendedCompatibility!
});
}
} else {
await trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId));
await trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resourceId
)
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
await Promise.all([
trx
.insert(resourceHeaderAuth)
.values({ resourceId, headerAuthHash }),
trx
.insert(resourceHeaderAuthExtendedCompatibility)
.values({
resourceId,
extendedCompatibilityIsActivated:
extendedCompatibility
})
]);
}
}
});
+46 -10
View File
@@ -1,7 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePassword } from "@server/db";
import {
resourcePassword,
resourcePolicyPassword,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -85,17 +89,49 @@ export async function setResourcePassword(
const { resourceId } = parsedParams.data;
const { password } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
await trx
.delete(resourcePassword)
.where(eq(resourcePassword.resourceId, resourceId));
if (password) {
const passwordHash = await hashPassword(password);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.insert(resourcePassword)
.values({ resourceId, passwordHash });
.delete(resourcePolicyPassword)
.where(
eq(resourcePolicyPassword.resourcePolicyId, policyId)
);
if (password) {
const passwordHash = await hashPassword(password);
await trx
.insert(resourcePolicyPassword)
.values({ resourcePolicyId: policyId, passwordHash });
}
} else {
await trx
.delete(resourcePassword)
.where(eq(resourcePassword.resourceId, resourceId));
if (password) {
const passwordHash = await hashPassword(password);
await trx
.insert(resourcePassword)
.values({ resourceId, passwordHash });
}
}
});
+44 -10
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePincode } from "@server/db";
import { resourcePincode, resourcePolicyPincode, resources } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -89,17 +89,51 @@ export async function setResourcePincode(
const { resourceId } = parsedParams.data;
const { pincode } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
await trx
.delete(resourcePincode)
.where(eq(resourcePincode.resourceId, resourceId));
if (pincode) {
const pincodeHash = await hashPassword(pincode);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.insert(resourcePincode)
.values({ resourceId, pincodeHash, digitLength: 6 });
.delete(resourcePolicyPincode)
.where(
eq(resourcePolicyPincode.resourcePolicyId, policyId)
);
if (pincode) {
const pincodeHash = await hashPassword(pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId: policyId,
pincodeHash,
digitLength: 6
});
}
} else {
await trx
.delete(resourcePincode)
.where(eq(resourcePincode.resourceId, resourceId));
if (pincode) {
const pincodeHash = await hashPassword(pincode);
await trx
.insert(resourcePincode)
.values({ resourceId, pincodeHash, digitLength: 6 });
}
}
});
+51 -18
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { apiKeys, roleResources, roles } from "@server/db";
import { apiKeys, roleResources, roles, rolePolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -129,28 +129,61 @@ export async function setResourceRoles(
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
if (adminRoleIds.length > 0) {
await trx.delete(roleResources).where(
and(
eq(roleResources.resourceId, resourceId),
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// For inline policy, preserve admin roles by only deleting non-admin entries
if (adminRoleIds.length > 0) {
await trx
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
ne(rolePolicies.roleId, adminRoleIds[0])
)
);
} else {
await trx
.delete(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, policyId));
}
await Promise.all(
roleIds.map((roleId) =>
trx
.insert(rolePolicies)
.values({ roleId, resourcePolicyId: policyId })
.returning()
)
);
} else {
await trx
.delete(roleResources)
.where(eq(roleResources.resourceId, resourceId));
}
if (adminRoleIds.length > 0) {
await trx.delete(roleResources).where(
and(
eq(roleResources.resourceId, resourceId),
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(roleResources)
.where(eq(roleResources.resourceId, resourceId));
}
const newRoleResources = await Promise.all(
roleIds.map((roleId) =>
trx
.insert(roleResources)
.values({ roleId, resourceId })
.returning()
)
);
await Promise.all(
roleIds.map((roleId) =>
trx
.insert(roleResources)
.values({ roleId, resourceId })
.returning()
)
);
}
return response(res, {
data: {},
+44 -12
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies, resources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -82,19 +82,51 @@ export async function setResourceUsers(
const { resourceId } = parsedParams.data;
await db.transaction(async (trx) => {
await trx
.delete(userResources)
.where(eq(userResources.resourceId, resourceId));
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
const newUserResources = await Promise.all(
userIds.map((userId) =>
trx
.insert(userResources)
.values({ userId, resourceId })
.returning()
)
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.delete(userPolicies)
.where(eq(userPolicies.resourcePolicyId, policyId));
await Promise.all(
userIds.map((userId) =>
trx
.insert(userPolicies)
.values({ userId, resourcePolicyId: policyId })
.returning()
)
);
} else {
await trx
.delete(userResources)
.where(eq(userResources.resourceId, resourceId));
await Promise.all(
userIds.map((userId) =>
trx
.insert(userResources)
.values({ userId, resourceId })
.returning()
)
);
}
return response(res, {
data: {},
+129 -46
View File
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -104,57 +109,135 @@ export async function setResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const whitelist = await db
.select()
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await db.transaction(async (trx) => {
// diff the emails
const existingEmails = whitelist.map((w) => w.email);
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
const emailsToAdd = emails.filter(
(e) => !existingEmails.includes(e)
);
const emailsToRemove = existingEmails.filter(
(e) => !emails.includes(e)
);
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
for (const email of emailsToAdd) {
await trx.insert(resourceWhitelist).values({
email,
resourceId
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingPolicyWhitelist = await db
.select()
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
await db.transaction(async (trx) => {
const existingEmails = existingPolicyWhitelist.map(
(w) => w.email
);
const emailsToAdd = emails.filter(
(e) => !existingEmails.includes(e)
);
const emailsToRemove = existingEmails.filter(
(e) => !emails.includes(e)
);
for (const email of emailsToAdd) {
await trx.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
}
for (const email of emailsToRemove) {
await trx
.delete(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Whitelist set for resource successfully",
status: HttpCode.CREATED
});
}
for (const email of emailsToRemove) {
await trx
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Whitelist set for resource successfully",
status: HttpCode.CREATED
});
});
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const whitelist = await db
.select()
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
await db.transaction(async (trx) => {
// diff the emails
const existingEmails = whitelist.map((w) => w.email);
const emailsToAdd = emails.filter(
(e) => !existingEmails.includes(e)
);
const emailsToRemove = existingEmails.filter(
(e) => !emails.includes(e)
);
for (const email of emailsToAdd) {
await trx.insert(resourceWhitelist).values({
email,
resourceId
});
}
for (const email of emailsToRemove) {
await trx
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Whitelist set for resource successfully",
status: HttpCode.CREATED
});
});
}
} catch (error) {
logger.error(error);
return next(
+52
View File
@@ -549,6 +549,58 @@ async function updateHttpResource(
updateData.maintenanceEstimatedTime = undefined;
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
const {
sso,
emailWhitelistEnabled,
applyRules,
skipToIdpId,
...resourceOnlyData
} = updateData;
const policyUpdate: Record<string, unknown> = {};
if (sso !== undefined) policyUpdate.sso = sso;
if (emailWhitelistEnabled !== undefined)
policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled;
if (applyRules !== undefined) policyUpdate.applyRules = applyRules;
if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId;
if (Object.keys(policyUpdate).length > 0) {
await db
.update(resourcePolicies)
.set(policyUpdate)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
}
const updatedResource = await db
.update(resources)
.set({ ...resourceOnlyData, headers })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
if (updatedResource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resource.resourceId} not found`
)
);
}
return response(res, {
data: updatedResource[0],
success: true,
error: false,
message: "HTTP resource updated successfully",
status: HttpCode.OK
});
}
const updatedResource = await db
.update(resources)
.set({ ...updateData, headers })
@@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({
// Notify parent of changes (create mode)
useEffect(() => {
onChange?.(targets);
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
}, [targets]);
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({
@@ -334,19 +334,15 @@ export function ProxyResourceTargetsForm({
{row.original.siteType === "newt" ? (
<Button
variant="outline"
className="flex items-center gap-2 w-full text-left cursor-pointer"
className="flex items-center space-x-2 w-full text-left cursor-pointer"
onClick={() =>
openHealthCheckDialog(row.original)
}
>
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
<span>{getStatusText(status)}</span>
</Button>
) : (
<span>-</span>
@@ -535,7 +531,7 @@ export function ProxyResourceTargetsForm({
accessorKey: "enabled",
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
@@ -554,9 +550,8 @@ export function ProxyResourceTargetsForm({
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<div className="flex items-center justify-end w-full">
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
@@ -7,7 +7,10 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
@@ -386,9 +389,9 @@ function SshServerForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</p>
</SettingsSubsectionTitle>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
@@ -397,9 +400,9 @@ function SshServerForm({
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshAuthenticationMethod")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
@@ -410,9 +413,9 @@ function SshServerForm({
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshAuthDaemonLocation")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
@@ -460,14 +463,14 @@ function SshServerForm({
)}
<div className="space-y-3">
<div>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</h2>
<p className="text-sm text-muted-foreground">
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</p>
</div>
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
@@ -2,13 +2,6 @@
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import {
SettingsContainer,
SettingsSection,
@@ -16,7 +9,10 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
@@ -48,29 +44,6 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
@@ -84,32 +57,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
LocalTarget,
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ChevronsUpDown,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -119,13 +76,11 @@ import { toASCII } from "punycode";
import {
useMemo,
useState,
useCallback,
useTransition,
useEffect
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@app/lib/cn";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -327,14 +282,25 @@ export default function Page() {
const rawResourcesAllowed =
env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"];
const base: NewResourceType[] = ["http"];
if (enterpriseModesAllowed) {
base.push("ssh", "rdp", "vnc");
}
if (rawResourcesAllowed) {
base.push("tcp", "udp");
}
return base;
}, [rawResourcesAllowed]);
}, [enterpriseModesAllowed, rawResourcesAllowed]);
useEffect(() => {
if (!availableTypes.includes(resourceType)) {
setResourceType("http");
}
}, [availableTypes, resourceType]);
const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema),
@@ -686,19 +652,25 @@ export default function Page() {
}
];
const typeLabels: Record<NewResourceType, string> = {
let typeLabels: Partial<Record<NewResourceType, string>> = {
http: "HTTP",
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
tcp: "TCP",
udp: "UDP"
};
if (enterpriseModesAllowed) {
typeLabels = {
...typeLabels,
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
}
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
availableTypes.map((type) => ({
value: type,
label: typeLabels[type]
label: typeLabels[type] ?? type.toUpperCase()
}));
return (
@@ -742,7 +714,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="base-resource-form"
>
<FormField
@@ -825,7 +797,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
@@ -910,10 +882,10 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Mode */}
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"standard" | "native"
>
@@ -924,12 +896,12 @@ export default function Page() {
/>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthenticationMethod"
)}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"passthrough" | "push"
>
@@ -944,12 +916,12 @@ export default function Page() {
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthDaemonLocation"
)}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"site" | "remote"
>
@@ -984,49 +956,55 @@ export default function Page() {
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</Form>
)}
{/* Server Destination */}
<div className="space-y-3">
<div>
<h2 className="text-sm font-semibold">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</h2>
<p className="text-sm text-muted-foreground">
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</p>
</div>
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
@@ -1038,7 +1016,7 @@ export default function Page() {
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
className="w-full md:w-1/2 justify-between font-normal"
>
<span className="truncate">
{nativeSelectedSite?.name ??
@@ -256,7 +256,7 @@ export default function GeneralPage() {
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-3">
<div className="">
<SwitchInput
id="auto-update-enabled"
label={t(
@@ -285,7 +285,7 @@ export default function GeneralPage() {
type="button"
variant="link"
size="sm"
className="h-auto p-0 pb-2 text-xs"
className="text-sm text-muted-foreground underline px-0"
onClick={() => {
form.setValue(
"autoUpdateOverrideOrg",
+1 -3
View File
@@ -243,10 +243,8 @@ export default function Page() {
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
description={t("idpAutoProvisionConfigureAfterCreate")}
/>
<p className="text-sm text-muted-foreground">
{t("idpAutoProvisionConfigureAfterCreate")}
</p>
</div>
</SettingsSectionBody>
</SettingsSection>
+2 -2
View File
@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.88 0.004 286.32);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 15%);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
+1 -1
View File
@@ -137,7 +137,7 @@ export const orgNavSections = (
}
]
},
...(build !== "oss"
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarPolicies",
+1 -1
View File
@@ -86,7 +86,7 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
const primaryOrg = orgs.find((org) => org.isPrimaryOrg);
if (!ownedOrg) {
if (primaryOrg) {
ownedOrg = primaryOrg;
+3 -3
View File
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
export default async function MaintenanceScreen() {
const t = await getTranslations();
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
let steps = t("privateMaintenanceScreenSteps");
const title = t("privateMaintenanceScreenTitle");
const message = t("privateMaintenanceScreenMessage");
const steps = t("privateMaintenanceScreenSteps");
return (
<div className="min-h-screen flex items-center justify-center p-4">
+181 -193
View File
@@ -1,9 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
@@ -22,7 +32,10 @@ import {
CardTitle,
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useTranslations } from "next-intl";
declare module "react" {
namespace JSX {
@@ -40,7 +53,7 @@ declare module "react" {
}
}
type FormState = {
type RdpCredentialsForm = {
username: string;
password: string;
domain: string;
@@ -49,6 +62,23 @@ type FormState = {
enableClipboard: boolean;
};
function loadStoredCredentials(key: string): RdpCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
}
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
@@ -60,33 +90,35 @@ const isIronError = (error: unknown): error is IronError => {
export default function RdpClient({
target,
error
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_rdp_credentials";
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
const formSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
password: z.string().min(1, { message: t("passwordRequired") }),
domain: z.string(),
kdcProxyUrl: z.string(),
pcb: z.string(),
enableClipboard: z.boolean()
});
const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
@@ -138,7 +170,7 @@ export default function RdpClient({
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
title: t("rdpFailedToLoadModule"),
description: `${err}`
});
});
@@ -160,25 +192,17 @@ export default function RdpClient({
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
const startSession = async (values: RdpCredentialsForm) => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
setSubmitError(t("rdpModuleInitializing"));
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
userInteraction.setEnableClipboard(values.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
@@ -193,7 +217,9 @@ export default function RdpClient({
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
title: t("rdpDownloadingFiles", {
count: downloadable.length
})
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -211,7 +237,9 @@ export default function RdpClient({
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
title: t("rdpDownloadFailed", {
fileName: file.name
}),
description: `${err}`
});
});
@@ -220,7 +248,7 @@ export default function RdpClient({
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
toast({ title: t("rdpUploaded", { fileName: file.name }) });
});
// Register with the web component so CLIPRDR extensions are
@@ -232,11 +260,7 @@ export default function RdpClient({
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
setSubmitError(t("rdpNoConnectionTarget"));
return;
}
@@ -244,13 +268,13 @@ export default function RdpClient({
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withUsername(values.username)
.withPassword(values.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withServerDomain(values.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
@@ -258,18 +282,18 @@ export default function RdpClient({
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
if (values.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(values.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
if (values.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
@@ -285,21 +309,18 @@ export default function RdpClient({
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
setSubmitError(err.backtrace());
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
setSubmitError(`${err}`);
}
}
};
const onSubmit = (values: RdpCredentialsForm) => {
setSubmitError(null);
startSession(values);
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
@@ -315,133 +336,114 @@ export default function RdpClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>RDP</CardTitle>
<CardTitle>{t("rdpTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{showLogin && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>Sign in to Remote Desktop</CardTitle>
<CardTitle>
{resourceName
? `${t("rdpSignInTitle")} - ${resourceName}`
: t("rdpSignInTitle")}
</CardTitle>
<CardDescription>
Enter Windows credentials to access xxxx
{resourceName
? `${t("rdpSignInDescription")} (${resourceName})`
: t("rdpSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{moduleReady
? "Connect"
: "Loading module..."}
</Button>
</div>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("domain")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!moduleReady || connecting}
loading={connecting}
className="w-full"
>
{t("browserGatewayConnect")}
</Button>
{submitError && (
<Alert variant="destructive">
<AlertDescription>
{submitError}
</AlertDescription>
</Alert>
)}
</form>
</Form>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
)}
<div
@@ -454,35 +456,35 @@ export default function RdpClient({
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
{t("rdpReal")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
{t("rdpMeta")}
</Button>
{/* <Button
size="sm"
@@ -504,19 +506,22 @@ export default function RdpClient({
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
title: t("rdpFilesReadyToPaste"),
description: t(
"rdpFilesReadyToPasteDescription",
{ count: files.length }
)
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
title: t("rdpUploadFailed"),
description: `${err}`
});
}
}}
>
Upload files
{t("rdpUploadFiles")}
</Button>
<Button
size="sm"
@@ -526,7 +531,7 @@ export default function RdpClient({
setShowLogin(true);
}}
>
Terminate
{t("sshTerminate")}
</Button>
<label className="ml-2 flex items-center gap-2">
<input
@@ -537,7 +542,7 @@ export default function RdpClient({
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
{t("rdpUnicodeKeyboardMode")}
</label>
</div>
@@ -554,20 +559,3 @@ export default function RdpClient({
</>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+18 -25
View File
@@ -1,40 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import RdpClient from "./RdpClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("RDP");
}
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
console.log("Fetched browser target:", target);
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<RdpClient target={target} error={error} />
<RdpClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />
+270 -240
View File
@@ -2,10 +2,19 @@
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import {
Card,
@@ -15,15 +24,17 @@ import {
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@app/lib/cn";
import { ExternalLink, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
type AuthTab = "password" | "privateKey";
type FormState = {
type SshCredentialsForm = {
username: string;
password: string;
privateKey: string;
@@ -36,32 +47,46 @@ type ConnectCredentials = {
certificate?: string;
};
function loadStoredCredentials(key: string): SshCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as SshCredentialsForm;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
}
export default function SshClient({
target,
error,
signedKeyData,
privateKey: signedPrivateKey
privateKey: signedPrivateKey,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
primaryColor?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const t = useTranslations();
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
const passwordTabSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
password: z.string().min(1, { message: t("passwordRequired") })
});
const t = useTranslations();
const privateKeyTabSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") })
});
const [authTab, setAuthTab] = useState<AuthTab>("password");
const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -70,11 +95,10 @@ export default function SshClient({
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
form.setValue("privateKey", text, { shouldDirty: true });
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
@@ -126,14 +150,12 @@ export default function SshClient({
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
@@ -142,7 +164,6 @@ export default function SshClient({
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
@@ -156,14 +177,12 @@ export default function SshClient({
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
@@ -171,7 +190,6 @@ export default function SshClient({
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
@@ -180,11 +198,12 @@ export default function SshClient({
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
function connect(
override?: ConnectCredentials,
authMethod: AuthTab = "password"
) {
setConnecting(true);
if (!target) {
@@ -193,12 +212,14 @@ export default function SshClient({
return;
}
const username = override?.username ?? form.username;
const values = form.getValues();
const username = override?.username ?? values.username;
const password =
override?.password ?? (authTab === "password" ? form.password : "");
override?.password ??
(authMethod === "password" ? values.password : "");
const privateKey =
override?.privateKey ??
(authTab === "privateKey" ? form.privateKey : "");
(authMethod === "privateKey" ? values.privateKey : "");
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
@@ -217,16 +238,10 @@ export default function SshClient({
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
// Track whether the server has confirmed auth by sending the first
// data frame. Until then, errors are shown in the login form.
let authConfirmed = false;
let authErrorShown = false;
ws.onopen = () => {
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data. Stay in "connecting"
// state until the server responds — this prevents the flash to the
// terminal page that would occur if we set connected=true here.
ws.send(
JSON.stringify({
type: "auth",
@@ -237,7 +252,10 @@ export default function SshClient({
);
if (!override) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(form.getValues())
);
} catch {
// ignore
}
@@ -261,7 +279,6 @@ export default function SshClient({
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
if (!authConfirmed) {
// Auth-phase error — show in the login form.
authErrorShown = true;
setConnecting(false);
setConnectError(
@@ -269,7 +286,7 @@ export default function SshClient({
);
} else {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
`\r\n\x1b[31m${t("sshTerminalError", { error: msg.error ?? "" })}\x1b[0m\r\n`
);
}
}
@@ -282,13 +299,13 @@ export default function SshClient({
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => {
evt.data.text().then((text) => {
if (!authConfirmed) {
authConfirmed = true;
setConnecting(false);
setConnected(true);
}
xtermRef.current?.write(t);
xtermRef.current?.write(text);
});
}
};
@@ -304,11 +321,9 @@ export default function SshClient({
if (authConfirmed) {
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
`\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n`
);
}
// If auth was never confirmed the login form is already visible;
// a generic error is shown only when no specific error was received.
if (!authConfirmed && !authErrorShown) {
setConnectError(t("sshErrorConnectionClosed"));
}
@@ -322,7 +337,40 @@ export default function SshClient({
setConnected(false);
}
// In push mode, show a connecting/connected state without the login form.
function applyTabSchemaErrors(
schema: z.ZodObject<z.ZodRawShape>,
values: SshCredentialsForm
) {
form.clearErrors();
const result = schema.safeParse(values);
if (result.success) return true;
for (const issue of result.error.issues) {
const field = issue.path[0];
if (typeof field === "string") {
form.setError(field as keyof SshCredentialsForm, {
message: issue.message
});
}
}
return false;
}
function onPasswordSubmit(e: React.FormEvent) {
e.preventDefault();
setConnectError(null);
const values = form.getValues();
if (!applyTabSchemaErrors(passwordTabSchema, values)) return;
connect(undefined, "password");
}
function onPrivateKeySubmit(e: React.FormEvent) {
e.preventDefault();
setConnectError(null);
const values = form.getValues();
if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return;
connect(undefined, "privateKey");
}
if (signedKeyData && signedPrivateKey) {
return (
<>
@@ -351,7 +399,6 @@ export default function SshClient({
variant="destructive"
className="w-full"
>
<AlertCircle className="h-5 w-5" />
<AlertDescription>
{connectError}
</AlertDescription>
@@ -376,202 +423,202 @@ export default function SshClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("sshPoweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("sshPoweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshSignInTitle")}</CardTitle>
<CardTitle>
{resourceName
? `${t("sshSignInTitle")} - ${resourceName}`
: t("sshSignInTitle")}
</CardTitle>
<CardDescription>
{t("sshSignInDescription")}
{resourceName
? `${t("sshSignInDescription")} (${resourceName})`
: t("sshSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{/* Tab row */}
<div className="flex space-x-4 border-b mb-4">
{(["password", "privateKey"] as const).map(
(tab) => (
<button
key={tab}
type="button"
onClick={() => setAuthTab(tab)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
authTab === tab
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:rounded-full"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab === "password"
? t("sshPasswordTab")
: t("sshPrivateKeyTab")}
</button>
)
)}
</div>
{authTab === "password" && (
<div className="space-y-4">
<Field
label={t("username")}
id="username-pw"
>
<Input
id="username-pw"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label={t("password")} id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
/>
</Field>
</div>
)}
{authTab === "privateKey" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "}
<Link
href="https://docs.pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline inline-flex items-center gap-1"
>
{t("sshLearnMore")}
<ExternalLink className="h-3 w-3" />
</Link>
</p>
<Field
label={t("username")}
id="username-key"
>
<Input
id="username-key"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field
label={t("sshPrivateKeyField")}
id="privateKey"
>
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={5}
className="font-mono text-xs"
/>
</Field>
<Field
label={t("sshPrivateKeyFile")}
id="privateKeyFile"
>
<Input
id="privateKeyFile"
type="file"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
</div>
)}
<div className="mt-4 space-y-3">
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(authTab === "password"
? !form.password
: !form.privateKey)
}
className="w-full"
<Form {...form}>
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{
title: t("sshPasswordTab"),
href: "#"
},
{
title: t("sshPrivateKeyTab"),
href: "#"
}
]}
>
{connecting
? t("sshConnecting")
: t("sshAuthenticate")}
</Button>
</div>
<form
onSubmit={onPasswordSubmit}
className="space-y-4 mt-4 p-1"
>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 space-y-3">
<Button
type="submit"
loading={connecting}
disabled={connecting}
className="w-full"
>
{t("sshAuthenticate")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</div>
</form>
<form
onSubmit={onPrivateKeySubmit}
className="space-y-4 mt-4 p-1"
>
<p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "}
<Link
href="https://docs.pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("sshLearnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
</p>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sshPrivateKeyField"
)}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t(
"sshPrivateKeyPlaceholder"
)}
rows={5}
className="font-mono text-xs"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>
{t("sshPrivateKeyFile")}
</FormLabel>
<FormControl>
<Input
type="file"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</FormControl>
</FormItem>
<div className="mt-4 space-y-3">
<Button
type="submit"
loading={connecting}
disabled={connecting}
className="w-full"
>
{t("sshAuthenticate")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</div>
</form>
</HorizontalTabs>
</Form>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
)}
{connected && (
@@ -595,20 +642,3 @@ export default function SshClient({
</>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+45 -43
View File
@@ -1,11 +1,15 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import SshClient from "./SshClient";
import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { getTranslations } from "next-intl/server";
const pollInitialDelayMs = 250;
const pollStartIntervalMs = 250;
@@ -99,14 +103,13 @@ function generateEphemeralKeyPair(): {
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("SSH");
}
export default async function SshPage() {
const t = await getTranslations();
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
const cookieHeader = headersList.get("cookie") || "";
let target: GetBrowserTargetResponse | null = null;
@@ -114,51 +117,49 @@ export default async function SshPage() {
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
const { target: browserTarget } = await getBrowserTargetForRequest();
target = browserTarget;
if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
if (!target) {
error = t("browserGatewayNoResourceForDomain");
} else if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
);
signedKeyData = res.data.data;
}
);
signedKeyData = res.data.data;
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error =
"Failed to sign SSH key for PAM push authentication. Did you sign in as a user?";
}
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error = t("sshErrorSignKeyFailed");
}
} catch (err) {
console.error("Error fetching browser target:", err);
error = "No resource found for this domain";
}
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
@@ -168,6 +169,7 @@ export default async function SshPage() {
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
primaryColor={primaryColor}
/>
</div>
</div>
+114 -116
View File
@@ -1,9 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import {
@@ -13,41 +23,52 @@ import {
CardTitle,
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useTranslations } from "next-intl";
type FormState = {
type VncCredentialsForm = {
password: string;
};
function loadStoredCredentials(key: string): VncCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as VncCredentialsForm;
} catch {
// ignore
}
return { password: "" };
}
export default function VncClient({
target,
error
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_vnc_credentials";
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
const formSchema = z.object({
password: z.string()
});
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
@@ -56,28 +77,20 @@ export default function VncClient({
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const connect = async () => {
const connect = async (values: VncCredentialsForm) => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
setConnectError(t("vncNoResourceTarget"));
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
@@ -90,14 +103,12 @@ export default function VncClient({
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
title: t("vncFailedToLoadNovnc"),
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
@@ -107,15 +118,13 @@ export default function VncClient({
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
if (values.password) {
options.credentials = { password: values.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
@@ -123,7 +132,7 @@ export default function VncClient({
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
@@ -141,92 +150,99 @@ export default function VncClient({
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
disconnect();
setConnectError(
e.detail.reason ??
t("vncAuthFailedStatus", {
status: e.detail.status
})
);
}
);
rfbRef.current = rfb;
};
const onSubmit = (values: VncCredentialsForm) => {
setConnectError(null);
connect(values);
};
if (error) {
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>{t("vncTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>
{resourceName
? `${t("vncTitle")} - ${resourceName}`
: t("vncTitle")}
</CardTitle>
<CardDescription>
Enter your credentials to access xxxx
{resourceName
? `${t("vncSignInDescription")} (${resourceName})`
: t("vncSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field
label="Password (optional)"
id="password"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("vncPasswordOptional")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
<Button type="submit" className="w-full">
{t("browserGatewayConnect")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</form>
</Form>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
)}
<div
@@ -243,7 +259,7 @@ export default function VncClient({
}
}}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
@@ -257,18 +273,17 @@ export default function VncClient({
.catch(() => {});
}}
>
Paste clipboard
{t("vncPasteClipboard")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
{t("sshTerminate")}
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
@@ -278,20 +293,3 @@ export default function VncClient({
</>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+18 -24
View File
@@ -1,39 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import VncClient from "./VncClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("VNC");
}
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<VncClient target={target} error={error} />
<VncClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type BrandedAuthSurfaceProps = {
primaryColor?: string | null;
children: React.ReactNode;
};
export default function BrandedAuthSurface({
primaryColor,
children
}: BrandedAuthSurfaceProps) {
const { isUnlocked } = useLicenseStatusContext();
return (
<div
style={{
// @ts-expect-error CSS variable
"--primary": isUnlocked() ? primaryColor : null
}}
>
{children}
</div>
);
}
@@ -105,7 +105,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
@@ -116,7 +115,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder={props.defaultPort.toString()}
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
@@ -23,19 +23,22 @@ import {
isHostname,
type InternalResourceFormValues
} from "./PrivateResourceForm";
import type { Selectedsite } from "./site-selector";
type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
initialSites?: Selectedsite[];
};
export default function CreatePrivateResourceDialog({
open,
setOpen,
orgId,
onSuccess
onSuccess,
initialSites
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -175,6 +178,7 @@ export default function CreatePrivateResourceDialog({
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
initialSites={initialSites}
/>
</CredenzaBody>
<CredenzaFooter>
+1 -1
View File
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0 md:overflow-hidden",
className
)}
{...props}
File diff suppressed because it is too large Load Diff
+5 -18
View File
@@ -14,9 +14,10 @@ import {
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
type OrgLoginPageProps = {
loginPage: LoadLoginPageResponse | undefined;
@@ -52,22 +53,8 @@ export default async function OrgLoginPage({
const env = pullEnv();
const t = await getTranslations();
return (
<div>
{build !== "enterprise" || !env.branding.hidePoweredBy ? (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
) : null}
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
<PoweredByPangolin />
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (
@@ -127,6 +114,6 @@ export default async function OrgLoginPage({
{t("loginBack")}
</Link>
</p>
</div>
</BrandedAuthSurface>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
function PoweredByLabel({ brandName }: { brandName: string }) {
const t = useTranslations();
return (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
{brandName === "Pangolin" ? (
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
) : (
brandName
)}
</span>
</div>
);
}
export default function PoweredByPangolin() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
if (isUnlocked() && build === "enterprise") {
if (
env.branding.resourceAuthPage?.hidePoweredBy ||
env.branding.hidePoweredBy
) {
return null;
}
return (
<PoweredByLabel
brandName={env.branding.appName || "Pangolin"}
/>
);
}
return <PoweredByLabel brandName="Pangolin" />;
}
+75 -56
View File
@@ -1,5 +1,10 @@
"use client";
import {
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
OptionSelect,
@@ -208,6 +213,7 @@ type InternalResourceFormProps = {
formId: string;
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
onSubmitDisabledChange?: (disabled: boolean) => void;
initialSites?: Selectedsite[];
};
export function PrivateResourceForm({
@@ -218,7 +224,8 @@ export function PrivateResourceForm({
siteResourceId,
formId,
onSubmit,
onSubmitDisabledChange
onSubmitDisabledChange,
initialSites = []
}: InternalResourceFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
@@ -609,6 +616,8 @@ export function PrivateResourceForm({
authDaemonMode === "remote";
const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null);
const initialSitesRef = useRef(initialSites);
initialSitesRef.current = initialSites;
useEffect(() => {
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
@@ -623,9 +632,13 @@ export function PrivateResourceForm({
// Reset when create dialog opens
useEffect(() => {
if (variant === "create" && open) {
const prefillSites =
initialSitesRef.current.length > 0
? initialSitesRef.current
: [];
form.reset({
name: "",
siteIds: [],
siteIds: prefillSites.map((s) => s.siteId),
mode: "host",
destination: "",
alias: null,
@@ -645,7 +658,7 @@ export function PrivateResourceForm({
users: [],
clients: []
});
setSelectedSites([]);
setSelectedSites(prefillSites);
setSshServerMode("native");
setTcpPortMode("all");
setUdpPortMode("all");
@@ -1799,10 +1812,10 @@ export function PrivateResourceForm({
/>
{/* Mode */}
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<"standard" | "native">
value={sshServerMode}
options={[
@@ -1856,10 +1869,10 @@ export function PrivateResourceForm({
/>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshAuthenticationMethod")}
</p>
</SettingsSubsectionTitle>
<FormField
control={form.control}
name="pamMode"
@@ -1951,10 +1964,10 @@ export function PrivateResourceForm({
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshAuthDaemonLocation")}
</p>
</SettingsSubsectionTitle>
<FormField
control={form.control}
name="authDaemonMode"
@@ -2054,51 +2067,57 @@ export function PrivateResourceForm({
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
disabled={
sshSectionDisabled
}
value={field.value ?? ""}
onChange={(e) => {
if (sshSectionDisabled)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
<div className="w-full md:w-1/2">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
disabled={
sshSectionDisabled
}
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
value={
field.value ?? ""
}
onChange={(e) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
+1 -1
View File
@@ -744,7 +744,7 @@ function TargetStatusCell({
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-2 font-normal"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
+7 -43
View File
@@ -41,8 +41,9 @@ import {
} from "@app/actions/server";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import BrandingLogo from "@app/components/BrandingLogo";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
@@ -366,57 +367,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
: 100;
return (
<div
style={{
// @ts-expect-error CSS variable
"--primary": isUnlocked() ? props.branding?.primaryColor : null
}}
>
<BrandedAuthSurface primaryColor={props.branding?.primaryColor}>
{!accessDenied ? (
<div>
{isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy &&
!env.branding.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
)
) : (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
)}
<PoweredByPangolin />
<Card>
<CardHeader>
{isUnlocked() &&
build !== "oss" &&
(env.branding?.resourceAuthPage?.showLogo ||
props.branding) && (
props.branding?.logoUrl && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={logoHeight}
width={logoWidth}
logoPath={props.branding?.logoUrl}
logoPath={props.branding.logoUrl}
/>
</div>
)}
@@ -790,6 +754,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
) : (
<ResourceAccessDenied />
)}
</div>
</BrandedAuthSurface>
);
}
+1 -1
View File
@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
+36
View File
@@ -63,6 +63,42 @@ export function SettingsSectionDescription({
return <p className="text-muted-foreground text-sm">{children}</p>;
}
export function SettingsSubsectionHeader({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-0.5", className)}>{children}</div>;
}
export function SettingsSubsectionTitle({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
);
}
export function SettingsSubsectionDescription({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
);
}
export function SettingsSectionBody({
children
}: {
+2 -2
View File
@@ -43,8 +43,8 @@ export function SwitchInput({
);
return (
<div>
<div className="flex items-center space-x-2 mb-2">
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
{label && (
<Label
htmlFor={id}
+29 -8
View File
@@ -6,7 +6,7 @@ import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useActionState, useMemo, useRef, useState } from "react";
import { useDebounce } from "use-debounce";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
@@ -88,6 +88,11 @@ export function LabelsSelector({
colorValues[Math.floor(Math.random() * colorValues.length)];
const [, action, isPending] = useActionState(createLabel, null);
const createFormRef = useRef<HTMLFormElement>(null);
const trimmedQuery = labelSearchQuery.trim();
const canCreateLabel =
trimmedQuery.length > 0 && labelsShown.length === 0 && !isPending;
async function createLabel(_: any, formData: FormData) {
const name = formData.get("name")?.toString();
@@ -120,21 +125,28 @@ export function LabelsSelector({
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
placeholder={t("labelSearchOrCreate")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
onKeyDown={(e) => {
if (e.key === "Enter" && canCreateLabel) {
e.preventDefault();
createFormRef.current?.requestSubmit();
}
}}
/>
<CommandList>
<CommandEmpty className="px-3 break-all wrap-anywhere text-wrap">
<CommandEmpty className="px-3 py-6 text-center text-wrap">
{labelSearchQuery.trim().length > 0 ? (
<div className="flex flex-col gap-2 items-center">
<span className="max-w-34">
<span className="max-w-34 break-words">
{t("createNewLabel", {
label: labelSearchQuery.trim()
})}
</span>
<form
ref={createFormRef}
action={action}
className="flex items-center gap-2"
>
@@ -159,14 +171,17 @@ export function LabelsSelector({
className="flex items-center gap-2"
>
<div
className="size-4 rounded-full bg-(--color) flex-none"
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>
{color}
{color
.charAt(0)
.toUpperCase() +
color.slice(1)}
</span>
</SelectItem>
)
@@ -176,7 +191,6 @@ export function LabelsSelector({
<Button
variant="outline"
size="sm"
loading={isPending}
type="submit"
>
@@ -185,7 +199,14 @@ export function LabelsSelector({
</form>
</div>
) : (
t("labelsNotFound")
<div className="flex flex-col gap-1 items-center">
<span className="text-muted-foreground">
{t("labelsNotFound")}
</span>
<span className="text-sm">
{t("labelsEmptyCreateHint")}
</span>
</div>
)}
</CommandEmpty>
<CommandGroup>
+110 -36
View File
@@ -10,7 +10,15 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import {
FaApple,
FaCubes,
FaDocker,
FaHdd,
FaLinux,
FaWindows
} from "react-icons/fa";
import { ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si";
export type CommandItem = string | { title: string; command: string };
@@ -20,6 +28,7 @@ const PLATFORMS = [
"macos",
"docker",
"kubernetes",
"advantech",
"podman",
"nixos",
"windows"
@@ -43,11 +52,15 @@ export function NewtSiteInstallCommands({
const t = useTranslations();
const [acceptClients, setAcceptClients] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
);
const showSiteConfiguration = platform !== "advantech";
const supportsSshOption = platform === "linux" || platform === "nixos";
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
? "\n - DISABLE_CLIENTS=true"
@@ -57,6 +70,11 @@ export function NewtSiteInstallCommands({
--set newtInstances[0].acceptClients=true`
: "";
const disableSshFlag =
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
const runAsRootPrefix =
supportsSshOption && allowPangolinSsh ? "sudo " : "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
linux: {
Run: [
@@ -66,7 +84,7 @@ export function NewtSiteInstallCommands({
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
}
],
"Systemd Service": [
@@ -86,6 +104,11 @@ PANGOLIN_ENDPOINT=${endpoint}${
? `
DISABLE_CLIENTS=true`
: ""
}${
!allowPangolinSsh
? `
DISABLE_SSH=true`
: ""
}
EOF
sudo chmod 600 /etc/newt/newt.env`
@@ -180,6 +203,9 @@ sudo systemctl enable --now newt`
--set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}`
]
},
advantech: {
Documentation: []
},
podman: {
"Podman Quadlet": [
`[Unit]
@@ -205,7 +231,7 @@ WantedBy=default.target`
},
nixos: {
Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
]
}
};
@@ -257,45 +283,87 @@ WantedBy=default.target`
className="mt-4"
/>
<div className="pt-4">
<p className="font-semibold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
{showSiteConfiguration && (
<div className="pt-4">
<p className="font-semibold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
{supportsSshOption && (
<>
<div className="flex items-center space-x-2 mb-2 mt-2">
<CheckboxWithLabel
id="allowPangolinSsh"
checked={allowPangolinSsh}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAllowPangolinSsh(value);
}}
label="Allow Pangolin SSH"
/>
</div>
<p
id="allowPangolinSsh-desc"
className="text-sm text-muted-foreground"
>
{t("sitePangolinSshDescription")}
</p>
</>
)}
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
</div>
)}
<div className="pt-4">
<p className="font-semibold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation
information, see{" "}
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noreferrer"
className="underline"
>
docs.pangolin.net/manage/sites/install-kubernetes
</a>
.
{t.rich("siteInstallKubernetesDocsDescription", {
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
})}
</p>
)}
{platform === "advantech" && (
<p className="text-sm text-muted-foreground mb-3">
{t.rich("siteInstallAdvantechDocsDescription", {
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/install-advantech"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
})}
</p>
)}
<div className="mt-2 space-y-3">
@@ -342,6 +410,8 @@ function getPlatformIcon(platformName: Platform) {
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
return <SiKubernetes className="h-4 w-4 mr-2" />;
case "advantech":
return <FaHdd className="h-4 w-4 mr-2" />;
case "podman":
return <FaCubes className="h-4 w-4 mr-2" />;
case "nixos":
@@ -363,6 +433,8 @@ function getPlatformName(platformName: Platform) {
return "Docker";
case "kubernetes":
return "Kubernetes";
case "advantech":
return "Advantech";
case "podman":
return "Podman";
case "nixos":
@@ -384,6 +456,8 @@ function getArchitectures(platform: Platform) {
return ["Docker Compose", "Docker Run"];
case "kubernetes":
return ["Helm Chart"];
case "advantech":
return ["Documentation"];
case "podman":
return ["Podman Quadlet", "Podman Run"];
case "nixos":
@@ -385,7 +385,7 @@ export function CreatePolicyAuthMethodsSectionForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
@@ -426,7 +426,10 @@ export function CreatePolicyAuthMethodsSectionForm({
{/* Password row */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
className={cn(
"flex items-center text-sm space-x-2",
password && "text-green-500"
)}
>
<Key size="14" />
<span>
@@ -456,7 +459,10 @@ export function CreatePolicyAuthMethodsSectionForm({
{/* Pincode row */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
className={cn(
"flex items-center space-x-2 text-sm",
pincode && "text-green-500"
)}
>
<Binary size="14" />
<span>
@@ -484,7 +490,10 @@ export function CreatePolicyAuthMethodsSectionForm({
{/* Header auth row */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
className={cn(
"flex items-center space-x-2 text-sm",
headerAuth && "text-green-500"
)}
>
<Bot size="14" />
<span>
@@ -491,7 +491,7 @@ export function EditPolicyAuthMethodsSectionForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
@@ -61,6 +61,11 @@ import {
import { MAJOR_ASNS } from "@server/db/asns";
import { COUNTRIES } from "@server/db/countries";
import {
REGIONS,
getRegionNameById,
isValidRegionId
} from "@server/db/regions";
import {
isValidCIDR,
isValidIP,
@@ -210,6 +215,8 @@ export function EditPolicyRulesSectionForm({
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
useState(false);
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
@@ -235,7 +242,8 @@ export function EditPolicyRulesSectionForm({
IP: "IP",
CIDR: t("ipAddressRange"),
COUNTRY: t("country"),
ASN: "ASN"
ASN: "ASN",
REGION: t("region")
}),
[t]
);
@@ -309,6 +317,14 @@ export function EditPolicyRulesSectionForm({
});
return;
}
if (data.match === "REGION" && !isValidRegionId(data.value)) {
toast({
variant: "destructive",
title: t("rulesErrorInvalidRegion"),
description: t("rulesErrorInvalidRegionDescription") || ""
});
return;
}
let priority = data.priority;
if (priority === undefined) {
@@ -378,6 +394,8 @@ export function EditPolicyRulesSectionForm({
return t("rulesMatchCountry");
case "ASN":
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
case "REGION":
return t("rulesMatchRegion");
}
},
[t]
@@ -476,7 +494,13 @@ export function EditPolicyRulesSectionForm({
defaultValue={row.original.match}
disabled={readonly || row.original.fromPolicy}
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
@@ -485,7 +509,9 @@ export function EditPolicyRulesSectionForm({
? "US"
: value === "ASN"
? "AS15169"
: row.original.value
: value === "REGION"
? "021"
: row.original.value
})
}
>
@@ -505,6 +531,11 @@ export function EditPolicyRulesSectionForm({
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAvailable && (
<SelectItem value="REGION">
{RuleMatch.REGION}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
@@ -666,6 +697,111 @@ export function EditPolicyRulesSectionForm({
</div>
</PopoverContent>
</Popover>
) : row.original.match === "REGION" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between"
>
{(() => {
const regionName = getRegionNameById(
row.original.value
);
if (!regionName) {
return t("selectRegion");
}
return `${t(regionName)} (${row.original.value})`;
})()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchRegions")}
/>
<CommandList>
<CommandEmpty>
{t("noRegionFound")}
</CommandEmpty>
{REGIONS.map((continent) => (
<CommandGroup
key={continent.id}
heading={t(continent.name)}
>
<CommandItem
value={continent.id}
keywords={[
t(continent.name),
continent.id
]}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: continent.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} (
{continent.id})
</CommandItem>
{continent.includes.map(
(subregion) => (
<CommandItem
key={subregion.id}
value={subregion.id}
keywords={[
t(
subregion.name
),
subregion.id
]}
onSelect={() =>
updateRule(
row.original
.ruleId,
{
value: subregion.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)}{" "}
({subregion.id})
</CommandItem>
)
)}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
@@ -988,6 +1124,13 @@ export function EditPolicyRulesSectionForm({
}
</SelectItem>
)}
{isMaxmindAvailable && (
<SelectItem value="REGION">
{
RuleMatch.REGION
}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
@@ -1240,6 +1383,160 @@ export function EditPolicyRulesSectionForm({
</div>
</PopoverContent>
</Popover>
) : addRuleForm.watch(
"match"
) === "REGION" ? (
<Popover
open={
openAddRuleRegionSelect
}
onOpenChange={
setOpenAddRuleRegionSelect
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly ||
(!isResourceOverlay &&
!rulesEnabled)
}
aria-expanded={
openAddRuleRegionSelect
}
className="w-full justify-between"
>
{field.value
? (() => {
const regionName =
getRegionNameById(
field.value
);
return regionName
? `${t(regionName)} (${field.value})`
: field.value;
})()
: t(
"selectRegion"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder={t(
"searchRegions"
)}
/>
<CommandList>
<CommandEmpty>
{t(
"noRegionFound"
)}
</CommandEmpty>
{REGIONS.map(
(
continent
) => (
<CommandGroup
key={
continent.id
}
heading={t(
continent.name
)}
>
<CommandItem
value={
continent.id
}
keywords={[
t(
continent.name
),
continent.id
]}
onSelect={() => {
field.onChange(
continent.id
);
setOpenAddRuleRegionSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(
continent.name
)}{" "}
(
{
continent.id
}
)
</CommandItem>
{continent.includes.map(
(
subregion
) => (
<CommandItem
key={
subregion.id
}
value={
subregion.id
}
keywords={[
t(
subregion.name
),
subregion.id
]}
onSelect={() => {
field.onChange(
subregion.id
);
setOpenAddRuleRegionSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(
subregion.name
)}{" "}
(
{
subregion.id
}
)
</CommandItem>
)
)}
</CommandGroup>
)
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
{...field}
@@ -670,7 +670,7 @@ export function PolicyAuthMethodsSection({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
+1 -1
View File
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className
)}
{...props}
+13
View File
@@ -0,0 +1,13 @@
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import type { Metadata } from "next";
export async function generateBrowserGatewayMetadata(
protocol: "SSH" | "RDP" | "VNC"
): Promise<Metadata> {
const { target } = await getBrowserTargetForRequest();
return {
title: target?.name
? `${protocol} - ${target.name}`
: `${protocol} - Pangolin`
};
}
+20
View File
@@ -0,0 +1,20 @@
import { priv } from "@app/lib/api";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { AxiosResponse } from "axios";
import { headers } from "next/headers";
import { cache } from "react";
export const getBrowserTargetForRequest = cache(async () => {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
return { target: res.data.data };
} catch {
return { target: null };
}
});
+31
View File
@@ -0,0 +1,31 @@
import { priv } from "@app/lib/api";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { build } from "@server/build";
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
export async function loadOrgLoginPageBranding(orgId: string): Promise<{
primaryColor: string | null;
}> {
if (build === "oss") {
return { primaryColor: null };
}
const subscribed = await isOrgSubscribed(orgId);
if (!subscribed) {
return { primaryColor: null };
}
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
return { primaryColor: res.data.data.primaryColor ?? null };
}
} catch {
// ignore
}
return { primaryColor: null };
}