diff --git a/args.js b/args.js index e9749a9..5207510 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('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 bcfb22b..46f8182 100644 --- a/lib.js +++ b/lib.js @@ -9,6 +9,8 @@ const pki = require('node-forge').pki; const path = require('path'); const { URL } = require('url'); +const DEFAULT_EXPIRATION_IN_MS = ms('30 days'); + const generateRsa = () => RSA.generateKeypairAsync(2048, 65537, {}); const pollUntilDeployed = (url, expectedContent, timeoutMs = 30 * 1000, retries = 10) => { @@ -99,8 +101,18 @@ module.exports = (options) => { }); }; + const hasValidCertificate = (pagesDomain) => { + 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; + } + return false; + }; + const createPagesDomains = (repo) => { return listPagesDomains(repo).then(pagesDomains => { + // names of existing domains in gitlab pages const pagesDomainsNames = pagesDomains.map(pagesDomain => { return pagesDomain.domain; @@ -111,12 +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 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) + .return(needsRenewal); }); }; @@ -128,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, @@ -141,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); + }); };