From 4240e00a46b3b24adbc06e3b32c9c013ed6d88e0 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Tue, 23 Sep 2025 18:02:00 +1000 Subject: [PATCH] 404 hosts add update complete, fix certbot renewals and remove the need for email and agreement on cert requests --- backend/internal/certificate.js | 95 ++-- backend/internal/dead-host.js | 35 +- backend/lib/certbot.js | 79 ++- backend/routes/nginx/certificates.js | 2 + .../schema/components/certificate-object.json | 6 - .../paths/nginx/certificates/certID/get.json | 2 - .../nginx/certificates/certID/renew/post.json | 2 - .../schema/paths/nginx/certificates/get.json | 2 - .../schema/paths/nginx/certificates/post.json | 2 - backend/setup.js | 12 +- frontend/package.json | 1 + frontend/src/App.css | 5 + .../Form/DNSProviderFields.module.css | 14 +- .../src/components/Form/DNSProviderFields.tsx | 41 +- .../src/components/Form/DomainNamesField.tsx | 68 +-- .../src/components/Form/NginxConfigField.tsx | 40 ++ .../components/Form/SSLCertificateField.tsx | 27 +- .../src/components/Form/SSLOptionsFields.tsx | 48 +- frontend/src/components/Form/index.ts | 1 + frontend/src/locale/lang/en.json | 20 +- frontend/src/locale/src/en.json | 54 +- frontend/src/modals/ChangePasswordModal.tsx | 8 +- frontend/src/modals/DeadHostModal.tsx | 183 ++----- frontend/src/modals/DeleteConfirmModal.tsx | 13 +- frontend/src/modals/PermissionsModal.tsx | 6 +- frontend/src/modals/SetPasswordModal.tsx | 5 +- frontend/src/modals/UserModal.tsx | 11 +- frontend/src/modules/Validations.tsx | 66 ++- frontend/src/pages/Dashboard/index.tsx | 1 + frontend/src/pages/Nginx/DeadHosts/Table.tsx | 14 +- .../pages/Nginx/DeadHosts/TableWrapper.tsx | 1 + frontend/yarn.lock | 488 +++++++++++++++++- test/cypress/e2e/api/Certificates.cy.js | 28 +- test/cypress/e2e/api/FullCertProvision.cy.js | 4 - 34 files changed, 936 insertions(+), 448 deletions(-) create mode 100644 frontend/src/components/Form/NginxConfigField.tsx diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index ec1abf26..00614cf0 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -13,6 +13,7 @@ import utils from "../lib/utils.js"; import { ssl as logger } from "../logger.js"; import certificateModel from "../models/certificate.js"; import tokenModel from "../models/token.js"; +import userModel from "../models/user.js"; import internalAuditLog from "./audit-log.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; @@ -81,7 +82,7 @@ const internalCertificate = { Promise.resolve({ permission_visibility: "all", }), - token: new tokenModel(), + token: tokenModel(), }, { id: certificate.id }, ) @@ -118,10 +119,7 @@ const internalCertificate = { data.nice_name = data.domain_names.join(", "); } - const certificate = await certificateModel - .query() - .insertAndFetch(data) - .then(utils.omitRow(omissions())); + const certificate = await certificateModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); if (certificate.provider === "letsencrypt") { // Request a new Cert from LE. Let the fun begin. @@ -139,12 +137,19 @@ const internalCertificate = { // 2. Disable them in nginx temporarily await internalCertificate.disableInUseHosts(inUseResult); + const user = await userModel.query().where("is_deleted", 0).andWhere("id", data.owner_user_id).first(); + if (!user || !user.email) { + throw new error.ValidationError( + "A valid email address must be set on your user account to use Let's Encrypt", + ); + } + // With DNS challenge no config is needed, so skip 3 and 5. if (certificate.meta?.dns_challenge) { try { await internalNginx.reload(); // 4. Request cert - await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate); + await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate, user.email); await internalNginx.reload(); // 6. Re-instate previously disabled hosts await internalCertificate.enableInUseHosts(inUseResult); @@ -159,9 +164,9 @@ const internalCertificate = { try { await internalNginx.generateLetsEncryptRequestConfig(certificate); await internalNginx.reload(); - setTimeout(() => {}, 5000) + setTimeout(() => {}, 5000); // 4. Request cert - await internalCertificate.requestLetsEncryptSsl(certificate); + await internalCertificate.requestLetsEncryptSsl(certificate, user.email); // 5. Remove LE config await internalNginx.deleteLetsEncryptRequestConfig(certificate); await internalNginx.reload(); @@ -204,13 +209,12 @@ const internalCertificate = { data.meta = _.assign({}, data.meta || {}, certificate.meta); // Add to audit log - await internalAuditLog - .add(access, { - action: "created", - object_type: "certificate", - object_id: certificate.id, - meta: data, - }); + await internalAuditLog.add(access, { + action: "created", + object_type: "certificate", + object_id: certificate.id, + meta: data, + }); return certificate; }, @@ -248,13 +252,12 @@ const internalCertificate = { } // Add to audit log - await internalAuditLog - .add(access, { - action: "updated", - object_type: "certificate", - object_id: row.id, - meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw - }); + await internalAuditLog.add(access, { + action: "updated", + object_type: "certificate", + object_id: row.id, + meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw + }); return savedRow; }, @@ -268,7 +271,7 @@ const internalCertificate = { * @return {Promise} */ get: async (access, data) => { - const accessData = await access.can("certificates:get", data.id) + const accessData = await access.can("certificates:get", data.id); const query = certificateModel .query() .where("is_deleted", 0) @@ -367,12 +370,9 @@ const internalCertificate = { throw new error.ItemNotFoundError(data.id); } - await certificateModel - .query() - .where("id", row.id) - .patch({ - is_deleted: 1, - }); + await certificateModel.query().where("id", row.id).patch({ + is_deleted: 1, + }); // Add to audit log row.meta = internalCertificate.cleanMeta(row.meta); @@ -435,10 +435,7 @@ const internalCertificate = { * @returns {Promise} */ getCount: async (userId, visibility) => { - const query = certificateModel - .query() - .count("id as count") - .where("is_deleted", 0); + const query = certificateModel.query().count("id as count").where("is_deleted", 0); if (visibility !== "all") { query.andWhere("owner_user_id", userId); @@ -501,12 +498,10 @@ const internalCertificate = { * @param {Access} access * @param {Object} data * @param {Array} data.domain_names - * @param {String} data.meta.letsencrypt_email - * @param {Boolean} data.meta.letsencrypt_agree * @returns {Promise} */ createQuickCertificate: async (access, data) => { - return internalCertificate.create(access, { + return await internalCertificate.create(access, { provider: "letsencrypt", domain_names: data.domain_names, meta: data.meta, @@ -652,7 +647,7 @@ const internalCertificate = { const certData = {}; try { - const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]) + const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]); // Examples: // subject=CN = *.jc21.com // subject=CN = something.example.com @@ -739,9 +734,10 @@ const internalCertificate = { /** * Request a certificate using the http challenge * @param {Object} certificate the certificate row + * @param {String} email the email address to use for registration * @returns {Promise} */ - requestLetsEncryptSsl: async (certificate) => { + requestLetsEncryptSsl: async (certificate, email) => { logger.info( `Requesting LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, ); @@ -760,7 +756,7 @@ const internalCertificate = { "--authenticator", "webroot", "--email", - certificate.meta.letsencrypt_email, + email, "--preferred-challenges", "dns,http", "--domains", @@ -779,9 +775,10 @@ const internalCertificate = { /** * @param {Object} certificate the certificate row + * @param {String} email the email address to use for registration * @returns {Promise} */ - requestLetsEncryptSslWithDnsChallenge: async (certificate) => { + requestLetsEncryptSslWithDnsChallenge: async (certificate, email) => { await installPlugin(certificate.meta.dns_provider); const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; logger.info( @@ -807,7 +804,7 @@ const internalCertificate = { `npm-${certificate.id}`, "--agree-tos", "--email", - certificate.meta.letsencrypt_email, + email, "--domains", certificate.domain_names.join(","), "--authenticator", @@ -847,7 +844,7 @@ const internalCertificate = { * @returns {Promise} */ renew: async (access, data) => { - await access.can("certificates:update", data) + await access.can("certificates:update", data); const certificate = await internalCertificate.get(access, data); if (certificate.provider === "letsencrypt") { @@ -860,11 +857,9 @@ const internalCertificate = { `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`, ); - const updatedCertificate = await certificateModel - .query() - .patchAndFetchById(certificate.id, { - expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), - }); + const updatedCertificate = await certificateModel.query().patchAndFetchById(certificate.id, { + expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), + }); // Add to audit log await internalAuditLog.add(access, { @@ -1159,7 +1154,9 @@ const internalCertificate = { return "no-host"; } // Other errors - logger.info(`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`); + logger.info( + `HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`, + ); return `other:${result.responsecode}`; } @@ -1201,7 +1198,7 @@ const internalCertificate = { getLiveCertPath: (certificateId) => { return `/etc/letsencrypt/live/npm-${certificateId}`; - } + }, }; export default internalCertificate; diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index 3121d2af..0cbb0105 100644 --- a/backend/internal/dead-host.js +++ b/backend/internal/dead-host.js @@ -54,10 +54,21 @@ const internalDeadHost = { thisData.advanced_config = ""; } - const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); + const row = await deadHostModel.query() + .insertAndFetch(thisData) + .then(utils.omitRow(omissions())); + + // Add to audit log + await internalAuditLog.add(access, { + action: "created", + object_type: "dead-host", + object_id: row.id, + meta: _.assign({}, data.meta || {}, row.meta), + }); if (createCertificate) { const cert = await internalCertificate.createQuickCertificate(access, data); + // update host with cert id await internalDeadHost.update(access, { id: row.id, @@ -71,17 +82,13 @@ const internalDeadHost = { expand: ["certificate", "owner"], }); + // Sanity check + if (createCertificate && !freshRow.certificate_id) { + throw new errs.InternalValidationError("The host was created but the Certificate creation failed."); + } + // Configure nginx await internalNginx.configure(deadHostModel, "dead_host", freshRow); - data.meta = _.assign({}, data.meta || {}, freshRow.meta); - - // Add to audit log - await internalAuditLog.add(access, { - action: "created", - object_type: "dead-host", - object_id: freshRow.id, - meta: data, - }); return freshRow; }, @@ -94,7 +101,6 @@ const internalDeadHost = { */ update: async (access, data) => { const createCertificate = data.certificate_id === "new"; - if (createCertificate) { delete data.certificate_id; } @@ -147,6 +153,13 @@ const internalDeadHost = { thisData = internalHost.cleanSslHstsData(thisData, row); + + // do the row update + await deadHostModel + .query() + .where({id: data.id}) + .patch(data); + // Add to audit log await internalAuditLog.add(access, { action: "updated", diff --git a/backend/lib/certbot.js b/backend/lib/certbot.js index d850b9ad..ea0b728f 100644 --- a/backend/lib/certbot.js +++ b/backend/lib/certbot.js @@ -6,46 +6,6 @@ import utils from "./utils.js"; const CERTBOT_VERSION_REPLACEMENT = "$(certbot --version | grep -Eo '[0-9](\\.[0-9]+)+')"; -/** - * @param {array} pluginKeys - */ -const installPlugins = async (pluginKeys) => { - let hasErrors = false; - - return new Promise((resolve, reject) => { - if (pluginKeys.length === 0) { - resolve(); - return; - } - - batchflow(pluginKeys) - .sequential() - .each((_i, pluginKey, next) => { - certbot - .installPlugin(pluginKey) - .then(() => { - next(); - }) - .catch((err) => { - hasErrors = true; - next(err); - }); - }) - .error((err) => { - logger.error(err.message); - }) - .end(() => { - if (hasErrors) { - reject( - new errs.CommandError("Some plugins failed to install. Please check the logs above", 1), - ); - } else { - resolve(); - } - }); - }); -}; - /** * Installs a cerbot plugin given the key for the object from * ../global/certbot-dns-plugins.json @@ -84,4 +44,43 @@ const installPlugin = async (pluginKey) => { }); }; +/** + * @param {array} pluginKeys + */ +const installPlugins = async (pluginKeys) => { + let hasErrors = false; + + return new Promise((resolve, reject) => { + if (pluginKeys.length === 0) { + resolve(); + return; + } + + batchflow(pluginKeys) + .sequential() + .each((_i, pluginKey, next) => { + installPlugin(pluginKey) + .then(() => { + next(); + }) + .catch((err) => { + hasErrors = true; + next(err); + }); + }) + .error((err) => { + logger.error(err.message); + }) + .end(() => { + if (hasErrors) { + reject( + new errs.CommandError("Some plugins failed to install. Please check the logs above", 1), + ); + } else { + resolve(); + } + }); + }); +}; + export { installPlugins, installPlugin }; diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js index 47a827b0..33ff1a96 100644 --- a/backend/routes/nginx/certificates.js +++ b/backend/routes/nginx/certificates.js @@ -98,6 +98,8 @@ router name: dnsPlugins[key].name, credentials: dnsPlugins[key].credentials, })); + + clean.sort((a, b) => a.name.localeCompare(b.name)); res.status(200).send(clean); } catch (err) { logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index dcc2a834..ef3553d9 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -62,15 +62,9 @@ "dns_provider_credentials": { "type": "string" }, - "letsencrypt_agree": { - "type": "boolean" - }, "letsencrypt_certificate": { "type": "object" }, - "letsencrypt_email": { - "$ref": "../common.json#/properties/email" - }, "propagation_seconds": { "type": "integer", "minimum": 0 diff --git a/backend/schema/paths/nginx/certificates/certID/get.json b/backend/schema/paths/nginx/certificates/certID/get.json index 22317b33..bc289573 100644 --- a/backend/schema/paths/nginx/certificates/certID/get.json +++ b/backend/schema/paths/nginx/certificates/certID/get.json @@ -36,8 +36,6 @@ "domain_names": ["test.example.com"], "expires_on": "2025-01-07T04:34:18.000Z", "meta": { - "letsencrypt_email": "jc@jc21.com", - "letsencrypt_agree": true, "dns_challenge": false } } diff --git a/backend/schema/paths/nginx/certificates/certID/renew/post.json b/backend/schema/paths/nginx/certificates/certID/renew/post.json index ef4d20e5..b2c1dcd6 100644 --- a/backend/schema/paths/nginx/certificates/certID/renew/post.json +++ b/backend/schema/paths/nginx/certificates/certID/renew/post.json @@ -37,8 +37,6 @@ "nice_name": "My Test Cert", "domain_names": ["test.jc21.supernerd.pro"], "meta": { - "letsencrypt_email": "jc@jc21.com", - "letsencrypt_agree": true, "dns_challenge": false } } diff --git a/backend/schema/paths/nginx/certificates/get.json b/backend/schema/paths/nginx/certificates/get.json index 2f4b556a..bd45e62a 100644 --- a/backend/schema/paths/nginx/certificates/get.json +++ b/backend/schema/paths/nginx/certificates/get.json @@ -36,8 +36,6 @@ "domain_names": ["test.example.com"], "expires_on": "2025-01-07T04:34:18.000Z", "meta": { - "letsencrypt_email": "jc@jc21.com", - "letsencrypt_agree": true, "dns_challenge": false } } diff --git a/backend/schema/paths/nginx/certificates/post.json b/backend/schema/paths/nginx/certificates/post.json index 5a3306c2..f2bb2fa2 100644 --- a/backend/schema/paths/nginx/certificates/post.json +++ b/backend/schema/paths/nginx/certificates/post.json @@ -52,8 +52,6 @@ "nice_name": "test.example.com", "domain_names": ["test.example.com"], "meta": { - "letsencrypt_email": "jc@jc21.com", - "letsencrypt_agree": true, "dns_challenge": false, "letsencrypt_certificate": { "cn": "test.example.com", diff --git a/backend/setup.js b/backend/setup.js index 4a9d5e05..b2c0dcb7 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -121,11 +121,13 @@ const setupCertbotPlugins = async () => { // Make sure credentials file exists const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; // Escape single quotes and backslashes - const escapedCredentials = certificate.meta.dns_provider_credentials - .replaceAll("'", "\\'") - .replaceAll("\\", "\\\\"); - const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; - promises.push(utils.exec(credentials_cmd)); + if (typeof certificate.meta.dns_provider_credentials === "string") { + const escapedCredentials = certificate.meta.dns_provider_credentials + .replaceAll("'", "\\'") + .replaceAll("\\", "\\\\"); + const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; + promises.push(utils.exec(credentials_cmd)); + } } return true; }); diff --git a/frontend/package.json b/frontend/package.json index 33e460a2..73b9b05c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@tabler/icons-react": "^3.35.0", "@tanstack/react-query": "^5.89.0", "@tanstack/react-table": "^8.21.3", + "@uiw/react-textarea-code-editor": "^3.1.1", "classnames": "^2.5.1", "country-flag-icons": "^1.5.20", "date-fns": "^4.1.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index 0a410b28..30ff867f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -65,3 +65,8 @@ } } } + +.textareaMono { + font-family: 'Courier New', Courier, monospace !important; + resize: vertical; +} diff --git a/frontend/src/components/Form/DNSProviderFields.module.css b/frontend/src/components/Form/DNSProviderFields.module.css index ba4ac629..fdf04b01 100644 --- a/frontend/src/components/Form/DNSProviderFields.module.css +++ b/frontend/src/components/Form/DNSProviderFields.module.css @@ -1,16 +1,8 @@ .dnsChallengeWarning { - border: 1px solid #fecaca; /* Tailwind's red-300 */ + border: 1px solid var(--tblr-orange-lt); padding: 1rem; - border-radius: 0.375rem; /* Tailwind's rounded-md */ + border-radius: 0.375rem; margin-top: 1rem; + background-color: var(--tblr-cyan-lt); } -.textareaMono { - font-family: 'Courier New', Courier, monospace !important; - /* background-color: #f9fafb; - border: 1px solid #d1d5db; - padding: 0.5rem; - border-radius: 0.375rem; - width: 100%; */ - resize: vertical; -} diff --git a/frontend/src/components/Form/DNSProviderFields.tsx b/frontend/src/components/Form/DNSProviderFields.tsx index bb9eef55..2811c604 100644 --- a/frontend/src/components/Form/DNSProviderFields.tsx +++ b/frontend/src/components/Form/DNSProviderFields.tsx @@ -1,4 +1,3 @@ -import cn from "classnames"; import { Field, useFormikContext } from "formik"; import { useState } from "react"; import Select, { type ActionMeta } from "react-select"; @@ -20,8 +19,8 @@ export function DNSProviderFields() { const v: any = values || {}; const handleChange = (newValue: any, _actionMeta: ActionMeta) => { - setFieldValue("dnsProvider", newValue?.value); - setFieldValue("dnsProviderCredentials", newValue?.credentials); + setFieldValue("meta.dnsProvider", newValue?.value); + setFieldValue("meta.dnsProviderCredentials", newValue?.credentials); setDnsProviderId(newValue?.value); }; @@ -34,12 +33,12 @@ export function DNSProviderFields() { return (
-

- This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective +

+ This section requires some knowledge about Certbot and DNS plugins. Please consult the respective plugins documentation.

- + {({ field }: any) => (