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

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