From 109e703d1ed3ce2a740e8518b1d2a20533d6f608 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Mon, 15 Jan 2018 23:59:59 +0100 Subject: [PATCH 1/9] check if any certificate needs to be renewed --- args.js | 4 ++++ lib.js | 27 +++++++++++++++++++++++++++ package-lock.json | 5 +++++ package.json | 1 + 4 files changed, 37 insertions(+) diff --git a/args.js b/args.js index e9749a9..50a0345 100755 --- a/args.js +++ b/args.js @@ -26,6 +26,10 @@ module.exports = yargs describe: 'Upload challenge files with a Jekyll-compatible YAML front matter (see https://jekyllrb.com/docs/frontmatter)', type: 'boolean', default: false + }).option('expiration', { + describe: 'Only generate a certificate if all existing certificates will expire in less than the given days.', + type: 'number', + default: 15 }).option('path', { describe: 'Absolute path in your repository where challenge files will be uploaded. Your .gitlab-ci.yml file must be configured to serve the contents of this directory under http://YOUR_SITE/.well-known/acme-challenge', type: 'string', diff --git a/lib.js b/lib.js index bcfb22b..880b14e 100644 --- a/lib.js +++ b/lib.js @@ -6,6 +6,7 @@ const ms = require('ms'); const request = require('request-promise'); const xtend = require('xtend'); const pki = require('node-forge').pki; +const moment = require('moment'); const path = require('path'); const { URL } = require('url'); @@ -101,8 +102,30 @@ module.exports = (options) => { const createPagesDomains = (repo) => { return listPagesDomains(repo).then(pagesDomains => { + + let noDomainNeedsRenewal = true; + // names of existing domains in gitlab pages const pagesDomainsNames = pagesDomains.map(pagesDomain => { + if (options.domain.includes(pagesDomain.domain) && noDomainNeedsRenewal) { + // no certificate, so we need to generate one + if (!pagesDomain.certificate) { + noDomainNeedsRenewal = false; + } else { + // certificate expired, we need to generate one + if (pagesDomain.certificate.expired) { + noDomainNeedsRenewal = false; + } else { + const certificate = pki.certificateFromPem(pagesDomain.certificate.certificate); + const validUntil = moment(certificate.validity.notAfter); + const diff = validUntil.diff(moment(), 'days'); + // certificate will expire soon, so we need to generate one + if (options.expiration > diff) { + noDomainNeedsRenewal = false; + } + } + } + } return pagesDomain.domain; }); @@ -111,6 +134,10 @@ module.exports = (options) => { return !pagesDomainsNames.includes(domain); }); + if (domainsToCreate.length === 0 && noDomainNeedsRenewal) { + return Promise.reject(`All domains ${options.domain.join(', ')} have a valid certificate (expiration in more than ${options.expiration} days)`); + } + // promises to create the new domains const promises = domainsToCreate.map(domain => { return createPagesDomain(repo, domain); diff --git a/package-lock.json b/package-lock.json index deb9aa6..6378f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1187,6 +1187,11 @@ "minimist": "0.0.8" } }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", diff --git a/package.json b/package.json index 9c83dc9..58b9d4a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "bluebird": "3.5.0", "le-acme-core": "https://github.com/rolodato/le-acme-core#semver:2.1.0", + "moment": "^2.20.1", "ms": "2.0.0", "node-forge": "0.7.1", "request": "2.81.0", From bc2e2e0d2b25999b080af2884c77b7c382cf584a Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Tue, 16 Jan 2018 11:27:15 +0100 Subject: [PATCH 2/9] Change default expiration to 31 days from 15 --- args.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/args.js b/args.js index 50a0345..389cf43 100755 --- a/args.js +++ b/args.js @@ -29,7 +29,7 @@ module.exports = yargs }).option('expiration', { describe: 'Only generate a certificate if all existing certificates will expire in less than the given days.', type: 'number', - default: 15 + default: 31 }).option('path', { describe: 'Absolute path in your repository where challenge files will be uploaded. Your .gitlab-ci.yml file must be configured to serve the contents of this directory under http://YOUR_SITE/.well-known/acme-challenge', type: 'string', From 013bcf095336e4b69207c798b78bb7d9362f41fa Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Wed, 17 Jan 2018 11:01:03 +0100 Subject: [PATCH 3/9] change minimum valid days to 30 days --- lib.js | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lib.js b/lib.js index 880b14e..a9baf4b 100644 --- a/lib.js +++ b/lib.js @@ -10,6 +10,8 @@ const moment = require('moment'); const path = require('path'); const { URL } = require('url'); +const EXPIRATION_IN_DAYS = 30; + const generateRsa = () => RSA.generateKeypairAsync(2048, 65537, {}); const pollUntilDeployed = (url, expectedContent, timeoutMs = 30 * 1000, retries = 10) => { @@ -100,32 +102,29 @@ module.exports = (options) => { }); }; + const hasValidCertificate = (pagesDomain) => { + if (options.domain.includes(pagesDomain.domain)) { + if (pagesDomain.certificate) { + if (!pagesDomain.certificate.expired) { + const certificate = pki.certificateFromPem(pagesDomain.certificate.certificate); + const validUntil = moment(certificate.validity.notAfter); + const diff = validUntil.diff(moment(), 'days'); + if (diff > EXPIRATION_IN_DAYS) { + return true; + } + } + } + return false; + } + // We do not care about the current domain, as we did not give it in the arguments + return true; + }; + const createPagesDomains = (repo) => { return listPagesDomains(repo).then(pagesDomains => { - let noDomainNeedsRenewal = true; - // names of existing domains in gitlab pages const pagesDomainsNames = pagesDomains.map(pagesDomain => { - if (options.domain.includes(pagesDomain.domain) && noDomainNeedsRenewal) { - // no certificate, so we need to generate one - if (!pagesDomain.certificate) { - noDomainNeedsRenewal = false; - } else { - // certificate expired, we need to generate one - if (pagesDomain.certificate.expired) { - noDomainNeedsRenewal = false; - } else { - const certificate = pki.certificateFromPem(pagesDomain.certificate.certificate); - const validUntil = moment(certificate.validity.notAfter); - const diff = validUntil.diff(moment(), 'days'); - // certificate will expire soon, so we need to generate one - if (options.expiration > diff) { - noDomainNeedsRenewal = false; - } - } - } - } return pagesDomain.domain; }); @@ -134,8 +133,12 @@ module.exports = (options) => { return !pagesDomainsNames.includes(domain); }); - if (domainsToCreate.length === 0 && noDomainNeedsRenewal) { - return Promise.reject(`All domains ${options.domain.join(', ')} have a valid certificate (expiration in more than ${options.expiration} days)`); + const needsNoRenewal = + domainsToCreate.length === 0 && + pagesDomains.every(hasValidCertificate); + + if (needsNoRenewal) { + return Promise.reject(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${EXPIRATION_IN_DAYS} days)`); } // promises to create the new domains From 408cc8d73f7b64c97fa8aa0ecbf426da4e5f03ba Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Wed, 17 Jan 2018 11:05:46 +0100 Subject: [PATCH 4/9] implement parameter `force-renewal` --- args.js | 8 ++++---- lib.js | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/args.js b/args.js index 389cf43..5207510 100755 --- a/args.js +++ b/args.js @@ -26,10 +26,10 @@ module.exports = yargs describe: 'Upload challenge files with a Jekyll-compatible YAML front matter (see https://jekyllrb.com/docs/frontmatter)', type: 'boolean', default: false - }).option('expiration', { - describe: 'Only generate a certificate if all existing certificates will expire in less than the given days.', - type: 'number', - default: 31 + }).option('force-renewal', { + describe: 'Force renewal of certificate, even if it expires in more than 30 days', + type: 'boolean', + default: false }).option('path', { describe: 'Absolute path in your repository where challenge files will be uploaded. Your .gitlab-ci.yml file must be configured to serve the contents of this directory under http://YOUR_SITE/.well-known/acme-challenge', type: 'string', diff --git a/lib.js b/lib.js index a9baf4b..b9036b8 100644 --- a/lib.js +++ b/lib.js @@ -134,6 +134,7 @@ module.exports = (options) => { }); const needsNoRenewal = + !options.forceRenewal && domainsToCreate.length === 0 && pagesDomains.every(hasValidCertificate); From ae4b80951c56f2f46602a0f4d18451c9eb2962a8 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Wed, 17 Jan 2018 11:08:08 +0100 Subject: [PATCH 5/9] ensure that the script exist with `0` if all certificates are valid --- lib.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib.js b/lib.js index b9036b8..8f4e589 100644 --- a/lib.js +++ b/lib.js @@ -139,7 +139,8 @@ module.exports = (options) => { pagesDomains.every(hasValidCertificate); if (needsNoRenewal) { - return Promise.reject(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${EXPIRATION_IN_DAYS} days)`); + console.log(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${EXPIRATION_IN_DAYS} days)`); + process.exit(0); } // promises to create the new domains From 5fe13f0fc2eeea6b0149d25b46554ce70fa20c53 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Tue, 30 Jan 2018 22:32:45 +0100 Subject: [PATCH 6/9] remove dependency to moment --- lib.js | 15 ++++++--------- package-lock.json | 5 ----- package.json | 1 - 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lib.js b/lib.js index 8f4e589..883dd1c 100644 --- a/lib.js +++ b/lib.js @@ -6,11 +6,11 @@ const ms = require('ms'); const request = require('request-promise'); const xtend = require('xtend'); const pki = require('node-forge').pki; -const moment = require('moment'); const path = require('path'); const { URL } = require('url'); -const EXPIRATION_IN_DAYS = 30; +const DEFAULT_EXPIRATION = '30 days'; +const DEFAULT_EXPIRATION_IN_MS = ms(DEFAULT_EXPIRATION); const generateRsa = () => RSA.generateKeypairAsync(2048, 65537, {}); @@ -106,12 +106,9 @@ module.exports = (options) => { if (options.domain.includes(pagesDomain.domain)) { if (pagesDomain.certificate) { if (!pagesDomain.certificate.expired) { - const certificate = pki.certificateFromPem(pagesDomain.certificate.certificate); - const validUntil = moment(certificate.validity.notAfter); - const diff = validUntil.diff(moment(), 'days'); - if (diff > EXPIRATION_IN_DAYS) { - return true; - } + const validUntil = pki.certificateFromPem(pagesDomain.certificate.certificate).validity.notAfter; + const expiresInMS = validUntil.getTime() - new Date().getTime(); + return expiresInMS > DEFAULT_EXPIRATION_IN_MS; } } return false; @@ -139,7 +136,7 @@ module.exports = (options) => { pagesDomains.every(hasValidCertificate); if (needsNoRenewal) { - console.log(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${EXPIRATION_IN_DAYS} days)`); + console.log(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${DEFAULT_EXPIRATION})`); process.exit(0); } diff --git a/package-lock.json b/package-lock.json index 6378f09..deb9aa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1187,11 +1187,6 @@ "minimist": "0.0.8" } }, - "moment": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", - "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", diff --git a/package.json b/package.json index 58b9d4a..9c83dc9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "dependencies": { "bluebird": "3.5.0", "le-acme-core": "https://github.com/rolodato/le-acme-core#semver:2.1.0", - "moment": "^2.20.1", "ms": "2.0.0", "node-forge": "0.7.1", "request": "2.81.0", From b7cf54c9af7974aa32474ca6a2afb90ccb0de306 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Tue, 30 Jan 2018 22:40:59 +0100 Subject: [PATCH 7/9] reduce complexity of `hasValidCertificate` --- lib.js | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lib.js b/lib.js index 883dd1c..083d396 100644 --- a/lib.js +++ b/lib.js @@ -103,18 +103,12 @@ module.exports = (options) => { }; const hasValidCertificate = (pagesDomain) => { - if (options.domain.includes(pagesDomain.domain)) { - if (pagesDomain.certificate) { - if (!pagesDomain.certificate.expired) { - const validUntil = pki.certificateFromPem(pagesDomain.certificate.certificate).validity.notAfter; - const expiresInMS = validUntil.getTime() - new Date().getTime(); - return expiresInMS > DEFAULT_EXPIRATION_IN_MS; - } - } - return false; + if (pagesDomain.certificate && !pagesDomain.certificate.expired) { + const validUntil = pki.certificateFromPem(pagesDomain.certificate.certificate).validity.notAfter; + const expiresInMS = validUntil.getTime() - new Date().getTime(); + return expiresInMS > DEFAULT_EXPIRATION_IN_MS; } - // We do not care about the current domain, as we did not give it in the arguments - return true; + return false; }; const createPagesDomains = (repo) => { @@ -130,10 +124,14 @@ module.exports = (options) => { return !pagesDomainsNames.includes(domain); }); + const domainsToCheck = pagesDomains.filter(pagesDomain => { + return options.domain.includes(pagesDomain.domain); + }); + const needsNoRenewal = !options.forceRenewal && domainsToCreate.length === 0 && - pagesDomains.every(hasValidCertificate); + domainsToCheck.every(hasValidCertificate); if (needsNoRenewal) { console.log(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${DEFAULT_EXPIRATION})`); From 34fd4dbeb7941ed839a4c1ba8e3109fc1becb110 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Wed, 31 Jan 2018 00:02:20 +0100 Subject: [PATCH 8/9] refactor renewal check to not exit process, if renewal is not necessary --- lib.js | 90 ++++++++++++++++++++++++++++++++------------------------- main.js | 34 +++++++++++++--------- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/lib.js b/lib.js index 083d396..4319d6c 100644 --- a/lib.js +++ b/lib.js @@ -9,8 +9,7 @@ const pki = require('node-forge').pki; const path = require('path'); const { URL } = require('url'); -const DEFAULT_EXPIRATION = '30 days'; -const DEFAULT_EXPIRATION_IN_MS = ms(DEFAULT_EXPIRATION); +const DEFAULT_EXPIRATION_IN_MS = ms('30 days'); const generateRsa = () => RSA.generateKeypairAsync(2048, 65537, {}); @@ -124,26 +123,22 @@ module.exports = (options) => { return !pagesDomainsNames.includes(domain); }); + // existing domains, which's certificates need to be checked const domainsToCheck = pagesDomains.filter(pagesDomain => { return options.domain.includes(pagesDomain.domain); }); - const needsNoRenewal = - !options.forceRenewal && - domainsToCreate.length === 0 && - domainsToCheck.every(hasValidCertificate); - - if (needsNoRenewal) { - console.log(`All domains (${options.domain.join(', ')}) have a valid certificate (expiration in more than ${DEFAULT_EXPIRATION})`); - process.exit(0); - } + const needsRenewal = options.forceRenewal || + domainsToCreate.length !== 0 || + !domainsToCheck.every(hasValidCertificate); // promises to create the new domains const promises = domainsToCreate.map(domain => { return createPagesDomain(repo, domain); }); - return Promise.all(promises); + return Promise.all(promises) + .then(() => needsRenewal); }); }; @@ -155,11 +150,10 @@ module.exports = (options) => { return Promise.all(promises); }; - let deleteChallengesPromise = null; + const runACMEWorkflow = (repo) => { - return Promise.join(getUrls, generateRsa(), generateRsa(), getRepository(repoUrl.pathname), - (urls, accountKp, domainKp, repo) => { - return createPagesDomains(repo).then(() => { + return Promise.all([getUrls, generateRsa(), generateRsa()]) + .spread((urls, accountKp, domainKp) => { return ACME.registerNewAccountAsync({ newRegUrl: urls.newReg, email: options.email, @@ -168,32 +162,50 @@ module.exports = (options) => { console.log(`By using Let's Encrypt, you are agreeing to the TOS at ${tosUrl}`); cb(null, true); } + }).then(() => { + + let deleteChallengesPromise = null; + + return ACME.getCertificateAsync({ + newAuthzUrl: urls.newAuthz, + newCertUrl: urls.newCert, + domainKeypair: domainKp, + accountKeypair: accountKp, + domains: options.domain, + setChallenge: (hostname, key, value, cb) => { + return Promise.resolve(deleteChallengesPromise) + .then(() => uploadChallenge(key, value, repo, hostname)) + .tap(res => console.log(`Uploaded challenge file, polling until it is available at ${res[0]}`)) + .spread(pollUntilDeployed) + .asCallback(cb); + }, + removeChallenge: (hostname, key, cb) => { + return (deleteChallengesPromise = deleteChallenges(key, repo)).finally(() => cb(null)); + } + }); }); - }).then(() => { - return ACME.getCertificateAsync({ - newAuthzUrl: urls.newAuthz, - newCertUrl: urls.newCert, - domainKeypair: domainKp, - accountKeypair: accountKp, - domains: options.domain, - setChallenge: (hostname, key, value, cb) => { - return Promise.resolve(deleteChallengesPromise) - .then(() => uploadChallenge(key, value, repo, hostname)) - .tap(res => console.log(`Uploaded challenge file, polling until it is available at ${res[0]}`)) - .spread(pollUntilDeployed) - .asCallback(cb); - }, - removeChallenge: (hostname, key, cb) => { - return (deleteChallengesPromise = deleteChallenges(key, repo)).finally(() => cb(null)); - } - }); - }).tap(cert => - options.production ? updatePagesDomainsWithCertificates(repo, cert) : cert - ).then(cert => xtend(cert, { + }) + .then(cert => options.production ? updatePagesDomainsWithCertificates(repo, cert).return(cert) : cert); + }; + + return getRepository(repoUrl.pathname) + .then((repo) => Promise.all([repo, createPagesDomains(repo)])) + .spread((repo, needsRenewal) => { + + const result = { domains: options.domain, repository: options.repository, pagesUrl: `${gitlabBaseUrl}/${options.repository}/pages`, - notAfter: pki.certificateFromPem(cert.cert).validity.notAfter - })); + needsRenewal: needsRenewal, + }; + + if (needsRenewal) { + return runACMEWorkflow(repo) + .then(cert => xtend(cert, result, { + notAfter: pki.certificateFromPem(cert.cert).validity.notAfter + })); + } + + return result; }); }; diff --git a/main.js b/main.js index d53a0ec..4139e0f 100644 --- a/main.js +++ b/main.js @@ -2,18 +2,24 @@ const getCertificate = require('./lib'); module.exports = (args) => { - return getCertificate(args).then(certs => { - process.stdout.write('Success! '); - if (!args.production) { - console.log(`A test certificate was successfully obtained for the following domains: ${certs.domains.join(', ')}`); - console.log(`To obtain a production certificate, run gitlab-le again and add the --production option.`); - } else { - console.log(`Your GitLab page has been configured to use an HTTPS certificate obtained from Let's Encrypt.`); - console.log(`Try it out: ${certs.domains.map(c => `https://${c}`).join(' ')} (GitLab might take a few minutes to start using your certificate for the first time)\n`); - console.log(`This certificate expires on ${certs.notAfter}. You will need to run gitlab-le again at some time before this date.`); - } - }).catch(err => { - console.error(err.detail || err.message || err); - process.exit(1); - }); + return getCertificate(args) + .then(result => { + if (!result.needsRenewal) { + console.log(`All domains (${result.domains.join(', ')}) have a valid certificate (expiration in more than 30 days)`); + return; + } + process.stdout.write('Success! '); + + if (!args.production) { + console.log(`A test certificate was successfully obtained for the following domains: ${result.domains.join(', ')}`); + console.log(`To obtain a production certificate, run gitlab-le again and add the --production option.`); + } else { + console.log(`Your GitLab page has been configured to use an HTTPS certificate obtained from Let's Encrypt.`); + console.log(`Try it out: ${result.domains.map(c => `https://${c}`).join(' ')} (GitLab might take a few minutes to start using your certificate for the first time)\n`); + console.log(`This certificate expires on ${result.notAfter}. You will need to run gitlab-le again at some time before this date.`); + } + }).catch(err => { + console.error(err.detail || err.message || err); + process.exit(1); + }); }; From 31a1b7f27dad42463a57b957e8f001a792b01105 Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Wed, 31 Jan 2018 00:09:12 +0100 Subject: [PATCH 9/9] use easier `return` instead of `then(() =>` --- lib.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib.js b/lib.js index 4319d6c..46f8182 100644 --- a/lib.js +++ b/lib.js @@ -138,7 +138,7 @@ module.exports = (options) => { }); return Promise.all(promises) - .then(() => needsRenewal); + .return(needsRenewal); }); };