add optial disconnect on regenerate credentials

This commit is contained in:
miloschwartz
2025-12-06 12:36:31 -05:00
parent 00174be8c0
commit f449fdc7ec
7 changed files with 281 additions and 84 deletions

View File

@@ -2221,7 +2221,7 @@
"credentials": "Credentials", "credentials": "Credentials",
"savecredentials": "Save Credentials", "savecredentials": "Save Credentials",
"regenerateCredentialsButton": "Regenerate Credentials", "regenerateCredentialsButton": "Regenerate Credentials",
"regenerateCredentials": "Regenerate and save your credentials", "regenerateCredentials": "Regenerate Credentials",
"generatedcredentials": "Generated Credentials", "generatedcredentials": "Generated Credentials",
"copyandsavethesecredentials": "Copy and save these credentials", "copyandsavethesecredentials": "Copy and save these credentials",
"copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.",
@@ -2253,5 +2253,20 @@
"clientAddress": "Client Address (Advanced)", "clientAddress": "Client Address (Advanced)",
"setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupFailedToFetchSubnet": "Failed to fetch default subnet",
"setupSubnetAdvanced": "Subnet (Advanced)", "setupSubnetAdvanced": "Subnet (Advanced)",
"setupSubnetDescription": "The subnet for this organization's internal network." "setupSubnetDescription": "The subnet for this organization's internal network.",
"siteRegenerateAndDisconnect": "Regenerate and Disconnect",
"siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?",
"siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.",
"siteRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this site?",
"siteRegenerateCredentialsWarning": "This will regenerate the credentials. The site will stay connected until you manually restart it and use the new credentials.",
"clientRegenerateAndDisconnect": "Regenerate and Disconnect",
"clientRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this client?",
"clientRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the client. The client will need to be restarted with the new credentials.",
"clientRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this client?",
"clientRegenerateCredentialsWarning": "This will regenerate the credentials. The client will stay connected until you manually restart it and use the new credentials.",
"remoteExitNodeRegenerateAndDisconnect": "Regenerate and Disconnect",
"remoteExitNodeRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this remote exit node?",
"remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.",
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials."
} }

View File

