Skip to content

Commit d57e69b

Browse files
JeremieAlokeshkatoclaude
committed
Add edit DNS credentials feature for Let's Encrypt certificates
Allow updating DNS provider, credentials, and propagation seconds on existing LE DNS certificates without triggering an immediate certbot renewal or disabling any hosts. The updated credentials are used at the next automatic or manual renewal. Based on NginxProxyManager#5377 by Lokesh, simplified to only persist credential changes (no immediate re-issuance). Co-Authored-By: Lokesh <lokesh@katomaran.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 84fb272 commit d57e69b

29 files changed

Lines changed: 374 additions & 50 deletions

File tree

backend/internal/certificate.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,35 @@ const internalCertificate = {
255255
);
256256
}
257257

258+
let patchPayload = data;
259+
260+
// Let's Encrypt DNS: allow updating dns_provider, dns_provider_credentials, propagation_seconds
261+
const isLetsEncryptDns =
262+
row.provider === "letsencrypt" &&
263+
row.meta &&
264+
row.meta.dns_challenge &&
265+
data.meta &&
266+
typeof data.meta === "object";
267+
268+
if (isLetsEncryptDns) {
269+
const mergedMeta = { ...row.meta };
270+
if (data.meta.dns_provider !== undefined) mergedMeta.dns_provider = data.meta.dns_provider;
271+
if (data.meta.dns_provider_credentials !== undefined) {
272+
mergedMeta.dns_provider_credentials = data.meta.dns_provider_credentials;
273+
}
274+
if (data.meta.propagation_seconds !== undefined) {
275+
mergedMeta.propagation_seconds = data.meta.propagation_seconds;
276+
}
277+
patchPayload = { ...data, meta: mergedMeta };
278+
}
279+
258280
const savedRow = await certificateModel
259281
.query()
260-
.patchAndFetchById(row.id, data)
282+
.patchAndFetchById(row.id, patchPayload)
261283
.then(utils.omitRow(omissions()));
262284

263285
savedRow.meta = internalCertificate.cleanMeta(savedRow.meta);
264-
data.meta = internalCertificate.cleanMeta(data.meta);
286+
patchPayload.meta = internalCertificate.cleanMeta(patchPayload.meta);
265287

266288
// Add row.nice_name for custom certs
267289
if (savedRow.provider === "other") {

backend/routes/nginx/certificates.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,33 @@ router
241241
}
242242
})
243243

