From a03916821728b72de88e332fdff4b6343049485c Mon Sep 17 00:00:00 2001 From: Julian <130256240+j4n-e4t@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:15:41 +0100 Subject: [PATCH 1/3] add ability to transfer a resource to another site --- server/routers/external.ts | 7 + server/routers/resource/index.ts | 1 + server/routers/resource/transferResource.ts | 84 +++++++++ .../resources/[resourceId]/general/page.tsx | 168 ++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 server/routers/resource/transferResource.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 0910f07d..b63f982d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -308,6 +308,13 @@ authenticated.get( resource.getResourceWhitelist ); +authenticated.post( + `/resource/:resourceId/transfer`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.transferResource +); + authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 7dbee1bf..6bde1a83 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,3 +16,4 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./transferResource"; diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts new file mode 100644 index 00000000..91cb9774 --- /dev/null +++ b/server/routers/resource/transferResource.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const transferResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const transferResourceBodySchema = z + .object({ + siteId: z.number().int().positive() + }) + .strict(); + +export async function transferResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = transferResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = transferResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { siteId } = parsedBody.data; + + const [updatedResource] = await db + .update(resources) + .set({ siteId }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + if (!updatedResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + return response(res, { + data: updatedResource, + success: true, + error: false, + message: "Resource transferred successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 6d3e5777..219854f3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -23,6 +23,7 @@ import { CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@app/lib/cn"; import { Popover, @@ -60,7 +61,12 @@ const GeneralFormSchema = z.object({ // siteId: z.number(), }); +const TransferFormSchema = z.object({ + siteId: z.number() +}); + type GeneralFormValues = z.infer; +type TransferFormValues = z.infer; export default function GeneralForm() { const params = useParams(); @@ -76,6 +82,8 @@ export default function GeneralForm() { const [sites, setSites] = useState([]); const [saveLoading, setSaveLoading] = useState(false); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); + const [transferLoading, setTransferLoading] = useState(false); + const [open, setOpen] = useState(false); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -87,6 +95,13 @@ export default function GeneralForm() { mode: "onChange" }); + const transferForm = useForm({ + resolver: zodResolver(TransferFormSchema), + defaultValues: { + siteId: resource.siteId ? Number(resource.siteId) : undefined + } + }); + useEffect(() => { const fetchSites = async () => { const res = await api.get>( @@ -131,6 +146,33 @@ export default function GeneralForm() { .finally(() => setSaveLoading(false)); } + async function onTransfer(data: TransferFormValues) { + setTransferLoading(true); + + api.post(`resource/${resource?.resourceId}/transfer`, { + siteId: data.siteId + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to transfer resource", + description: formatAxiosError( + e, + "An error occurred while transferring the resource" + ) + }); + }) + .then(() => { + toast({ + title: "Resource transferred", + description: + "The resource has been transferred successfully" + }); + router.refresh(); + }) + .finally(() => setTransferLoading(false)); + } + return ( @@ -212,6 +254,132 @@ export default function GeneralForm() { + + + + + Transfer Resource + + + Transfer this resource to a different site + + + + + +
+ + ( + + + Destination Site + + + + + + + + + + + + No sites found. + + + {sites.map( + (site) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + Select the site you want to + transfer this resource to + + + + )} + /> + + +
+
+ + + + +
); } From b5420a40ab401bf9b67c9e10129ce449a1193c67 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 1 Feb 2025 18:36:12 -0500 Subject: [PATCH 2/3] Clean up and add target manipulation --- server/routers/gerbil/getConfig.ts | 16 +-- server/routers/newt/targets.ts | 8 +- server/routers/resource/deleteResource.ts | 19 +-- server/routers/resource/transferResource.ts | 110 +++++++++++++++++- server/routers/target/createTarget.ts | 2 +- server/routers/target/deleteTarget.ts | 19 +-- .../routers/target/{ports.ts => helpers.ts} | 18 +++ server/routers/target/updateTarget.ts | 2 +- .../resources/[resourceId]/general/page.tsx | 65 ++++++----- 9 files changed, 172 insertions(+), 87 deletions(-) rename server/routers/target/{ports.ts => helpers.ts} (71%) diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 314e715a..28b576d8 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -11,6 +11,7 @@ import config from "@server/lib/config"; import { getUniqueExitNodeEndpointName } from '@server/db/names'; import { findNextAvailableCidr } from "@server/lib/ip"; import { fromError } from 'zod-validation-error'; +import { getAllowedIps } from '../target/helpers'; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), @@ -83,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) }); const peers = await Promise.all(sitesRes.map(async (site) => { - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId), - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all(resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId), - }); - return targetsRes.map(target => `${target.ip}/32`); - })); - return { publicKey: site.pubKey, - allowedIps: targetIps.flat(), + allowedIps: await getAllowedIps(site.siteId) }; })); diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index e5f7855c..2c1143e6 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,11 +1,11 @@ import { Target } from "@server/db/schema"; import { sendToClient } from "../ws"; -export async function addTargets( +export function addTargets( newtId: string, targets: Target[], protocol: string -): Promise { +) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ @@ -22,11 +22,11 @@ export async function addTargets( sendToClient(newtId, payload); } -export async function removeTargets( +export function removeTargets( newtId: string, targets: Target[], protocol: string -): Promise { +) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index ed0fc95f..8acf0d77 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { removeTargets } from "../newt/targets"; +import { getAllowedIps } from "../target/helpers"; // Define Zod schema for request parameters validation const deleteResourceSchema = z @@ -75,25 +76,9 @@ export async function deleteResource( if (site.pubKey) { if (site.type == "wireguard") { - // TODO: is this all inefficient? - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); - await addPeer(site.exitNodeId!, { publicKey: site.pubKey, - allowedIps: targetIps.flat() + allowedIps: await getAllowedIps(site.siteId) }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index 91cb9774..31777c30 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -1,13 +1,16 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources } from "@server/db/schema"; +import { newts, resources, sites, targets } from "@server/db/schema"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { addPeer } from "../gerbil/peers"; +import { addTargets, removeTargets } from "../newt/targets"; +import { getAllowedIps } from "../target/helpers"; const transferResourceParamsSchema = z .object({ @@ -53,6 +56,60 @@ export async function transferResource( const { resourceId } = parsedParams.data; const { siteId } = parsedBody.data; + const [oldResource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!oldResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (oldResource.siteId === siteId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Resource is already assigned to site with ID ${siteId}` + ) + ); + } + + const [newSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!newSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + const [oldSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, oldResource.siteId)) + .limit(1); + + if (!oldSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${oldResource.siteId} not found` + ) + ); + } + const [updatedResource] = await db .update(resources) .set({ siteId }) @@ -68,6 +125,57 @@ export async function transferResource( ); } + const resourceTargets = await db + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); + + if (resourceTargets.length > 0) { + ////// REMOVE THE TARGETS FROM THE OLD SITE ////// + if (oldSite.pubKey) { + if (oldSite.type == "wireguard") { + await addPeer(oldSite.exitNodeId!, { + publicKey: oldSite.pubKey, + allowedIps: await getAllowedIps(oldSite.siteId) + }); + } else if (oldSite.type == "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, oldSite.siteId)) + .limit(1); + + removeTargets( + newt.newtId, + resourceTargets, + updatedResource.protocol + ); + } + } + + ////// ADD THE TARGETS TO THE NEW SITE ////// + if (newSite.pubKey) { + if (newSite.type == "wireguard") { + await addPeer(newSite.exitNodeId!, { + publicKey: newSite.pubKey, + allowedIps: await getAllowedIps(newSite.siteId) + }); + } else if (newSite.type == "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, newSite.siteId)) + .limit(1); + + addTargets( + newt.newtId, + resourceTargets, + updatedResource.protocol + ); + } + } + } + return response(res, { data: updatedResource, success: true, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 3d5e8d0e..b1080d87 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -11,7 +11,7 @@ import { isIpInCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; -import { pickPort } from "./ports"; +import { pickPort } from "./helpers"; // Regular expressions for validation const DOMAIN_REGEX = diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 97dab71c..7472b73d 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; +import { getAllowedIps } from "./helpers"; const deleteTargetSchema = z .object({ @@ -80,25 +81,9 @@ export async function deleteTarget( if (site.pubKey) { if (site.type == "wireguard") { - // TODO: is this all inefficient? - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); - await addPeer(site.exitNodeId!, { publicKey: site.pubKey, - allowedIps: targetIps.flat() + allowedIps: await getAllowedIps(site.siteId) }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/target/ports.ts b/server/routers/target/helpers.ts similarity index 71% rename from server/routers/target/ports.ts rename to server/routers/target/helpers.ts index bfa8f280..606e2290 100644 --- a/server/routers/target/ports.ts +++ b/server/routers/target/helpers.ts @@ -46,3 +46,21 @@ export async function pickPort(siteId: number): Promise<{ return { internalPort, targetIps }; } + +export async function getAllowedIps(siteId: number) { + // TODO: is this all inefficient? + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, siteId) + }); + + // Fetch targets for all resources of this site + const targetIps = await Promise.all( + resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId) + }); + return targetsRes.map((target) => `${target.ip}/32`); + }) + ); + return targetIps.flat(); +} diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4125fd9c..2ae6222d 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,7 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { pickPort } from "./ports"; +import { pickPort } from "./helpers"; // Regular expressions for validation const DOMAIN_REGEX = diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index bfcaa134..b4b99eef 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -19,7 +19,7 @@ import { CommandEmpty, CommandGroup, CommandInput, - CommandItem, + CommandItem } from "@/components/ui/command"; import { cn } from "@app/lib/cn"; import { @@ -144,14 +144,15 @@ export default function GeneralForm() { async function onSubmit(data: GeneralFormValues) { setSaveLoading(true); - api.post>( - `resource/${resource?.resourceId}`, - { - name: data.name, - subdomain: data.subdomain - // siteId: data.siteId, - } - ) + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + name: data.name, + subdomain: data.subdomain + // siteId: data.siteId, + } + ) .catch((e) => { toast({ variant: "destructive", @@ -161,26 +162,26 @@ export default function GeneralForm() { "An error occurred while updating the resource" ) }); - }) - .then(() => { - toast({ - title: "Resource updated", - description: "The resource has been updated successfully" - }); + }); - updateResource({ name: data.name, subdomain: data.subdomain }); + if (res && res.status === 200) { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully" + }); - router.refresh(); - }) - .finally(() => setSaveLoading(false)); + updateResource({ name: data.name, subdomain: data.subdomain }); + } + setSaveLoading(false); } async function onTransfer(data: TransferFormValues) { setTransferLoading(true); - api.post(`resource/${resource?.resourceId}/transfer`, { - siteId: data.siteId - }) + const res = await api + .post(`resource/${resource?.resourceId}/transfer`, { + siteId: data.siteId + }) .catch((e) => { toast({ variant: "destructive", @@ -190,16 +191,16 @@ export default function GeneralForm() { "An error occurred while transferring the resource" ) }); - }) - .then(() => { - toast({ - title: "Resource transferred", - description: - "The resource has been transferred successfully" - }); - router.refresh(); - }) - .finally(() => setTransferLoading(false)); + }); + + if (res && res.status === 200) { + toast({ + title: "Resource transferred", + description: "The resource has been transferred successfully" + }); + router.refresh(); + } + setTransferLoading(false); } return ( From 53660a163c85d53e20c3cc0915205d36cb7b00d0 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 1 Feb 2025 21:11:31 -0500 Subject: [PATCH 3/3] minor changes to verbiage and id value --- server/routers/resource/transferResource.ts | 2 +- .../[orgId]/settings/resources/[resourceId]/general/page.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index 31777c30..69c9a2a6 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -75,7 +75,7 @@ export async function transferResource( return next( createHttpError( HttpCode.BAD_REQUEST, - `Resource is already assigned to site with ID ${siteId}` + `Resource is already assigned to this site` ) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index b4b99eef..3aa9e761 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -396,7 +396,7 @@ export default function GeneralForm() { (site) => ( - Select the site you want to - transfer this resource to + Select the new site to transfer this resource to.