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

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.

View File

@@ -0,0 +1,7 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.

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}}

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}}

View File

@@ -1,6 +1,4 @@
{{if .IsRedis}}
redis:
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"
{{end}}
password: "{{.IsRedisPass}}"{{end}}

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")

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"
}

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()
});

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()
});

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,

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);

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,

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));
}
}
}

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>;

View File

@@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2(
return;
}
let targets: SubnetProxyTargetV2[] = [];
const targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),

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;

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();

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)

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({}),

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;

View File

@@ -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,

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

View File

@@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = {
orgId: string;
resourceId: number;
niceId: string;
name: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};

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}`,

View File

@@ -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);
}

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,

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,

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)

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)

View File

@@ -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: {

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: {

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);

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";
@@ -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,

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,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,

View File

@@ -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
})
]);
}
}
});

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 });
}
}
});

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 });
}
}
});

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: {},

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: {},

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(

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 })

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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 ??

View File

@@ -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",

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>

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);

View File

@@ -137,7 +137,7 @@ export const orgNavSections = (
}
]
},
...(build !== "oss"
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarPolicies",

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;

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">

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>
);
}

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 />

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>
);
}

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>

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>
);
}

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 />

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>
);
}

View File

@@ -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)

View File

@@ -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>

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

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>
);
}

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" />;
}

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>
)}

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">

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>
);
}

View File

@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}

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
}: {

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}

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>

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":

View File

@@ -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>

View File

@@ -491,7 +491,7 @@ export function EditPolicyAuthMethodsSectionForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}

View File

@@ -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}

View File

@@ -670,7 +670,7 @@ export function PolicyAuthMethodsSection({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}

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}

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`
};
}

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 };
}
});

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 };
}