244+
/**
245+
* PUT /api/nginx/certificates/123
246+
*
247+
* Update an existing certificate (e.g. DNS provider credentials for Let's Encrypt)
248+
*/
249+
.put(async (req, res, next) => {
250+
try {
251+
const certificateId = Number.parseInt(req.params.certificate_id, 10);
252+
const payload = await apiValidator(
253+
getValidationSchema("/nginx/certificates/{certID}", "put"),
254+
req.body,
255+
);
256+
const result = await internalCertificate.update(res.locals.access, {
257+
id: certificateId,
258+
meta: payload.meta,
259+
});
260+
res.status(200).send(result);
261+
} catch (err) {
262+
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
263+
next(err);
264+
}
265+
})
266+
244267
/**
245268
* DELETE /api/nginx/certificates/123
246269
*
247-
* Update and existing certificate
270+
* Delete a certificate
248271
*/
249272
.delete(async (req, res, next) => {
250273
try {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"operationId": "updateCertificate",
3+
"summary": "Update a Certificate (e.g. DNS provider credentials for Let's Encrypt)",
4+
"tags": ["certificates"],
5+
"security": [
6+
{
7+
"bearerAuth": ["certificates.manage"]
8+
}
9+
],
10+
"parameters": [
11+
{
12+
"in": "path",
13+
"name": "certID",
14+
"description": "Certificate ID",
15+
"schema": {
16+
"type": "integer",
17+
"minimum": 1
18+
},
19+
"required": true,
20+
"example": 1
21+
}
22+
],
23+
"requestBody": {
24+
"description": "Certificate update payload",
25+
"required": true,
26+
"content": {
27+
"application/json": {
28+
"schema": {
29+
"type": "object",
30+
"required": ["meta"],
31+
"additionalProperties": false,
32+
"properties": {
33+
"meta": {
34+
"type": "object",
35+
"additionalProperties": false,
36+
"minProperties": 1,
37+
"properties": {
38+
"dns_provider": {
39+
"type": "string"
40+
},
41+
"dns_provider_credentials": {
42+
"type": "string"
43+
},
44+
"propagation_seconds": {
45+
"type": "integer",
46+
"minimum": 0
47+
}
48+
}
49+
}
50+
}
51+
}
52+
}
53+
}
54+
},
55+
"responses": {
56+
"200": {
57+
"description": "200 response",
58+
"content": {
59+
"application/json": {
60+
"schema": {
61+
"$ref": "../../../../components/certificate-object.json"
62+
}
63+
}
64+
}
65+
}
66+
}
67+
}

backend/schema/swagger.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@
127127
"get": {
128128
"$ref": "./paths/nginx/certificates/certID/get.json"
129129
},
130+
"put": {
131+
"$ref": "./paths/nginx/certificates/certID/put.json"
132+
},
130133
"delete": {
131134
"$ref": "./paths/nginx/certificates/certID/delete.json"
132135
}

frontend/src/api/backend/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export * from "./toggleRedirectionHost";
5151
export * from "./toggleStream";
5252
export * from "./toggleUser";
5353
export * from "./updateAccessList";
54+
export * from "./updateCertificate";
5455
export * from "./updateAuth";
5556
export * from "./updateDeadHost";
5657
export * from "./updateProxyHost";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as api from "./base";
2+
import type { Certificate } from "./models";
3+
4+
export interface UpdateCertificatePayload {
5+
meta: {
6+
dnsProvider?: string;
7+
dnsProviderCredentials?: string;
8+
propagationSeconds?: number;
9+
};
10+
}
11+
12+
export async function updateCertificate(
13+
id: number,
14+
payload: UpdateCertificatePayload,
15+
): Promise<Certificate> {
16+
return await api.put({
17+
url: `/nginx/certificates/${id}`,
18+
data: payload,
19+
});
20+
}

frontend/src/components/Form/DNSProviderFields.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconAlertTriangle } from "@tabler/icons-react";
22
import CodeEditor from "@uiw/react-textarea-code-editor";
33
import { Field, useFormikContext } from "formik";
4-
import { useState } from "react";
4+
import { useEffect, useState } from "react";
55
import Select, { type ActionMeta } from "react-select";
66
import type { DNSProvider } from "src/api/backend";
77
import { useDnsProviders } from "src/hooks";
@@ -16,8 +16,10 @@ interface DNSProviderOption {
1616

1717
interface Props {
1818
showBoundaryBox?: boolean;
19+
/** When true (edit mode), credentials field is for new credentials only; existing credentials are never displayed */
20+
editMode?: boolean;
1921
}
20-
export function DNSProviderFields({ showBoundaryBox = false }: Props) {
22+
export function DNSProviderFields({ showBoundaryBox = false, editMode = false }: Props) {
2123
const { values, setFieldValue } = useFormikContext();
2224
const { data: dnsProviders, isLoading } = useDnsProviders();
2325
const [dnsProviderId, setDnsProviderId] = useState<string | null>(null);
@@ -37,6 +39,16 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) {
3739
credentials: p.credentials,
3840
})) || [];
3941

42+
const selectedOption = options.find((o) => o.value === v.meta?.dnsProvider) ?? null;
43+
const showCredentials = dnsProviderId ?? v.meta?.dnsProvider;
44+
45+
// When a provider is selected and credentials are empty, use the template from dns-plugins.json
46+
useEffect(() => {
47+
if (selectedOption && (v.meta?.dnsProviderCredentials ?? "") === "") {
48+
setFieldValue("meta.dnsProviderCredentials", selectedOption.credentials);
49+
}
50+
}, [selectedOption, selectedOption?.credentials, v.meta?.dnsProviderCredentials, setFieldValue]);
51+
4052
return (
4153
<div className={showBoundaryBox ? styles.dnsChallengeWarning : undefined}>
4254
<p className="text-warning">
@@ -60,20 +72,21 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) {
6072
placeholder={intl.formatMessage({ id: "certificates.dns.provider.placeholder" })}
6173
isLoading={isLoading}
6274
isSearchable
75+
value={selectedOption}
6376
onChange={handleChange}
6477
options={options}
6578
/>
6679
</div>
6780
)}
6881
</Field>
6982

