From 9f9aa07c2d8a2bc37edfadef2f7ba38d43bff4c2 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 24 Oct 2025 23:36:20 +0530 Subject: [PATCH] Option to regenerate remote-nodes keys --- server/private/routers/external.ts | 8 + .../private/routers/remoteExitNode/index.ts | 1 + .../remoteExitNode/updateRemoteExitNode.ts | 106 +++++++++++ server/routers/remoteExitNode/types.ts | 5 + .../remote-exit-nodes/ExitNodesTable.tsx | 9 + .../[remoteExitNodeId]/general/page.tsx | 170 +++++++++++++++++- .../[remoteExitNodeId]/layout.tsx | 14 +- src/components/ExitNodeInfoCard.tsx | 52 ++++++ src/hooks/useRemoteExitNodeContext.ts | 6 +- 9 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 server/private/routers/remoteExitNode/updateRemoteExitNode.ts create mode 100644 src/components/ExitNodeInfoCard.tsx diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 00ad117f..8e2b2bbc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -236,6 +236,14 @@ authenticated.put( remoteExitNode.createRemoteExitNode ); +authenticated.put( + "/org/:orgId/update-remote-exit-node", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + remoteExitNode.updateRemoteExitNode +); + authenticated.get( "/org/:orgId/remote-exit-nodes", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 2a04f9d9..a30e204c 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; +export * from "./updateRemoteExitNode" diff --git a/server/private/routers/remoteExitNode/updateRemoteExitNode.ts b/server/private/routers/remoteExitNode/updateRemoteExitNode.ts new file mode 100644 index 00000000..9de017f8 --- /dev/null +++ b/server/private/routers/remoteExitNode/updateRemoteExitNode.ts @@ -0,0 +1,106 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { remoteExitNodes } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import { and, eq } from "drizzle-orm"; +import { UpdateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; +import { paramsSchema } from "./createRemoteExitNode"; + +const bodySchema = z + .object({ + remoteExitNodeId: z.string().length(15), + secret: z.string().length(48) + }) + .strict(); + +export async function updateRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { remoteExitNodeId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const [existingRemoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + if (!existingRemoteExitNode) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Remote Exit Node does not exist") + ); + } + + const secretHash = await hashPassword(secret); + + await db + .update(remoteExitNodes) + .set({ secretHash }) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + return response(res, { + data: { + remoteExitNodeId, + secret, + }, + success: true, + error: false, + message: "Remote Exit Node secret updated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to update remoteExitNode", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update remoteExitNode" + ) + ); + } +} diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 55d0a286..ae0c2130 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -6,6 +6,11 @@ export type CreateRemoteExitNodeResponse = { secret: string; }; +export type UpdateRemoteExitNodeResponse = { + remoteExitNodeId: string; + secret: string; +} + export type PickRemoteExitNodeDefaultsResponse = { remoteExitNodeId: string; secret: string; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index 6e9ab237..06da3dc5 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -232,6 +232,7 @@ export default function ExitNodesTable({ id: "actions", cell: ({ row }) => { const nodeRow = row.original; + const remoteExitNodeId = nodeRow.id; return (
@@ -242,6 +243,14 @@ export default function ExitNodesTable({ + + + {t("viewSettings")} + + { setSelectedNode(nodeRow); diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx index 191ce3f3..6711177b 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx @@ -1,3 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { + PickRemoteExitNodeDefaultsResponse, + QuickStartRemoteExitNodeResponse +} from "@server/routers/remoteExitNode/types"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; + export default function GeneralPage() { - return <>; + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + + const [credentials, setCredentials] = + useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Clear credentials when user leaves/reloads + useEffect(() => { + const clearCreds = () => setCredentials(null); + window.addEventListener("beforeunload", clearCreds); + return () => window.removeEventListener("beforeunload", clearCreds); + }, []); + + const handleRegenerate = async () => { + try { + setLoading(true); + const response = await api.get< + AxiosResponse + >(`/org/${orgId}/pick-remote-exit-node-defaults`); + + setCredentials(response.data.data); + toast({ + title: t("success"), + description: t("Credentials generated successfully."), + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError( + error, + t("Failed to generate credentials") + ), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!credentials) return; + + try { + setSaving(true); + + const response = await api.put< + AxiosResponse + >(`/org/${orgId}/update-remote-exit-node`, { + remoteExitNodeId: remoteExitNode.remoteExitNodeId, + secret: credentials.secret, + }); + + toast({ + title: t("success"), + description: t("Credentials saved successfully."), + }); + + // For security, clear them from UI + setCredentials(null); + + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError( + error, + t("Failed to save credentials") + ), + variant: "destructive", + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + + {t("Generated Credentials")} + + + {t("Regenerate and save your managed credentials")} + + + + + {!credentials ? ( + + ) : ( + <> + + + + + + {t("Copy and save these credentials")} + + + {t( + "These credentials will not be shown again after you leave this page. Save them securely now." + )} + + + +
+ + +
+ + )} +
+
+
+ ); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index 7a7b3611..6473999d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -6,6 +6,8 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import RemoteExitNodeProvider from "@app/providers/RemoteExitNodeProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import ExitNodeInfoCard from "@app/components/ExitNodeInfoCard"; interface SettingsLayoutProps { children: React.ReactNode; @@ -31,6 +33,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); + const navItems = [ + { + title: t('general'), + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/general" + } + ]; + return ( <> -
{children}
+
+ + {children} +
); diff --git a/src/components/ExitNodeInfoCard.tsx b/src/components/ExitNodeInfoCard.tsx new file mode 100644 index 00000000..49ae1b61 --- /dev/null +++ b/src/components/ExitNodeInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; + +type ExitNodeInfoCardProps = {}; + +export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) { + const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + const t = useTranslations(); + + return ( + + + + <> + + {t("status")} + + {remoteExitNode.online ? ( +
+
+ {t("online")} +
+ ) : ( +
+
+ {t("offline")} +
+ )} +
+
+ + + {t("address")} + + {remoteExitNode.address} + + +
+
+
+ ); +} diff --git a/src/hooks/useRemoteExitNodeContext.ts b/src/hooks/useRemoteExitNodeContext.ts index 486147c4..6fe244c8 100644 --- a/src/hooks/useRemoteExitNodeContext.ts +++ b/src/hooks/useRemoteExitNodeContext.ts @@ -2,11 +2,15 @@ import RemoteExitNodeContext from "@app/contexts/remoteExitNodeContext"; import { build } from "@server/build"; +import { GetRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { useContext } from "react"; export function useRemoteExitNodeContext() { if (build == "oss") { - return null; + return { + remoteExitNode: {} as GetRemoteExitNodeResponse, + updateRemoteExitNode: () => {}, + }; } const context = useContext(RemoteExitNodeContext); if (context === undefined) {