@@ -31,7 +31,8 @@ const reGenerateSecretParamsSchema = z.strictObject({
const reGenerateSecretBodySchema = z.strictObject({ const reGenerateSecretBodySchema = z.strictObject({
// olmId: z.string().min(1).optional(), // olmId: z.string().min(1).optional(),
secret: z.string().min(1) secret: z.string().min(1),
disconnect: z.boolean().optional().default(true)
}); });
export type ReGenerateSecretBody = z.infer<typeof reGenerateSecretBodySchema>; export type ReGenerateSecretBody = z.infer<typeof reGenerateSecretBodySchema>;
@@ -52,7 +53,7 @@ export async function reGenerateClientSecret(
); );
} }
const { secret } = parsedBody.data; const { secret, disconnect } = parsedBody.data;
const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -114,18 +115,21 @@ export async function reGenerateClientSecret(
}) })
.where(eq(olms.olmId, existingOlms[0].olmId)); .where(eq(olms.olmId, existingOlms[0].olmId));
const payload = { // Only disconnect if explicitly requested
type: `olm/terminate`, if (disconnect) {
data: {} const payload = {
}; type: `olm/terminate`,
// Don't await this to prevent blocking the response data: {}
sendToClient(existingOlms[0].olmId, payload).catch((error) => { };
logger.error("Failed to send termination message to olm:", error); // Don't await this to prevent blocking the response
}); sendToClient(existingOlms[0].olmId, payload).catch((error) => {
logger.error("Failed to send termination message to olm:", error);
});
disconnectClient(existingOlms[0].olmId).catch((error) => { disconnectClient(existingOlms[0].olmId).catch((error) => {
logger.error("Failed to disconnect olm after re-key:", error); logger.error("Failed to disconnect olm after re-key:", error);
}); });
}
return response(res, { return response(res, {
data: { data: {

View File

@@ -23,7 +23,7 @@ import { hashPassword } from "@server/auth/password";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { disconnectClient } from "#private/routers/ws"; import { disconnectClient, sendToClient } from "#private/routers/ws";
export const paramsSchema = z.object({ export const paramsSchema = z.object({
orgId: z.string() orgId: z.string()
@@ -31,7 +31,8 @@ export const paramsSchema = z.object({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
remoteExitNodeId: z.string().length(15), remoteExitNodeId: z.string().length(15),
secret: z.string().length(48) secret: z.string().length(48),
disconnect: z.boolean().optional().default(true)
}); });
export async function reGenerateExitNodeSecret( export async function reGenerateExitNodeSecret(
@@ -60,7 +61,7 @@ export async function reGenerateExitNodeSecret(
); );
} }
const { remoteExitNodeId, secret } = parsedBody.data; const { remoteExitNodeId, secret, disconnect } = parsedBody.data;
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -83,11 +84,31 @@ export async function reGenerateExitNodeSecret(
.set({ secretHash }) .set({ secretHash })
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
disconnectClient(existingRemoteExitNode.remoteExitNodeId).catch( // Only disconnect if explicitly requested
(error) => { if (disconnect) {
logger.error("Failed to disconnect newt after re-key:", error); const payload = {
} type: `remoteExitNode/terminate`,
); data: {}
};
// Don't await this to prevent blocking the response
sendToClient(existingRemoteExitNode.remoteExitNodeId, payload).catch(
(error) => {
logger.error(
"Failed to send termination message to remote exit node:",
error
);
}
);
disconnectClient(existingRemoteExitNode.remoteExitNodeId).catch(
(error) => {
logger.error(
"Failed to disconnect remote exit node after re-key:",
error
);
}
);
}
return response(res, { return response(res, {
data: null, data: null,

View File

@@ -33,7 +33,8 @@ const updateSiteParamsSchema = z.strictObject({
const updateSiteBodySchema = z.strictObject({ const updateSiteBodySchema = z.strictObject({
type: z.enum(["newt", "wireguard"]), type: z.enum(["newt", "wireguard"]),
secret: z.string().min(1).max(255).optional(), secret: z.string().min(1).max(255).optional(),
pubKey: z.string().optional() pubKey: z.string().optional(),
disconnect: z.boolean().optional().default(true)
}); });
export async function reGenerateSiteSecret( export async function reGenerateSiteSecret(
@@ -63,7 +64,7 @@ export async function reGenerateSiteSecret(
} }
const { siteId } = parsedParams.data; const { siteId } = parsedParams.data;
const { type, pubKey, secret } = parsedBody.data; const { type, pubKey, secret, disconnect } = parsedBody.data;
let existingNewt: Newt | null = null; let existingNewt: Newt | null = null;
if (type === "newt") { if (type === "newt") {
@@ -112,21 +113,24 @@ export async function reGenerateSiteSecret(
}) })
.where(eq(newts.newtId, existingNewts[0].newtId)); .where(eq(newts.newtId, existingNewts[0].newtId));
const payload = { // Only disconnect if explicitly requested
type: `newt/wg/terminate`, if (disconnect) {
data: {} const payload = {
}; type: `newt/wg/terminate`,
// Don't await this to prevent blocking the response data: {}
sendToClient(existingNewts[0].newtId, payload).catch((error) => { };
logger.error( // Don't await this to prevent blocking the response
"Failed to send termination message to newt:", sendToClient(existingNewts[0].newtId, payload).catch((error) => {
error logger.error(
); "Failed to send termination message to newt:",
}); error
);
});
disconnectClient(existingNewts[0].newtId).catch((error) => { disconnectClient(existingNewts[0].newtId).catch((error) => {
logger.error("Failed to disconnect newt after re-key:", error); logger.error("Failed to disconnect newt after re-key:", error);
}); });
}
logger.info(`Regenerated Newt credentials for site ${siteId}`); logger.info(`Regenerated Newt credentials for site ${siteId}`);
} else if (type === "wireguard") { } else if (type === "wireguard") {

View File

@@ -55,6 +55,7 @@ export default function CredentialsPage() {
null null
); );
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
@@ -79,7 +80,8 @@ export default function CredentialsPage() {
AxiosResponse<QuickStartRemoteExitNodeResponse> AxiosResponse<QuickStartRemoteExitNodeResponse>
>(`/re-key/${orgId}/regenerate-remote-exit-node-secret`, { >(`/re-key/${orgId}/regenerate-remote-exit-node-secret`, {
remoteExitNodeId: remoteExitNode.remoteExitNodeId, remoteExitNodeId: remoteExitNode.remoteExitNodeId,
secret: data.secret secret: data.secret,
disconnect: shouldDisconnect
}); });
if (rekeyRes && rekeyRes.status === 200) { if (rekeyRes && rekeyRes.status === 200) {
@@ -193,12 +195,27 @@ export default function CredentialsPage() {
)} )}
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <div className="flex gap-2">
onClick={() => setModalOpen(true)} <Button
disabled={isSecurityFeatureDisabled()} variant="outline"
> onClick={() => {
{t("regenerateCredentialsButton")} setShouldDisconnect(false);
</Button> setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("remoteExitNodeRegenerateAndDisconnect")}
</Button>
</div>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
@@ -216,11 +233,32 @@ export default function CredentialsPage() {
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("regenerateCredentialsConfirmation")}</p> {shouldDisconnect ? (
<p>{t("regenerateCredentialsWarning")}</p> <>
<p>
{t("remoteExitNodeRegenerateAndDisconnectConfirmation")}
</p>
<p>
{t("remoteExitNodeRegenerateAndDisconnectWarning")}
</p>
</>
) : (
<>
<p>
{t("remoteExitNodeRegenerateCredentialsConfirmation")}
</p>
<p>
{t("remoteExitNodeRegenerateCredentialsWarning")}
</p>
</>
)}
</div> </div>
} }
buttonText={t("regenerateCredentialsButton")} buttonText={
shouldDisconnect
? t("remoteExitNodeRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate} onConfirm={handleConfirmRegenerate}
string={getConfirmationString()} string={getConfirmationString()}
title={t("regenerateCredentials")} title={t("regenerateCredentials")}

View File

@@ -49,6 +49,7 @@ export default function CredentialsPage() {
null null
); );
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
@@ -69,7 +70,8 @@ export default function CredentialsPage() {
const rekeyRes = await api.post( const rekeyRes = await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`, `/re-key/${client?.clientId}/regenerate-client-secret`,
{ {
secret: data.olmSecret secret: data.olmSecret,
disconnect: shouldDisconnect
} }
); );
@@ -173,12 +175,27 @@ export default function CredentialsPage() {
)} )}
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <div className="flex gap-2">
onClick={() => setModalOpen(true)} <Button
disabled={isSecurityFeatureDisabled()} variant="outline"
> onClick={() => {
{t("regenerateCredentialsButton")} setShouldDisconnect(false);
</Button> setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("clientRegenerateAndDisconnect")}
</Button>
</div>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
@@ -196,11 +213,32 @@ export default function CredentialsPage() {
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("regenerateCredentialsConfirmation")}</p> {shouldDisconnect ? (
<p>{t("regenerateCredentialsWarning")}</p> <>
<p>
{t("clientRegenerateAndDisconnectConfirmation")}
</p>
<p>
{t("clientRegenerateAndDisconnectWarning")}
</p>
</>
) : (
<>
<p>
{t("clientRegenerateCredentialsConfirmation")}
</p>
<p>
{t("clientRegenerateCredentialsWarning")}
</p>
</>
)}
</div> </div>
} }
buttonText={t("regenerateCredentialsButton")} buttonText={
shouldDisconnect
? t("clientRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate} onConfirm={handleConfirmRegenerate}
string={getConfirmationString()} string={getConfirmationString()}
title={t("regenerateCredentials")} title={t("regenerateCredentials")}

View File

@@ -53,13 +53,16 @@ export default function CredentialsPage() {
useState<PickSiteDefaultsResponse | null>(null); useState<PickSiteDefaultsResponse | null>(null);
const [wgConfig, setWgConfig] = useState(""); const [wgConfig, setWgConfig] = useState("");
const [publicKey, setPublicKey] = useState(""); const [publicKey, setPublicKey] = useState("");
const [currentNewtId, setCurrentNewtId] = useState<string | null>(site.newtId); const [currentNewtId, setCurrentNewtId] = useState<string | null>(
site.newtId
);
const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>( const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>(
null null
); );
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [showWireGuardAlert, setShowWireGuardAlert] = useState(false); const [showWireGuardAlert, setShowWireGuardAlert] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(false); const [loadingDefaults, setLoadingDefaults] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
@@ -77,7 +80,9 @@ export default function CredentialsPage() {
if (site?.type === "wireguard" && !siteDefaults && orgId) { if (site?.type === "wireguard" && !siteDefaults && orgId) {
setLoadingDefaults(true); setLoadingDefaults(true);
try { try {
const res = await api.get(`/org/${orgId}/pick-site-defaults`); const res = await api.get(
`/org/${orgId}/pick-site-defaults`
);
if (res && res.status === 200) { if (res && res.status === 200) {
setSiteDefaults(res.data.data); setSiteDefaults(res.data.data);
} }
@@ -93,7 +98,6 @@ export default function CredentialsPage() {
fetchSiteDefaults(); fetchSiteDefaults();
}, []); }, []);
const handleConfirmRegenerate = async () => { const handleConfirmRegenerate = async () => {
try { try {
let generatedPublicKey = ""; let generatedPublicKey = "";
@@ -140,7 +144,8 @@ export default function CredentialsPage() {
`/re-key/${site?.siteId}/regenerate-site-secret`, `/re-key/${site?.siteId}/regenerate-site-secret`,
{ {
type: "newt", type: "newt",
secret: data.newtSecret secret: data.newtSecret,
disconnect: shouldDisconnect
} }
); );
@@ -233,7 +238,11 @@ export default function CredentialsPage() {
text={displaySecret} text={displaySecret}
/> />
) : ( ) : (
<span>{"••••••••••••••••••••••••••••••••"}</span> <span>
{
"••••••••••••••••••••••••••••••••"
}
</span>
)} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
@@ -252,12 +261,27 @@ export default function CredentialsPage() {
)} )}
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <div className="flex gap-2">
onClick={() => setModalOpen(true)} <Button
disabled={isSecurityFeatureDisabled()} variant="outline"
> onClick={() => {
{t("regenerateCredentialsButton")} setShouldDisconnect(false);
</Button> setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</div>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
)} )}
@@ -280,7 +304,10 @@ export default function CredentialsPage() {
<> <>
{wgConfig ? ( {wgConfig ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<CopyTextBox text={wgConfig} outline={true} /> <CopyTextBox
text={wgConfig}
outline={true}
/>
<div className="relative w-fit border rounded-md"> <div className="relative w-fit border rounded-md">
<div className="bg-white p-6 rounded-md"> <div className="bg-white p-6 rounded-md">
<QRCodeCanvas <QRCodeCanvas
@@ -293,24 +320,47 @@ export default function CredentialsPage() {
</div> </div>
) : ( ) : (
<CopyTextBox <CopyTextBox
text={generateObfuscatedWireGuardConfig({ text={generateObfuscatedWireGuardConfig(
subnet: siteDefaults?.subnet || site?.subnet || null, {
address: siteDefaults?.address || site?.address || null, subnet:
endpoint: siteDefaults?.endpoint || site?.endpoint || null, siteDefaults?.subnet ||
listenPort: siteDefaults?.listenPort || site?.listenPort || null, site?.subnet ||
publicKey: siteDefaults?.publicKey || site?.publicKey || site?.pubKey || null null,
})} address:
siteDefaults?.address ||
site?.address ||
null,
endpoint:
siteDefaults?.endpoint ||
site?.endpoint ||
null,
listenPort:
siteDefaults?.listenPort ||
site?.listenPort ||
null,
publicKey:
siteDefaults?.publicKey ||
site?.publicKey ||
site?.pubKey ||
null
}
)}
outline={true} outline={true}
/> />
)} )}
{showWireGuardAlert && wgConfig && ( {showWireGuardAlert && wgConfig && (
<Alert variant="neutral" className="mt-4"> <Alert
variant="neutral"
className="mt-4"
>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
{t("siteCredentialsSave")} {t("siteCredentialsSave")}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{t("siteCredentialsSaveDescription")} {t(
"siteCredentialsSaveDescription"
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -322,7 +372,7 @@ export default function CredentialsPage() {
onClick={() => setModalOpen(true)} onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()} disabled={isSecurityFeatureDisabled()}
> >
{t("regenerateCredentialsButton")} {t("siteRegenerateAndDisconnect")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
@@ -343,11 +393,38 @@ export default function CredentialsPage() {
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("regenerateCredentialsConfirmation")}</p> {shouldDisconnect ? (
<p>{t("regenerateCredentialsWarning")}</p> <>
<p>
{t(
"siteRegenerateAndDisconnectConfirmation"
)}
</p>
<p>
{t(
"siteRegenerateAndDisconnectWarning"
)}
</p>
</>
) : (
<>
<p>
{t(
"siteRegenerateCredentialsConfirmation"
)}
</p>
<p>
{t("siteRegenerateCredentialsWarning")}
</p>
</>
)}
</div> </div>
} }
buttonText={t("regenerateCredentialsButton")} buttonText={
shouldDisconnect
? t("siteRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate} onConfirm={handleConfirmRegenerate}
string={getConfirmationString()} string={getConfirmationString()}
title={t("regenerateCredentials")} title={t("regenerateCredentials")}