diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index a1ae13373..08b8063f8 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -19,6 +19,9 @@ export async function createResourceSession(opts: { userSessionId?: string | null; whitelistId?: number | null; accessTokenId?: string | null; + policyPasswordId?: number | null; + policyPincodeId?: number | null; + policyWhitelistId?: number | null; doNotExtend?: boolean; expiresAt?: number | null; sessionLength?: number | null; @@ -28,7 +31,10 @@ export async function createResourceSession(opts: { !opts.pincodeId && !opts.whitelistId && !opts.accessTokenId && - !opts.userSessionId + !opts.userSessionId && + !opts.policyPasswordId && + !opts.policyPincodeId && + !opts.policyWhitelistId ) { throw new Error("Auth method must be provided"); } @@ -49,6 +55,9 @@ export async function createResourceSession(opts: { whitelistId: opts.whitelistId || null, doNotExtend: opts.doNotExtend || false, accessTokenId: opts.accessTokenId || null, + policyPasswordId: opts.policyPasswordId || null, + policyPincodeId: opts.policyPincodeId || null, + policyWhitelistId: opts.policyWhitelistId || null, isRequestToken: opts.isRequestToken || false, userSessionId: opts.userSessionId || null, issuedAt: new Date().getTime() diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a329b8486..f8ad3f385 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -820,6 +820,24 @@ export const resourceSessions = pgTable("resourceSessions", { onDelete: "cascade" } ), + policyPasswordId: integer("policyPasswordId").references( + () => resourcePolicyPassword.passwordId, + { + onDelete: "cascade" + } + ), + policyPincodeId: integer("policyPincodeId").references( + () => resourcePolicyPincode.pincodeId, + { + onDelete: "cascade" + } + ), + policyWhitelistId: integer("policyWhitelistId").references( + () => resourcePolicyWhiteList.whitelistId, + { + onDelete: "cascade" + } + ), issuedAt: bigint("issuedAt", { mode: "number" }) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index b1947ccd7..aff55b74e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1148,6 +1148,24 @@ export const resourceSessions = sqliteTable("resourceSessions", { onDelete: "cascade" } ), + policyPasswordId: integer("policyPasswordId").references( + () => resourcePolicyPassword.passwordId, + { + onDelete: "cascade" + } + ), + policyPincodeId: integer("policyPincodeId").references( + () => resourcePolicyPincode.pincodeId, + { + onDelete: "cascade" + } + ), + policyWhitelistId: integer("policyWhitelistId").references( + () => resourcePolicyWhiteList.whitelistId, + { + onDelete: "cascade" + } + ), issuedAt: integer("issuedAt") }); diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 64a263097..42baf41b5 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -520,7 +520,8 @@ export class TraefikConfigManager { build != "oss", // generate the login pages on the cloud and hybrid, build == "saas" ? false - : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config + : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config + build != "oss" // generate browser gateway targets on cloud and enterprise ); const domains = new Set(); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index cc8f38ef3..7ad6b853b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -85,7 +85,8 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, generateLoginPageRouters = false, allowRawResources = true, - allowMaintenancePage = true + allowMaintenancePage = true, + 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 @@ -276,64 +277,6 @@ export async function getTraefikConfig( }); }); - // Query browser gateway targets for this exit node - const browserGatewayRows = await db - .select({ - // Resource fields - resourceId: resources.resourceId, - resourceName: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - subdomain: resources.subdomain, - domainId: resources.domainId, - enabled: resources.enabled, - wildcard: resources.wildcard, - domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert, - domainNamespaceId: domainNamespaces.domainNamespaceId, - // Maintenance fields - maintenanceModeEnabled: resources.maintenanceModeEnabled, - maintenanceModeType: resources.maintenanceModeType, - maintenanceTitle: resources.maintenanceTitle, - maintenanceMessage: resources.maintenanceMessage, - maintenanceEstimatedTime: resources.maintenanceEstimatedTime, - // Browser gateway target fields - browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId, - bgType: browserGatewayTarget.type, - // Site fields - siteId: sites.siteId, - siteType: sites.type, - siteOnline: sites.online, - subnet: sites.subnet, - siteExitNodeId: sites.exitNodeId - }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .innerJoin( - resources, - eq(resources.resourceId, browserGatewayTarget.resourceId) - ) - .leftJoin(domains, eq(domains.domainId, resources.domainId)) - .leftJoin( - domainNamespaces, - eq(domainNamespaces.domainId, resources.domainId) - ) - .where( - and( - eq(resources.enabled, true), - or( - eq(sites.exitNodeId, exitNodeId), - and( - isNull(sites.exitNodeId), - sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, - eq(sites.type, "local"), - sql`(${build != "saas" ? 1 : 0} = 1)` - ) - ), - inArray(sites.type, siteTypes) - ) - ); - // Group browser gateway targets by resource type BrowserGatewayResourceEntry = { resourceId: number; @@ -366,39 +309,100 @@ export async function getTraefikConfig( BrowserGatewayResourceEntry >(); - for (const row of browserGatewayRows) { - if (filterOutNamespaceDomains && row.domainNamespaceId) { - continue; - } - if (!browserGatewayResourcesMap.has(row.resourceId)) { - browserGatewayResourcesMap.set(row.resourceId, { - resourceId: row.resourceId, - name: sanitize(row.resourceName) || "", - fullDomain: row.fullDomain, - ssl: row.ssl, - subdomain: row.subdomain, - domainId: row.domainId, - enabled: row.enabled, - wildcard: row.wildcard, - domainCertResolver: row.domainCertResolver, - preferWildcardCert: row.preferWildcardCert, - maintenanceModeEnabled: row.maintenanceModeEnabled, - maintenanceModeType: row.maintenanceModeType, - maintenanceTitle: row.maintenanceTitle, - maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime, - targets: [] + if (allowBrowserGatewayResources) { + // Query browser gateway targets for this exit node + const browserGatewayRows = await db + .select({ + // Resource fields + resourceId: resources.resourceId, + resourceName: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + wildcard: resources.wildcard, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert, + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Maintenance fields + maintenanceModeEnabled: resources.maintenanceModeEnabled, + maintenanceModeType: resources.maintenanceModeType, + maintenanceTitle: resources.maintenanceTitle, + maintenanceMessage: resources.maintenanceMessage, + maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + // Browser gateway target fields + browserGatewayTargetId: + browserGatewayTarget.browserGatewayTargetId, + bgType: browserGatewayTarget.type, + // Site fields + siteId: sites.siteId, + siteType: sites.type, + siteOnline: sites.online, + subnet: sites.subnet, + siteExitNodeId: sites.exitNodeId + }) + .from(browserGatewayTarget) + .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) + .innerJoin( + resources, + eq(resources.resourceId, browserGatewayTarget.resourceId) + ) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .leftJoin( + domainNamespaces, + eq(domainNamespaces.domainId, resources.domainId) + ) + .where( + and( + eq(resources.enabled, true), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` + ) + ), + inArray(sites.type, siteTypes) + ) + ); + + for (const row of browserGatewayRows) { + if (filterOutNamespaceDomains && row.domainNamespaceId) { + continue; + } + if (!browserGatewayResourcesMap.has(row.resourceId)) { + browserGatewayResourcesMap.set(row.resourceId, { + resourceId: row.resourceId, + name: sanitize(row.resourceName) || "", + fullDomain: row.fullDomain, + ssl: row.ssl, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + wildcard: row.wildcard, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert, + maintenanceModeEnabled: row.maintenanceModeEnabled, + maintenanceModeType: row.maintenanceModeType, + maintenanceTitle: row.maintenanceTitle, + maintenanceMessage: row.maintenanceMessage, + maintenanceEstimatedTime: row.maintenanceEstimatedTime, + targets: [] + }); + } + browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ + browserGatewayTargetId: row.browserGatewayTargetId, + bgType: row.bgType, + siteId: row.siteId, + siteType: row.siteType, + siteOnline: row.siteOnline, + subnet: row.subnet, + siteExitNodeId: row.siteExitNodeId }); } - browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - browserGatewayTargetId: row.browserGatewayTargetId, - bgType: row.bgType, - siteId: row.siteId, - siteType: row.siteType, - siteOnline: row.siteOnline, - subnet: row.subnet, - siteExitNodeId: row.siteExitNodeId - }); } let siteResourcesWithFullDomain: { @@ -1055,245 +1059,257 @@ export async function getTraefikConfig( } } - // Generate Traefik config for browser gateway resources - const browserGatewayPort = 39999; - for (const [, bgResource] of browserGatewayResourcesMap.entries()) { - if (!bgResource.enabled) continue; - if (!bgResource.domainId) continue; - if (!bgResource.fullDomain) continue; + if (allowBrowserGatewayResources) { + // Generate Traefik config for browser gateway resources + const browserGatewayPort = 39999; + for (const [, bgResource] of browserGatewayResourcesMap.entries()) { + if (!bgResource.enabled) continue; + if (!bgResource.domainId) continue; + if (!bgResource.fullDomain) continue; - if (!config_output.http.routers) config_output.http.routers = {}; - if (!config_output.http.services) config_output.http.services = {}; + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; - const fullDomain = bgResource.fullDomain; - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares = [ - badgerMiddlewareName, - ...additionalMiddlewares - ]; + const fullDomain = bgResource.fullDomain; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; - const hostRule = `Host(\`${fullDomain}\`)`; + const hostRule = `Host(\`${fullDomain}\`)`; - // Build TLS config - let tls = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - let wildCard: string; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; + // Build TLS config + let tls = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = fullDomain.split("."); + let wildCard: string; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + if (!bgResource.subdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + const resolverName = bgResource.domainCertResolver + ? bgResource.domainCertResolver.trim() + : globalDefaultResolver; + const preferWildcard = + bgResource.preferWildcardCert !== undefined && + bgResource.preferWildcardCert !== null + ? bgResource.preferWildcardCert + : globalDefaultPreferWildcard; + + tls = { + certResolver: resolverName, + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) + }; } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - if (!bgResource.subdomain) { - wildCard = fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - const resolverName = bgResource.domainCertResolver - ? bgResource.domainCertResolver.trim() - : globalDefaultResolver; - const preferWildcard = - bgResource.preferWildcardCert !== undefined && - bgResource.preferWildcardCert !== null - ? bgResource.preferWildcardCert - : globalDefaultPreferWildcard; - - tls = { - certResolver: resolverName, - ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) - }; - } else { - const matchingCert = validCerts.find( - (cert) => cert.queriedDomain === fullDomain - ); - if (!matchingCert) { - logger.debug( - `No matching certificate found for browser gateway domain: ${fullDomain}` + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for browser gateway domain: ${fullDomain}` + ); + continue; + } + } + + const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`; + + if (bgResource.ssl) { + const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`; + config_output.http.routers![redirectRouterName] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: bgUiServiceName, + rule: hostRule, + priority: 100 + }; + } + + // Collect online sites for this resource (for any type) + const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); + + // Maintenance page logic for browser gateway resources + let showBgMaintenancePage = false; + if (bgResource.maintenanceModeEnabled) { + if (bgResource.maintenanceModeType === "forced") { + showBgMaintenancePage = true; + } else if (bgResource.maintenanceModeType === "automatic") { + showBgMaintenancePage = !anySiteOnline; + } + } + + if (showBgMaintenancePage && allowMaintenancePage) { + const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; + const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; + const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; + + const entrypointHttp = + config.getRawConfig().traefik.http_entrypoint; + const entrypointHttps = + config.getRawConfig().traefik.https_entrypoint; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + if (!config_output.http.services) + config_output.http.services = {}; + if (!config_output.http.middlewares) + config_output.http.middlewares = {}; + if (!config_output.http.routers) + config_output.http.routers = {}; + + config_output.http.services![bgMaintenanceServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${maintenanceHost}:${maintenancePort}` + } + ], + passHostHeader: true + } + }; + + config_output.http.middlewares![bgRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/maintenance-screen" + } + }; + + config_output.http.routers![bgMaintenanceRouterName] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + middlewares: [bgRewriteMiddlewareName], + rule: hostRule, + priority: 2000, + ...(bgResource.ssl ? { tls } : {}) + }; + + config_output.http.routers![ + `${bgMaintenanceRouterName}-assets` + ] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 2001, + ...(bgResource.ssl ? { tls } : {}) + }; + continue; } - } - const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`; - - if (bgResource.ssl) { - const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`; - config_output.http.routers![redirectRouterName] = { - entryPoints: [config.getRawConfig().traefik.http_entrypoint], - middlewares: [redirectHttpsMiddlewareName], - service: bgUiServiceName, - rule: hostRule, - priority: 100 - }; - } - - // Collect online sites for this resource (for any type) - const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); - - // Maintenance page logic for browser gateway resources - let showBgMaintenancePage = false; - if (bgResource.maintenanceModeEnabled) { - if (bgResource.maintenanceModeType === "forced") { - showBgMaintenancePage = true; - } else if (bgResource.maintenanceModeType === "automatic") { - showBgMaintenancePage = !anySiteOnline; + // Group targets by type and generate per-type websocket routers and services + const typeMap = new Map(); + for (const t of bgResource.targets) { + if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []); + typeMap.get(t.bgType)!.push(t); } - } - if (showBgMaintenancePage && allowMaintenancePage) { - const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; - const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; - const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; + for (const [bgType, typedTargets] of typeMap.entries()) { + const bgKey = `bg-r${bgResource.resourceId}-${bgType}`; + const bgRouterName = `${bgKey}-router`; + const bgServiceName = `${bgKey}-service`; + const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`; - const entrypointHttp = - config.getRawConfig().traefik.http_entrypoint; - const entrypointHttps = - config.getRawConfig().traefik.https_entrypoint; + const servers = typedTargets + .filter((t) => { + if (!t.siteOnline && anySiteOnline) return false; + if (t.siteType === "newt") return !!t.subnet; + return false; // browser gateway only supported on newt sites + }) + .map((t) => ({ + url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}` + })) + .filter( + (v, i, a) => a.findIndex((u) => u.url === v.url) === i + ); - const maintenancePort = config.getRawConfig().server.next_port; - const maintenanceHost = - config.getRawConfig().server.internal_hostname; - - if (!config_output.http.services) config_output.http.services = {}; - if (!config_output.http.middlewares) - config_output.http.middlewares = {}; - if (!config_output.http.routers) config_output.http.routers = {}; - - config_output.http.services![bgMaintenanceServiceName] = { - loadBalancer: { - servers: [ - { url: `http://${maintenanceHost}:${maintenancePort}` } + config_output.http.routers![bgRouterName] = { + entryPoints: [ + bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint ], - passHostHeader: true - } - }; + middlewares: routerMiddlewares, + service: bgServiceName, + rule: bgRule, + priority: 110, // highest - websocket path takes precedence + ...(bgResource.ssl ? { tls } : {}) + }; - config_output.http.middlewares![bgRewriteMiddlewareName] = { + config_output.http.services![bgServiceName] = { + loadBalancer: { + servers + } + }; + } + + // UI: serve the browser gateway page from the internal pangolin instance. + // The primary type is used for the path rewrite (e.g. /rdp), mirroring + // how the maintenance page rewrites everything to /maintenance-screen. + const primaryType = typeMap.keys().next().value as string; + const internalHost = config.getRawConfig().server.internal_hostname; + const internalPort = config.getRawConfig().server.next_port; + const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; + const entrypoint = bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint; + + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + config_output.http.middlewares![uiRewriteMiddlewareName] = { replacePathRegex: { regex: "^/(.*)", - replacement: "/maintenance-screen" + replacement: `/${primaryType}` } }; - config_output.http.routers![bgMaintenanceRouterName] = { - entryPoints: [ - bgResource.ssl ? entrypointHttps : entrypointHttp - ], - service: bgMaintenanceServiceName, - middlewares: [bgRewriteMiddlewareName], - rule: hostRule, - priority: 2000, - ...(bgResource.ssl ? { tls } : {}) - }; - - config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = { - entryPoints: [ - bgResource.ssl ? entrypointHttps : entrypointHttp - ], - service: bgMaintenanceServiceName, - rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, - priority: 2001, - ...(bgResource.ssl ? { tls } : {}) - }; - - continue; - } - - // Group targets by type and generate per-type websocket routers and services - const typeMap = new Map(); - for (const t of bgResource.targets) { - if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []); - typeMap.get(t.bgType)!.push(t); - } - - for (const [bgType, typedTargets] of typeMap.entries()) { - const bgKey = `bg-r${bgResource.resourceId}-${bgType}`; - const bgRouterName = `${bgKey}-router`; - const bgServiceName = `${bgKey}-service`; - const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`; - - const servers = typedTargets - .filter((t) => { - if (!t.siteOnline && anySiteOnline) return false; - if (t.siteType === "newt") return !!t.subnet; - return false; // browser gateway only supported on newt sites - }) - .map((t) => ({ - url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}` - })) - .filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i); - - config_output.http.routers![bgRouterName] = { - entryPoints: [ - bgResource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: bgServiceName, - rule: bgRule, - priority: 110, // highest - websocket path takes precedence - ...(bgResource.ssl ? { tls } : {}) - }; - - config_output.http.services![bgServiceName] = { + config_output.http.services![bgUiServiceName] = { loadBalancer: { - servers + servers: [ + { + url: `http://${internalHost}:${internalPort}` + } + ] } }; - } - // UI: serve the browser gateway page from the internal pangolin instance. - // The primary type is used for the path rewrite (e.g. /rdp), mirroring - // how the maintenance page rewrites everything to /maintenance-screen. - const primaryType = typeMap.keys().next().value as string; - const internalHost = config.getRawConfig().server.internal_hostname; - const internalPort = config.getRawConfig().server.next_port; - const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; - const entrypoint = bgResource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint; + // Assets router at higher priority so /_next files load without rewrite + config_output.http.routers![ + `bg-r${bgResource.resourceId}-assets-router` + ] = { + entryPoints: [entrypoint], + middlewares: routerMiddlewares, + service: bgUiServiceName, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 101, + ...(bgResource.ssl ? { tls } : {}) + }; - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - - config_output.http.middlewares![uiRewriteMiddlewareName] = { - replacePathRegex: { - regex: "^/(.*)", - replacement: `/${primaryType}` - } - }; - - config_output.http.services![bgUiServiceName] = { - loadBalancer: { - servers: [ - { - url: `http://${internalHost}:${internalPort}` - } - ] - } - }; - - // Assets router at higher priority so /_next files load without rewrite - config_output.http.routers![ - `bg-r${bgResource.resourceId}-assets-router` - ] = { - entryPoints: [entrypoint], - middlewares: routerMiddlewares, - service: bgUiServiceName, - rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, - priority: 101, - ...(bgResource.ssl ? { tls } : {}) - }; - - // Catch-all router rewrites everything on the domain to /{primaryType} - config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] = - { + // Catch-all router rewrites everything on the domain to /{primaryType} + config_output.http.routers![ + `bg-r${bgResource.resourceId}-ui-router` + ] = { entryPoints: [entrypoint], middlewares: [...routerMiddlewares, uiRewriteMiddlewareName], service: bgUiServiceName, @@ -1301,6 +1317,7 @@ export async function getTraefikConfig( priority: 100, ...(bgResource.ssl ? { tls } : {}) }; + } } // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 508952341..11f46e68d 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -270,7 +270,8 @@ hybridRouter.get( true, // But don't allow domain namespace resources false, // Dont include login pages, true, // allow raw resources - false // dont generate maintenance page + false, // dont generate maintenance page + false // dont generate browser gateway targets ); return response(res, { diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 5acb301a6..d556379b1 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -1,7 +1,7 @@ import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { orgs, resourcePassword, resources } from "@server/db"; +import { orgs, resourcePassword, resourcePolicies, resourcePolicyPassword, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -61,17 +61,29 @@ export async function authWithPassword( const [result] = await db .select() .from(resources) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .leftJoin( + resourcePolicies, + eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + resourcePolicyPassword, + eq(resourcePolicyPassword.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) - .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; const org = result?.orgs; - const definedPassword = result?.resourcePassword; + + // Policy password takes precedence over resource-level password + const policyPassword = result?.resourcePolicyPassword ?? null; + const definedPassword = policyPassword ?? result?.resourcePassword ?? null; + const isPolicyPassword = !!policyPassword; if (!org) { return next( @@ -89,10 +101,7 @@ export async function authWithPassword( return next( createHttpError( HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Resource has no password protection" - ) + "Resource has no password protection" ) ); } @@ -126,7 +135,8 @@ export async function authWithPassword( await createResourceSession({ resourceId, token, - passwordId: definedPassword.passwordId, + passwordId: isPolicyPassword ? null : definedPassword.passwordId, + policyPasswordId: isPolicyPassword ? definedPassword.passwordId : null, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 5480f3d04..49b1fca4d 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { orgs, resourcePincode, resources } from "@server/db"; +import { orgs, resourcePincode, resourcePolicies, resourcePolicyPincode, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -60,17 +60,29 @@ export async function authWithPincode( const [result] = await db .select() .from(resources) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .leftJoin( + resourcePolicies, + eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + resourcePolicyPincode, + eq(resourcePolicyPincode.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) - .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; const org = result?.orgs; - const definedPincode = result?.resourcePincode; + + // Policy pincode takes precedence over resource-level pincode + const policyPincode = result?.resourcePolicyPincode ?? null; + const definedPincode = policyPincode ?? result?.resourcePincode ?? null; + const isPolicyPincode = !!policyPincode; if (!org) { return next( @@ -125,7 +137,8 @@ export async function authWithPincode( await createResourceSession({ resourceId, token, - pincodeId: definedPincode.pincodeId, + pincodeId: isPolicyPincode ? null : definedPincode.pincodeId, + policyPincodeId: isPolicyPincode ? definedPincode.pincodeId : null, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 0f64cea4a..08908abfe 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db"; +import { orgs, resourceOtp, resources, resourceWhitelist, resourcePolicyWhiteList } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -59,82 +59,21 @@ export async function authWithWhitelist( const { email, otp } = parsedBody.data; try { - const [result] = await db + // Fetch resource and org first + const [resourceResult] = await db .select() - .from(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, email) - ) - ) - .leftJoin( - resources, - eq(resources.resourceId, resourceWhitelist.resourceId) - ) + .from(resources) .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .where(eq(resources.resourceId, resourceId)) .limit(1); - let resource = result?.resources; - let org = result?.orgs; - let whitelistedEmail = result?.resourceWhitelist; + const resource = resourceResult?.resources; + const org = resourceResult?.orgs; - if (!whitelistedEmail) { - // if email is not found, check for wildcard email - const wildcard = "*@" + email.split("@")[1]; - - logger.debug("Checking for wildcard email: " + wildcard); - - const [result] = await db - .select() - .from(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, wildcard) - ) - ) - .leftJoin( - resources, - eq(resources.resourceId, resourceWhitelist.resourceId) - ) - .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) - .limit(1); - - resource = result?.resources; - org = result?.orgs; - whitelistedEmail = result?.resourceWhitelist; - - // if wildcard is still not found, return unauthorized - if (!whitelistedEmail) { - if (config.getRawConfig().app.log_failed_attempts) { - logger.info( - `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` - ); - } - - if (org && resource) { - logAccessAudit({ - orgId: org.orgId, - resourceId: resource.resourceId, - action: false, - type: "whitelistedEmail", - metadata: { email }, - userAgent: req.headers["user-agent"], - requestIp: req.ip - }); - } - - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Email is not whitelisted" - ) - ) - ); - } + if (!resource) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + ); } if (!org) { @@ -143,9 +82,100 @@ export async function authWithWhitelist( ); } - if (!resource) { + const wildcard = "*@" + email.split("@")[1]; + + // Check policy whitelist first (policy takes precedence over resource whitelist) + let policyWhitelistEntry: { whitelistId: number; email: string } | null = null; + if (resource.resourcePolicyId) { + const [exact] = await db + .select() + .from(resourcePolicyWhiteList) + .where( + and( + eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId), + eq(resourcePolicyWhiteList.email, email) + ) + ) + .limit(1); + + if (exact) { + policyWhitelistEntry = exact; + } else { + logger.debug("Checking for wildcard email in policy: " + wildcard); + const [wildcardMatch] = await db + .select() + .from(resourcePolicyWhiteList) + .where( + and( + eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId), + eq(resourcePolicyWhiteList.email, wildcard) + ) + ) + .limit(1); + if (wildcardMatch) policyWhitelistEntry = wildcardMatch; + } + } + + // Fall back to resource whitelist if not found in policy + let resourceWhitelistEntry: { whitelistId: number; email: string } | null = null; + if (!policyWhitelistEntry) { + const [exact] = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ) + .limit(1); + + if (exact) { + resourceWhitelistEntry = exact; + } else { + logger.debug("Checking for wildcard email: " + wildcard); + const [wildcardMatch] = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, wildcard) + ) + ) + .limit(1); + if (wildcardMatch) resourceWhitelistEntry = wildcardMatch; + } + } + + const isPolicyWhitelist = !!policyWhitelistEntry; + const whitelistedEmail = policyWhitelistEntry ?? resourceWhitelistEntry; + + if (!whitelistedEmail) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` + ); + } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "whitelistedEmail", + metadata: { email }, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( - createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not whitelisted" + ) + ) ); } @@ -211,7 +241,8 @@ export async function authWithWhitelist( await createResourceSession({ resourceId, token, - whitelistId: whitelistedEmail.whitelistId, + whitelistId: isPolicyWhitelist ? null : whitelistedEmail.whitelistId, + policyWhitelistId: isPolicyWhitelist ? whitelistedEmail.whitelistId : null, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 02f890604..5da8eba4b 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -22,7 +22,8 @@ export async function traefikConfigProvider( config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source build != "oss", // generate the login pages on the cloud and and enterprise, - config.getRawConfig().traefik.allow_raw_resources + config.getRawConfig().traefik.allow_raw_resources, + build != "oss" // generate browser gateway resources on cloud and enterprise ); if (traefikConfig?.http?.middlewares) { diff --git a/server/setup/scriptsPg/1.19.0.ts b/server/setup/scriptsPg/1.19.0.ts index 68aa83afc..f8685e80b 100644 --- a/server/setup/scriptsPg/1.19.0.ts +++ b/server/setup/scriptsPg/1.19.0.ts @@ -583,6 +583,24 @@ export default async function migration() { DELETE FROM "resourceWhitelist" WHERE "resourceId" = ${resource.resourceId} `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD COLUMN "policyPasswordId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD COLUMN "policyPincodeId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD COLUMN "policyWhitelistId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPasswordId_resourcePolicyPassword_passwordId_fk" FOREIGN KEY ("policyPasswordId") REFERENCES "public"."resourcePolicyPassword"("passwordId") ON DELETE cascade ON UPDATE no action; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPincodeId_resourcePolicyPincode_pincodeId_fk" FOREIGN KEY ("policyPincodeId") REFERENCES "public"."resourcePolicyPincode"("pincodeId") ON DELETE cascade ON UPDATE no action; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action; + `); } await db.execute(sql`COMMIT`); diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index 7f0e726d1..ac6f53901 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -337,6 +337,21 @@ export default async function migration() { ALTER TABLE 'sites' ADD 'autoUpdateOverrideOrg' integer DEFAULT false NOT NULL; ` ).run(); + db.prepare( + ` + ALTER TABLE 'resourceSessions' ADD 'policyPasswordId' integer REFERENCES resourcePolicyPassword(passwordId); + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resourceSessions' ADD 'policyPincodeId' integer REFERENCES resourcePolicyPincode(pincodeId); + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id); + ` + ).run(); })(); const existingResources = db