import { domains, orgDomains, Resource, resourcePincode, resourceRules, resourceWhitelist, roleResources, roles, Target, Transaction, userOrgs, userResources, users } from "@server/db"; import { resources, targets, sites } from "@server/db"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; import { Config, ConfigSchema, isTargetsOnlyResource, TargetData } from "./types"; import logger from "@server/logger"; import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; export type ProxyResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; }[]; export async function updateProxyResources( orgId: string, config: Config, trx: Transaction, siteId?: number ): Promise { const results: ProxyResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] )) { const targetsToUpdate: Target[] = []; let resource: Resource; async function createTarget( // reusable function to create a target resourceId: number, targetData: TargetData ) { const targetSiteId = targetData.site; let site; if (targetSiteId) { // Look up site by niceId [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.niceId, targetSiteId), eq(sites.orgId, orgId) ) ) .limit(1); } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) ) .limit(1); } else { throw new Error(`Target site is required`); } if (!site) { throw new Error( `Site not found: ${targetSiteId} in org ${orgId}` ); } let internalPortToCreate; if (!targetData["internal-port"]) { const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); internalPortToCreate = internalPort; } else { internalPortToCreate = targetData["internal-port"]; } // Create target const [newTarget] = await trx .insert(targets) .values({ resourceId: resourceId, siteId: site.siteId, ip: targetData.hostname, method: targetData.method, port: targetData.port, enabled: targetData.enabled, internalPort: internalPortToCreate, path: targetData.path, pathMatchType: targetData["path-match"] }) .returning(); targetsToUpdate.push(newTarget); } // Find existing resource by niceId and orgId const [existingResource] = await trx .select() .from(resources) .where( and( eq(resources.niceId, resourceNiceId), eq(resources.orgId, orgId) ) ) .limit(1); const http = resourceData.protocol == "http"; const protocol = resourceData.protocol == "http" ? "tcp" : resourceData.protocol; const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true : resourceData.enabled; const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; let headers = ""; for (const header of resourceData.headers || []) { headers += `${header.name}: ${header.value},`; } // if there are headers, remove the trailing comma if (headers.endsWith(",")) { headers = headers.slice(0, -1); } if (existingResource) { let domain; if (http) { domain = await getDomain( existingResource.resourceId, resourceData["full-domain"]!, orgId, trx ); } // check if the only key in the resource is targets, if so, skip the update if (isTargetsOnlyResource(resourceData)) { logger.debug( `Skipping update for resource ${existingResource.resourceId} as only targets are provided` ); resource = existingResource; } else { // Update existing resource [resource] = await trx .update(resources) .set({ name: resourceData.name || "Unnamed Resource", protocol: protocol || "http", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, ssl: resourceSsl, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, emailWhitelistEnabled: resourceData.auth?.[ "whitelist-users" ] ? resourceData.auth["whitelist-users"].length > 0 : false, headers: headers || null, applyRules: resourceData.rules && resourceData.rules.length > 0 }) .where( eq(resources.resourceId, existingResource.resourceId) ) .returning(); await trx .delete(resourcePassword) .where( eq( resourcePassword.resourceId, existingResource.resourceId ) ); if (resourceData.auth?.password) { const passwordHash = await hashPassword( resourceData.auth.password ); await trx.insert(resourcePassword).values({ resourceId: existingResource.resourceId, passwordHash }); } await trx .delete(resourcePincode) .where( eq( resourcePincode.resourceId, existingResource.resourceId ) ); if (resourceData.auth?.pincode) { const pincodeHash = await hashPassword( resourceData.auth.pincode.toString() ); await trx.insert(resourcePincode).values({ resourceId: existingResource.resourceId, pincodeHash, digitLength: 6 }); } if (resourceData.auth?.["sso-roles"]) { const ssoRoles = resourceData.auth?.["sso-roles"]; await syncRoleResources( existingResource.resourceId, ssoRoles, orgId, trx ); } if (resourceData.auth?.["sso-users"]) { const ssoUsers = resourceData.auth?.["sso-users"]; await syncUserResources( existingResource.resourceId, ssoUsers, orgId, trx ); } if (resourceData.auth?.["whitelist-users"]) { const whitelistUsers = resourceData.auth?.["whitelist-users"]; await syncWhitelistUsers( existingResource.resourceId, whitelistUsers, orgId, trx ); } } const existingResourceTargets = await trx .select() .from(targets) .where(eq(targets.resourceId, existingResource.resourceId)) .orderBy(asc(targets.targetId)); // Create new targets for (const [index, targetData] of resourceData.targets.entries()) { if ( !targetData || (typeof targetData === "object" && Object.keys(targetData).length === 0) ) { // If targetData is null or an empty object, we can skip it continue; } const existingTarget = existingResourceTargets[index]; if (existingTarget) { const targetSiteId = targetData.site; let site; if (targetSiteId) { // Look up site by niceId [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.niceId, targetSiteId), eq(sites.orgId, orgId) ) ) .limit(1); } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.siteId, siteId), eq(sites.orgId, orgId) ) ) .limit(1); } else { throw new Error(`Target site is required`); } if (!site) { throw new Error( `Site not found: ${targetSiteId} in org ${orgId}` ); } // update this target const [updatedTarget] = await trx .update(targets) .set({ siteId: site.siteId, ip: targetData.hostname, method: http ? targetData.method : null, port: targetData.port, enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData["path-match"] }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); if (checkIfTargetChanged(existingTarget, updatedTarget)) { let internalPortToUpdate; if (!targetData["internal-port"]) { const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); internalPortToUpdate = internalPort; } else { internalPortToUpdate = targetData["internal-port"]; } const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after .update(targets) .set({ internalPort: internalPortToUpdate }) .where( eq(targets.targetId, existingTarget.targetId) ) .returning(); targetsToUpdate.push(finalUpdatedTarget); } } else { await createTarget(existingResource.resourceId, targetData); } } if (existingResourceTargets.length > resourceData.targets.length) { const targetsToDelete = existingResourceTargets.slice( resourceData.targets.length ); logger.debug( `Targets to delete: ${JSON.stringify(targetsToDelete)}` ); for (const target of targetsToDelete) { if (!target) { continue; } if (siteId && target.siteId !== siteId) { logger.debug( `Skipping target ${target.targetId} for deletion. Site ID does not match filter.` ); continue; // only delete targets for the specified siteId } logger.debug(`Deleting target ${target.targetId}`); await trx .delete(targets) .where(eq(targets.targetId, target.targetId)); } } const existingRules = await trx .select() .from(resourceRules) .where( eq(resourceRules.resourceId, existingResource.resourceId) ) .orderBy(resourceRules.priority); // Sync rules for (const [index, rule] of resourceData.rules?.entries() || []) { const existingRule = existingRules[index]; if (existingRule) { if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || existingRule.value !== rule.value ) { validateRule(rule); await trx .update(resourceRules) .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: rule.value }) .where( eq(resourceRules.ruleId, existingRule.ruleId) ); } } else { validateRule(rule); await trx.insert(resourceRules).values({ resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: rule.value, priority: index + 1 // start priorities at 1 }); } } if (existingRules.length > (resourceData.rules?.length || 0)) { const rulesToDelete = existingRules.slice( resourceData.rules?.length || 0 ); for (const rule of rulesToDelete) { await trx .delete(resourceRules) .where(eq(resourceRules.ruleId, rule.ruleId)); } } logger.debug(`Updated resource ${existingResource.resourceId}`); } else { // create a brand new resource let domain; if (http) { domain = await getDomain( undefined, resourceData["full-domain"]!, orgId, trx ); } // Create new resource const [newResource] = await trx .insert(resources) .values({ orgId, niceId: resourceNiceId, name: resourceData.name || "Unnamed Resource", protocol: resourceData.protocol || "http", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, headers: headers || null, applyRules: resourceData.rules && resourceData.rules.length > 0 }) .returning(); if (resourceData.auth?.password) { const passwordHash = await hashPassword( resourceData.auth.password ); await trx.insert(resourcePassword).values({ resourceId: newResource.resourceId, passwordHash }); } if (resourceData.auth?.pincode) { const pincodeHash = await hashPassword( resourceData.auth.pincode.toString() ); await trx.insert(resourcePincode).values({ resourceId: newResource.resourceId, pincodeHash, digitLength: 6 }); } resource = newResource; 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`); } await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId }); if (resourceData.auth?.["sso-roles"]) { const ssoRoles = resourceData.auth?.["sso-roles"]; await syncRoleResources( newResource.resourceId, ssoRoles, orgId, trx ); } if (resourceData.auth?.["sso-users"]) { const ssoUsers = resourceData.auth?.["sso-users"]; await syncUserResources( newResource.resourceId, ssoUsers, orgId, trx ); } if (resourceData.auth?.["whitelist-users"]) { const whitelistUsers = resourceData.auth?.["whitelist-users"]; await syncWhitelistUsers( newResource.resourceId, whitelistUsers, orgId, trx ); } // Create new targets for (const targetData of resourceData.targets) { if (!targetData) { // If targetData is null or an empty object, we can skip it continue; } await createTarget(newResource.resourceId, targetData); } for (const [index, rule] of resourceData.rules?.entries() || []) { validateRule(rule); await trx.insert(resourceRules).values({ resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: rule.value, priority: index + 1 // start priorities at 1 }); } logger.debug(`Created resource ${newResource.resourceId}`); } results.push({ proxyResource: resource, targetsToUpdate }); } return results; } function getRuleAction(input: string) { let action = "DROP"; if (input == "allow") { action = "ACCEPT"; } else if (input == "deny") { action = "DROP"; } else if (input == "pass") { action = "PASS"; } return action; } function validateRule(rule: any) { if (rule.match === "cidr") { if (!isValidCIDR(rule.value)) { throw new Error(`Invalid CIDR provided: ${rule.value}`); } } else if (rule.match === "ip") { if (!isValidIP(rule.value)) { throw new Error(`Invalid IP provided: ${rule.value}`); } } else if (rule.match === "path") { if (!isValidUrlGlobPattern(rule.value)) { throw new Error(`Invalid URL glob pattern: ${rule.value}`); } } } async function syncRoleResources( resourceId: number, ssoRoles: string[], orgId: string, trx: Transaction ) { const existingRoleResources = await trx .select() .from(roleResources) .where(eq(roleResources.resourceId, resourceId)); for (const roleName of ssoRoles) { if (roleName === "Admin") { continue; // never add admin access } const [role] = await trx .select() .from(roles) .where(and(eq(roles.name, roleName), eq(roles.orgId, orgId))) .limit(1); if (!role) { throw new Error(`Role not found: ${roleName} in org ${orgId}`); } const existingRoleResource = existingRoleResources.find( (rr) => rr.roleId === role.roleId ); if (!existingRoleResource) { await trx.insert(roleResources).values({ roleId: role.roleId, resourceId: resourceId }); } } for (const existingRoleResource of existingRoleResources) { const [role] = await trx .select() .from(roles) .where(eq(roles.roleId, existingRoleResource.roleId)) .limit(1); if (role.isAdmin) { continue; // never remove admin access } if (role && !ssoRoles.includes(role.name)) { await trx .delete(roleResources) .where( and( eq(roleResources.roleId, existingRoleResource.roleId), eq(roleResources.resourceId, resourceId) ) ); } } } async function syncUserResources( resourceId: number, ssoUsers: string[], orgId: string, trx: Transaction ) { const existingUserResources = await trx .select() .from(userResources) .where(eq(userResources.resourceId, resourceId)); for (const email of ssoUsers) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) .limit(1); if (!user) { throw new Error(`User not found: ${email} in org ${orgId}`); } const existingUserResource = existingUserResources.find( (rr) => rr.userId === user.user.userId ); if (!existingUserResource) { await trx.insert(userResources).values({ userId: user.user.userId, resourceId: resourceId }); } } for (const existingUserResource of existingUserResources) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( eq(users.userId, existingUserResource.userId), eq(userOrgs.orgId, orgId) ) ) .limit(1); if (user && user.user.email && !ssoUsers.includes(user.user.email)) { await trx .delete(userResources) .where( and( eq(userResources.userId, existingUserResource.userId), eq(userResources.resourceId, resourceId) ) ); } } } async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], orgId: string, trx: Transaction ) { const existingWhitelist = await trx .select() .from(resourceWhitelist) .where(eq(resourceWhitelist.resourceId, resourceId)); for (const email of whitelistUsers) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) .limit(1); if (!user) { throw new Error(`User not found: ${email} in org ${orgId}`); } const existingWhitelistEntry = existingWhitelist.find( (w) => w.email === email ); if (!existingWhitelistEntry) { await trx.insert(resourceWhitelist).values({ email, resourceId: resourceId }); } } for (const existingWhitelistEntry of existingWhitelist) { if (!whitelistUsers.includes(existingWhitelistEntry.email)) { await trx .delete(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq( resourceWhitelist.email, existingWhitelistEntry.email ) ) ); } } } function checkIfTargetChanged( existing: Target | undefined, incoming: Target | undefined ): boolean { if (!existing && incoming) return true; if (existing && !incoming) return true; if (!existing || !incoming) return false; if (existing.ip !== incoming.ip) return true; if (existing.port !== incoming.port) return true; if (existing.siteId !== incoming.siteId) return true; return false; } async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, trx: Transaction ) { const [fullDomainExists] = await trx .select({ resourceId: resources.resourceId }) .from(resources) .where( and( eq(resources.fullDomain, fullDomain), eq(resources.orgId, orgId), resourceId ? ne(resources.resourceId, resourceId) : isNotNull(resources.resourceId) ) ) .limit(1); if (fullDomainExists) { throw new Error( `Resource already exists: ${fullDomain} in org ${orgId}` ); } const domain = await getDomainId(orgId, fullDomain, trx); if (!domain) { throw new Error( `Domain not found for full-domain: ${fullDomain} in org ${orgId}` ); } return domain; } async function getDomainId( orgId: string, fullDomain: string, trx: Transaction ): Promise<{ subdomain: string | null; domainId: string } | null> { const possibleDomains = await trx .select() .from(domains) .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) .execute(); if (possibleDomains.length === 0) { return null; } const validDomains = possibleDomains.filter((domain) => { if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { return ( fullDomain === domain.domains.baseDomain || fullDomain.endsWith(`.${domain.domains.baseDomain}`) ); } else if (domain.domains.type == "cname") { return fullDomain === domain.domains.baseDomain; } }); if (validDomains.length === 0) { return null; } const domainSelection = validDomains[0].domains; const baseDomain = domainSelection.baseDomain; // remove the base domain of the domain let subdomain = null; if (domainSelection.type == "ns") { if (fullDomain != baseDomain) { subdomain = fullDomain.replace(`.${baseDomain}`, ""); } } // Return the first valid domain return { subdomain: subdomain, domainId: domainSelection.domainId }; }