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/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/index.ts b/server/routers/resource/index.ts index ca06dfc3..187d23fe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,4 +16,5 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./transferResource"; export * from "./getExchangeToken"; diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts new file mode 100644 index 00000000..69c9a2a6 --- /dev/null +++ b/server/routers/resource/transferResource.ts @@ -0,0 +1,192 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +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({ + 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 [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 this site` + ) + ); + } + + 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 }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + if (!updatedResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + 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, + 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/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 df179d73..3aa9e761 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -14,6 +14,19 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem +} from "@/components/ui/command"; +import { cn } from "@app/lib/cn"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ListSitesResponse } from "@server/routers/site"; import { useEffect, useState } from "react"; @@ -37,6 +50,7 @@ import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; const GeneralFormSchema = z .object({ @@ -75,7 +89,12 @@ const GeneralFormSchema = z } ); +const TransferFormSchema = z.object({ + siteId: z.number() +}); + type GeneralFormValues = z.infer; +type TransferFormValues = z.infer; export default function GeneralForm() { const params = useParams(); @@ -91,6 +110,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), @@ -103,6 +124,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>( @@ -116,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", @@ -133,18 +162,45 @@ export default function GeneralForm() { "An error occurred while updating the resource" ) }); + }); + + if (res && res.status === 200) { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully" + }); + + updateResource({ name: data.name, subdomain: data.subdomain }); + } + setSaveLoading(false); + } + + async function onTransfer(data: TransferFormValues) { + setTransferLoading(true); + + const res = await api + .post(`resource/${resource?.resourceId}/transfer`, { + siteId: data.siteId }) - .then(() => { + .catch((e) => { toast({ - title: "Resource updated", - description: "The resource has been updated successfully" + variant: "destructive", + title: "Failed to transfer resource", + description: formatAxiosError( + e, + "An error occurred while transferring the resource" + ) }); + }); - updateResource({ name: data.name, subdomain: data.subdomain }); - - router.refresh(); - }) - .finally(() => setSaveLoading(false)); + if (res && res.status === 200) { + toast({ + title: "Resource transferred", + description: "The resource has been transferred successfully" + }); + router.refresh(); + } + setTransferLoading(false); } return ( @@ -273,6 +329,131 @@ 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 new site to transfer this resource to. + + + + )} + /> + + +
+
+ + + + +
); }