diff --git a/.cursor/rules/Button-loading-state.mdc b/.cursor/rules/Button-loading-state.mdc new file mode 100644 index 000000000..351380ddd --- /dev/null +++ b/.cursor/rules/Button-loading-state.mdc @@ -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. diff --git a/.cursor/rules/TypeScript-rules.mdc b/.cursor/rules/TypeScript-rules.mdc new file mode 100644 index 000000000..0b0a4ba28 --- /dev/null +++ b/.cursor/rules/TypeScript-rules.mdc @@ -0,0 +1,7 @@ +--- +alwaysApply: true +--- + +When writing TypeScript: + +Prefer to use types instead of interfaces. diff --git a/.cursor/rules/Use-React-form-and-Zod-schemas.mdc b/.cursor/rules/Use-React-form-and-Zod-schemas.mdc new file mode 100644 index 000000000..1dc5c33aa --- /dev/null +++ b/.cursor/rules/Use-React-form-and-Zod-schemas.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +When creating forms, use React form for validation and use Zod schemas. diff --git a/install/config/config.yml b/install/config/config.yml index 85dbc944f..f64190fec 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -38,7 +38,5 @@ flags: disable_user_create_org: false allow_raw_resources: true -{{if .IsPostgreSQL}} -postgres: - connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin -{{end}} +{{if .IsPostgreSQL}}postgres: + connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}} diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index fe6a41644..96b15ce47 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -7,23 +7,17 @@ services: deploy: resources: limits: - memory: 1g + memory: 2g reservations: - memory: 256m -{{if or .IsPostgreSQL .IsRedis}} - depends_on: - {{if .IsPostgreSQL}} - postgres: - condition: service_healthy - {{end}} - {{if .IsRedis}} - redis: - condition: service_healthy - {{end}} + memory: 512m + {{if or .IsPostgreSQL .IsRedis}}depends_on: + {{if .IsPostgreSQL}}postgres: + condition: service_healthy{{end}} + {{if .IsRedis}}redis: + condition: service_healthy{{end}} networks: - default - - backend -{{end}} + - backend{{end}} volumes: - ./config:/app/config healthcheck: @@ -31,8 +25,8 @@ services: interval: "10s" timeout: "10s" retries: 15 -{{if .InstallGerbil}} - gerbil: + + {{if .InstallGerbil}}gerbil: image: docker.io/fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped @@ -53,17 +47,16 @@ services: - 21820:21820/udp - 443:443 - 443:443/udp # For http3 QUIC if desired - - 80:80 -{{end}} + - 80:80{{end}} + traefik: image: docker.io/traefik:v3.6 container_name: traefik restart: unless-stopped -{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} + {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} ports: - 443:443 - - 80:80 -{{end}} + - 80:80{{end}} depends_on: pangolin: condition: service_healthy @@ -74,8 +67,7 @@ services: - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs -{{if .IsPostgreSQL}} - postgres: + {{if .IsPostgreSQL}}postgres: image: postgres:18 container_name: postgres restart: unless-stopped @@ -91,11 +83,9 @@ services: timeout: 5s retries: 5 networks: - - backend -{{end}} + - backend{{end}} -{{if .IsRedis}} - redis: + {{if .IsRedis}}redis: image: redis:8-trixie container_name: redis restart: unless-stopped @@ -113,17 +103,14 @@ services: retries: 3 start_period: 10s networks: - - backend -{{end}} + - backend{{end}} networks: default: driver: bridge name: pangolin_frontend {{if .EnableIPv6}} enable_ipv6: true{{end}} -{{if or .IsPostgreSQL .IsRedis}} - backend: +{{if or .IsPostgreSQL .IsRedis}} backend: driver: bridge name: pangolin_backend - internal: true -{{end}} + internal: true{{end}} diff --git a/install/config/privateConfig.yml b/install/config/privateConfig.yml index 58a4c9435..1afe55c1c 100644 --- a/install/config/privateConfig.yml +++ b/install/config/privateConfig.yml @@ -1,6 +1,4 @@ -{{if .IsRedis}} -redis: +{{if .IsRedis}}redis: host: "redis" port: 6379 - password: "{{.IsRedisPass}}" -{{end}} + password: "{{.IsRedisPass}}"{{end}} diff --git a/install/main.go b/install/main.go index 5687f6f5d..001f09a52 100644 --- a/install/main.go +++ b/install/main.go @@ -71,9 +71,12 @@ const ( Undefined SupportedContainer = "undefined" ) +var redisFlag *bool + func main() { crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt") + redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.") flag.Parse() // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking @@ -491,13 +494,13 @@ func collectUserInput() Config { config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") if config.IsEnterprise { - config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.") - if config.IsRedis { + if *redisFlag { + config.IsRedis = true config.IsRedisPass = readPassword("Enter a unique password for the Redis service.") } } - config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false) + config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false) if config.IsPostgreSQL { config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.") } @@ -544,7 +547,7 @@ func collectUserInput() Config { fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) - config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true) + config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") diff --git a/messages/en-US.json b/messages/en-US.json index 42bbdf2c0..85a61e237 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "View Private Resources", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", + "siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", @@ -1220,8 +1222,10 @@ "addLabels": "Add labels", "siteLabelsTab": "Labels", "siteLabelsDescription": "Manage labels associated with this site.", - "labelsNotFound": "Labels not found", + "labelsNotFound": "No labels found.", + "labelsEmptyCreateHint": "Start typing above to create a label.", "labelSearch": "Search labels", + "labelSearchOrCreate": "Search or create a label", "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Clear label filters", @@ -1648,7 +1652,7 @@ "standaloneHcFilterResourceIdFallback": "Resource {id}", "blueprints": "Blueprints", "blueprintsLog": "Blueprints Log", - "blueprintsDescription": "View past blueprint applications and their results", + "blueprintsDescription": "View past blueprint applications and their results or apply a new blueprint", "blueprintAdd": "Add Blueprint", "blueprintGoBack": "See all Blueprints", "blueprintCreate": "Create Blueprint", @@ -2047,6 +2051,7 @@ "requireDeviceApproval": "Require Device Approvals", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "sshSettings": "SSH Settings", + "sshAccess": "SSH Access", "rdpSettings": "RDP Settings", "vncSettings": "VNC Settings", "sshServer": "SSH Server", @@ -2073,7 +2078,7 @@ "sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.", "sshDaemonPort": "Daemon Port", "sshServerDestination": "Server Destination", - "sshServerDestinationDescription": "Configure the destination and port of the SSH server", + "sshServerDestinationDescription": "Configure the destination of the SSH server", "destination": "Destination", "bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.", "roleAllowSsh": "Allow SSH", @@ -3455,5 +3460,43 @@ "sshErrorNoTarget": "No target specified", "sshErrorWebSocket": "WebSocket connection failed", "sshErrorAuthFailed": "Authentication failed", - "sshErrorConnectionClosed": "Connection closed before authentication completed" + "sshErrorConnectionClosed": "Connection closed before authentication completed", + "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.", + "browserGatewayNoResourceForDomain": "No resource found for this domain", + "browserGatewayNoTarget": "No target", + "browserGatewayConnect": "Connect", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?", + "sshTerminalError": "Error: {error}", + "sshConnectionClosedCode": "Connection closed (code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Private key is required", + "vncTitle": "VNC", + "vncSignInDescription": "Enter your VNC password to connect", + "vncPasswordOptional": "Password (optional)", + "vncNoResourceTarget": "No resource target is available", + "vncFailedToLoadNovnc": "Failed to load noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Paste clipboard", + "rdpTitle": "RDP", + "rdpSignInTitle": "Sign in to Remote Desktop", + "rdpSignInDescription": "Enter Windows credentials to connect", + "rdpLoadingModule": "Loading module...", + "rdpFailedToLoadModule": "Failed to load RDP module", + "rdpNotReady": "Not ready", + "rdpModuleInitializing": "RDP module is still initializing", + "rdpDownloadingFiles": "Downloading {count} file(s) from remote…", + "rdpDownloadFailed": "Download failed: {fileName}", + "rdpUploaded": "Uploaded: {fileName}", + "rdpNoConnectionTarget": "No connection target available", + "rdpConnectionFailed": "Connection failed", + "rdpFit": "Fit", + "rdpFull": "Full", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Upload files", + "rdpFilesReadyToPaste": "Files ready to paste", + "rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.", + "rdpUploadFailed": "Upload failed", + "rdpUnicodeKeyboardMode": "Unicode keyboard mode" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6ca067ade..6b4ce32b8 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -886,7 +886,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), - match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + match: varchar("match") + .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .notNull(), value: varchar("value").notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4291df6b0..492576cc6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1248,7 +1248,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), - match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + match: text("match") + .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .notNull(), value: text("value").notNull() }); diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 12d18f653..5296bb4d2 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -20,6 +20,7 @@ import { ClientResourcesResults, updateClientResources } from "./clientResources"; +import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { generateName } from "@server/db/names"; @@ -56,6 +57,8 @@ export async function applyBlueprint({ let proxyResourcesResults: ProxyResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { + await updateResourcePolicies(orgId, config, trx); + proxyResourcesResults = await updateProxyResources( orgId, config, diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 44cd9956b..34e668984 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -23,6 +23,8 @@ import logger from "@server/logger"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { getNextAvailableAliasAddress } from "../ip"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "../billing/tierMatrix"; async function getDomainForSiteResource( siteResourceId: number | undefined, @@ -114,6 +116,30 @@ export async function updateClientResources( for (const [resourceNiceId, resourceData] of Object.entries( config["client-resources"] )) { + if (resourceData.mode === "http") { + const hasHttpFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix.advancedPrivateResources + ); + if (!hasHttpFeature) { + throw new Error( + "HTTP private resources are not included in your current plan. Please upgrade." + ); + } + } + + if (resourceData.mode === "ssh") { + const hasSshFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix.advancedPrivateResources + ); + if (!hasSshFeature) { + throw new Error( + "SSH private resources are not included in your current plan. Please upgrade." + ); + } + } + const [existingResource] = await trx .select() .from(siteResources) @@ -366,7 +392,9 @@ export async function updateClientResources( })) ); existingRoles.push(created); - logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`); + logger.info( + `Auto-created role "${name}" in org ${orgId} from blueprint` + ); } const roleIds = existingRoles.map((role) => role.roleId); @@ -510,7 +538,9 @@ export async function updateClientResources( })) ); existingRoles.push(created); - logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`); + logger.info( + `Auto-created role "${name}" in org ${orgId} from blueprint` + ); } const roleIds = existingRoles.map((role) => role.roleId); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 28bc1c90d..6c37d17b8 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -47,6 +47,7 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; +import { build } from "@server/build"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -222,17 +223,59 @@ export async function updateProxyResources( headers = JSON.stringify(resourceData.headers); } + if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.advancedPublicResources + ); + if (!isLicensed) { + throw new Error( + "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." + ); + } + } + + if (resourceData.policy) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.resourcePolicies + ); + if (!isLicensed) { + throw new Error( + "Your current subscription does not support shared resource policies. Please upgrade to access this feature." + ); + } + } + if (existingResource) { let domain; if ( ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") ) { + if (resourceData["full-domain"]?.startsWith("*.")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + throw new Error( + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ); + } + } + domain = await getDomain( existingResource.resourceId, resourceData["full-domain"]!, orgId, trx ); + + await enforceDomainNamespacePaywall( + orgId, + domain.domainId, + trx + ); } // check if the only key in the resource is targets, if so, skip the update @@ -906,12 +949,30 @@ export async function updateProxyResources( if ( ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") ) { + if (resourceData["full-domain"]?.startsWith("*.")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + throw new Error( + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ); + } + } + domain = await getDomain( undefined, resourceData["full-domain"]!, orgId, trx ); + + await enforceDomainNamespacePaywall( + orgId, + domain.domainId, + trx + ); } const isLicensed = await isLicensedOrSubscribed( @@ -1866,6 +1927,37 @@ function checkIfTargetChanged( return false; } +async function enforceDomainNamespacePaywall( + orgId: string, + domainId: string, + trx: Transaction +) { + if (build !== "saas") { + return; + } + + const hasDomainNamespaceAccess = await isLicensedOrSubscribed( + orgId, + tierMatrix.domainNamespaces + ); + + if (hasDomainNamespaceAccess) { + return; + } + + const [namespaceDomain] = await trx + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (namespaceDomain) { + throw new Error( + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ); + } +} + export async function getDomain( resourceId: number | undefined, fullDomain: string, diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts new file mode 100644 index 000000000..7a794c55a --- /dev/null +++ b/server/lib/blueprints/resourcePolicies.ts @@ -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 { + 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)); + } + } +} diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 454d83aa9..ad3676c4b 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -83,7 +83,8 @@ export const RuleSchema = z action: z.enum(["allow", "deny", "pass"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), value: z.coerce.string(), - priority: z.int().optional() + priority: z.int().optional(), + enabled: z.boolean().optional().default(true) }) .refine( (rule) => { @@ -507,6 +508,52 @@ export const PrivateResourceSchema = z } ); +export const ResourcePolicyRuleSchema = RuleSchema; + +export const ResourcePolicySchema = z.object({ + name: z.string().min(1).max(255), + sso: z.boolean().optional().default(true), + "auto-login-idp": z.int().positive().optional().nullable(), + "sso-roles": z + .array(z.string()) + .optional() + .default([]) + .refine((roles) => !roles.includes("Admin"), { + error: "Admin role cannot be included in sso-roles" + }), + "sso-users": z.array(z.string()).optional().default([]), + password: z.string().min(4).max(100).optional().nullable(), + pincode: z + .string() + .regex(/^\d{6}$/) + .optional() + .nullable(), + "basic-auth": z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + "extended-compatibility": z.boolean().default(true) + }) + .optional() + .nullable(), + "email-whitelist-enabled": z.boolean().optional().default(false), + "whitelist-users": z + .array( + z.email().or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + error: "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) + .optional() + .default([]), + "apply-rules": z.boolean().optional().default(false), + rules: z.array(ResourcePolicyRuleSchema).optional().default([]) +}); +export type ResourcePolicyData = z.infer; + // Schema for the entire configuration object export const ConfigSchema = z .object({ @@ -526,6 +573,10 @@ export const ConfigSchema = z .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), + "resource-policies": z + .record(z.string(), ResourcePolicySchema) + .optional() + .prefault({}), sites: z.record(z.string(), SiteSchema).optional().prefault({}) }) .transform((data) => { @@ -556,6 +607,10 @@ export const ConfigSchema = z string, z.infer >; + "resource-policies": Record< + string, + z.infer + >; sites: Record>; }; }) @@ -695,3 +750,4 @@ export type Site = z.infer; export type Target = z.infer; export type Resource = z.infer; export type Config = z.infer; +export type BlueprintResourcePolicy = z.infer; diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 24ee3e834..d7e85e7ac 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2( return; } - let targets: SubnetProxyTargetV2[] = []; + const targets: SubnetProxyTargetV2[] = []; const portRange = [ ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 518dd964c..48eb03638 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -44,7 +44,8 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, - allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE + allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE + allowBrowserGatewayResources = true ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources @@ -240,7 +241,7 @@ export async function getTraefikConfig( continue; } - if (resource.http) { + if (resource.mode === "http") { if (!resource.domainId || !resource.fullDomain) { continue; } @@ -572,7 +573,7 @@ export async function getTraefikConfig( serviceName ].loadBalancer.serversTransport = transportName; } - } else { + } else if (resource.mode === "tcp" || resource.mode === "udp") { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy || !resource.proxyPort) { continue; diff --git a/server/middlewares/verifySiteAccess.test.ts b/server/middlewares/verifySiteAccess.test.ts new file mode 100644 index 000000000..6a08eda18 --- /dev/null +++ b/server/middlewares/verifySiteAccess.test.ts @@ -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(); diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index e630cf0f1..c4d35a52f 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -71,6 +71,15 @@ export async function verifySiteAccess( ); } + if (req.userOrgId && site.orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this site" + ) + ); + } + if (!req.userOrg) { // Get user's role ID in the organization const userOrgRole = await db @@ -128,10 +137,7 @@ export async function verifySiteAccess( .where( and( eq(roleSites.siteId, site.siteId), - inArray( - roleSites.roleId, - req.userOrgRoleIds! - ) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 087143007..565a0151a 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,7 +109,11 @@ export const privateConfigSchema = z enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(true) + enable_acme_cert_sync: z.boolean().optional().default(true), + disable_private_http_placeholder: z + .boolean() + .optional() + .default(false) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 7ad6b853b..7ff452880 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -410,7 +410,11 @@ export async function getTraefikConfig( fullDomain: string | null; mode: "http" | "host" | "cidr" | "ssh"; }[] = []; - if (build == "enterprise") { + if ( + build == "enterprise" && + !privateConfig.getRawPrivateConfig().flags + .disable_private_http_placeholder + ) { // we dont want to do this on the cloud // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge siteResourcesWithFullDomain = await db @@ -493,16 +497,29 @@ export async function getTraefikConfig( const transportName = `${key}-transport`; const headersMiddlewareName = `${key}-headers-middleware`; + logger.debug( + `Processing resource ${resource.name} with domain ${fullDomain} and ${targets.length} targets` + ); + if (!resource.enabled) { + logger.debug( + `Resource ${resource.name} is disabled, skipping Traefik config` + ); continue; } - if (resource.http) { + if (resource.mode == "http") { if (!resource.domainId) { + logger.debug( + `Resource ${resource.name} does not have a domainId, skipping Traefik config` + ); continue; } if (!resource.fullDomain) { + logger.debug( + `Resource ${resource.name} does not have a fullDomain, skipping Traefik config` + ); continue; } @@ -958,7 +975,7 @@ export async function getTraefikConfig( serviceName ].loadBalancer.serversTransport = transportName; } - } else { + } else if (resource.mode == "tcp" || resource.mode == "udp") { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy) { continue; diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 7feda01e5..51e16de75 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -58,6 +58,7 @@ export async function getBrowserTarget( authToken: browserGatewayTarget.authToken, resourceId: resources.resourceId, niceId: resources.niceId, + name: resources.name, orgId: resources.orgId, pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode @@ -93,7 +94,8 @@ export async function getBrowserTarget( authDaemonMode: browserTarget.authDaemonMode, orgId: browserTarget.orgId, resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId + niceId: browserTarget.niceId, + name: browserTarget.name }, success: true, error: false, diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index f10cd407d..dac4ae62a 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -12,6 +12,7 @@ */ import { Request, Response, NextFunction } from "express"; +import { randomInt } from "crypto"; import { z } from "zod"; import { actionAuditLog, @@ -392,7 +393,7 @@ export async function signSshKey( if (existingUserWithSameName) { let foundUniqueUsername = false; for (let attempt = 0; attempt < 20; attempt++) { - const randomNum = Math.floor(Math.random() * 101); // 0 to 100 + const randomNum = randomInt(0, 101); // 0 to 100 const candidateUsername = `${usernameToUse}${randomNum}`; const [existingUser] = await db diff --git a/server/routers/browserGatewayTarget/types.ts b/server/routers/browserGatewayTarget/types.ts index e644c952a..df6302391 100644 --- a/server/routers/browserGatewayTarget/types.ts +++ b/server/routers/browserGatewayTarget/types.ts @@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = { orgId: string; resourceId: number; niceId: string; + name: string; pamMode: "passthrough" | "push" | null; authDaemonMode: "site" | "remote" | "native" | null; }; diff --git a/server/routers/external.ts b/server/routers/external.ts index 440bb5f21..db0db594a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -561,6 +561,7 @@ authenticated.delete( authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), @@ -612,6 +613,7 @@ authenticated.get( authenticated.post( "/target/:targetId", verifyTargetAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), @@ -1234,7 +1236,8 @@ export const authRouter = Router(); unauthenticated.use("/auth", authRouter); authRouter.use( rateLimit({ - windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, + windowMs: + config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, max: config.getRawConfig().rate_limits.auth.max_requests, keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index b5415c52d..8188f46da 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -332,17 +332,6 @@ export async function validateOidcCallback( .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); - - for (const org of allOrgs) { - const subscribed = await isSubscribed( - org.orgId, - tierMatrix.autoProvisioning - ); - if (!subscribed) { - // filter out the org - allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); - } - } } else { allOrgs = await db.select().from(orgs); } diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index 8192f779c..5637cf8f8 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { roleResources, roles } from "@server/db"; +import { roleResources, roles, rolePolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -131,31 +131,64 @@ export async function addRoleToResource( ); } - // Check if role already exists in resource - const existingEntry = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Role already assigned to resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // Check if role already exists in the inline policy + const existingEntry = await db + .select() + .from(rolePolicies) + .where( + and( + eq(rolePolicies.resourcePolicyId, policyId), + eq(rolePolicies.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to resource" + ) + ); + } + + await db.insert(rolePolicies).values({ + roleId, + resourcePolicyId: policyId + }); + } else { + // Check if role already exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to resource" + ) + ); + } + + await db.insert(roleResources).values({ + roleId, + resourceId + }); } - await db.insert(roleResources).values({ - roleId, - resourceId - }); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index 3a75b0043..0b749e04f 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -103,31 +103,64 @@ export async function addUserToResource( ); } - // Check if user already exists in resource - const existingEntry = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.resourceId, resourceId), - eq(userResources.userId, userId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "User already assigned to resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // Check if user already exists in the inline policy + const existingEntry = await db + .select() + .from(userPolicies) + .where( + and( + eq(userPolicies.resourcePolicyId, policyId), + eq(userPolicies.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to resource" + ) + ); + } + + await db.insert(userPolicies).values({ + userId, + resourcePolicyId: policyId + }); + } else { + // Check if user already exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to resource" + ) + ); + } + + await db.insert(userResources).values({ + userId, + resourceId + }); } - await db.insert(userResources).values({ - userId, - resourceId - }); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index f55eb4112..14ac69fcc 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; +import { resourceRules, resourcePolicyRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -153,6 +153,34 @@ export async function createResourceRule( } } + // Create the new resource rule + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const [newRule] = await db + .insert(resourcePolicyRules) + .values({ + resourcePolicyId: policyId, + action, + match, + value, + priority, + enabled + }) + .returning(); + + return response(res, { + data: newRule, + success: true, + error: false, + message: "Resource rule created successfully", + status: HttpCode.CREATED + }); + } + // Create the new resource rule const [newRule] = await db .insert(resourceRules) diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index ef40ecaab..a3a1543f8 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; +import { resourceRules, resourcePolicyRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -59,6 +59,48 @@ export async function deleteResourceRule( const { ruleId } = parsedParams.data; + // Look up resource to determine which table to use + const { resourceId } = parsedParams.data; + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + if (isInlinePolicy) { + const [deletedRule] = await db + .delete(resourcePolicyRules) + .where(eq(resourcePolicyRules.ruleId, ruleId)) + .returning(); + + if (!deletedRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Resource rule deleted successfully", + status: HttpCode.OK + }); + } + // Delete the rule and return the deleted record const [deletedRule] = await db .delete(resourceRules) diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index bb6105b0b..c773f89b9 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -1,7 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceWhitelist, users } from "@server/db"; // Assuming these are the correct tables +import { + resourceWhitelist, + resourcePolicyWhiteList, + resources +} from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) { .where(eq(resourceWhitelist.resourceId, resourceId)); } +async function queryPolicyWhitelist(policyId: number) { + return await db + .select({ + email: resourcePolicyWhiteList.email + }) + .from(resourcePolicyWhiteList) + .where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId)); +} + export type GetResourceWhitelistResponse = { whitelist: NonNullable>>; }; @@ -71,7 +84,25 @@ export async function getResourceWhitelist( const { resourceId } = parsedParams.data; - const whitelist = await queryWhitelist(resourceId); + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + const whitelist = isInlinePolicy + ? await queryPolicyWhitelist(resource.defaultResourcePolicyId!) + : await queryWhitelist(resourceId); return response(res, { data: { diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index ffff8c602..4ee3f9535 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roleResources, roles } from "@server/db"; +import { roleResources, roles, rolePolicies, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -27,6 +27,19 @@ async function query(resourceId: number) { .where(eq(roleResources.resourceId, resourceId)); } +async function queryInlinePolicy(policyId: number) { + return await db + .select({ + roleId: roles.roleId, + name: roles.name, + description: roles.description, + isAdmin: roles.isAdmin + }) + .from(rolePolicies) + .innerJoin(roles, eq(rolePolicies.roleId, roles.roleId)) + .where(eq(rolePolicies.resourcePolicyId, policyId)); +} + export type ListResourceRolesResponse = { roles: NonNullable>>; }; @@ -75,7 +88,25 @@ export async function listResourceRoles( const { resourceId } = parsedParams.data; - const resourceRolesList = await query(resourceId); + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + const resourceRolesList = isInlinePolicy + ? await queryInlinePolicy(resource.defaultResourcePolicyId!) + : await query(resourceId); return response(res, { data: { diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index b1b2581ed..76d6fb97e 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -1,5 +1,5 @@ import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; +import { resourceRules, resourcePolicyRules, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, sql } from "drizzle-orm"; @@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) { return baseQuery; } +function queryPolicyRules(policyId: number) { + return db + .select({ + ruleId: resourcePolicyRules.ruleId, + resourceId: sql`null`, + action: resourcePolicyRules.action, + match: resourcePolicyRules.match, + value: resourcePolicyRules.value, + priority: resourcePolicyRules.priority, + enabled: resourcePolicyRules.enabled + }) + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.resourcePolicyId, policyId)); +} + export type ListResourceRulesResponse = { rules: Awaited>; pagination: { total: number; limit: number; offset: number }; @@ -125,16 +140,34 @@ export async function listResourceRules( ); } - const baseQuery = queryResourceRules(resourceId); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - const countQuery = db - .select({ count: sql`cast(count(*) as integer)` }) - .from(resourceRules) - .where(eq(resourceRules.resourceId, resourceId)); + let rulesList: Awaited>; + let totalCount: number; - let rulesList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const policyRules = await queryPolicyRules(policyId) + .limit(limit) + .offset(offset); + const countResult = await db + .select({ count: sql`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`cast(count(*) as integer)` }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + rulesList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + totalCount = totalCountResult[0].count; + } // sort rules list by the priority in ascending order rulesList = rulesList.sort((a, b) => a.priority - b.priority); diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index 66da1d377..9f1323cca 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { roleResources, roles } from "@server/db"; +import { roleResources, roles, rolePolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -130,35 +130,71 @@ export async function removeRoleFromResource( ); } - // Check if role exists in resource - const existingEntry = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Role not found in resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + const existingEntry = await db + .select() + .from(rolePolicies) + .where( + and( + eq(rolePolicies.resourcePolicyId, policyId), + eq(rolePolicies.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in resource" + ) + ); + } + + await db + .delete(rolePolicies) + .where( + and( + eq(rolePolicies.resourcePolicyId, policyId), + eq(rolePolicies.roleId, roleId) + ) + ); + } else { + // Check if role exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in resource" + ) + ); + } + + await db + .delete(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); } - await db - .delete(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 17f2380a2..234ef642d 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -103,35 +103,71 @@ export async function removeUserFromResource( ); } - // Check if user exists in resource - const existingEntry = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.resourceId, resourceId), - eq(userResources.userId, userId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "User not found in resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + const existingEntry = await db + .select() + .from(userPolicies) + .where( + and( + eq(userPolicies.resourcePolicyId, policyId), + eq(userPolicies.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in resource" + ) + ); + } + + await db + .delete(userPolicies) + .where( + and( + eq(userPolicies.resourcePolicyId, policyId), + eq(userPolicies.userId, userId) + ) + ); + } else { + // Check if user exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in resource" + ) + ); + } + + await db + .delete(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); } - await db - .delete(userResources) - .where( - and( - eq(userResources.resourceId, resourceId), - eq(userResources.userId, userId) - ) - ); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index cec9ad96a..44571eb6a 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import { db, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility + resourceHeaderAuthExtendedCompatibility, + resourcePolicyHeaderAuth, + resources } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -89,36 +91,73 @@ export async function setResourceHeaderAuth( const { resourceId } = parsedParams.data; const { user, password, extendedCompatibility } = parsedBody.data; + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + await db.transaction(async (trx) => { - await trx - .delete(resourceHeaderAuth) - .where(eq(resourceHeaderAuth.resourceId, resourceId)); - await trx - .delete(resourceHeaderAuthExtendedCompatibility) - .where( - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resourceId - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + await trx + .delete(resourcePolicyHeaderAuth) + .where( + eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId) + ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); + if (user && password && extendedCompatibility !== null) { + const headerAuthHash = await hashPassword( + Buffer.from(`${user}:${password}`).toString("base64") + ); - await Promise.all([ - trx - .insert(resourceHeaderAuth) - .values({ resourceId, headerAuthHash }), - trx - .insert(resourceHeaderAuthExtendedCompatibility) - .values({ - resourceId, - extendedCompatibilityIsActivated: - extendedCompatibility - }) - ]); + await trx.insert(resourcePolicyHeaderAuth).values({ + resourcePolicyId: policyId, + headerAuthHash, + extendedCompatibility: extendedCompatibility! + }); + } + } else { + await trx + .delete(resourceHeaderAuth) + .where(eq(resourceHeaderAuth.resourceId, resourceId)); + await trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resourceId + ) + ); + + if (user && password && extendedCompatibility !== null) { + const headerAuthHash = await hashPassword( + Buffer.from(`${user}:${password}`).toString("base64") + ); + + await Promise.all([ + trx + .insert(resourceHeaderAuth) + .values({ resourceId, headerAuthHash }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId, + extendedCompatibilityIsActivated: + extendedCompatibility + }) + ]); + } } }); diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 0f89bccd4..355e20a49 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -1,7 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourcePassword } from "@server/db"; +import { + resourcePassword, + resourcePolicyPassword, + resources +} from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -85,17 +89,49 @@ export async function setResourcePassword( const { resourceId } = parsedParams.data; const { password } = parsedBody.data; + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + await db.transaction(async (trx) => { - await trx - .delete(resourcePassword) - .where(eq(resourcePassword.resourceId, resourceId)); - - if (password) { - const passwordHash = await hashPassword(password); - + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; await trx - .insert(resourcePassword) - .values({ resourceId, passwordHash }); + .delete(resourcePolicyPassword) + .where( + eq(resourcePolicyPassword.resourcePolicyId, policyId) + ); + + if (password) { + const passwordHash = await hashPassword(password); + await trx + .insert(resourcePolicyPassword) + .values({ resourcePolicyId: policyId, passwordHash }); + } + } else { + await trx + .delete(resourcePassword) + .where(eq(resourcePassword.resourceId, resourceId)); + + if (password) { + const passwordHash = await hashPassword(password); + + await trx + .insert(resourcePassword) + .values({ resourceId, passwordHash }); + } } }); diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 9135529fb..67651fc9e 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourcePincode } from "@server/db"; +import { resourcePincode, resourcePolicyPincode, resources } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -89,17 +89,51 @@ export async function setResourcePincode( const { resourceId } = parsedParams.data; const { pincode } = parsedBody.data; + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + await db.transaction(async (trx) => { - await trx - .delete(resourcePincode) - .where(eq(resourcePincode.resourceId, resourceId)); - - if (pincode) { - const pincodeHash = await hashPassword(pincode); - + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; await trx - .insert(resourcePincode) - .values({ resourceId, pincodeHash, digitLength: 6 }); + .delete(resourcePolicyPincode) + .where( + eq(resourcePolicyPincode.resourcePolicyId, policyId) + ); + + if (pincode) { + const pincodeHash = await hashPassword(pincode); + await trx.insert(resourcePolicyPincode).values({ + resourcePolicyId: policyId, + pincodeHash, + digitLength: 6 + }); + } + } else { + await trx + .delete(resourcePincode) + .where(eq(resourcePincode.resourceId, resourceId)); + + if (pincode) { + const pincodeHash = await hashPassword(pincode); + + await trx + .insert(resourcePincode) + .values({ resourceId, pincodeHash, digitLength: 6 }); + } } }); diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 0015e373a..e091bfa85 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { apiKeys, roleResources, roles } from "@server/db"; +import { apiKeys, roleResources, roles, rolePolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -129,28 +129,61 @@ export async function setResourceRoles( ); const adminRoleIds = adminRoles.map((role) => role.roleId); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + await db.transaction(async (trx) => { - if (adminRoleIds.length > 0) { - await trx.delete(roleResources).where( - and( - eq(roleResources.resourceId, resourceId), - ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // For inline policy, preserve admin roles by only deleting non-admin entries + if (adminRoleIds.length > 0) { + await trx + .delete(rolePolicies) + .where( + and( + eq(rolePolicies.resourcePolicyId, policyId), + ne(rolePolicies.roleId, adminRoleIds[0]) + ) + ); + } else { + await trx + .delete(rolePolicies) + .where(eq(rolePolicies.resourcePolicyId, policyId)); + } + + await Promise.all( + roleIds.map((roleId) => + trx + .insert(rolePolicies) + .values({ roleId, resourcePolicyId: policyId }) + .returning() ) ); } else { - await trx - .delete(roleResources) - .where(eq(roleResources.resourceId, resourceId)); - } + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + and( + eq(roleResources.resourceId, resourceId), + ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(roleResources) + .where(eq(roleResources.resourceId, resourceId)); + } - const newRoleResources = await Promise.all( - roleIds.map((roleId) => - trx - .insert(roleResources) - .values({ roleId, resourceId }) - .returning() - ) - ); + await Promise.all( + roleIds.map((roleId) => + trx + .insert(roleResources) + .values({ roleId, resourceId }) + .returning() + ) + ); + } return response(res, { data: {}, diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index 4c2b7457a..86532c6e2 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies, resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -82,19 +82,51 @@ export async function setResourceUsers( const { resourceId } = parsedParams.data; - await db.transaction(async (trx) => { - await trx - .delete(userResources) - .where(eq(userResources.resourceId, resourceId)); + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - const newUserResources = await Promise.all( - userIds.map((userId) => - trx - .insert(userResources) - .values({ userId, resourceId }) - .returning() - ) + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); + } + + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + await db.transaction(async (trx) => { + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + await trx + .delete(userPolicies) + .where(eq(userPolicies.resourcePolicyId, policyId)); + + await Promise.all( + userIds.map((userId) => + trx + .insert(userPolicies) + .values({ userId, resourcePolicyId: policyId }) + .returning() + ) + ); + } else { + await trx + .delete(userResources) + .where(eq(userResources.resourceId, resourceId)); + + await Promise.all( + userIds.map((userId) => + trx + .insert(userResources) + .values({ userId, resourceId }) + .returning() + ) + ); + } return response(res, { data: {}, diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index ff6c9fd02..b228d66ed 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -1,7 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, resourceWhitelist } from "@server/db"; +import { + resources, + resourceWhitelist, + resourcePolicies, + resourcePolicyWhiteList +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -104,57 +109,135 @@ export async function setResourceWhitelist( ); } - if (!resource.emailWhitelistEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Email whitelist is not enabled for this resource" - ) - ); - } + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - const whitelist = await db - .select() - .from(resourceWhitelist) - .where(eq(resourceWhitelist.resourceId, resourceId)); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; - await db.transaction(async (trx) => { - // diff the emails - const existingEmails = whitelist.map((w) => w.email); + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); - const emailsToAdd = emails.filter( - (e) => !existingEmails.includes(e) - ); - const emailsToRemove = existingEmails.filter( - (e) => !emails.includes(e) - ); + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Resource policy not found" + ) + ); + } - for (const email of emailsToAdd) { - await trx.insert(resourceWhitelist).values({ - email, - resourceId + if (!policy.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + const existingPolicyWhitelist = await db + .select() + .from(resourcePolicyWhiteList) + .where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId)); + + await db.transaction(async (trx) => { + const existingEmails = existingPolicyWhitelist.map( + (w) => w.email + ); + + const emailsToAdd = emails.filter( + (e) => !existingEmails.includes(e) + ); + const emailsToRemove = existingEmails.filter( + (e) => !emails.includes(e) + ); + + for (const email of emailsToAdd) { + await trx.insert(resourcePolicyWhiteList).values({ + email, + resourcePolicyId: policyId + }); + } + + for (const email of emailsToRemove) { + await trx + .delete(resourcePolicyWhiteList) + .where( + and( + eq( + resourcePolicyWhiteList.resourcePolicyId, + policyId + ), + eq(resourcePolicyWhiteList.email, email) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Whitelist set for resource successfully", + status: HttpCode.CREATED }); - } - - for (const email of emailsToRemove) { - await trx - .delete(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, email) - ) - ); - } - - return response(res, { - data: {}, - success: true, - error: false, - message: "Whitelist set for resource successfully", - status: HttpCode.CREATED }); - }); + } else { + if (!resource.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + const whitelist = await db + .select() + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resourceId)); + + await db.transaction(async (trx) => { + // diff the emails + const existingEmails = whitelist.map((w) => w.email); + + const emailsToAdd = emails.filter( + (e) => !existingEmails.includes(e) + ); + const emailsToRemove = existingEmails.filter( + (e) => !emails.includes(e) + ); + + for (const email of emailsToAdd) { + await trx.insert(resourceWhitelist).values({ + email, + resourceId + }); + } + + for (const email of emailsToRemove) { + await trx + .delete(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Whitelist set for resource successfully", + status: HttpCode.CREATED + }); + }); + } } catch (error) { logger.error(error); return next( diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 9647bb68e..ea1ae66d7 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -549,6 +549,58 @@ async function updateHttpResource( updateData.maintenanceEstimatedTime = undefined; } + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const { + sso, + emailWhitelistEnabled, + applyRules, + skipToIdpId, + ...resourceOnlyData + } = updateData; + + const policyUpdate: Record = {}; + if (sso !== undefined) policyUpdate.sso = sso; + if (emailWhitelistEnabled !== undefined) + policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled; + if (applyRules !== undefined) policyUpdate.applyRules = applyRules; + if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId; + + if (Object.keys(policyUpdate).length > 0) { + await db + .update(resourcePolicies) + .set(policyUpdate) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); + } + + const updatedResource = await db + .update(resources) + .set({ ...resourceOnlyData, headers }) + .where(eq(resources.resourceId, resource.resourceId)) + .returning(); + + if (updatedResource.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resource.resourceId} not found` + ) + ); + } + + return response(res, { + data: updatedResource[0], + success: true, + error: false, + message: "HTTP resource updated successfully", + status: HttpCode.OK + }); + } + const updatedResource = await db .update(resources) .set({ ...updateData, headers }) diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 7e0e86066..7289c2767 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({ // Notify parent of changes (create mode) useEffect(() => { onChange?.(targets); - }, [targets]); // eslint-disable-line react-hooks/exhaustive-deps + }, [targets]); // Poll health status only in edit mode const { data: polledTargets } = useQuery({ @@ -334,19 +334,15 @@ export function ProxyResourceTargetsForm({ {row.original.siteType === "newt" ? ( ) : ( - @@ -535,7 +531,7 @@ export function ProxyResourceTargetsForm({ accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => ( -
+
@@ -554,9 +550,8 @@ export function ProxyResourceTargetsForm({ const actionsColumn: ColumnDef = { id: "actions", - header: () => {t("actions")}, cell: ({ row }) => ( -
+
-
+ ( + + + {t("domain")} + + + + + + + )} + /> + ( + + + {t("username")} + + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + + {submitError && ( + + + {submitError} + + + )} + + -
+ )}
ui()?.setScale(1)} > - Fit + {t("rdpFit")} {/*
@@ -554,20 +559,3 @@ export default function RdpClient({ ); } - -function Field({ - label, - id, - children -}: { - label: string; - id: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 368d2f477..b7190b428 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -1,40 +1,33 @@ -import { headers } from "next/headers"; -import { priv } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; +import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; +import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding"; import RdpClient from "./RdpClient"; import AuthFooter from "@app/components/AuthFooter"; +import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; -export const metadata = { - title: "RDP" -}; +export async function generateMetadata() { + return generateBrowserGatewayMetadata("RDP"); +} export default async function RdpPage() { - const headersList = await headers(); - const host = headersList.get("host") || ""; - const hostname = host.split(":")[0]; - - let target: GetBrowserTargetResponse | null = null; - let error: string | null = null; - - try { - const res = await priv.get>( - `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` - ); - target = res.data.data; - console.log("Fetched browser target:", target); - } catch (error) { - console.error("Error fetching browser target:", error); - error = "No resource found for this domain"; - } + const t = await getTranslations(); + const { target } = await getBrowserTargetForRequest(); + const error = target ? null : t("browserGatewayNoResourceForDomain"); + const { primaryColor } = target + ? await loadOrgLoginPageBranding(target.orgId) + : { primaryColor: null }; return (
- +
diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index bf899887f..9ffdf53ce 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -2,10 +2,19 @@ import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Textarea } from "@app/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, @@ -15,15 +24,17 @@ import { CardDescription } from "@app/components/ui/card"; import Link from "next/link"; -import { ExternalLink, Loader2, AlertCircle } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { cn } from "@app/lib/cn"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; type AuthTab = "password" | "privateKey"; -type FormState = { +type SshCredentialsForm = { username: string; password: string; privateKey: string; @@ -36,32 +47,46 @@ type ConnectCredentials = { certificate?: string; }; +function loadStoredCredentials(key: string): SshCredentialsForm { + try { + const saved = localStorage.getItem(key); + if (saved) return JSON.parse(saved) as SshCredentialsForm; + } catch { + // ignore + } + return { username: "", password: "", privateKey: "" }; +} + export default function SshClient({ target, error, signedKeyData, - privateKey: signedPrivateKey + privateKey: signedPrivateKey, + primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; signedKeyData?: SignSshKeyResponse | null; privateKey?: string | null; + primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; + const t = useTranslations(); + const resourceName = target?.name?.trim() || null; - const [form, setForm] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) return JSON.parse(saved) as FormState; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; + const passwordTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }) }); - const t = useTranslations(); + const privateKeyTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") }) + }); - const [authTab, setAuthTab] = useState("password"); + const form = useForm({ + defaultValues: loadStoredCredentials(STORAGE_KEY) + }); function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -70,11 +95,10 @@ export default function SshClient({ reader.onload = (ev) => { const text = ev.target?.result; if (typeof text === "string") { - setForm((prev) => ({ ...prev, privateKey: text })); + form.setValue("privateKey", text, { shouldDirty: true }); } }; reader.readAsText(file); - // Reset input so the same file can be re-selected if needed. e.target.value = ""; } @@ -126,14 +150,12 @@ export default function SshClient({ xtermRef.current = terminal; fitAddonRef.current = fitAddon; - // Send user keystrokes to the WebSocket. terminal.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "data", data })); } }); - // Send resize events. terminal.onResize(({ cols, rows }) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( @@ -142,7 +164,6 @@ export default function SshClient({ } }); - // Send the initial size once the terminal is rendered. const { cols, rows } = terminal; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( @@ -156,14 +177,12 @@ export default function SshClient({ }; }, [connected]); - // Refit terminal when the window resizes. useEffect(() => { const onResize = () => fitAddonRef.current?.fit(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); - // Cleanup on unmount. useEffect(() => { return () => { wsRef.current?.close(); @@ -171,7 +190,6 @@ export default function SshClient({ }; }, []); - // Auto-connect when signed key data is provided (push PAM mode). useEffect(() => { if (signedKeyData && signedPrivateKey && target) { connect({ @@ -180,11 +198,12 @@ export default function SshClient({ certificate: signedKeyData.certificate }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - function connect(override?: ConnectCredentials) { - setConnectError(null); + function connect( + override?: ConnectCredentials, + authMethod: AuthTab = "password" + ) { setConnecting(true); if (!target) { @@ -193,12 +212,14 @@ export default function SshClient({ return; } - const username = override?.username ?? form.username; + const values = form.getValues(); + const username = override?.username ?? values.username; const password = - override?.password ?? (authTab === "password" ? form.password : ""); + override?.password ?? + (authMethod === "password" ? values.password : ""); const privateKey = override?.privateKey ?? - (authTab === "privateKey" ? form.privateKey : ""); + (authMethod === "privateKey" ? values.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; @@ -217,16 +238,10 @@ export default function SshClient({ const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; - // Track whether the server has confirmed auth by sending the first - // data frame. Until then, errors are shown in the login form. let authConfirmed = false; let authErrorShown = false; ws.onopen = () => { - // Send credentials as the first frame so the proxy can complete - // SSH authentication before piping pty data. Stay in "connecting" - // state until the server responds — this prevents the flash to the - // terminal page that would occur if we set connected=true here. ws.send( JSON.stringify({ type: "auth", @@ -237,7 +252,10 @@ export default function SshClient({ ); if (!override) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(form.getValues()) + ); } catch { // ignore } @@ -261,7 +279,6 @@ export default function SshClient({ xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { - // Auth-phase error — show in the login form. authErrorShown = true; setConnecting(false); setConnectError( @@ -269,7 +286,7 @@ export default function SshClient({ ); } else { xtermRef.current?.writeln( - `\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n` + `\r\n\x1b[31m${t("sshTerminalError", { error: msg.error ?? "" })}\x1b[0m\r\n` ); } } @@ -282,13 +299,13 @@ export default function SshClient({ xtermRef.current?.write(evt.data); } } else if (evt.data instanceof Blob) { - evt.data.text().then((t) => { + evt.data.text().then((text) => { if (!authConfirmed) { authConfirmed = true; setConnecting(false); setConnected(true); } - xtermRef.current?.write(t); + xtermRef.current?.write(text); }); } }; @@ -304,11 +321,9 @@ export default function SshClient({ if (authConfirmed) { setConnected(false); xtermRef.current?.writeln( - `\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n` + `\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` ); } - // If auth was never confirmed the login form is already visible; - // a generic error is shown only when no specific error was received. if (!authConfirmed && !authErrorShown) { setConnectError(t("sshErrorConnectionClosed")); } @@ -322,7 +337,40 @@ export default function SshClient({ setConnected(false); } - // In push mode, show a connecting/connected state without the login form. + function applyTabSchemaErrors( + schema: z.ZodObject, + values: SshCredentialsForm + ) { + form.clearErrors(); + const result = schema.safeParse(values); + if (result.success) return true; + for (const issue of result.error.issues) { + const field = issue.path[0]; + if (typeof field === "string") { + form.setError(field as keyof SshCredentialsForm, { + message: issue.message + }); + } + } + return false; + } + + function onPasswordSubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(passwordTabSchema, values)) return; + connect(undefined, "password"); + } + + function onPrivateKeySubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return; + connect(undefined, "privateKey"); + } + if (signedKeyData && signedPrivateKey) { return ( <> @@ -351,7 +399,6 @@ export default function SshClient({ variant="destructive" className="w-full" > - {connectError} @@ -376,202 +423,202 @@ export default function SshClient({ if (error) { return ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + {t("sshTitle")} -

{error}

+ + {error} +
-
+ ); } return ( <> {!connected && ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + - {t("sshSignInTitle")} + + {resourceName + ? `${t("sshSignInTitle")} - ${resourceName}` + : t("sshSignInTitle")} + - {t("sshSignInDescription")} + {resourceName + ? `${t("sshSignInDescription")} (${resourceName})` + : t("sshSignInDescription")} - {/* Tab row */} -
- {(["password", "privateKey"] as const).map( - (tab) => ( - - ) - )} -
- - {authTab === "password" && ( -
- - - setForm({ - ...form, - username: e.target.value - }) - } - placeholder="root" - /> - - - - setForm({ - ...form, - password: e.target.value - }) - } - /> - -
- )} - - {authTab === "privateKey" && ( -
-

- {t("sshPrivateKeyDisclaimer")}{" "} - - {t("sshLearnMore")} - - -

- - - setForm({ - ...form, - username: e.target.value - }) - } - placeholder="root" - /> - - -