Option to regenerate remote-nodes keys

This commit is contained in:
Pallavi Kumari
2025-10-24 23:36:20 +05:30
parent 2a7529c39e
commit 9f9aa07c2d
9 changed files with 368 additions and 3 deletions

View File

@@ -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,

View File

@@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode";
export * from "./listRemoteExitNodes";
export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode";
export * from "./updateRemoteExitNode"

View File

@@ -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<any> {
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<UpdateRemoteExitNodeResponse>(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"
)
);
}
}

View File

@@ -6,6 +6,11 @@ export type CreateRemoteExitNodeResponse = {
secret: string;
};
export type UpdateRemoteExitNodeResponse = {
remoteExitNodeId: string;
secret: string;
}
export type PickRemoteExitNodeDefaultsResponse = {
remoteExitNodeId: string;
secret: string;

View File

@@ -232,6 +232,7 @@ export default function ExitNodesTable({
id: "actions",
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
@@ -242,6 +243,14 @@ export default function ExitNodesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedNode(nodeRow);

View File

@@ -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<PickRemoteExitNodeDefaultsResponse | null>(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<PickRemoteExitNodeDefaultsResponse>
>(`/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<QuickStartRemoteExitNodeResponse>
>(`/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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("Generated Credentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("Regenerate and save your managed credentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!credentials ? (
<Button
onClick={handleRegenerate}
loading={loading}
disabled={loading}
>
{t("Regenerate Credentials")}
</Button>
) : (
<>
<CopyTextBox
text={`managed:
id: "${remoteExitNode.remoteExitNodeId}"
secret: "${credentials.secret}"`}
/>
<Alert variant="neutral" className="mt-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("Copy and save these credentials")}
</AlertTitle>
<AlertDescription>
{t(
"These credentials will not be shown again after you leave this page. Save them securely now."
)}
</AlertDescription>
</Alert>
<div className="flex justify-end mt-6 space-x-2">
<Button
variant="outline"
onClick={() => setCredentials(null)}
>
{t("Cancel")}
</Button>
<Button
onClick={handleSave}
loading={saving}
disabled={saving}
>
{t("Save Credentials")}
</Button>
</div>
</>
)}
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -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 (
<>
<SettingsSectionTitle
@@ -39,7 +48,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<RemoteExitNodeProvider remoteExitNode={remoteExitNode}>
<div className="space-y-6">{children}</div>
<div className="space-y-6">
<ExitNodeInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</RemoteExitNodeProvider>
</>
);

View File

@@ -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 (
<Alert>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.address}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -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) {