pangolin/server/lib/blueprints/proxyResources.ts
2025-09-14 22:06:05 -07:00

885 lines
30 KiB
TypeScript

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