Skip to content
This repository was archived by the owner on Aug 21, 2019. It is now read-only.
4 changes: 4 additions & 0 deletions args.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be phrased with plural instead:

Force renewal of certificates, even if they expire in more than 30 days.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues with that:

  1. --production also is in singular:

    Obtain a real certificate instead of a dummy one

  2. Technically you only obtain one certificate per execution, right?

Should I still change the text?

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',
Expand Down
95 changes: 67 additions & 28 deletions lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
};

Expand All @@ -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,
Expand All @@ -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;
});
};
34 changes: 20 additions & 14 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
};