70-
{dnsProviderId ? (
83+
{showCredentials ? (
7184
<>
7285
<Field name="meta.dnsProviderCredentials">
7386
{({ field }: any) => (
7487
<div className="mt-3">
7588
<label htmlFor="dnsProviderCredentials" className="form-label">
76-
<T id="certificates.dns.credentials" />
89+
<T id={editMode ? "certificates.dns.credentials-update" : "certificates.dns.credentials"} />
7790
</label>
7891
<CodeEditor
7992
language="bash"

frontend/src/locale/src/bg.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@
125125
"certificates.dns.credentials": {
126126
"defaultMessage": "Съдържание на файл с удостоверения"
127127
},
128+
"certificates.dns.credentials-update": {
129+
"defaultMessage": "New Credentials File Content"
130+
},
128131
"certificates.dns.credentials-note": {
129132
"defaultMessage": "Този плъгин изисква конфигурационен файл с API токен или други идентификационни данни."
130133
},
@@ -146,6 +149,9 @@
146149
"certificates.dns.warning": {
147150
"defaultMessage": "Този раздел изисква познания за Certbot и неговите DNS плъгини. Моля, консултирайте се с документацията."
148151
},
152+
"certificates.edit-dns-settings": {
153+
"defaultMessage": "Edit DNS Settings"
154+
},
149155
"certificates.http.reachability-404": {
150156
"defaultMessage": "Сървър е намерен на този домейн, но не изглежда да е Nginx Proxy Manager. Уверете се, че домейнът сочи към IP адреса, където работи NPM."
151157
},

frontend/src/locale/src/cs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@
182182
"certificates.dns.credentials": {
183183
"defaultMessage": "Obsah souboru s přihlašovacími údaji"
184184
},
185+
"certificates.dns.credentials-update": {
186+
"defaultMessage": "New Credentials File Content"
187+
},
185188
"certificates.dns.credentials-note": {
186189
"defaultMessage": "Tento doplněk vyžaduje konfigurační soubor obsahující API token nebo jiné přihlašovací údaje vašeho poskytovatele"
187190
},
@@ -203,6 +206,9 @@
203206
"certificates.dns.warning": {
204207
"defaultMessage": "Tato sekce vyžaduje znalost Certbotu a jeho DNS doplňků. Prosím, podívejte se do dokumentace příslušného doplňku."
205208
},
209+
"certificates.edit-dns-settings": {
210+
"defaultMessage": "Edit DNS Settings"
211+
},
206212
"certificates.http.reachability-404": {
207213
"defaultMessage": "Na této doméně byl nalezen server, ale nezdá se, že jde o Nginx Proxy Manager. Ujistěte se, že vaše doména směřuje na IP, kde běží vaše instance NPM."
208214
},

frontend/src/locale/src/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@
113113
"certificates.dns.credentials": {
114114
"defaultMessage": "Inhalt der Anmeldedaten-Datei"
115115
},
116+
"certificates.dns.credentials-update": {
117+
"defaultMessage": "New Credentials File Content"
118+
},
116119
"certificates.dns.credentials-note": {
117120
"defaultMessage": "Dieses Plugin erfordert eine Konfigurationsdatei, die einen API-Token oder andere Anmeldedaten für Ihren Anbieter enthält."
118121
},
@@ -131,6 +134,9 @@
131134
"certificates.dns.warning": {
132135
"defaultMessage": "Dieser Abschnitt erfordert einige Kenntnisse über Certbot und seine DNS-Plugins. Bitte konsultieren Sie die jeweilige Plugin-Dokumentation."
133136
},
137+
"certificates.edit-dns-settings": {
138+
"defaultMessage": "Edit DNS Settings"
139+
},
134140
"certificates.http.reachability-404": {
135141
"defaultMessage": "Unter dieser Domain wurde ein Server gefunden, aber es scheint sich nicht um Nginx Proxy Manager zu handeln. Bitte stellen Sie sicher, dass Ihre Domain auf die IP-Adresse verweist, unter der Ihre NPM-Instanz ausgeführt wird."
136142
},

0 commit comments

Comments
 (0)