mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-14 19:37:29 +00:00
When a blueprint references a role that doesn't exist, create it automatically with default permissions (getOrg, getResource, listResources) instead of throwing an error or silently dropping the association.
1266 lines
46 KiB
TypeScript
1266 lines
46 KiB
TypeScript
import {
|
|
domains,
|
|
domainNamespaces,
|
|
orgDomains,
|
|
Resource,
|
|
resourceHeaderAuth,
|
|
resourceHeaderAuthExtendedCompatibility,
|
|
resourcePincode,
|
|
resourceRules,
|
|
resourceWhitelist,
|
|
roleActions,
|
|
roleResources,
|
|
roles,
|
|
Target,
|
|
TargetHealthCheck,
|
|
targetHealthCheck,
|
|
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 { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
|
import { pickPort } from "@server/routers/target/helpers";
|
|
import { resourcePassword } from "@server/db";
|
|
import { hashPassword } from "@server/auth/password";
|
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
|
import { isValidRegionId } from "@server/db/regions";
|
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
|
|
import { tierMatrix } from "../billing/tierMatrix";
|
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
|
|
|
export type ProxyResourcesResults = {
|
|
proxyResource: Resource;
|
|
targetsToUpdate: Target[];
|
|
healthchecksToUpdate: TargetHealthCheck[];
|
|
}[];
|
|
|
|
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[] = [];
|
|
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
|
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"],
|
|
rewritePath:
|
|
targetData.rewritePath ||
|
|
targetData["rewrite-path"] ||
|
|
(targetData["rewrite-match"] === "stripPrefix"
|
|
? "/"
|
|
: undefined),
|
|
rewritePathType: targetData["rewrite-match"],
|
|
priority: targetData.priority
|
|
})
|
|
.returning();
|
|
|
|
targetsToUpdate.push(newTarget);
|
|
|
|
const healthcheckData = targetData.healthcheck;
|
|
|
|
const hcHeaders = healthcheckData?.headers
|
|
? JSON.stringify(healthcheckData.headers)
|
|
: null;
|
|
|
|
const [newHealthcheck] = await trx
|
|
.insert(targetHealthCheck)
|
|
.values({
|
|
name: `${targetData.hostname}:${targetData.port}`,
|
|
siteId: site.siteId,
|
|
targetId: newTarget.targetId,
|
|
orgId: orgId,
|
|
hcEnabled: healthcheckData?.enabled || false,
|
|
hcPath: healthcheckData?.path,
|
|
hcScheme: healthcheckData?.scheme,
|
|
hcMode: healthcheckData?.mode,
|
|
hcHostname: healthcheckData?.hostname,
|
|
hcPort: healthcheckData?.port,
|
|
hcInterval: healthcheckData?.interval,
|
|
hcUnhealthyInterval:
|
|
healthcheckData?.unhealthyInterval ||
|
|
healthcheckData?.["unhealthy-interval"],
|
|
hcTimeout: healthcheckData?.timeout,
|
|
hcHeaders: hcHeaders,
|
|
hcFollowRedirects:
|
|
healthcheckData?.followRedirects ||
|
|
healthcheckData?.["follow-redirects"],
|
|
hcMethod: healthcheckData?.method,
|
|
hcStatus: healthcheckData?.status,
|
|
hcHealth: "unknown",
|
|
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
|
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
|
})
|
|
.returning();
|
|
|
|
healthchecksToUpdate.push(newHealthcheck);
|
|
|
|
// Insert unknown status history when HC is created in disabled state
|
|
if (!healthcheckData?.enabled) {
|
|
await fireHealthCheckUnknownAlert(
|
|
orgId,
|
|
newHealthcheck.targetHealthCheckId,
|
|
newHealthcheck.name,
|
|
newHealthcheck.targetId,
|
|
undefined,
|
|
true,
|
|
trx
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 = "";
|
|
if (resourceData.headers) {
|
|
headers = JSON.stringify(resourceData.headers);
|
|
}
|
|
|
|
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
|
|
|
|
const isLicensed = await isLicensedOrSubscribed(
|
|
orgId,
|
|
tierMatrix.maintencePage
|
|
);
|
|
if (!isLicensed) {
|
|
resourceData.maintenance = undefined;
|
|
}
|
|
|
|
[resource] = await trx
|
|
.update(resources)
|
|
.set({
|
|
name: resourceData.name || "Unnamed Resource",
|
|
protocol: protocol || "tcp",
|
|
http: http,
|
|
proxyPort: http ? null : resourceData["proxy-port"],
|
|
fullDomain: http ? resourceData["full-domain"] : null,
|
|
subdomain: domain ? domain.subdomain : null,
|
|
domainId: domain ? domain.domainId : null,
|
|
wildcard: domain ? domain.wildcard : false,
|
|
enabled: resourceEnabled,
|
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
|
skipToIdpId:
|
|
resourceData.auth?.["auto-login-idp"] || null,
|
|
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,
|
|
maintenanceModeEnabled:
|
|
resourceData.maintenance?.enabled,
|
|
maintenanceModeType: resourceData.maintenance?.type,
|
|
maintenanceTitle: resourceData.maintenance?.title,
|
|
maintenanceMessage: resourceData.maintenance?.message,
|
|
maintenanceEstimatedTime:
|
|
resourceData.maintenance?.["estimated-time"]
|
|
})
|
|
.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
|
|
});
|
|
}
|
|
|
|
await trx
|
|
.delete(resourceHeaderAuth)
|
|
.where(
|
|
eq(
|
|
resourceHeaderAuth.resourceId,
|
|
existingResource.resourceId
|
|
)
|
|
);
|
|
|
|
await trx
|
|
.delete(resourceHeaderAuthExtendedCompatibility)
|
|
.where(
|
|
eq(
|
|
resourceHeaderAuthExtendedCompatibility.resourceId,
|
|
existingResource.resourceId
|
|
)
|
|
);
|
|
|
|
if (resourceData.auth?.["basic-auth"]) {
|
|
const headerAuthUser =
|
|
resourceData.auth?.["basic-auth"]?.user;
|
|
const headerAuthPassword =
|
|
resourceData.auth?.["basic-auth"]?.password;
|
|
const headerAuthExtendedCompatibility =
|
|
resourceData.auth?.["basic-auth"]
|
|
?.extendedCompatibility;
|
|
if (
|
|
headerAuthUser &&
|
|
headerAuthPassword &&
|
|
headerAuthExtendedCompatibility !== null
|
|
) {
|
|
const headerAuthHash = await hashPassword(
|
|
Buffer.from(
|
|
`${headerAuthUser}:${headerAuthPassword}`
|
|
).toString("base64")
|
|
);
|
|
await Promise.all([
|
|
trx.insert(resourceHeaderAuth).values({
|
|
resourceId: existingResource.resourceId,
|
|
headerAuthHash
|
|
}),
|
|
trx
|
|
.insert(resourceHeaderAuthExtendedCompatibility)
|
|
.values({
|
|
resourceId: existingResource.resourceId,
|
|
extendedCompatibilityIsActivated:
|
|
headerAuthExtendedCompatibility
|
|
})
|
|
]);
|
|
}
|
|
}
|
|
|
|
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"],
|
|
rewritePath:
|
|
targetData.rewritePath ||
|
|
targetData["rewrite-path"] ||
|
|
(targetData["rewrite-match"] === "stripPrefix"
|
|
? "/"
|
|
: undefined),
|
|
rewritePathType: targetData["rewrite-match"],
|
|
priority: targetData.priority
|
|
})
|
|
.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);
|
|
}
|
|
|
|
const healthcheckData = targetData.healthcheck;
|
|
|
|
const [oldHealthcheck] = await trx
|
|
.select()
|
|
.from(targetHealthCheck)
|
|
.where(
|
|
eq(
|
|
targetHealthCheck.targetId,
|
|
existingTarget.targetId
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
const hcHeaders = healthcheckData?.headers
|
|
? JSON.stringify(healthcheckData.headers)
|
|
: null;
|
|
|
|
const [newHealthcheck] = await trx
|
|
.update(targetHealthCheck)
|
|
.set({
|
|
hcEnabled: healthcheckData?.enabled || false,
|
|
hcPath: healthcheckData?.path,
|
|
hcScheme: healthcheckData?.scheme,
|
|
hcMode: healthcheckData?.mode,
|
|
hcHostname: healthcheckData?.hostname,
|
|
hcPort: healthcheckData?.port,
|
|
hcInterval: healthcheckData?.interval,
|
|
hcUnhealthyInterval:
|
|
healthcheckData?.unhealthyInterval ||
|
|
healthcheckData?.["unhealthy-interval"],
|
|
hcTimeout: healthcheckData?.timeout,
|
|
hcHeaders: hcHeaders,
|
|
hcFollowRedirects:
|
|
healthcheckData?.followRedirects ||
|
|
healthcheckData?.["follow-redirects"],
|
|
hcMethod: healthcheckData?.method,
|
|
hcStatus: healthcheckData?.status,
|
|
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
|
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
|
})
|
|
.where(
|
|
eq(
|
|
targetHealthCheck.targetId,
|
|
existingTarget.targetId
|
|
)
|
|
)
|
|
.returning();
|
|
|
|
if (
|
|
checkIfHealthcheckChanged(
|
|
oldHealthcheck,
|
|
newHealthcheck
|
|
)
|
|
) {
|
|
healthchecksToUpdate.push(newHealthcheck);
|
|
// if the target is not already in the targetsToUpdate array, add it
|
|
if (
|
|
!targetsToUpdate.find(
|
|
(t) => t.targetId === updatedTarget.targetId
|
|
)
|
|
) {
|
|
targetsToUpdate.push(updatedTarget);
|
|
}
|
|
}
|
|
|
|
// Insert unknown status history when HC is disabled
|
|
const isDisablingHc =
|
|
!healthcheckData?.enabled && oldHealthcheck?.hcEnabled;
|
|
if (isDisablingHc) {
|
|
await fireHealthCheckUnknownAlert(
|
|
orgId,
|
|
newHealthcheck.targetHealthCheckId,
|
|
newHealthcheck.name,
|
|
newHealthcheck.targetId,
|
|
undefined,
|
|
true,
|
|
trx
|
|
);
|
|
}
|
|
} 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 intendedPriority = rule.priority ?? index + 1;
|
|
const existingRule = existingRules[index];
|
|
if (existingRule) {
|
|
if (
|
|
existingRule.action !== getRuleAction(rule.action) ||
|
|
existingRule.match !== rule.match.toUpperCase() ||
|
|
existingRule.value !==
|
|
getRuleValue(
|
|
rule.match.toUpperCase(),
|
|
rule.value
|
|
) ||
|
|
existingRule.priority !== intendedPriority
|
|
) {
|
|
validateRule(rule);
|
|
await trx
|
|
.update(resourceRules)
|
|
.set({
|
|
action: getRuleAction(rule.action),
|
|
match: rule.match.toUpperCase(),
|
|
value: getRuleValue(
|
|
rule.match.toUpperCase(),
|
|
rule.value
|
|
),
|
|
priority: intendedPriority
|
|
})
|
|
.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: getRuleValue(
|
|
rule.match.toUpperCase(),
|
|
rule.value
|
|
),
|
|
priority: intendedPriority
|
|
});
|
|
}
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
const isLicensed = await isLicensedOrSubscribed(
|
|
orgId,
|
|
tierMatrix.maintencePage
|
|
);
|
|
if (!isLicensed) {
|
|
resourceData.maintenance = undefined;
|
|
}
|
|
|
|
// Create new resource
|
|
const [newResource] = await trx
|
|
.insert(resources)
|
|
.values({
|
|
orgId,
|
|
niceId: resourceNiceId,
|
|
name: resourceData.name || "Unnamed Resource",
|
|
protocol: protocol || "tcp",
|
|
http: http,
|
|
proxyPort: http ? null : resourceData["proxy-port"],
|
|
fullDomain: http ? resourceData["full-domain"] : null,
|
|
subdomain: domain ? domain.subdomain : null,
|
|
domainId: domain ? domain.domainId : null,
|
|
wildcard: domain ? domain.wildcard : false,
|
|
enabled: resourceEnabled,
|
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
|
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
|
|
setHostHeader: resourceData["host-header"] || null,
|
|
tlsServerName: resourceData["tls-server-name"] || null,
|
|
ssl: resourceSsl,
|
|
headers: headers || null,
|
|
applyRules:
|
|
resourceData.rules && resourceData.rules.length > 0,
|
|
maintenanceModeEnabled: resourceData.maintenance?.enabled,
|
|
maintenanceModeType: resourceData.maintenance?.type,
|
|
maintenanceTitle: resourceData.maintenance?.title,
|
|
maintenanceMessage: resourceData.maintenance?.message,
|
|
maintenanceEstimatedTime:
|
|
resourceData.maintenance?.["estimated-time"]
|
|
})
|
|
.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
|
|
});
|
|
}
|
|
|
|
if (resourceData.auth?.["basic-auth"]) {
|
|
const headerAuthUser = resourceData.auth?.["basic-auth"]?.user;
|
|
const headerAuthPassword =
|
|
resourceData.auth?.["basic-auth"]?.password;
|
|
const headerAuthExtendedCompatibility =
|
|
resourceData.auth?.["basic-auth"]?.extendedCompatibility;
|
|
|
|
if (
|
|
headerAuthUser &&
|
|
headerAuthPassword &&
|
|
headerAuthExtendedCompatibility !== null
|
|
) {
|
|
const headerAuthHash = await hashPassword(
|
|
Buffer.from(
|
|
`${headerAuthUser}:${headerAuthPassword}`
|
|
).toString("base64")
|
|
);
|
|
|
|
await Promise.all([
|
|
trx.insert(resourceHeaderAuth).values({
|
|
resourceId: newResource.resourceId,
|
|
headerAuthHash
|
|
}),
|
|
trx
|
|
.insert(resourceHeaderAuthExtendedCompatibility)
|
|
.values({
|
|
resourceId: newResource.resourceId,
|
|
extendedCompatibilityIsActivated:
|
|
headerAuthExtendedCompatibility
|
|
})
|
|
]);
|
|
}
|
|
}
|
|
|
|
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: getRuleValue(rule.match.toUpperCase(), rule.value),
|
|
priority: rule.priority ?? index + 1
|
|
});
|
|
}
|
|
|
|
logger.debug(`Created resource ${newResource.resourceId}`);
|
|
}
|
|
|
|
results.push({
|
|
proxyResource: resource,
|
|
targetsToUpdate,
|
|
healthchecksToUpdate
|
|
});
|
|
}
|
|
|
|
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 getRuleValue(match: string, value: string) {
|
|
// if the match is a country, uppercase the value
|
|
if (match == "COUNTRY") {
|
|
return value.toUpperCase();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
} else if (rule.match === "region") {
|
|
if (!isValidRegionId(rule.value)) {
|
|
throw new Error(`Invalid region ID provided: ${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) {
|
|
let [role] = await trx
|
|
.select()
|
|
.from(roles)
|
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
|
.limit(1);
|
|
|
|
if (!role) {
|
|
const [created] = await trx
|
|
.insert(roles)
|
|
.values({ name: roleName, orgId })
|
|
.returning();
|
|
await trx.insert(roleActions).values(
|
|
defaultRoleAllowedActions.map((action) => ({
|
|
roleId: created.roleId,
|
|
actionId: action,
|
|
orgId
|
|
}))
|
|
);
|
|
role = created;
|
|
logger.info(`Auto-created role "${roleName}" in org ${orgId} from blueprint`);
|
|
}
|
|
|
|
if (role.isAdmin) {
|
|
continue; // never add admin access
|
|
}
|
|
|
|
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 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) {
|
|
throw new Error(`User not found: ${username} 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.username &&
|
|
!ssoUsers.includes(user.user.username)
|
|
) {
|
|
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 checkIfHealthcheckChanged(
|
|
existing: TargetHealthCheck | undefined,
|
|
incoming: TargetHealthCheck | undefined
|
|
) {
|
|
if (!existing && incoming) return true;
|
|
if (existing && !incoming) return true;
|
|
if (!existing || !incoming) return false;
|
|
|
|
if (existing.hcEnabled !== incoming.hcEnabled) return true;
|
|
if (existing.hcPath !== incoming.hcPath) return true;
|
|
if (existing.hcScheme !== incoming.hcScheme) return true;
|
|
if (existing.hcMode !== incoming.hcMode) return true;
|
|
if (existing.hcHostname !== incoming.hcHostname) return true;
|
|
if (existing.hcPort !== incoming.hcPort) return true;
|
|
if (existing.hcInterval !== incoming.hcInterval) return true;
|
|
if (existing.hcUnhealthyInterval !== incoming.hcUnhealthyInterval)
|
|
return true;
|
|
if (existing.hcTimeout !== incoming.hcTimeout) return true;
|
|
if (existing.hcFollowRedirects !== incoming.hcFollowRedirects) return true;
|
|
if (existing.hcMethod !== incoming.hcMethod) return true;
|
|
if (existing.hcStatus !== incoming.hcStatus) return true;
|
|
if (
|
|
JSON.stringify(existing.hcHeaders) !==
|
|
JSON.stringify(incoming.hcHeaders)
|
|
)
|
|
return true;
|
|
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true;
|
|
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export 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}`
|
|
);
|
|
}
|
|
|
|
await createCertificate(domain.domainId, fullDomain, trx);
|
|
|
|
return domain;
|
|
}
|
|
|
|
async function getDomainId(
|
|
orgId: string,
|
|
fullDomain: string,
|
|
trx: Transaction
|
|
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
|
|
const isWildcardFullDomain = fullDomain.startsWith("*.");
|
|
|
|
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) => {
|
|
// Wildcard full-domains are not allowed on CNAME domains
|
|
if (isWildcardFullDomain && domain.domains.type === "cname") {
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
|
|
// Wildcard full-domains are not allowed on namespace (provided/free) domains
|
|
if (isWildcardFullDomain) {
|
|
const [namespaceDomain] = await trx
|
|
.select()
|
|
.from(domainNamespaces)
|
|
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
|
|
.limit(1);
|
|
|
|
if (namespaceDomain) {
|
|
throw new Error(
|
|
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// remove the base domain of the domain
|
|
let subdomain = null;
|
|
if (fullDomain != baseDomain) {
|
|
subdomain = fullDomain.replace(`.${baseDomain}`, "");
|
|
}
|
|
|
|
// Return the first valid domain
|
|
return {
|
|
subdomain: subdomain,
|
|
domainId: domainSelection.domainId,
|
|
wildcard: isWildcardFullDomain
|
|
};
|
|
}
|