From cd09db09e9f6e569efc14a75e2ffd2b20dbec616 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Thu, 5 Mar 2026 14:47:58 +0530 Subject: [PATCH 1/2] Edit feature for certification to update the DNS settings --- backend/internal/certificate.js | 76 ++++++++- backend/routes/nginx/certificates.js | 26 ++- .../paths/nginx/certificates/certID/put.json | 65 +++++++ backend/schema/swagger.json | 3 + frontend/src/api/backend/index.ts | 1 + frontend/src/api/backend/updateCertificate.ts | 20 +++ .../src/components/Form/DNSProviderFields.tsx | 21 ++- frontend/src/locale/src/bg.json | 6 + frontend/src/locale/src/cs.json | 6 + frontend/src/locale/src/de.json | 6 + frontend/src/locale/src/en.json | 6 + frontend/src/locale/src/es.json | 6 + frontend/src/locale/src/et.json | 6 + frontend/src/locale/src/fr.json | 6 + frontend/src/locale/src/ga.json | 6 + frontend/src/locale/src/hu.json | 6 + frontend/src/locale/src/id.json | 6 + frontend/src/locale/src/it.json | 6 + frontend/src/locale/src/ja.json | 6 + frontend/src/locale/src/ko.json | 6 + frontend/src/locale/src/nl.json | 6 + frontend/src/locale/src/no.json | 6 + frontend/src/locale/src/pl.json | 6 + frontend/src/locale/src/pt.json | 6 + frontend/src/locale/src/ru.json | 6 + frontend/src/locale/src/sk.json | 6 + frontend/src/locale/src/tr.json | 6 + frontend/src/locale/src/vi.json | 6 + frontend/src/locale/src/zh.json | 6 + frontend/src/modals/DNSCertificateModal.tsx | 161 +++++++++++------- frontend/src/pages/Certificates/Table.tsx | 19 ++- 31 files changed, 453 insertions(+), 71 deletions(-) create mode 100644 backend/schema/paths/nginx/certificates/certID/put.json create mode 100644 frontend/src/api/backend/updateCertificate.ts diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index d54e941d2e..9d1c30b4c3 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -255,13 +255,81 @@ const internalCertificate = { ); } - const savedRow = await certificateModel + let patchPayload = data; + + // Let's Encrypt DNS: allow updating only dns_provider, dns_provider_credentials, propagation_seconds + const isLetsEncryptDns = + row.provider === "letsencrypt" && + row.meta && + row.meta.dns_challenge && + data.meta && + typeof data.meta === "object"; + + if (isLetsEncryptDns) { + const mergedMeta = { ...row.meta }; + if (data.meta.dns_provider !== undefined) mergedMeta.dns_provider = data.meta.dns_provider; + if (data.meta.dns_provider_credentials !== undefined) { + mergedMeta.dns_provider_credentials = data.meta.dns_provider_credentials; + } + if (data.meta.propagation_seconds !== undefined) { + mergedMeta.propagation_seconds = data.meta.propagation_seconds; + } + patchPayload = { ...data, meta: mergedMeta }; + } + + let savedRow = await certificateModel .query() - .patchAndFetchById(row.id, data) + .patchAndFetchById(row.id, patchPayload) .then(utils.omitRow(omissions())); + if (isLetsEncryptDns) { + // Request a new Cert from LE via DNS challenge. Let the fun begin. + + // 1. Find out any hosts that are using any of the hostnames in this cert + // 2. Disable them in nginx temporarily + // 3. Request cert + // 4. Re-instate previously disabled hosts + const inUseResult = await internalHost.getHostsWithDomains(row.domain_names); + await internalCertificate.disableInUseHosts(inUseResult); + + const user = await userModel + .query() + .where("is_deleted", 0) + .andWhere("id", row.owner_user_id) + .first(); + if (!user || !user.email) { + await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); + throw new error.ValidationError( + "A valid email address must be set on your user account to use Let's Encrypt", + ); + } + + const certWithNewMeta = { ...savedRow, meta: patchPayload.meta }; + try { + await internalNginx.reload(); + await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certWithNewMeta, user.email); + await internalNginx.reload(); + await internalCertificate.enableInUseHosts(inUseResult); + } catch (err) { + await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); + throw err; + } + + const certInfo = await internalCertificate.getCertificateInfoFromFile( + `${internalCertificate.getLiveCertPath(row.id)}/fullchain.pem`, + ); + savedRow = await certificateModel + .query() + .patchAndFetchById(row.id, { + expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), + }) + .then(utils.omitRow(omissions())); + } + savedRow.meta = internalCertificate.cleanMeta(savedRow.meta); - data.meta = internalCertificate.cleanMeta(data.meta); + data.meta = internalCertificate.cleanMeta(patchPayload.meta); // Add row.nice_name for custom certs if (savedRow.provider === "other") { @@ -957,7 +1025,7 @@ const internalCertificate = { args.push(...adds.args); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - + const result = await utils.execFile(certbotCommand, args, adds.opts); logger.info(result); return result; diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js index 99f429b446..290d2c0dfd 100644 --- a/backend/routes/nginx/certificates.js +++ b/backend/routes/nginx/certificates.js @@ -241,10 +241,34 @@ router } }) + /** + * PUT /api/nginx/certificates/123 + * + * Update an existing certificate (e.g. DNS provider API credentials for Let's Encrypt) + */ + .put(async (req, res, next) => { + try { + req.setTimeout(900000); // 15 minutes (update may run certbot renew) + const certificateId = Number.parseInt(req.params.certificate_id, 10); + const payload = await apiValidator( + getValidationSchema("/nginx/certificates/{certID}", "put"), + req.body, + ); + const result = await internalCertificate.update(res.locals.access, { + id: certificateId, + meta: payload.meta, + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + /** * DELETE /api/nginx/certificates/123 * - * Update and existing certificate + * Delete a certificate */ .delete(async (req, res, next) => { try { diff --git a/backend/schema/paths/nginx/certificates/certID/put.json b/backend/schema/paths/nginx/certificates/certID/put.json new file mode 100644 index 0000000000..1847fc2bdc --- /dev/null +++ b/backend/schema/paths/nginx/certificates/certID/put.json @@ -0,0 +1,65 @@ +{ + "operationId": "updateCertificate", + "summary": "Update a Certificate (e.g. DNS provider credentials for Let's Encrypt)", + "tags": ["certificates"], + "security": [ + { + "bearerAuth": ["certificates.manage"] + } + ], + "parameters": [ + { + "in": "path", + "name": "certID", + "description": "Certificate ID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "example": 1 + } + ], + "requestBody": { + "description": "Certificate update payload (DNS provider and credentials for Let's Encrypt DNS certificates)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "meta": { + "type": "object", + "additionalProperties": false, + "properties": { + "dns_provider": { + "type": "string" + }, + "dns_provider_credentials": { + "type": "string" + }, + "propagation_seconds": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../components/certificate-object.json" + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..5500b1011a 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -127,6 +127,9 @@ "get": { "$ref": "./paths/nginx/certificates/certID/get.json" }, + "put": { + "$ref": "./paths/nginx/certificates/certID/put.json" + }, "delete": { "$ref": "./paths/nginx/certificates/certID/delete.json" } diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..cfa4c78c66 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -51,6 +51,7 @@ export * from "./toggleRedirectionHost"; export * from "./toggleStream"; export * from "./toggleUser"; export * from "./updateAccessList"; +export * from "./updateCertificate"; export * from "./updateAuth"; export * from "./updateDeadHost"; export * from "./updateProxyHost"; diff --git a/frontend/src/api/backend/updateCertificate.ts b/frontend/src/api/backend/updateCertificate.ts new file mode 100644 index 0000000000..44abf16661 --- /dev/null +++ b/frontend/src/api/backend/updateCertificate.ts @@ -0,0 +1,20 @@ +import * as api from "./base"; +import type { Certificate } from "./models"; + +export interface UpdateCertificatePayload { + meta?: { + dnsProvider?: string; + dnsProviderCredentials?: string; + propagationSeconds?: number; + }; +} + +export async function updateCertificate( + id: number, + payload: UpdateCertificatePayload, +): Promise { + return await api.put({ + url: `/nginx/certificates/${id}`, + data: payload, + }); +} diff --git a/frontend/src/components/Form/DNSProviderFields.tsx b/frontend/src/components/Form/DNSProviderFields.tsx index 182654811a..b3b5617411 100644 --- a/frontend/src/components/Form/DNSProviderFields.tsx +++ b/frontend/src/components/Form/DNSProviderFields.tsx @@ -1,7 +1,7 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import CodeEditor from "@uiw/react-textarea-code-editor"; import { Field, useFormikContext } from "formik"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Select, { type ActionMeta } from "react-select"; import type { DNSProvider } from "src/api/backend"; import { useDnsProviders } from "src/hooks"; @@ -16,8 +16,10 @@ interface DNSProviderOption { interface Props { showBoundaryBox?: boolean; + /** When true (edit mode), credentials field is for new credentials only; existing credentials are never displayed */ + editMode?: boolean; } -export function DNSProviderFields({ showBoundaryBox = false }: Props) { +export function DNSProviderFields({ showBoundaryBox = false, editMode = false }: Props) { const { values, setFieldValue } = useFormikContext(); const { data: dnsProviders, isLoading } = useDnsProviders(); const [dnsProviderId, setDnsProviderId] = useState(null); @@ -37,6 +39,16 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) { credentials: p.credentials, })) || []; + const selectedOption = options.find((o) => o.value === v.meta?.dnsProvider) ?? null; + const showCredentials = dnsProviderId ?? v.meta?.dnsProvider; + + // When a provider is selected and credentials are empty, use the template from dns-plugins.json as the value + useEffect(() => { + if (selectedOption && (v.meta?.dnsProviderCredentials ?? "") === "") { + setFieldValue("meta.dnsProviderCredentials", selectedOption.credentials); + } + }, [selectedOption?.value]); + return (

