diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..f859b127 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,21 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-label: 'stale' + stale-pr-label: 'stale' + stale-issue-message: 'Issue is now considered stale. If you want to keep it open, please comment :+1:' + stale-pr-message: 'PR is now considered stale. If you want to keep it open, please comment :+1:' + close-issue-message: 'Issue was closed due to inactivity.' + close-pr-message: 'PR was closed due to inactivity.' + days-before-stale: 182 + days-before-close: 365 + operations-per-run: 50 diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 80fafea7..23bf0660 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -6,9 +6,10 @@ const logger = require('../logger').ssl; const error = require('../lib/error'); const utils = require('../lib/utils'); const certificateModel = require('../models/certificate'); -const dnsPlugins = require('../certbot-dns-plugins'); +const dnsPlugins = require('../certbot-dns-plugins.json'); const internalAuditLog = require('./audit-log'); const internalNginx = require('./nginx'); +const certbot = require('../lib/certbot'); const archiver = require('archiver'); const crypto = require('crypto'); const path = require('path'); @@ -812,35 +813,30 @@ const internalCertificate = { /** * @param {Object} certificate the certificate row - * @param {String} dns_provider the dns provider name (key used in `certbot-dns-plugins.js`) + * @param {String} dns_provider the dns provider name (key used in `certbot-dns-plugins.json`) * @param {String | null} credentials the content of this providers credentials file - * @param {String} propagation_seconds the cloudflare api token + * @param {String} propagation_seconds * @returns {Promise} */ - requestLetsEncryptSslWithDnsChallenge: (certificate) => { - const dns_plugin = dnsPlugins[certificate.meta.dns_provider]; - - if (!dns_plugin) { - throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`); - } - - logger.info(`Requesting Certbot certificates via ${dns_plugin.display_name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); + requestLetsEncryptSslWithDnsChallenge: async (certificate) => { + await certbot.installPlugin(certificate.meta.dns_provider); + const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; + logger.info(`Requesting Certbot certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); const credentialsLocation = '/data/tls/certbot/credentials/credentials-' + certificate.id; // Escape single quotes and backslashes const escapedCredentials = certificate.meta.dns_provider_credentials.replaceAll('\'', '\\\'').replaceAll('\\', '\\\\'); const credentialsCmd = `echo '${escapedCredentials}' | tee '${credentialsLocation}'`; - const prepareCmd = 'pip install --no-cache-dir ' + dns_plugin.package_name; let mainCmd = certbotCommand + ' certonly ' + '--config "' + certbotConfig + '" ' + '--cert-name "npm-' + certificate.id + '" ' + '--domains "' + certificate.domain_names.join(',') + '" ' + - '--authenticator ' + dns_plugin.full_plugin_name + ' ' + - '--' + dns_plugin.full_plugin_name + '-credentials "' + credentialsLocation + '"' + + '--authenticator ' + dnsPlugin.full_plugin_name + ' ' + + '--' + dnsPlugin.full_plugin_name + '-credentials "' + credentialsLocation + '"' + ( certificate.meta.propagation_seconds !== undefined - ? ' --' + dns_plugin.full_plugin_name + '-propagation-seconds ' + certificate.meta.propagation_seconds + ? ' --' + dnsPlugin.full_plugin_name + '-propagation-seconds ' + certificate.meta.propagation_seconds : '' ); @@ -850,24 +846,19 @@ const internalCertificate = { mainCmd = mainCmd + ' --email "' + certificate.meta.letsencrypt_email + '" '; } - logger.info('Command:', `${credentialsCmd} && ${prepareCmd} && ${mainCmd}`); + logger.info('Command:', `${credentialsCmd} && ${mainCmd}`); - return utils.exec(credentialsCmd) - .then(() => { - return utils.exec(prepareCmd) - .then(() => { - return utils.exec(mainCmd) - .then(async (result) => { - logger.info(result); - return result; - }); - }); - }).catch(async (err) => { - // Don't fail if file does not exist - const delete_credentialsCmd = `rm -f '${credentialsLocation}' || true`; - await utils.exec(delete_credentialsCmd); - throw err; - }); + try { + await utils.exec(credentialsCmd); + const result = await utils.exec(mainCmd); + logger.info(result); + return result; + } catch (err) { + // Don't fail if file does not exist + const delete_credentialsCmd = `rm -f '${credentialsLocation}' || true`; + await utils.exec(delete_credentialsCmd); + throw err; + } }, @@ -942,13 +933,13 @@ const internalCertificate = { * @returns {Promise} */ renewLetsEncryptSslWithDnsChallenge: (certificate) => { - const dns_plugin = dnsPlugins[certificate.meta.dns_provider]; + const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; - if (!dns_plugin) { + if (!dnsPlugin) { throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`); } - logger.info(`Renewing Certbot certificates via ${dns_plugin.display_name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); + logger.info(`Renewing Certbot certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); let mainCmd = certbotCommand + ' renew --force-renewal ' + '--config "' + certbotConfig + '" ' + diff --git a/backend/lib/certbot.js b/backend/lib/certbot.js new file mode 100644 index 00000000..3b15f007 --- /dev/null +++ b/backend/lib/certbot.js @@ -0,0 +1,77 @@ +const dnsPlugins = require('../global/certbot-dns-plugins.json'); +const utils = require('./utils'); +const error = require('./error'); +const logger = require('../logger').certbot; +const batchflow = require('batchflow'); + +const CERTBOT_VERSION_REPLACEMENT = '$(certbot --version | grep -Eo \'[0-9](\\.[0-9]+)+\')'; + +const certbot = { + + /** + * @param {array} pluginKeys + */ + installPlugins: async function (pluginKeys) { + let hasErrors = false; + + return new Promise((resolve, reject) => { + if (pluginKeys.length === 0) { + 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 error.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 + * + * @param {string} pluginKey + * @returns {Object} + */ + installPlugin: async function (pluginKey) { + if (typeof dnsPlugins[pluginKey] === 'undefined') { + // throw Error(`Certbot plugin ${pluginKey} not found`); + throw new error.ItemNotFoundError(pluginKey); + } + + const plugin = dnsPlugins[pluginKey]; + logger.start(`Installing ${pluginKey}...`); + + plugin.version = plugin.version.replace(/{{certbot-version}}/g, CERTBOT_VERSION_REPLACEMENT); + plugin.dependencies = plugin.dependencies.replace(/{{certbot-version}}/g, CERTBOT_VERSION_REPLACEMENT); + + const cmd = '. /opt/certbot/bin/activate && pip install --no-cache-dir ' + plugin.dependencies + ' ' + plugin.package_name + plugin.version + ' ' + ' && deactivate'; + return utils.exec(cmd) + .then((result) => { + logger.complete(`Installed ${pluginKey}`); + return result; + }) + .catch((err) => { + throw err; + }); + }, +}; + +module.exports = certbot; diff --git a/backend/lib/error.js b/backend/lib/error.js index 9e456f05..413d6a7d 100644 --- a/backend/lib/error.js +++ b/backend/lib/error.js @@ -82,7 +82,16 @@ module.exports = { this.message = message; this.public = false; this.status = 400; - } + }, + + CommandError: function (stdErr, code, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = stdErr; + this.code = code; + this.public = false; + }, }; _.forEach(module.exports, function (error) { diff --git a/backend/lib/utils.js b/backend/lib/utils.js index ec7d97fd..56b1dd3e 100644 --- a/backend/lib/utils.js +++ b/backend/lib/utils.js @@ -4,23 +4,27 @@ const spawn = require('child_process').spawn; const execFile = require('child_process').execFile; const { Liquid } = require('liquidjs'); const logger = require('../logger').global; +const error = require('./error'); module.exports = { - /** - * @param {String} cmd - * @returns {Promise} - */ - exec: function (cmd) { - return new Promise((resolve, reject) => { - exec(cmd, function (err, stdout, /*stderr*/) { - if (err && typeof err === 'object') { - reject(err); + exec: async function(cmd, options = {}) { + logger.debug('CMD:', cmd); + + const { stdout, stderr } = await new Promise((resolve, reject) => { + const child = exec(cmd, options, (isError, stdout, stderr) => { + if (isError) { + reject(new error.CommandError(stderr, isError)); } else { - resolve(stdout.trim()); + resolve({ stdout, stderr }); } }); + + child.on('error', (e) => { + reject(new error.CommandError(stderr, 1, e)); + }); }); + return stdout; }, /** @@ -56,7 +60,8 @@ module.exports = { * @returns {Promise} */ execFile: function (cmd, args) { - logger.debug('CMD: ' + cmd + ' ' + (args ? args.join(' ') : '')); + // logger.debug('CMD: ' + cmd + ' ' + (args ? args.join(' ') : '')); + return new Promise((resolve, reject) => { execFile(cmd, args, function (err, stdout, /*stderr*/) { if (err && typeof err === 'object') { diff --git a/backend/logger.js b/backend/logger.js index 680af6d5..0ebb07c5 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -7,6 +7,7 @@ module.exports = { access: new Signale({scope: 'Access '}), nginx: new Signale({scope: 'Nginx '}), ssl: new Signale({scope: 'SSL '}), + certbot: new Signale({scope: 'Certbot '}), import: new Signale({scope: 'Importer '}), setup: new Signale({scope: 'Setup '}), ip_ranges: new Signale({scope: 'IP Ranges'}) diff --git a/backend/scripts/install-certbot-plugins b/backend/scripts/install-certbot-plugins new file mode 100755 index 00000000..bf995410 --- /dev/null +++ b/backend/scripts/install-certbot-plugins @@ -0,0 +1,49 @@ +#!/usr/bin/node + +// Usage: +// Install all plugins defined in `certbot-dns-plugins.json`: +// ./install-certbot-plugins +// Install one or more specific plugins: +// ./install-certbot-plugins route53 cloudflare +// +// Usage with a running docker container: +// docker exec npm_core /command/s6-setuidgid 1000:1000 bash -c "/app/scripts/install-certbot-plugins" +// + +const dnsPlugins = require('../global/certbot-dns-plugins.json'); +const certbot = require('../lib/certbot'); +const logger = require('../logger').certbot; +const batchflow = require('batchflow'); + +let hasErrors = false; +let failingPlugins = []; + +let pluginKeys = Object.keys(dnsPlugins); +if (process.argv.length > 2) { + pluginKeys = process.argv.slice(2); +} + +batchflow(pluginKeys).sequential() + .each((i, pluginKey, next) => { + certbot.installPlugin(pluginKey) + .then(() => { + next(); + }) + .catch((err) => { + hasErrors = true; + failingPlugins.push(pluginKey); + next(err); + }); + }) + .error((err) => { + logger.error(err.message); + }) + .end(() => { + if (hasErrors) { + logger.error('Some plugins failed to install. Please check the logs above. Failing plugins: ' + '\n - ' + failingPlugins.join('\n - ')); + process.exit(1); + } else { + logger.complete('Plugins installed successfully'); + process.exit(0); + } + }); diff --git a/backend/setup.js b/backend/setup.js index 4407b72b..f9863e1b 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -6,7 +6,7 @@ const userPermissionModel = require('./models/user_permission'); const utils = require('./lib/utils'); const authModel = require('./models/auth'); const settingModel = require('./models/setting'); -const dns_plugins = require('./certbot-dns-plugins'); +const certbot = require('./lib/certbot'); /** * Creates a default admin users if one doesn't already exist in the database @@ -116,10 +116,9 @@ const setupCertbotPlugins = () => { certificates.map(function (certificate) { if (certificate.meta && certificate.meta.dns_challenge === true) { - const dns_plugin = dns_plugins[certificate.meta.dns_provider]; - const packages_to_install = `${dns_plugin.package_name}`; - - if (plugins.indexOf(packages_to_install) === -1) plugins.push(packages_to_install); + if (plugins.indexOf(certificate.meta.dns_provider) === -1) { + plugins.push(certificate.meta.dns_provider); + } // Make sure credentials file exists const credentials_loc = '/data/tls/certbot/credentials/credentials-' + certificate.id; @@ -130,17 +129,15 @@ const setupCertbotPlugins = () => { } }); - if (plugins.length) { - const install_cmd = 'pip install --no-cache-dir ' + plugins.join(' '); - promises.push(utils.exec(install_cmd)); - } - - if (promises.length) { - return Promise.all(promises) - .then(() => { - logger.info('Added Certbot plugins ' + plugins.join(', ')); - }); - } + return certbot.installPlugins(plugins) + .then(() => { + if (promises.length) { + return Promise.all(promises) + .then(() => { + logger.info('Added Certbot plugins ' + plugins.join(', ')); + }); + } + }); } }); }; diff --git a/frontend/js/app/nginx/certificates/form.ejs b/frontend/js/app/nginx/certificates/form.ejs index fa9c0bf0..899fd970 100644 --- a/frontend/js/app/nginx/certificates/form.ejs +++ b/frontend/js/app/nginx/certificates/form.ejs @@ -21,7 +21,7 @@
- + <%- i18n('certificates', 'reachability-info') %>
@@ -37,11 +37,11 @@