diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index da0cc65a..f1bbf222 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -2,24 +2,23 @@ const _ = require('lodash'); const fs = require('fs'); const https = require('https'); const moment = require('moment'); +const archiver = require('archiver'); +const path = require('path'); +const crypto = require('crypto'); +const { isArray } = require('lodash'); const logger = require('../logger').ssl; const error = require('../lib/error'); const utils = require('../lib/utils'); +const certbot = require('../lib/certbot'); const certificateModel = require('../models/certificate'); 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'); -const { isArray } = require('lodash'); -const certbotConfig = '/data/tls/certbot/config.ini'; -const certbotCommand = 'certbot --logs-dir /tmp/certbot-log --work-dir /tmp/certbot-work --config-dir /data/tls/certbot'; +const certbotCommand = 'certbot --logs-dir /tmp/certbot-log --work-dir /tmp/certbot-work --config-dir /data/tls/certbot --agree-tos -non-interactive --config /etc/tls/certbot.ini'; function omissions() { - return ['is_deleted']; + return ['is_deleted', 'owner.is_deleted']; } const internalCertificate = { @@ -41,7 +40,7 @@ const internalCertificate = { internalCertificate.intervalProcessing = true; logger.info('Renewing TLS certs close to expiry...'); - const cmd = certbotCommand + ' renew --quiet ' + '--config "' + certbotConfig + '" ' + '--preferred-challenges "dns,http" ' + '--no-random-sleep-on-renew'; + const cmd = '${certbotCommand} renew --quiet'; return utils .exec(cmd) @@ -121,7 +120,7 @@ const internalCertificate = { // Request a new Cert using Certbot. Let the fun begin. if (certificate.meta.dns_challenge) { return internalCertificate - .requestLetsEncryptSslWithDnsChallenge(certificate) + .requestCertbotWithDnsChallenge(certificate) .then(() => { return certificate; }) @@ -131,7 +130,7 @@ const internalCertificate = { }); } else { return internalCertificate - .requestLetsEncryptSsl(certificate) + .requestCertbot(certificate) .then(() => { return certificate; }) @@ -156,6 +155,7 @@ const internalCertificate = { .patchAndFetchById(certificate.id, { expires_on: moment(cert_info.dates.to, 'X').format('YYYY-MM-DD HH:mm:ss'), }) + .then(utils.omitRow(omissions())) .then((saved_row) => { // Add cert data for audit log saved_row.meta = _.assign({}, saved_row.meta, { @@ -385,7 +385,7 @@ const internalCertificate = { .then(() => { if (row.provider === 'letsencrypt') { // Revoke the cert - return internalCertificate.revokeLetsEncryptSsl(row); + return internalCertificate.revokeCertbot(row); } }); }) @@ -672,28 +672,22 @@ const internalCertificate = { return utils .exec('openssl x509 -in ' + certificate_file + ' -subject -noout') .then((result) => { - // subject=CN = something.example.com const regex = /(?:subject=)?[^=]+=\s*(\S+)/gim; const match = regex.exec(result); - - if (typeof match[1] === 'undefined') { - throw new error.ValidationError('Could not determine subject from certificate: ' + result); + if (match && typeof match[1] !== 'undefined') { + certData['cn'] = match[1]; } - - certData.cn = match[1]; }) .then(() => { return utils.exec('openssl x509 -in ' + certificate_file + ' -issuer -noout'); }) + .then((result) => { const regex = /^(?:issuer=)?(.*)$/gim; const match = regex.exec(result); - - if (typeof match[1] === 'undefined') { - throw new error.ValidationError('Could not determine issuer from certificate: ' + result); + if (match && typeof match[1] !== 'undefined') { + certData['issuer'] = match[1]; } - - certData.issuer = match[1]; }) .then(() => { return utils.exec('openssl x509 -in ' + certificate_file + ' -dates -noout'); @@ -766,16 +760,10 @@ const internalCertificate = { * @param {Object} certificate the certificate row * @returns {Promise} */ - requestLetsEncryptSsl: (certificate) => { + requestCertbot: (certificate) => { logger.info('Requesting Certbot certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); - let cmd = certbotCommand + ' certonly ' + '--config "' + certbotConfig + '" ' + '--cert-name "npm-' + certificate.id + '" ' + '--authenticator webroot ' + '--preferred-challenges "http,dns" ' + '--domains "' + certificate.domain_names.join(',') + '"'; - - if (certificate.meta.letsencrypt_email === '') { - cmd = cmd + ' --register-unsafely-without-email '; - } else { - cmd = cmd + ' --email "' + certificate.meta.letsencrypt_email + '" '; - } + let cmd = `${certbotCommand} certonly --cert-name "npm-${certificate.id}" --domains "${certificate.domain_names.join(',')}" --server "${process.env.ACME_SERVER}" --authenticator webroot --webroot-path "/tmp/acme-challenge"`; logger.info('Command:', cmd); @@ -792,22 +780,20 @@ const internalCertificate = { * @param {String} propagation_seconds the time to wait until the dns record should be changed * @returns {Promise} */ - requestLetsEncryptSslWithDnsChallenge: async (certificate) => { - await certbot.installPlugin(certificate.meta.dns_provider); + requestCertbotWithDnsChallenge: async (certificate) => { const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; + if (!dnsPlugin) { + throw Error(`Unknown DNS provider: ${certificate.meta.dns_provider}`); + } + await certbot.installPlugin(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; fs.mkdirSync('/data/tls/certbot/credentials', { recursive: true }); fs.writeFileSync(credentialsLocation, certificate.meta.dns_provider_credentials, { mode: 0o600 }); - let mainCmd = certbotCommand + ' certonly ' + '--config "' + certbotConfig + '" ' + '--cert-name "npm-' + certificate.id + '" ' + '--domains "' + certificate.domain_names.join(',') + '" ' + '--authenticator ' + dnsPlugin.full_plugin_name + ' ' + '--' + dnsPlugin.full_plugin_name + '-credentials "' + credentialsLocation + '"' + (certificate.meta.propagation_seconds !== undefined ? ' --' + dnsPlugin.full_plugin_name + '-propagation-seconds ' + certificate.meta.propagation_seconds : ''); + let mainCmd = `${certbotCommand} certonly --cert-name "npm-${certificate.id}" --domains "${certificate.domain_names.join(',')}" --server "${process.env.ACME_SERVER}" --authenticator ${dnsPlugin.full_plugin_name} --${dnsPlugin.full_plugin_name}-credentials "${credentialsLocation}"`; - if (certificate.meta.letsencrypt_email === '') { - mainCmd = mainCmd + ' --register-unsafely-without-email '; - } else { - mainCmd = mainCmd + ' --email "' + certificate.meta.letsencrypt_email + '" '; - } logger.info('Command:', mainCmd); @@ -836,8 +822,7 @@ const internalCertificate = { }) .then((certificate) => { if (certificate.provider === 'letsencrypt') { - const renewMethod = certificate.meta.dns_challenge ? internalCertificate.renewLetsEncryptSslWithDnsChallenge : internalCertificate.renewLetsEncryptSsl; - + const renewMethod = certificate.meta.dns_challenge ? internalCertificate.renewCertbotWithDnsChallenge : internalCertificate.renewCertbot; return renewMethod(certificate) .then(() => { return internalCertificate.getCertificateInfoFromFile('/data/tls/certbot/live/npm-' + certificate.id + '/fullchain.pem'); @@ -867,20 +852,20 @@ const internalCertificate = { }, /** - * @param {Object} certificate the certificate row + * @param {Object} certificate the certificate row * @returns {Promise} */ - renewLetsEncryptSsl: async (certificate) => { - logger.info('Renewing Certbot certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); + renewCertbot: async (certificate) => { + logger.info(`Renewing Certbot certificates for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); - const cmdr = certbotCommand + ' revoke ' + '--config "' + certbotConfig + '" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/privkey.pem" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/fullchain.pem" --no-delete-after-revoke'; + const cmdr = `${certbotCommand} revoke --cert-path "/data/tls/certbot/live/npm-${certificate.id}/fullchain.pem" --cert-path "/data/tls/certbot/live/npm-${certificate.id}/privkey.pem" --no-delete-after-revoke`; logger.info('Command:', cmdr); const revokeResult = await utils.exec(cmdr); logger.info(revokeResult); - const cmd = certbotCommand + ' renew --force-renewal ' + '--config "' + certbotConfig + '" ' + '--cert-name "npm-' + certificate.id + '" ' + '--preferred-challenges "http,dns" ' + '--no-random-sleep-on-renew'; + const cmd = `${certbotCommand} renew --force-renewal --cert-name "npm-${certificate.id}"`; logger.info('Command:', cmd); @@ -894,27 +879,27 @@ const internalCertificate = { * @param {Object} certificate the certificate row * @returns {Promise} */ - renewLetsEncryptSslWithDnsChallenge: async (certificate) => { + renewCertbotWithDnsChallenge: async (certificate) => { const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; - if (!dnsPlugin) { - throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`); + throw Error(`Unknown DNS provider: ${certificate.meta.dns_provider}`); } + await certbot.installPlugin(certificate.meta.dns_provider); logger.info(`Renewing Certbot certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); - const mainCmdr = certbotCommand + ' revoke ' + '--config "' + certbotConfig + '" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/privkey.pem" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/fullchain.pem" --no-delete-after-revoke'; + const cmdr = `${certbotCommand} revoke --cert-path "/data/tls/certbot/live/npm-${certificate.id}/fullchain.pem" --cert-path "/data/tls/certbot/live/npm-${certificate.id}/privkey.pem" --no-delete-after-revoke`; - logger.info('Command:', mainCmdr); + logger.info('Command:', cmdr); - const revokeResult = await utils.exec(mainCmdr); + const revokeResult = await utils.exec(cmdr); logger.info(revokeResult); - const mainCmd = certbotCommand + ' renew --force-renewal ' + '--config "' + certbotConfig + '" ' + '--cert-name "npm-' + certificate.id + '" ' + '--preferred-challenges "dns,http" ' + '--no-random-sleep-on-renew'; + const cmd = `${certbotCommand} renew --force-renewal --cert-name "npm-${certificate.id}"`; - logger.info('Command:', mainCmd); + logger.info('Command:', cmd); - const renewResult = await utils.exec(mainCmd); + const renewResult = await utils.exec(cmd); logger.info(renewResult); return renewResult; @@ -925,10 +910,10 @@ const internalCertificate = { * @param {Boolean} [throw_errors] * @returns {Promise} */ - revokeLetsEncryptSsl: (certificate, throw_errors) => { + revokeCertbot: (certificate, throw_errors) => { logger.info('Revoking Certbot certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); - const mainCmd = certbotCommand + ' revoke ' + '--config "' + certbotConfig + '" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/privkey.pem" ' + '--cert-path "/data/tls/certbot/live/npm-' + certificate.id + '/fullchain.pem" ' + '--delete-after-revoke'; + const mainCmd = `${certbotCommand} revoke --cert-path "/data/tls/certbot/live/npm-${certificate.id}/fullchain.pem" --cert-path "/data/tls/certbot/live/npm-${certificate.id}/privkey.pem" --no-delete-after-revoke`; return utils .exec(mainCmd) @@ -955,16 +940,6 @@ const internalCertificate = { }); }, - /** - * @param {Object} certificate - * @returns {Boolean} - */ - hasLetsEncryptSslCerts: (certificate) => { - const letsencryptPath = '/data/tls/certbot/live/npm-' + certificate.id; - - return fs.existsSync(letsencryptPath + '/fullchain.pem') && fs.existsSync(letsencryptPath + '/privkey.pem'); - }, - /** * @param {Object} in_use_result * @param {Number} in_use_result.total_count diff --git a/backend/lib/config.js b/backend/lib/config.js index e034cb48..1836d6f7 100644 --- a/backend/lib/config.js +++ b/backend/lib/config.js @@ -148,15 +148,6 @@ module.exports = { return instance.database.knex && instance.database.knex.client === 'better-sqlite3'; }, - /** - * Are we running in debug mode? - * - * @returns {boolean} - */ - debug: function () { - return !!process.env.DEBUG; - }, - /** * Returns a public key * @@ -176,11 +167,4 @@ module.exports = { instance === null && configure(); return instance.keys.key; }, - - /** - * @returns {boolean} - */ - useLetsencryptStaging: function () { - return !!process.env.LE_STAGING; - }, }; diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index 04cd8980..b75dcf61 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -24,22 +24,34 @@ "description": "Nice Name for the custom certificate" }, "domain_names": { - "$ref": "../common.json#/properties/domain_names" + "description": "Domain Names separated by a comma", + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" + } }, "expires_on": { "description": "Date and time of expiration", "readOnly": true, "type": "string" }, + "owner": { + "$ref": "./user-object.json" + }, "meta": { "type": "object", "additionalProperties": false, "properties": { - "letsencrypt_email": { - "type": "string" + "certificate": { + "type": "string", + "minLength": 1 }, - "letsencrypt_agree": { - "type": "boolean" + "certificate_key": { + "type": "string", + "minLength": 1 }, "dns_challenge": { "type": "boolean" @@ -50,13 +62,18 @@ "dns_provider_credentials": { "type": "string" }, + "letsencrypt_agree": { + "type": "boolean" + }, + "letsencrypt_certificate": { + "type": "object" + }, + "letsencrypt_email": { + "type": "string" + }, "propagation_seconds": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - } - ] + "type": "integer", + "minimum": 0 } } } diff --git a/backend/schema/paths/nginx/certificates/certID/upload/post.json b/backend/schema/paths/nginx/certificates/certID/upload/post.json index e9274856..f38b8102 100644 --- a/backend/schema/paths/nginx/certificates/certID/upload/post.json +++ b/backend/schema/paths/nginx/certificates/certID/upload/post.json @@ -55,6 +55,25 @@ "certificate_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1n9j9C5Bes1nd\nqACDckERauxXVNKCnUlUM1buGBx1xc+j2e2Ar23wUJJuWBY18VfT8yqfqVDktO2w\nrbmvZvLuPmXePOKbIKS+XXh+2NG9L5bDG9rwGFCRXnbQj+GWCdMfzx14+CR1IHge\nYz6Cv/Si2/LJPCh/CoBfM4hUQJON3lxAWrWBpdbZnKYMrxuPBRfW9OuzTbCVXToQ\noxRAHiOR9081Xn1WeoKr7kVBIa5UphlvWXa12w1YmUwJu7YndnJGIavLWeNCVc7Z\nEo+nS8Wr/4QWicatIWZXpVaEOPhRoeplQDxNWg5b/Q26rYoVd7PrCmRs7sVcH79X\nzGONeH1PAgMBAAECggEAANb3Wtwl07pCjRrMvc7WbC0xYIn82yu8/g2qtjkYUJcU\nia5lQbYN7RGCS85Oc/tkq48xQEG5JQWNH8b918jDEMTrFab0aUEyYcru1q9L8PL6\nYHaNgZSrMrDcHcS8h0QOXNRJT5jeGkiHJaTR0irvB526tqF3knbK9yW22KTfycUe\na0Z9voKn5xRk1DCbHi/nk2EpT7xnjeQeLFaTIRXbS68omkr4YGhwWm5OizoyEGZu\nW0Zum5BkQyMr6kor3wdxOTG97ske2rcyvvHi+ErnwL0xBv0qY0Dhe8DpuXpDezqw\no72yY8h31Fu84i7sAj24YuE5Df8DozItFXQpkgbQ6QKBgQDPrufhvIFm2S/MzBdW\nH8JxY7CJlJPyxOvc1NIl9RczQGAQR90kx52cgIcuIGEG6/wJ/xnGfMmW40F0DnQ+\nN+oLgB9SFxeLkRb7s9Z/8N3uIN8JJFYcerEOiRQeN2BXEEWJ7bUThNtsVrAcKoUh\nELsDmnHW/3V+GKwhd0vpk842+wKBgQDf4PGLG9PTE5tlAoyHFodJRd2RhTJQkwsU\nMDNjLJ+KecLv+Nl+QiJhoflG1ccqtSFlBSCG067CDQ5LV0xm3mLJ7pfJoMgjcq31\nqjEmX4Ls91GuVOPtbwst3yFKjsHaSoKB5fBvWRcKFpBUezM7Qcw2JP3+dQT+bQIq\ncMTkRWDSvQKBgQDOdCQFDjxg/lR7NQOZ1PaZe61aBz5P3pxNqa7ClvMaOsuEQ7w9\nvMYcdtRq8TsjA2JImbSI0TIg8gb2FQxPcYwTJKl+FICOeIwtaSg5hTtJZpnxX5LO\nutTaC0DZjNkTk5RdOdWA8tihyUdGqKoxJY2TVmwGe2rUEDjFB++J4inkEwKBgB6V\ng0nmtkxanFrzOzFlMXwgEEHF+Xaqb9QFNa/xs6XeNnREAapO7JV75Cr6H2hFMFe1\nmJjyqCgYUoCWX3iaHtLJRnEkBtNY4kzyQB6m46LtsnnnXO/dwKA2oDyoPfFNRoDq\nYatEd3JIXNU9s2T/+x7WdOBjKhh72dTkbPFmTPDdAoGAU6rlPBevqOFdObYxdPq8\nEQWu44xqky3Mf5sBpOwtu6rqCYuziLiN7K4sjN5GD5mb1cEU+oS92ZiNcUQ7MFXk\n8yTYZ7U0VcXyAcpYreWwE8thmb0BohJBr+Mp3wLTx32x0HKdO6vpUa0d35LUTUmM\nRrKmPK/msHKK/sVHiL+NFqo=\n-----END PRIVATE KEY-----\n" } } + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["certificate", "certificate_key"], + "properties": { + "certificate": { + "type": "string", + "minLength": 1 + }, + "certificate_key": { + "type": "string", + "minLength": 1 + }, + "intermediate_certificate": { + "type": "string", + "minLength": 1 + } + } } } } diff --git a/scripts/cypress-dev b/scripts/cypress-dev new file mode 100755 index 00000000..a0c64ad9 --- /dev/null +++ b/scripts/cypress-dev @@ -0,0 +1,13 @@ +#!/bin/bash -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$DIR/.common.sh" + +# Ensure docker-compose exists +if hash docker-compose 2>/dev/null; then + cd "${DIR}/.." + rm -rf "$DIR/../test/results" + docker-compose up --build cypress +else + echo -e "${RED}❯ docker-compose command is not available${RESET}" +fi diff --git a/test/cypress/e2e/api/FullCertProvision.cy.js b/test/cypress/e2e/api/FullCertProvision.cy.js new file mode 100644 index 00000000..93cacce9 --- /dev/null +++ b/test/cypress/e2e/api/FullCertProvision.cy.js @@ -0,0 +1,61 @@ +/// + +describe('Full Certificate Provisions', () => { + let token; + + before(() => { + cy.getToken().then((tok) => { + token = tok; + }); + }); + + it.only('Should be able to create new http certificate', function() { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { + domain_names: [ + 'website1.example.com' + ], + meta: { + letsencrypt_email: 'admin@example.com', + letsencrypt_agree: true, + dns_challenge: false + }, + provider: 'letsencrypt' + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/certificates', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + expect(data.provider).to.be.equal('letsencrypt'); + }); + }); + + it('Should be able to create new DNS certificate with Powerdns', function() { + cy.task('backendApiPost', { + token: token, + path: '/api/certificates', + data: { + domain_names: [ + 'website2.example.com' + ], + meta: { + letsencrypt_email: "admin@example.com", + dns_challenge: true, + dns_provider: 'powerdns', + dns_provider_credentials: 'dns_powerdns_api_url = http://ns1.pdns:8081\r\ndns_powerdns_api_key = npm', + letsencrypt_agree: true + }, + provider: 'letsencrypt' + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/certificates', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + expect(data.provider).to.be.equal('letsencrypt'); + expect(data.meta.dns_provider).to.be.equal('powerdns'); + }); + }); + +});