@@ -60,6 +72,7 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) { placeholder={intl.formatMessage({ id: "certificates.dns.provider.placeholder" })} isLoading={isLoading} isSearchable + value={selectedOption} onChange={handleChange} options={options} /> @@ -67,13 +80,13 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) { )} - {dnsProviderId ? ( + {showCredentials ? ( <> {({ field }: any) => (

{ - EasyModal.show(DNSCertificateModal); -}; +export interface DNSCertificateModalProps { + certificate?: Certificate; +} -const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { - const queryClient = useQueryClient(); - const [errorMsg, setErrorMsg] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); +interface Props extends InnerModalProps, DNSCertificateModalProps {} - const onSubmit = async (values: any, { setSubmitting }: any) => { - if (isSubmitting) return; - setIsSubmitting(true); - setErrorMsg(null); +const showDNSCertificateModal = (certificate?: Certificate) => { + EasyModal.show(DNSCertificateModal, { certificate }); +}; - try { - await createCertificate(values); - showObjectSuccess("certificate", "saved"); - remove(); - } catch (err: any) { - setErrorMsg(); - } - queryClient.invalidateQueries({ queryKey: ["certificates"] }); - setIsSubmitting(false); - setSubmitting(false); +const getInitialValues = (certificate?: Certificate) => { + if (certificate) { + return { + id: certificate.id, + domainNames: certificate.domainNames ?? [], + provider: "letsencrypt" as const, + meta: { + dnsChallenge: true, + dnsProvider: certificate.meta?.dnsProvider ?? "", + dnsProviderCredentials: (certificate.meta?.dnsProviderCredentials as string) ?? "", + propagationSeconds: certificate.meta?.propagationSeconds ?? undefined, + keyType: (certificate.meta?.keyType as string) ?? "ecdsa", + }, + }; + } + return { + domainNames: [] as string[], + provider: "letsencrypt" as const, + meta: { + dnsChallenge: true, + keyType: "ecdsa", + }, }; +}; - return ( - - { + const queryClient = useQueryClient(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const isEdit = Boolean(certificate?.id); + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + + try { + if (isEdit && values.id) { + await updateCertificate(values.id, { meta: { - dnsChallenge: true, - keyType: "ecdsa", + dnsProvider: values.meta?.dnsProvider, + dnsProviderCredentials: values.meta?.dnsProviderCredentials, + propagationSeconds: values.meta?.propagationSeconds, }, - } as any + }); + } else { + await createCertificate(values); } - onSubmit={onSubmit} - > + showObjectSuccess("certificate", "saved"); + remove(); + } catch (err: any) { + setErrorMsg(); + } + queryClient.invalidateQueries({ queryKey: ["certificates"] }); + setIsSubmitting(false); + setSubmitting(false); + }; + + return ( + + {() => (
- + @@ -63,32 +98,36 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro
- - - {({ field }: any) => ( -
- - - - - -
- )} -
- + {!isEdit && ( + <> + + + {({ field }: any) => ( +
+ + + + + +
+ )} +
+ + )} +
diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx index 7eaeb77701..eb51d1fe7e 100644 --- a/frontend/src/pages/Certificates/Table.tsx +++ b/frontend/src/pages/Certificates/Table.tsx @@ -1,4 +1,4 @@ -import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { IconDotsVertical, IconDownload, IconEdit, IconRefresh, IconTrash } from "@tabler/icons-react"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useMemo } from "react"; import type { Certificate } from "src/api/backend"; @@ -127,6 +127,23 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload, + {info.row.original.provider === "letsencrypt" && + info.row.original.meta?.dnsChallenge && + info.row.original.meta?.dnsProvider && ( + + { + e.preventDefault(); + showDNSCertificateModal(info.row.original); + }} + > + + + + + )} Date: Thu, 5 Mar 2026 16:48:00 +0530 Subject: [PATCH 2/2] Fix lint error --- frontend/src/components/Form/DNSProviderFields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Form/DNSProviderFields.tsx b/frontend/src/components/Form/DNSProviderFields.tsx index b3b5617411..7cfdef1751 100644 --- a/frontend/src/components/Form/DNSProviderFields.tsx +++ b/frontend/src/components/Form/DNSProviderFields.tsx @@ -47,7 +47,7 @@ export function DNSProviderFields({ showBoundaryBox = false, editMode = false }: if (selectedOption && (v.meta?.dnsProviderCredentials ?? "") === "") { setFieldValue("meta.dnsProviderCredentials", selectedOption.credentials); } - }, [selectedOption?.value]); + }, [selectedOption, selectedOption?.credentials, v.meta?.dnsProviderCredentials, setFieldValue]); return (