404 hosts add update complete, fix certbot renewals

and remove the need for email and agreement on cert requests
This commit is contained in:
Jamie Curnow
2025-09-23 18:02:00 +10:00
parent f39efb3e63
commit 4240e00a46
34 changed files with 936 additions and 448 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 };

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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;
});