Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion backend/routes/nginx/certificates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions backend/schema/paths/nginx/certificates/certID/put.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
3 changes: 3 additions & 0 deletions backend/schema/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/api/backend/updateCertificate.ts
Original file line number Diff line number Diff line change
@@ -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<Certificate> {
return await api.put({
url: `/nginx/certificates/${id}`,
data: payload,
});
}
21 changes: 17 additions & 4 deletions frontend/src/components/Form/DNSProviderFields.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string | null>(null);
Expand All @@ -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, selectedOption?.credentials, v.meta?.dnsProviderCredentials, setFieldValue]);

return (
<div className={showBoundaryBox ? styles.dnsChallengeWarning : undefined}>
<p className="text-warning">
Expand All @@ -60,20 +72,21 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) {
placeholder={intl.formatMessage({ id: "certificates.dns.provider.placeholder" })}
isLoading={isLoading}
isSearchable
value={selectedOption}
onChange={handleChange}
options={options}
/>
</div>
)}
</Field>

{dnsProviderId ? (
{showCredentials ? (
<>
<Field name="meta.dnsProviderCredentials">
{({ field }: any) => (
<div className="mt-3">
<label htmlFor="dnsProviderCredentials" className="form-label">
<T id="certificates.dns.credentials" />
<T id={editMode ? "certificates.dns.credentials-update" : "certificates.dns.credentials"} />
</label>
<CodeEditor
language="bash"
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
"certificates.dns.credentials-note": {
"defaultMessage": "Този плъгин изисква конфигурационен файл с API токен или други идентификационни данни."
},
"certificates.dns.credentials-update": {
"defaultMessage": "Ново съдържание на файл с идентификационни данни (заменя съществуващото)"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "Тези данни ще бъдат съхранени като обикновен текст в базата и във файл!"
},
Expand All @@ -146,6 +149,9 @@
"certificates.dns.warning": {
"defaultMessage": "Този раздел изисква познания за Certbot и неговите DNS плъгини. Моля, консултирайте се с документацията."
},
"certificates.edit-dns-settings": {
"defaultMessage": "Редактиране на DNS настройките"
},
"certificates.http.reachability-404": {
"defaultMessage": "Сървър е намерен на този домейн, но не изглежда да е Nginx Proxy Manager. Уверете се, че домейнът сочи към IP адреса, където работи NPM."
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@
"certificates.dns.credentials-note": {
"defaultMessage": "Tento doplněk vyžaduje konfigurační soubor obsahující API token nebo jiné přihlašovací údaje vašeho poskytovatele"
},
"certificates.dns.credentials-update": {
"defaultMessage": "Nový obsah souboru s přihlašovacími údaji (nahradí stávající)"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "Tyto údaje budou uloženy v databázi a v souboru jako obyčejný text!"
},
Expand All @@ -203,6 +206,9 @@
"certificates.dns.warning": {
"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."
},
"certificates.edit-dns-settings": {
"defaultMessage": "Upravit DNS nastavení"
},
"certificates.http.reachability-404": {
"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."
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@
"certificates.dns.credentials-note": {
"defaultMessage": "Dieses Plugin erfordert eine Konfigurationsdatei, die einen API-Token oder andere Anmeldedaten für Ihren Anbieter enthält."
},
"certificates.dns.credentials-update": {
"defaultMessage": "Neuer Inhalt der Anmeldedaten-Datei (ersetzt die bestehende)"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "Diese Daten werden als Klartext in der Datenbank und in einer Datei gespeichert!"
},
Expand All @@ -131,6 +134,9 @@
"certificates.dns.warning": {
"defaultMessage": "Dieser Abschnitt erfordert einige Kenntnisse über Certbot und seine DNS-Plugins. Bitte konsultieren Sie die jeweilige Plugin-Dokumentation."
},
"certificates.edit-dns-settings": {
"defaultMessage": "DNS-Einstellungen bearbeiten"
},
"certificates.http.reachability-404": {
"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."
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@
"certificates.dns.credentials-note": {
"defaultMessage": "This plugin requires a configuration file containing an API token or other credentials for your provider"
},
"certificates.dns.credentials-update": {
"defaultMessage": "New Credentials File Content (replaces existing)"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "This data will be stored as plaintext in the database and in a file!"
},
Expand All @@ -203,6 +206,9 @@
"certificates.dns.warning": {
"defaultMessage": "This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective plugins documentation."
},
"certificates.edit-dns-settings": {
"defaultMessage": "Edit DNS Settings"
},
"certificates.http.reachability-404": {
"defaultMessage": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running."
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
"certificates.dns.credentials-note": {
"defaultMessage": "Este plugin requiere un archivo de configuración que contenga un token de API u otras credenciales para tu proveedor"
},
"certificates.dns.credentials-update": {
"defaultMessage": "Nuevo contenido del archivo de credenciales (reemplaza el existente)"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "¡Estos datos se almacenarán como texto plano en la base de datos y en un archivo!"
},
Expand All @@ -146,6 +149,9 @@
"certificates.dns.warning": {
"defaultMessage": "Esta sección requiere algunos conocimientos sobre Certbot y sus plugins DNS. Consulta la documentación de los plugins respectivos."
},
"certificates.edit-dns-settings": {
"defaultMessage": "Editar configuración DNS"
},
"certificates.http.reachability-404": {
"defaultMessage": "Se encontró un servidor en este dominio pero no parece ser Nginx Proxy Manager. Asegúrate de que tu dominio apunte a la IP donde se está ejecutando tu instancia de NPM."
},
Expand Down
Loading