Update blueprints to support new clients

This commit is contained in:
Owen
2025-12-06 17:32:49 -05:00
parent 0beaadf512
commit 66fc8529c2
5 changed files with 578 additions and 282 deletions

View File

@@ -14,6 +14,7 @@ import {
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
type ApplyBlueprintArgs = {
orgId: string;
@@ -57,7 +58,6 @@ export async function applyBlueprint({
trx,
siteId
);
});
logger.debug(
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
@@ -66,7 +66,7 @@ export async function applyBlueprint({
// We need to update the targets on the newts from the successfully updated information
for (const result of proxyResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await db
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
@@ -108,13 +108,13 @@ export async function applyBlueprint({
// We need to update the targets on the newts from the successfully updated information
for (const result of clientResourcesResults) {
const [site] = await db
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, result.resource.siteId),
eq(sites.siteId, result.newSiteResource.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
@@ -122,9 +122,26 @@ export async function applyBlueprint({
)
.limit(1);
if (!site) {
logger.debug(
`Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}`
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
continue;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${site.sites.siteId}`
);
if (result.oldSiteResource) {
// this was an update
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
{ siteId: site.sites.siteId, orgId: site.sites.orgId },
trx
);
}
// await addClientTargets(
// site.newt.newtId,
@@ -134,6 +151,7 @@ export async function applyBlueprint({
// result.resource.proxyPort
// );
}
});
blueprintSucceeded = true;
blueprintMessage = "Blueprint applied successfully";
@@ -171,53 +189,3 @@ export async function applyBlueprint({
return blueprint;
}
// await updateDatabaseFromConfig("org_i21aifypnlyxur2", {
// resources: {
// "resource-nice-id": {
// name: "this is my resource",
// protocol: "http",
// "full-domain": "level1.test.example.com",
// "host-header": "example.com",
// "tls-server-name": "example.com",
// auth: {
// pincode: 123456,
// password: "sadfasdfadsf",
// "sso-enabled": true,
// "sso-roles": ["Member"],
// "sso-users": ["owen@pangolin.net"],
// "whitelist-users": ["owen@pangolin.net"]
// },
// targets: [
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// method: "http",
// port: 8000,
// healthcheck: {
// port: 8000,
// hostname: "localhost"
// }
// },
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// method: "http",
// port: 8001
// }
// ]
// },
// "resource-nice-id2": {
// name: "http server",
// protocol: "tcp",
// "proxy-port": 3000,
// targets: [
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// port: 3000,
// }
// ]
// }
// }
// });

View File

@@ -1,17 +1,23 @@
import {
clients,
clientSiteResources,
roles,
roleSiteResources,
SiteResource,
siteResources,
Transaction,
userOrgs,
users,
userSiteResources
} from "@server/db";
import { sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import {
Config,
} from "./types";
import { eq, and, ne, inArray } from "drizzle-orm";
import { Config } from "./types";
import logger from "@server/logger";
export type ClientResourcesResults = {
resource: SiteResource;
newSiteResource: SiteResource;
oldSiteResource?: SiteResource;
}[];
export async function updateClientResources(
@@ -69,17 +75,22 @@ export async function updateClientResources(
}
if (existingResource) {
if (existingResource.siteId !== site.siteId) {
throw new Error(
`You can not change the site of an existing client resource (${resourceNiceId}). Please delete and recreate it instead.`
);
}
// Update existing resource
const [updatedResource] = await trx
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: "port",
proxyPort: resourceData["proxy-port"]!,
destination: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
mode: resourceData.mode,
destination: resourceData.destination,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null
})
.where(
eq(
@@ -89,7 +100,110 @@ export async function updateClientResources(
)
.returning();
results.push({ resource: updatedResource });
const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
if (resourceData.machines.length > 0) {
// get clientIds from niceIds
const clientsToUpdate = await trx
.select()
.from(clients)
.where(
and(
inArray(clients.niceId, resourceData.machines),
eq(clients.orgId, orgId)
)
);
const clientIds = clientsToUpdate.map(
(client) => client.clientId
);
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (resourceData.users.length > 0) {
// get userIds from username
const usersToUpdate = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
inArray(users.username, resourceData.users),
eq(userOrgs.orgId, orgId)
)
);
const userIds = usersToUpdate.map((user) => user.user.userId);
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({ userId, siteResourceId }))
);
}
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)));
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(roleSiteResources.siteResourceId, siteResourceId),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(roleSiteResources)
.where(
eq(roleSiteResources.siteResourceId, siteResourceId)
);
}
if (resourceData.roles.length > 0) {
// Re-add specified roles but we need to get the roleIds from the role name in the array
const rolesToUpdate = await trx
.select()
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.name, resourceData.roles)
)
);
const roleIds = rolesToUpdate.map((role) => role.roleId);
await trx
.insert(roleSiteResources)
.values(
roleIds.map((roleId) => ({ roleId, siteResourceId }))
);
}
results.push({
newSiteResource: updatedResource,
oldSiteResource: existingResource
});
} else {
// Create new resource
const [newResource] = await trx
@@ -99,19 +213,103 @@ export async function updateClientResources(
siteId: site.siteId,
niceId: resourceNiceId,
name: resourceData.name || resourceNiceId,
mode: "port",
proxyPort: resourceData["proxy-port"]!,
destination: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
mode: resourceData.mode,
destination: resourceData.destination,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null
})
.returning();
const siteResourceId = newResource.siteResourceId;
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 for org ${orgId}`);
}
await trx.insert(roleSiteResources).values({
roleId: adminRole.roleId,
siteResourceId: siteResourceId
});
if (resourceData.roles.length > 0) {
// get roleIds from role names
const rolesToUpdate = await trx
.select()
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.name, resourceData.roles)
)
);
const roleIds = rolesToUpdate.map((role) => role.roleId);
await trx
.insert(roleSiteResources)
.values(
roleIds.map((roleId) => ({ roleId, siteResourceId }))
);
}
if (resourceData.users.length > 0) {
// get userIds from username
const usersToUpdate = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
inArray(users.username, resourceData.users),
eq(userOrgs.orgId, orgId)
)
);
const userIds = usersToUpdate.map((user) => user.user.userId);
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({ userId, siteResourceId }))
);
}
if (resourceData.machines.length > 0) {
// get clientIds from niceIds
const clientsToUpdate = await trx
.select()
.from(clients)
.where(
and(
inArray(clients.niceId, resourceData.machines),
eq(clients.orgId, orgId)
)
);
const clientIds = clientsToUpdate.map(
(client) => client.clientId
);
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
logger.info(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
results.push({ resource: newResource });
results.push({ newSiteResource: newResource });
}
}

View File

@@ -221,7 +221,8 @@ export async function updateProxyResources(
domainId: domain ? domain.domainId : null,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
skipToIdpId:
resourceData.auth?.["auto-login-idp"] || null,
ssl: resourceSsl,
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
@@ -546,7 +547,8 @@ export async function updateProxyResources(
if (
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !== getRuleValue(rule.match.toUpperCase(), rule.value)
existingRule.value !==
getRuleValue(rule.match.toUpperCase(), rule.value)
) {
validateRule(rule);
await trx
@@ -554,7 +556,10 @@ export async function updateProxyResources(
.set({
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: getRuleValue(rule.match.toUpperCase(), rule.value),
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
)
})
.where(
eq(resourceRules.ruleId, existingRule.ruleId)
@@ -566,7 +571,10 @@ export async function updateProxyResources(
resourceId: existingResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: getRuleValue(rule.match.toUpperCase(), rule.value),
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
),
priority: index + 1 // start priorities at 1
});
}
@@ -852,16 +860,16 @@ async function syncUserResources(
.from(userResources)
.where(eq(userResources.resourceId, resourceId));
for (const email of ssoUsers) {
for (const username 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)))
.where(and(eq(users.username, username), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!user) {
throw new Error(`User not found: ${email} in org ${orgId}`);
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserResource = existingUserResources.find(
@@ -889,7 +897,11 @@ async function syncUserResources(
)
.limit(1);
if (user && user.user.email && !ssoUsers.includes(user.user.email)) {
if (
user &&
user.user.username &&
!ssoUsers.includes(user.user.username)
) {
await trx
.delete(userResources)
.where(

View File

@@ -16,7 +16,11 @@ export const TargetHealthCheckSchema = z.object({
"unhealthy-interval": z.int().default(30),
unhealthyInterval: z.int().optional(), // deprecated alias
timeout: z.int().default(5),
headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional().default(null),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional()
.default(null),
"follow-redirects": z.boolean().default(true),
followRedirects: z.boolean().optional(), // deprecated alias
method: z.string().default("GET"),
@@ -36,7 +40,10 @@ export const TargetSchema = z.object({
healthcheck: TargetHealthCheckSchema.optional(),
rewritePath: z.string().optional(), // deprecated alias
"rewrite-path": z.string().optional(),
"rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
"rewrite-match": z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.int().min(1).max(1000).optional().default(100)
});
export type TargetData = z.infer<typeof TargetSchema>;
@@ -45,10 +52,12 @@ export const AuthSchema = z.object({
// pincode has to have 6 digits
pincode: z.number().min(100000).max(999999).optional(),
password: z.string().min(1).optional(),
"basic-auth": z.object({
"basic-auth": z
.object({
user: z.string().min(1),
password: z.string().min(1)
}).optional(),
})
.optional(),
"sso-enabled": z.boolean().optional().default(false),
"sso-roles": z
.array(z.string())
@@ -59,7 +68,7 @@ export const AuthSchema = z.object({
}),
"sso-users": z.array(z.email()).optional().default([]),
"whitelist-users": z.array(z.email()).optional().default([]),
"auto-login-idp": z.int().positive().optional(),
"auto-login-idp": z.int().positive().optional()
});
export const RuleSchema = z.object({
@@ -122,7 +131,6 @@ export const ResourceSchema = z
{
path: ["targets"],
error: "When protocol is 'http', all targets must have a 'method' field"
}
)
.refine(
@@ -204,23 +212,122 @@ export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets;
}
export const ClientResourceSchema = z.object({
name: z.string().min(2).max(100),
site: z.string().min(2).max(100).optional(),
protocol: z.enum(["tcp", "udp"]),
"proxy-port": z.number().min(1).max(65535),
"hostname": z.string().min(1).max(255),
"internal-port": z.number().min(1).max(65535),
enabled: z.boolean().optional().default(true)
});
export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr"]),
site: z.string(),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
// enabled: z.boolean().default(true),
alias: z
.string()
.regex(
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
"Alias must be a fully qualified domain name (e.g., example.com)"
)
.optional(),
roles: z
.array(z.string())
.optional()
.default([])
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in roles"
}),
users: z.array(z.email()).optional().default([]),
machines: z.array(z.string()).optional().default([])
})
.refine(
(data) => {
if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
.union([z.ipv4(), z.ipv6()])
.safeParse(data.destination).success;
if (isValidIP) {
return true;
}
// Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const isValidDomain = domainRegex.test(data.destination);
const isValidAlias = data.alias && domainRegex.test(data.alias);
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
}
return true;
},
{
message:
"Destination must be a valid IP address or valid domain AND alias is required"
}
)
.refine(
(data) => {
if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()])
.safeParse(data.destination).success;
return isValidCIDR;
}
return true;
},
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
);
// Schema for the entire configuration object
export const ConfigSchema = z
.object({
"proxy-resources": z.record(z.string(), ResourceSchema).optional().prefault({}),
"client-resources": z.record(z.string(), ClientResourceSchema).optional().prefault({}),
"proxy-resources": z
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"public-resources": z
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"client-resources": z
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
"private-resources": z
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
})
.transform((data) => {
// Merge public-resources into proxy-resources
if (data["public-resources"]) {
data["proxy-resources"] = {
...data["proxy-resources"],
...data["public-resources"]
};
delete (data as any)["public-resources"];
}
// Merge private-resources into client-resources
if (data["private-resources"]) {
data["client-resources"] = {
...data["client-resources"],
...data["private-resources"]
};
delete (data as any)["private-resources"];
}
return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
"client-resources": Record<string, z.infer<typeof ClientResourceSchema>>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
})
.refine(
// Enforce the full-domain uniqueness across resources in the same stack
(config) => {
@@ -278,12 +385,10 @@ export const ConfigSchema = z
const duplicates = Array.from(protocolPortMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([protocolPort, resourceKeys]) => {
const [protocol, port] = protocolPort.split(':');
.map(([protocolPort, resourceKeys]) => {
const [protocol, port] = protocolPort.split(":");
return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`;
}
)
})
.join("; ");
if (duplicates.length !== 0) {
@@ -295,35 +400,35 @@ export const ConfigSchema = z
}
)
.refine(
// Enforce proxy-port uniqueness within client-resources
// Enforce alias uniqueness within client-resources
(config) => {
// Extract duplicates for error message
const proxyPortMap = new Map<number, string[]>();
const aliasMap = new Map<string, string[]>();
Object.entries(config["client-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
const alias = resource.alias;
if (alias !== undefined) {
if (!aliasMap.has(alias)) {
aliasMap.set(alias, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
aliasMap.get(alias)!.push(resourceKey);
}
}
);
const duplicates = Array.from(proxyPortMap.entries())
const duplicates = Array.from(aliasMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([proxyPort, resourceKeys]) =>
`port ${proxyPort} used by client-resources: ${resourceKeys.join(", ")}`
([alias, resourceKeys]) =>
`alias '${alias}' used by client-resources: ${resourceKeys.join(", ")}`
)
.join("; ");
if (duplicates.length !== 0) {
return {
path: ["client-resources"],
error: `Duplicate 'proxy-port' values found in client-resources: ${duplicates}`
error: `Duplicate 'alias' values found in client-resources: ${duplicates}`
};
}
}

View File

@@ -8,6 +8,7 @@ import {
roles,
roleSiteResources,
sites,
Transaction,
userSiteResources
} from "@server/db";
import { siteResources, SiteResource } from "@server/db";
@@ -296,6 +297,42 @@ export async function updateSiteResource(
);
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource!,
{ siteId: site.siteId, orgId: site.orgId },
trx
);
});
return response(res, {
data: updatedSiteResource,
success: true,
error: false,
message: "Site resource updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error("Error updating site resource:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update site resource"
)
);
}
}
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource,
updatedSiteResource: SiteResource,
site: { siteId: number; orgId: string },
trx: Transaction
) {
const { mergedAllClients } =
await rebuildClientAssociationsFromSiteResource(
existingSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
@@ -304,8 +341,7 @@ export async function updateSiteResource(
// after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed
const destinationChanged =
existingSiteResource.destination !==
updatedSiteResource.destination;
existingSiteResource.destination !== updatedSiteResource.destination;
const aliasChanged =
existingSiteResource.alias !== updatedSiteResource.alias;
@@ -317,8 +353,8 @@ export async function updateSiteResource(
.limit(1);
if (!newt) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
throw new Error(
"Newt not found for site during site resource update"
);
}
@@ -381,8 +417,7 @@ export async function updateSiteResource(
updatedSiteResource.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
oldRemoteSubnets: !oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
@@ -408,26 +443,4 @@ export async function updateSiteResource(
await Promise.all(olmJobs);
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
});
return response(res, {
data: updatedSiteResource,
success: true,
error: false,
message: "Site resource updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error("Error updating site resource:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update site resource"
)
);
}
}