Handle changing site by recreating site resource

This commit is contained in:
Owen
2025-12-17 17:28:39 -05:00
committed by Owen Schwartz
parent 35ea01610a
commit bb43e0c325
3 changed files with 360 additions and 134 deletions

View File

@@ -1,4 +1,4 @@
import { db, newts, blueprints, Blueprint } from "@server/db"; import { db, newts, blueprints, Blueprint, Site, siteResources, roleSiteResources, userSiteResources, clientSiteResources } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
@@ -15,6 +15,7 @@ import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
@@ -108,38 +109,139 @@ export async function applyBlueprint({
// We need to update the targets on the newts from the successfully updated information // We need to update the targets on the newts from the successfully updated information
for (const result of clientResourcesResults) { for (const result of clientResourcesResults) {
const [site] = await trx if (
.select() result.oldSiteResource &&
.from(sites) result.oldSiteResource.siteId !=
.innerJoin(newts, eq(sites.siteId, newts.siteId)) result.newSiteResource.siteId
.where( ) {
and( // the site resource has moved sites
eq(sites.siteId, result.newSiteResource.siteId), // insert it first so we get a new siteResourceId just in case
eq(sites.orgId, orgId), const [insertedSiteResource] = await trx
eq(sites.type, "newt"), .insert(siteResources)
isNotNull(sites.pubKey) .values({
...result.oldSiteResource,
siteResourceId: undefined // to generate a new one
})
.returning();
// query existing associations
const existingRoleIds = await trx
.select()
.from(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
) )
) .then((rows) => rows.map((row) => row.roleId));
.limit(1);
if (!site) { const existingUserIds= await trx
logger.debug( .select()
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` .from(userSiteResources)
.where(
eq(
userSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
).then((rows) => rows.map((row) => row.userId));
const existingClientIds = await trx
.select()
.from(clientSiteResources)
.where(
eq(
clientSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
).then((rows) => rows.map((row) => row.clientId));
// delete the existing site resource
await trx
.delete(siteResources)
.where(
and(eq(siteResources.siteResourceId, result.oldSiteResource.siteResourceId))
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
const [newSite] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, result.newSiteResource.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!newSite) {
logger.debug(
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
continue;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
);
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
{
siteId: newSite.sites.siteId,
orgId: newSite.sites.orgId
},
trx
); );
continue;
} }
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${site.sites.siteId}`
);
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
{ siteId: site.sites.siteId, orgId: site.sites.orgId },
trx
);
// await addClientTargets( // await addClientTargets(
// site.newt.newtId, // site.newt.newtId,
// result.resource.destination, // result.resource.destination,

View File

@@ -12,9 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
const deleteSiteResourceParamsSchema = z.strictObject({ const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive()), siteResourceId: z.string().transform(Number).pipe(z.int().positive())
siteId: z.string().transform(Number).pipe(z.int().positive()),
orgId: z.string()
}); });
export type DeleteSiteResourceResponse = { export type DeleteSiteResourceResponse = {
@@ -23,7 +21,7 @@ export type DeleteSiteResourceResponse = {
registry.registerPath({ registry.registerPath({
method: "delete", method: "delete",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", path: "/site-resource/{siteResourceId}",
description: "Delete a site resource.", description: "Delete a site resource.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {
@@ -50,29 +48,13 @@ export async function deleteSiteResource(
); );
} }
const { siteResourceId, siteId, orgId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(and(eq(siteResources.siteResourceId, siteResourceId)))
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1); .limit(1);
if (!existingSiteResource) { if (!existingSiteResource) {
@@ -85,19 +67,13 @@ export async function deleteSiteResource(
// Delete the site resource // Delete the site resource
const [removedSiteResource] = await trx const [removedSiteResource] = await trx
.delete(siteResources) .delete(siteResources)
.where( .where(and(eq(siteResources.siteResourceId, siteResourceId)))
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.returning(); .returning();
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, removedSiteResource.siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
@@ -113,7 +89,7 @@ export async function deleteSiteResource(
}); });
logger.info( logger.info(
`Deleted site resource ${siteResourceId} for site ${siteId}` `Deleted site resource ${siteResourceId}`
); );
return response(res, { return response(res, {

View File

@@ -196,6 +196,27 @@ export async function updateSiteResource(
); );
} }
let existingSite = site;
let siteChanged = false;
if (existingSiteResource.siteId !== siteId) {
siteChanged = true;
// get the existing site
[existingSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingSiteResource.siteId))
.limit(1);
if (!existingSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Existing site not found"
)
);
}
}
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
if (alias) { if (alias) {
const [conflict] = await db const [conflict] = await db
@@ -222,95 +243,222 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
[updatedSiteResource] = await trx if (siteChanged) {
.update(siteResources) // create the new site resource from the removed one with the new siteId and updated fields
.set({ // insert it first so we get a new siteResourceId just in case
name: name, const [insertedSiteResource] = await trx
siteId: siteId, .insert(siteResources)
mode: mode, .values({
destination: destination, ...existingSiteResource,
enabled: enabled, siteResourceId: undefined // to generate a new one
alias: alias && alias.trim() ? alias : null, })
tcpPortRangeString: tcpPortRangeString, .returning();
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning();
//////////////////// update the associations //////////////////// // delete the existing site resource
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (userIds.length > 0) {
await trx await trx
.insert(userSiteResources) .delete(siteResources)
.values( .where(
userIds.map((userId) => ({ userId, siteResourceId })) and(eq(siteResources.siteResourceId, siteResourceId))
); );
}
// Get all admin role IDs for this org to exclude from deletion await rebuildClientAssociationsFromSiteResource(
const adminRoles = await trx existingSiteResource,
.select() trx
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
); );
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) { // wait some time to allow for messages to be handled
await trx.delete(roleSiteResources).where( await new Promise((resolve) => setTimeout(resolve, 750));
and(
eq(roleSiteResources.siteResourceId, siteResourceId), [updatedSiteResource] = await trx
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role .update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where(
and(
eq(
siteResources.siteResourceId,
insertedSiteResource.siteResourceId
)
)
) )
.returning();
if (!updatedSiteResource) {
throw new Error(
"Failed to create updated site resource after site change"
);
}
//////////////////// update the associations ////////////////////
const [adminRole] = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
)
.limit(1);
if (!adminRole) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Admin role not found`
)
);
}
await trx.insert(roleSiteResources).values({
roleId: adminRole.roleId,
siteResourceId: updatedSiteResource.siteResourceId
});
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
); );
} else { } else {
await trx // Update the site resource
.delete(roleSiteResources) [updatedSiteResource] = await trx
.update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where( .where(
eq(roleSiteResources.siteResourceId, siteResourceId) and(eq(siteResources.siteResourceId, siteResourceId))
); )
} .returning();
//////////////////// update the associations ////////////////////
if (roleIds.length > 0) {
await trx await trx
.insert(roleSiteResources) .delete(clientSiteResources)
.values( .where(
roleIds.map((roleId) => ({ roleId, siteResourceId })) eq(clientSiteResources.siteResourceId, siteResourceId)
); );
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(
eq(userSiteResources.siteResourceId, siteResourceId)
);
if (userIds.length > 0) {
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, updatedSiteResource.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 (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId },
trx
);
} }
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource!,
{ siteId: site.siteId, orgId: site.orgId },
trx
);
}); });
return response(res, { return response(res, {