From 7d8674cb7e70ed5b3e658c917ee2017361f363d2 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 20 Oct 2019 02:51:19 -0600 Subject: [PATCH] wip: yeah! --- .gitignore | 4 + README.md | 23 +- accounts.js | 184 +++++++++++++++ bin/certonly.js | 90 ++++++-- bin/plugins.js | 58 ++++- certificates.js | 279 +++++++++++++++++++++++ errors.js | 58 +++++ examples/manage.js | 60 +++++ examples/server.js | 16 ++ express.js | 42 ++++ greenlock.js | 541 +++++++++++++++++++++++++++++++++++++++++++++ order.js | 97 ++++++++ package-lock.json | 141 ++++++++++++ package.json | 52 +++++ plugins.js | 153 +++++++++++++ tests/index.js | 41 ++++ tests/manager.js | 259 ++++++++++++++++++++++ user-events.js | 7 + utils.js | 267 ++++++++++++++++++++++ 19 files changed, 2356 insertions(+), 16 deletions(-) create mode 100644 accounts.js create mode 100644 certificates.js create mode 100644 errors.js create mode 100644 examples/manage.js create mode 100644 examples/server.js create mode 100644 express.js create mode 100644 greenlock.js create mode 100644 order.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plugins.js create mode 100644 tests/index.js create mode 100644 tests/manager.js create mode 100644 user-events.js create mode 100644 utils.js diff --git a/.gitignore b/.gitignore index 144585f..d0fb096 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +TODO.txt +link.sh +.env + # ---> Node # Logs logs diff --git a/README.md b/README.md index b2c8a92..53799e4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,24 @@ # root-greenlock.js -🔐 Free SSL, Free Wildcard SSL, and Fully Automated HTTPS for Node.js and Browsers, issued by Let's Encrypt v2 via ACME \ No newline at end of file +🔐 Free SSL, Free Wildcard SSL, and Fully Automated HTTPS for Node.js and Browsers, issued by Let's Encrypt v2 via ACME + +Typically file propagation is faster and more reliably than DNS propagation. +Therefore, http-01 will be preferred to dns-01 except when wildcards or **private domains** are in use. + +http-01 will only be supplied as a defaut if no other challenge is provided. + +``` +Greenlock.create +Greenlock#add +Greenlock#order... or Greenlock#issue? +Greenlock#renew... or Greenlock#issue? +Greenlock#remove +Greenlock#get +Greenlock#all +``` + +Better scaling + +cluster lazy-load, remote management + +`server identifier (for sharding, for manager)` diff --git a/accounts.js b/accounts.js new file mode 100644 index 0000000..71de6f4 --- /dev/null +++ b/accounts.js @@ -0,0 +1,184 @@ +'use strict'; + +var A = module.exports; +var U = require('./utils.js'); +var E = require('./errors.js'); + +var pending = {}; + +A._getOrCreate = function(greenlock, db, acme, args) { + console.log('[debug] A get or create', args); + var email = args.subscriberEmail || greenlock._defaults.subscriberEmail; + + if (!email) { + console.log('[debug] throw no sub'); + throw E.NO_SUBSCRIBER('get account', args.subject); + } + + // TODO send welcome message with benefit info + return U._validMx(email) + .catch(function() { + throw E.NO_SUBSCRIBER('get account', args.subcriberEmail); + }) + .then(function() { + console.log('[debug] valid email'); + if (pending[email]) { + console.log('[debug] return pending'); + return pending[email]; + } + + pending[email] = A._rawGetOrCreate(greenlock, db, acme, args, email) + .catch(function(e) { + delete pending[email]; + throw e; + }) + .then(function(result) { + delete pending[email]; + return result; + }); + + console.log('[debug] return new'); + return pending[email]; + }); +}; + +// What we really need out of this is the private key and the ACME "key" id +A._rawGetOrCreate = function(greenlock, db, acme, args, email) { + var p; + if (db.check) { + p = A._checkStore(greenlock, db, acme, args, email); + } else { + p = Promise.resolve(null); + } + + return p.then(function(fullAccount) { + console.log('[debug] full account', fullAccount); + if (!fullAccount) { + return A._newAccount(greenlock, db, acme, args, email, null); + } + + if (fullAccount.keypair && fullAccount.key && fullAccount.key.kid) { + return fullAccount; + } + + return A._newAccount(greenlock, db, acme, args, email, fullAccount); + }); +}; + +A._newAccount = function(greenlock, db, acme, args, email, fullAccount) { + var keyType = args.accountKeyType || greenlock._defaults.accountKeyType; + var query = { + subject: args.subject, + email: email, + account: fullAccount || {} + }; + + return U._getOrCreateKeypair(db, args.subject, query, keyType).then( + function(kresult) { + var keypair = kresult.keypair; + var accReg = { + subscriberEmail: email, + agreeToTerms: + args.agreeToTerms || greenlock._defaults.agreeToTerms, + accountKeypair: keypair, + debug: args.debug + }; + console.log('[debug] create account', accReg); + return acme.accounts.create(accReg).then(function(receipt) { + var reg = { + keypair: keypair, + receipt: receipt, + // shudder... not actually a KeyID... but so it is called anyway... + kid: + receipt && + receipt.key && + (receipt.key.kid || receipt.kid), + email: args.email + }; + + var keyP; + if (kresult.exists) { + keyP = Promise.resolve(); + } else { + query.keypair = keypair; + query.receipt = receipt; + keyP = db.setKeypair(query, keypair); + } + + return keyP + .then(function() { + if (!db.set) { + return Promise.resolve({ + keypair: keypair + }); + } + return db.set( + { + // id to be set by Store + email: email, + agreeTos: true + }, + reg + ); + }) + .then(function(fullAccount) { + if (fullAccount && 'object' !== typeof fullAccount) { + throw new Error( + "accounts.set should either return 'null' or an object with an 'id' string" + ); + } + + if (!fullAccount) { + fullAccount = {}; + } + fullAccount.keypair = keypair; + if (!fullAccount.key) { + fullAccount.key = {}; + } + fullAccount.key.kid = reg.kid; + + return fullAccount; + }); + }); + } + ); +}; + +A._checkStore = function(greenlock, db, acme, args, email) { + if ((args.domain || args.domains) && !args.subject) { + console.warn("use 'subject' instead of 'domain'"); + args.subject = args.domain; + } + + var account = args.account; + if (!account) { + account = {}; + } + + if (args.accountKeypair) { + console.warn( + 'rather than passing accountKeypair, put it directly into your account key store' + ); + // TODO we probably don't need this + return U._importKeypair(args.accountKeypair); + } + + if (!db.check) { + return Promise.resolve(null); + } + + return db + .check({ + //keypair: undefined, + //receipt: undefined, + email: email, + account: account + }) + .then(function(fullAccount) { + if (!fullAccount) { + return null; + } + + return fullAccount; + }); +}; diff --git a/bin/certonly.js b/bin/certonly.js index dbb14cd..f1be9fa 100755 --- a/bin/certonly.js +++ b/bin/certonly.js @@ -33,11 +33,17 @@ cli.parse({ ' Domain names to apply. For multiple domains you can enter a comma separated list of domains as a parameter. (default: [])', 'string' ], + 'renew-offset': [ + false, + ' Positive (time after issue) or negative (time before expiry) offset, such as 30d or -45d', + 'string', + '45d' + ], 'renew-within': [ false, - ' Renew certificates this many days before expiry', - 'int', - 7 + ' (ignored) use renew-offset instead', + 'ignore', + undefined ], 'cert-path': [ false, @@ -157,16 +163,18 @@ cli.parse({ ], 'rsa-key-size': [ false, - ' (ignored) use domain-key-type or account-key-type instead', + ' (ignored) use server-key-type or account-key-type instead', 'ignore', 2048 ], - 'domain-key-path': [ + 'server-key-path': [ false, - ' Path to privkey.pem to use for domain (default: generate new)', - 'string' + ' Path to privkey.pem to use for certificate (default: generate new)', + 'string', + undefined, + 'domain-key-path' ], - 'domain-key-type': [ + 'server-key-type': [ false, " One of 'RSA' (2048), 'RSA-3084', 'RSA-4096', 'ECDSA' (P-256), or 'P-384'. For best compatibility, security, and efficiency use the default (More bits != More security)", 'string', @@ -184,7 +192,7 @@ cli.parse({ 'P-256' ], webroot: [false, ' (ignored) for certbot compatibility', 'ignore', false], - //, 'standalone-supported-challenges': [ false, " Supported challenges, order preferences are randomly chosen. (default: http-01,tls-sni-01)", 'string', 'http-01,tls-sni-01'] + //, 'standalone-supported-challenges': [ false, " Supported challenges, order preferences are randomly chosen. (default: http-01,tls-alpn-01)", 'string', 'http-01'] 'work-dir': [ false, ' for certbot compatibility (ignored)', @@ -286,12 +294,66 @@ cli.main(function(_, options) { } function run() { + var challenges = {}; + if (/http.?01/i.test(args.challenge)) { + challenges['http-01'] = args.challengeOpts; + } + if (/dns.?01/i.test(args.challenge)) { + challenges['dns-01'] = args.challengeOpts; + } + if (/alpn.?01/i.test(args.challenge)) { + challenges['tls-alpn-01'] = args.challengeOpts; + } + if (!Object.keys(challenges).length) { + throw new Error( + "Could not determine the challenge type for '" + + args.challengeOpts.module + + "'. Expected a name like @you/acme-xxxx-01-foo. Please name the module with http-01, dns-01, or tls-alpn-01." + ); + } + args.challengeOpts.module = args.challenge; + args.storeOpts.module = args.store; + console.log('\ngot to the run step'); - process.exit(1); - require('../') - .run(args) - .then(function(status) { - process.exit(status); + require(args.challenge); + require(args.store); + + var greenlock = require('../').create({ + maintainerEmail: args.maintainerEmail || 'coolaj86@gmail.com', + manager: './manager.js', + configFile: '~/.config/greenlock/certs.json', + challenges: challenges, + store: args.storeOpts, + renewOffset: args.renewOffset || '30d', + renewStagger: '1d' + }); + + // for long-running processes + if (args.renewEvery) { + setInterval(function() { + greenlock.renew({ + period: args.renewEvery + }); + }, args.renewEvery); + } + + // TODO should greenlock.add simply always include greenlock.renew? + // the concern is conflating error events + return greenlock + .add({ + subject: args.subject, + altnames: args.altnames, + subscriberEmail: args.subscriberEmail || args.email + }) + .then(function(changes) { + console.info(changes); + // renew should always + return greenlock + .renew({ + subject: args.subject, + force: false + }) + .then(function() {}); }); } diff --git a/bin/plugins.js b/bin/plugins.js index a5a90a8..f6fd809 100644 --- a/bin/plugins.js +++ b/bin/plugins.js @@ -1,9 +1,65 @@ 'use strict'; var spawn = require('child_process').spawn; +var spawnSync = require('child_process').spawnSync; var path = require('path'); var PKG_DIR = path.join(__dirname, '..'); +module.exports.installSync = function(moduleName) { + var npm = 'npm'; + var args = ['install', '--save', moduleName]; + var out = ''; + var cmd; + + try { + cmd = spawnSync(npm, args, { + cwd: PKG_DIR, + windowsHide: true + }); + } catch (e) { + console.error( + "Failed to start: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error(e.message); + process.exit(1); + } + + if (!cmd.status) { + return; + } + + out += cmd.stdout.toString('utf8'); + out += cmd.stderr.toString('utf8'); + + if (out) { + console.error(out); + console.error(); + console.error(); + } + + console.error( + "Failed to run: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + + console.error( + 'Try for yourself:\n\tcd ' + PKG_DIR + '\n\tnpm ' + args.join(' ') + ); + + process.exit(1); +}; + module.exports.install = function(moduleName) { return new Promise(function(resolve) { if (!moduleName) { @@ -71,5 +127,5 @@ module.exports.install = function(moduleName) { }; if (require.main === module) { - module.exports.install(process.argv[2]); + module.exports.installSync(process.argv[2]); } diff --git a/certificates.js b/certificates.js new file mode 100644 index 0000000..4ecf44a --- /dev/null +++ b/certificates.js @@ -0,0 +1,279 @@ +'use strict'; + +var C = module.exports; +var U = require('./utils.js'); +var CSR = require('@root/csr'); +var Enc = require('@root/encoding'); + +var pending = {}; +var rawPending = {}; + +// Certificates +C._getOrOrder = function(greenlock, db, acme, challenges, account, args) { + var email = args.subscriberEmail || greenlock._defaults.subscriberEmail; + + var id = args.altnames.join(' '); + if (pending[id]) { + return pending[id]; + } + + pending[id] = C._rawGetOrOrder( + greenlock, + db, + acme, + challenges, + account, + email, + args + ) + .then(function(pems) { + delete pending[id]; + return pems; + }) + .catch(function(err) { + delete pending[id]; + throw err; + }); + + return pending[id]; +}; + +// Certificates +C._rawGetOrOrder = function( + greenlock, + db, + acme, + challenges, + account, + email, + args +) { + return C._check(db, args).then(function(pems) { + // No pems? get some! + if (!pems) { + return C._rawOrder( + greenlock, + db, + acme, + challenges, + account, + email, + args + ).then(function(newPems) { + // do not wait on notify + greenlock.notify('cert_issue', { + options: args, + subject: args.subject, + altnames: args.altnames, + account: account, + email: email, + pems: newPems + }); + return newPems; + }); + } + + // Nice and fresh? We're done! + if (!C._isStale(greenlock, args, pems)) { + // return existing unexpired (although potentially stale) certificates when available + // there will be an additional .renewing property if the certs are being asynchronously renewed + //pems._type = 'current'; + return pems; + } + + // Getting stale? Let's renew to freshen up! + var p = C._rawOrder( + greenlock, + db, + acme, + challenges, + account, + email, + args + ).then(function(renewedPems) { + // do not wait on notify + greenlock.notify('cert_renewal', { + options: args, + subject: args.subject, + altnames: args.altnames, + account: account, + email: email, + pems: renewedPems + }); + return renewedPems; + }); + + // TODO what should this be? + if (args.waitForRenewal) { + return p; + } + + return pems; + }); +}; + +// we have another promise here because it the optional renewal +// may resolve in a different stack than the returned pems +C._rawOrder = function(greenlock, db, acme, challenges, account, email, args) { + var id = args.altnames + .slice(0) + .sort() + .join(' '); + if (rawPending[id]) { + return rawPending[id]; + } + + var keyType = args.serverKeyType || greenlock._defaults.serverKeyType; + var query = { + subject: args.subject, + certificate: args.certificate || {} + }; + rawPending[id] = U._getOrCreateKeypair(db, args.subject, query, keyType) + .then(function(kresult) { + var serverKeypair = kresult.keypair; + var domains = args.altnames.slice(0); + + return CSR.csr({ + jwk: serverKeypair.privateKeyJwk, + domains: domains, + encoding: 'der' + }) + .then(function(csrDer) { + // TODO let CSR support 'urlBase64' ? + return Enc.bufToUrlBase64(csrDer); + }) + .then(function(csr) { + function notify() { + greenlock.notify('challenge_status', { + options: args, + subject: args.subject, + altnames: args.altnames, + account: account, + email: email + }); + } + var certReq = { + debug: args.debug || greenlock._defaults.debug, + + challenges: challenges, + account: account, // only used if accounts.key.kid exists + accountKeypair: account.keypair, + keypair: account.keypair, // TODO + csr: csr, + domains: domains, // because ACME.js v3 uses `domains` still, actually + onChallengeStatus: notify, + notify: notify // TODO + + // TODO handle this in acme-v2 + //subject: args.subject, + //altnames: args.altnames.slice(0), + }; + return acme.certificates + .create(certReq) + .then(U._attachCertInfo); + }) + .then(function(pems) { + if (kresult.exists) { + return pems; + } + return db.setKeypair(query, serverKeypair).then(function() { + return pems; + }); + }); + }) + .then(function(pems) { + // TODO put this in the docs + // { cert, chain, privkey, subject, altnames, issuedAt, expiresAt } + // Note: the query has been updated + query.pems = pems; + return db.set(query); + }) + .then(function() { + return C._check(db, args); + }) + .then(function(bundle) { + // TODO notify Manager + delete rawPending[id]; + return bundle; + }) + .catch(function(err) { + // Todo notify manager + delete rawPending[id]; + throw err; + }); + + return rawPending[id]; +}; + +// returns pems, if they exist +C._check = function(db, args) { + var query = { + subject: args.subject, + // may contain certificate.id + certificate: args.certificate + }; + return db.check(query).then(function(pems) { + if (!pems) { + return null; + } + + pems = U._attachCertInfo(pems); + + // For eager management + if (args.subject && !U._certHasDomain(pems, args.subject)) { + // TODO report error, but continue the process as with no cert + return null; + } + + // For lazy SNI requests + if (args.domain && !U._certHasDomain(pems, args.domain)) { + // TODO report error, but continue the process as with no cert + return null; + } + + return U._getKeypair(db, args.subject, query) + .then(function(keypair) { + pems.privkey = keypair.privateKeyPem; + return pems; + }) + .catch(function() { + // TODO report error, but continue the process as with no cert + return null; + }); + }); +}; + +// Certificates +C._isStale = function(greenlock, args, pems) { + if (args.duplicate) { + return true; + } + + var renewAt = C._renewableAt(greenlock, args, pems); + + if (Date.now() >= renewAt) { + return true; + } + + return false; +}; + +C._renewableAt = function(greenlock, args, pems) { + if (args.renewAt) { + return args.renewAt; + } + + var renewOffset = args.renewOffset || greenlock._defaults.renewOffset || 0; + var week = 1000 * 60 * 60 * 24 * 6; + if (!args.force && Math.abs(renewOffset) < week) { + throw new Error( + 'developer error: `renewOffset` should always be at least a week, use `force` to not safety-check renewOffset' + ); + } + + if (renewOffset > 0) { + return pems.issuedAt + renewOffset; + } + + return pems.expiresAt + renewOffset; +}; diff --git a/errors.js b/errors.js new file mode 100644 index 0000000..26cad21 --- /dev/null +++ b/errors.js @@ -0,0 +1,58 @@ +'use strict'; + +var E = module.exports; + +function create(code, msg) { + E[code] = function(ctx, msg2) { + var err = new Error(msg); + err.code = code; + err.context = ctx; + if (msg2) { + err.message += ': ' + msg2; + } + /* + Object.keys(extras).forEach(function(k) { + if ('message' === k) { + err.message += ': ' + extras[k]; + } else { + err[k] = extras[k]; + } + }); + */ + return err; + }; +} + +// TODO open issues and link to them as the error url +create( + 'NO_MAINTAINER', + 'please supply `maintainerEmail` as a contact for security and critical bug notices' +); +create( + 'BAD_ORDER', + 'altnames should be in deterministic order, with subject as the first altname' +); +create('NO_SUBJECT', 'no certificate subject given'); +create( + 'NO_SUBSCRIBER', + 'please supply `subscriberEmail` as a contact for failed renewal and certificate revocation' +); +create( + 'INVALID_SUBSCRIBER', + '`subscriberEmail` is not a valid address, please check for typos' +); +create( + 'INVALID_HOSTNAME', + 'valid hostnames must be restricted to a-z0-9_.- and contain at least one "."' +); +create( + 'INVALID_DOMAIN', + 'one or more domains do not exist on public DNS SOA record' +); +create( + 'NOT_UNIQUE', + 'found duplicate domains, or a subdomain that overlaps a wildcard' +); + +// exported for testing only +E._create = create; diff --git a/examples/manage.js b/examples/manage.js new file mode 100644 index 0000000..e336d05 --- /dev/null +++ b/examples/manage.js @@ -0,0 +1,60 @@ +'use strict'; + +// tradeoff - lazy load certs vs DOS invalid sni + +var Manager = module.exports; + +var Cache = {}; + +Manager.create = function(conf) { + var domains = conf.domains; + var manager = {}; + + // { servername, wildname } + manager.getSubject = function(opts) { + if ( + !opts.domains.includes(opts.domain) && + !opts.domains.includes(opts.wildname) + ) { + throw new Error('not a registered domain'); + } + return opts.domains[0]; + }; + + manager.add = function() {}; + + // { servername, wildname } + manager.configure = function(opts) {}; + + // { servername } + manager._contexts = {}; +}; + +var manager = Manager.create({ + domains: ['example.com', '*.example.com'] +}); + +Cache.getTlsContext = function(servername) { + // TODO exponential fallback certificate renewal + if (Cache._contexts[servername]) { + // may be a context, or a promise for a context + return Cache._contexts[servername]; + } + + var wildname = + '*.' + + (servername || '') + .split('.') + .slice(1) + .join('.'); + + var opts = { + servername: servername, + domain: servername, + wildname: wildname + }; + manager._contexts[servername] = manager + .orderCertificate(opts) + .then(function() {}) + .catch(function(e) {}); +}; diff --git a/examples/server.js b/examples/server.js new file mode 100644 index 0000000..cd0e391 --- /dev/null +++ b/examples/server.js @@ -0,0 +1,16 @@ +'use strict'; + +var http = require('http'); +var https = require('http2'); +var greenlock = require('../greenlock.js').create({ + maintainerEmail: 'jon@example.com' +}); + +function app(req, res) { + res.end('Hello, Encrypted World!'); +} + +http.createServer(greenlock.plainMiddleware()).listen(8080); +https + .createServer(greenlock.tlsOptions, greenlock.secureMiddleware(app)) + .listen(8443); diff --git a/express.js b/express.js new file mode 100644 index 0000000..4ea43ee --- /dev/null +++ b/express.js @@ -0,0 +1,42 @@ +'use strict'; + +var Greenlock = module.exports; + +Greenlock.server = function (opts) { + var opts = Greenlock.create(opts); + + opts.plainMiddleware = function(req, res) { + return Greenlock._plainMiddleware(opts, req, res); + }; + + opts.secureMiddleware = function(req, res) { + return Greenlock._secureMiddleware(opts, req, res); + }; + + opts.tlsOptions = { + SNICallback: function(servername, cb) { + return Greenlock._sniCallback(opts, servername) + .then(function() { + cb(null); + }) + .catch(function(err) { + cb(err); + }); + } + }; + + return opts; +}; + +// must handle http-01 challenges +Greenlock._plainMiddleware = function(opts, req, res) {}; + +// should check for domain fronting +Greenlock._secureMiddleware = function(opts, req, res) {}; + +// should check to see if domain is allowed, and if domain should be renewed +// manage should be able to clear the internal cache +Greenlock._sniCallback = function(opts, servername) {}; + +Greenlock._onSniRejection(function () { +}); diff --git a/greenlock.js b/greenlock.js new file mode 100644 index 0000000..62c7974 --- /dev/null +++ b/greenlock.js @@ -0,0 +1,541 @@ +'use strict'; + +var pkg = require('./package.json'); + +var ACME = require('@root/acme'); +var Greenlock = module.exports; + +var G = Greenlock; +var U = require('./utils.js'); +var E = require('./errors.js'); +var P = require('./plugins.js'); +var A = require('./accounts.js'); +var C = require('./certificates.js'); +var UserEvents = require('./user-events.js'); + +var promisify = require('util').promisify; + +var caches = {}; + +// { maintainerEmail, directoryUrl, subscriberEmail, store, challenges } +G.create = function(gconf) { + var greenlock = {}; + if (!gconf) { + gconf = {}; + } + + if (!gconf.maintainerEmail) { + throw E.NO_MAINTAINER('create'); + } + + // TODO send welcome message with benefit info + U._validMx(gconf.maintainerEmail).catch(function() { + console.error( + 'invalid maintainer contact info:', + gconf.maintainer.Email + ); + // maybe a little harsh? + process.exit(1); + }); + + // TODO default servername is GLE only + + if (!gconf.manager) { + gconf.manager = 'greenlock-manager-fs'; + } + + var Manager; + if ('string' === typeof gconf.manager) { + try { + Manager = require(gconf.manager); + } catch (e) { + if ('MODULE_NOT_FOUND' !== e.code) { + throw e; + } + console.error(e.code); + console.error(e.message); + console.error(gconf.manager); + P._installSync(gconf.manager); + Manager = require(gconf.manager); + } + } + + // minimal modification to the original object + var defaults = G._defaults(gconf); + + greenlock.manager = Manager.create(defaults); + + // The goal here is to reduce boilerplate, such as error checking + // and duration parsing, that a manager must implement + greenlock.add = function(args) { + return Promise.resolve().then(function() { + // durations + if (args.renewOffset) { + args.renewOffset = U._parseDuration(args.renewOffset); + } + if (args.renewStagger) { + args.renewStagger = U._parseDuration(args.renewStagger); + } + + if (!args.subject) { + throw E.NO_SUBJECT('add'); + } + + if (!args.altnames) { + args.altnames = [args.subject]; + } + if ('string' === typeof args.altnames) { + args.altnames = args.altnames.split(/[,\s]+/); + } + if (args.subject !== args.altnames[0]) { + throw E.BAD_ORDER( + 'add', + '(' + args.subject + ") '" + args.altnames.join("' '") + "'" + ); + } + args.altnames = args.altnames.map(U._encodeName); + + if ( + !args.altnames.every(function(d) { + return U._validName(d); + }) + ) { + throw E.INVALID_HOSTNAME( + 'add', + "'" + args.altnames.join("' '") + "'" + ); + } + + // at this point we know that subject is the first of altnames + return Promise.all( + args.altnames.map(function(d) { + d = d.replace('*.', ''); + return U._validDomain(d); + }) + ).then(function() { + if (!U._uniqueNames(args.altnames)) { + throw E.NOT_UNIQUE( + 'add', + "'" + args.altnames.join("' '") + "'" + ); + } + + return greenlock.manager.add(args); + }); + }); + }; + + greenlock._notify = function(ev, params) { + var mng = greenlock.manager; + if (mng.notify) { + try { + var p = mng.notify(ev, params); + if (p && p.catch) { + p.catch(function(e) { + console.error("Error on event '" + ev + "':"); + console.error(e); + }); + } + } catch (e) { + console.error("Error on event '" + ev + "':"); + console.error(e); + } + } else { + if (/error/i.test(ev)) { + console.error("Error event '" + ev + "':"); + console.error(params); + } + } + /* + *'cert_issue', { + options: args, + subject: args.subject, + altnames: args.altnames, + account: account, + email: email, + pems: newPems + } + */ + + if (-1 !== ['cert_issue', 'cert_renewal'].indexOf(ev)) { + // We will notify all greenlock users of mandatory and security updates + // We'll keep track of versions and os so we can make sure things work well + // { name, version, email, domains, action, communityMember, telemetry } + // TODO look at the other one + UserEvents.notify({ + // maintainer should be only on pre-publish, or maybe install, I think + maintainerEmail: greenlock._defaults._maintainerEmail, + name: greenlock._defaults._maintainerPackage, + version: greenlock._defaults._maintainerPackageVersion, + action: params.pems._type, + domains: params.altnames, + subscriberEmail: greenlock._defaults._subscriberEmail, + // TODO enable for Greenlock Pro + //customerEmail: args.customerEmail + telemetry: greenlock._defaults.telemetry + }); + } + }; + + // needs to get info about the renewal, such as which store and challenge(s) to use + greenlock.renew = function(args) { + if (!args) { + args = {}; + } + + // durations + if (args.renewOffset) { + args.renewOffset = U._parseDuration(args.renewOffset); + } + if (args.renewStagger) { + args.renewStagger = U._parseDuration(args.renewStagger); + } + + if (args.domain) { + // this doesn't have to be the subject, it can be anything + // however, not sure how useful this really is... + args.domain = args.toLowerCase(); + } + + args.defaults = greenlock.defaults; + return greenlock.manager.find(args).then(function(sites) { + // Note: the manager must guaranteed that these are mutable copies + + console.log('[debug] found what?', sites); + var renewedOrFailed = []; + + function next() { + var site = sites.shift(); + if (!site) { + return null; + } + + var order = { + site: site + }; + renewedOrFailed.push(order); + // TODO merge args + result? + return greenlock + .order(site) + .then(function(pems) { + order.pems = pems; + }) + .catch(function(err) { + order.error = err; + greenlock._notify('order_error', order); + }) + .then(function() { + return next(); + }); + } + + return next().then(function() { + return renewedOrFailed; + }); + }); + }; + + greenlock._acme = function(args) { + var acme = ACME.create({ + debug: args.debug + }); + var dirUrl = args.directoryUrl || greenlock._defaults.directoryUrl; + + var dir = caches[dirUrl]; + + // don't cache more than an hour + if (dir && Date.now() - dir.ts < 1 * 60 * 60 * 1000) { + return dir.promise; + } + + return acme + .init(dirUrl) + .then(function(/*meta*/) { + caches[dirUrl] = { + promise: Promise.resolve(acme), + ts: Date.now() + }; + return acme; + }) + .catch(function(err) { + // TODO + // let's encrypt is possibly down for maintenaince... + // this is a special kind of failure mode + throw err; + }); + }; + + greenlock.order = function(args) { + return greenlock._acme(args).then(function(acme) { + console.log('[debug] acme meta', acme); + var storeConf = args.store || greenlock._defaults.store; + return P._load(storeConf.module).then(function(plugin) { + var store = Greenlock._normalizeStore( + storeConf.module, + plugin.create(storeConf) + ); + + console.log('[debug] store', storeConf); + return A._getOrCreate( + greenlock, + store.accounts, + acme, + args + ).then(function(account) { + console.log('[debug] account', account); + var challengeConfs = + args.challenges || greenlock._defaults.challenges; + console.log('[debug] challenge confs', challengeConfs); + return Promise.all( + Object.keys(challengeConfs).map(function(typ01) { + var chConf = challengeConfs[typ01]; + return P._load(chConf.module).then(function( + plugin + ) { + var ch = Greenlock._normalizeChallenge( + chConf.module, + plugin.create(chConf) + ); + ch._type = typ01; + return ch; + }); + }) + ).then(function(arr) { + var challenges = {}; + arr.forEach(function(el) { + challenges[el._type] = el; + }); + return C._getOrOrder( + greenlock, + store.certificates, + acme, + challenges, + account, + args + ); + }); + }); + }); + }); + }; + + greenlock._options = gconf; + greenlock._defaults = defaults; + + if (!gconf.onOrderFailure) { + gconf.onOrderFailure = function(err) { + G._onOrderFailure(gconf, err); + }; + } + + return greenlock; +}; + +G._defaults = function(opts) { + var defaults = {}; + + // [ 'store', 'challenges' ] + Object.keys(opts).forEach(function(k) { + // manage is the only thing that is, potentially, not plain-old JSON + if ('manage' === k && 'string' !== typeof opts[k]) { + return; + } + defaults[k] = opts[k]; + }); + + if (!defaults._maintainerPackage) { + defaults._maintainerPackage = pkg.name; + defaults._maintainerPackageVersion = pkg.version; + } + + if (!defaults.directoryUrl) { + if (defaults.staging) { + defaults.directoryUrl = + 'https://acme-staging-v02.api.letsencrypt.org/directory'; + } else { + defaults.directoryUrl = + 'https://acme-v02.api.letsencrypt.org/directory'; + } + } else { + if (defaults.staging) { + throw new Error('supply `directoryUrl` or `staging`, but not both'); + } + } + console.info('ACME Directory URL:', defaults.directoryUrl); + + // Load the default store module + if (!defaults.store) { + defaults.store = { + module: 'greenlock-store-fs', + basePath: '~/.config/greenlock/' + }; + } + P._loadSync(defaults.store.module); + //defaults.store = store; + + // Load the default challenge modules + var challenges; + if (!defaults.challenges) { + defaults.challenges = {}; + } + challenges = defaults.challenges; + + // TODO provide http-01 when http-01 and/or(?) dns-01 don't exist + if (!challenges['http-01'] && !challenges['dns-01']) { + challenges['http-01'] = { + module: 'acme-http-01-standalone' + }; + } + + if (challenges['http-01']) { + if ('string' === typeof challenges['http-01'].module) { + P._loadSync(challenges['http-01'].module); + } + } + + if (challenges['dns-01']) { + if ('string' === typeof challenges['dns-01'].module) { + P._loadSync(challenges['dns-01'].module); + } + } + + if (defaults.agreeToTerms === true || defaults.agreeTos === true) { + defaults.agreeToTerms = function(tos) { + return Promise.resolve(tos); + }; + } + + if (!defaults.accountKeyType) { + defaults.accountKeyType = 'EC-P256'; + } + if (!defaults.serverKeyType) { + if (defaults.domainKeyType) { + console.warn('use serverKeyType instead of domainKeyType'); + defaults.serverKeyType = defaults.domainKeyType; + } + defaults.serverKeyType = 'RSA-2048'; + } + if (defaults.domainKeypair) { + console.warn('use serverKeypair instead of domainKeypair'); + defaults.serverKeypair = + defaults.serverKeypair || defaults.domainKeypair; + } + + Object.defineProperty(defaults, 'domainKeypair', { + write: false, + get: function() { + console.warn('use serverKeypair instead of domainKeypair'); + return defaults.serverKeypair; + } + }); + + return defaults; +}; + +Greenlock._normalizeStore = function(name, store) { + var acc = store.accounts; + var crt = store.certificates; + + var warned = false; + function warn() { + if (warned) { + return; + } + warned = true; + console.warn( + "'" + + name + + "' may have incorrect function signatures, or contains deprecated use of callbacks" + ); + } + + // accs + if (acc.check && 2 === acc.check.length) { + warn(); + acc._thunk_check = acc.check; + acc.check = promisify(acc._thunk_check); + } + if (acc.set && 3 === acc.set.length) { + warn(); + acc._thunk_set = acc.set; + acc.set = promisify(acc._thunk_set); + } + if (2 === acc.checkKeypair.length) { + warn(); + acc._thunk_checkKeypair = acc.checkKeypair; + acc.checkKeypair = promisify(acc._thunk_checkKeypair); + } + if (3 === acc.setKeypair.length) { + warn(); + acc._thunk_setKeypair = acc.setKeypair; + acc.setKeypair = promisify(acc._thunk_setKeypair); + } + + // certs + if (2 === crt.check.length) { + warn(); + crt._thunk_check = crt.check; + crt.check = promisify(crt._thunk_check); + } + if (3 === crt.set.length) { + warn(); + crt._thunk_set = crt.set; + crt.set = promisify(crt._thunk_set); + } + if (2 === crt.checkKeypair.length) { + warn(); + crt._thunk_checkKeypair = crt.checkKeypair; + crt.checkKeypair = promisify(crt._thunk_checkKeypair); + } + if (2 === crt.setKeypair.length) { + warn(); + crt._thunk_setKeypair = crt.setKeypair; + crt.setKeypair = promisify(crt._thunk_setKeypair); + } + + return store; +}; + +Greenlock._normalizeChallenge = function(name, ch) { + var warned = false; + function warn() { + if (warned) { + return; + } + warned = true; + console.warn( + "'" + + name + + "' may have incorrect function signatures, or contains deprecated use of callbacks" + ); + } + + // init, zones, set, get, remove + if (ch.init && 2 === ch.init.length) { + warn(); + ch._thunk_init = ch.init; + ch.init = promisify(ch._thunk_init); + } + if (ch.zones && 2 === ch.zones.length) { + warn(); + ch._thunk_zones = ch.zones; + ch.zones = promisify(ch._thunk_zones); + } + if (2 === ch.set.length) { + warn(); + ch._thunk_set = ch.set; + ch.set = promisify(ch._thunk_set); + } + if (2 === ch.remove.length) { + warn(); + ch._thunk_remove = ch.remove; + ch.remove = promisify(ch._thunk_remove); + } + if (ch.get && 2 === ch.get.length) { + warn(); + ch._thunk_get = ch.get; + ch.get = promisify(ch._thunk_get); + } + + return ch; +}; diff --git a/order.js b/order.js new file mode 100644 index 0000000..afad294 --- /dev/null +++ b/order.js @@ -0,0 +1,97 @@ + var accountKeypair = await Keypairs.generate({ kty: accKty }); + if (config.debug) { + console.info('Account Key Created'); + console.info(JSON.stringify(accountKeypair, null, 2)); + console.info(); + console.info(); + } + + var account = await acme.accounts.create({ + agreeToTerms: agree, + // TODO detect jwk/pem/der? + accountKeypair: { privateKeyJwk: accountKeypair.private }, + subscriberEmail: config.email + }); + + // TODO top-level agree + function agree(tos) { + if (config.debug) { + console.info('Agreeing to Terms of Service:'); + console.info(tos); + console.info(); + console.info(); + } + agreed = true; + return Promise.resolve(tos); + } + if (config.debug) { + console.info('New Subscriber Account'); + console.info(JSON.stringify(account, null, 2)); + console.info(); + console.info(); + } + if (!agreed) { + throw new Error('Failed to ask the user to agree to terms'); + } + + var certKeypair = await Keypairs.generate({ kty: srvKty }); + var pem = await Keypairs.export({ + jwk: certKeypair.private, + encoding: 'pem' + }); + if (config.debug) { + console.info('Server Key Created'); + console.info('privkey.jwk.json'); + console.info(JSON.stringify(certKeypair, null, 2)); + // This should be saved as `privkey.pem` + console.info(); + console.info('privkey.' + srvKty.toLowerCase() + '.pem:'); + console.info(pem); + console.info(); + } + + // 'subject' should be first in list + var domains = randomDomains(rnd); + if (config.debug) { + console.info('Get certificates for random domains:'); + console.info( + domains + .map(function(puny) { + var uni = punycode.toUnicode(puny); + if (puny !== uni) { + return puny + ' (' + uni + ')'; + } + return puny; + }) + .join('\n') + ); + console.info(); + } + + // Create CSR + var csrDer = await CSR.csr({ + jwk: certKeypair.private, + domains: domains, + encoding: 'der' + }); + var csr = Enc.bufToUrlBase64(csrDer); + var csrPem = PEM.packBlock({ + type: 'CERTIFICATE REQUEST', + bytes: csrDer /* { jwk: jwk, domains: opts.domains } */ + }); + if (config.debug) { + console.info('Certificate Signing Request'); + console.info(csrPem); + console.info(); + } + + var results = await acme.certificates.create({ + account: account, + accountKeypair: { privateKeyJwk: accountKeypair.private }, + csr: csr, + domains: domains, + challenges: challenges, // must be implemented + customerEmail: null + }); + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9ab14fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "@root/greenlock", + "version": "3.0.0-wip.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@root/acme": { + "version": "3.0.0-wip.3", + "resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.0.0-wip.3.tgz", + "integrity": "sha512-7Fq9FuO0WQgKPgyYmKHst71EbIqH764A3j6vF1aKemgWXXq2Wqy8G+2SJwt3/MSXhQ7X+qLmWRLLJ7U4Zlygsg==", + "requires": { + "@root/csr": "^0.8.1", + "@root/encoding": "^1.0.1", + "@root/keypairs": "^0.9.0", + "@root/pem": "^1.0.4", + "@root/request": "^1.3.11", + "@root/x509": "^0.7.2" + }, + "dependencies": { + "@root/csr": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@root/csr/-/csr-0.8.1.tgz", + "integrity": "sha512-hKl0VuE549TK6SnS2Yn9nRvKbFZXn/oAg+dZJU/tlKl/f/0yRXeuUzf8akg3JjtJq+9E592zDqeXZ7yyrg8fSQ==", + "requires": { + "@root/asn1": "^1.0.0", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + } + }, + "@root/keypairs": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.9.0.tgz", + "integrity": "sha512-NXE2L9Gv7r3iC4kB/gTPZE1vO9Ox/p14zDzAJ5cGpTpytbWOlWF7QoHSJbtVX4H7mRG/Hp7HR3jWdWdb2xaaXg==", + "requires": { + "@root/encoding": "^1.0.1", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + } + } + } + }, + "@root/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@root/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-0lfZNuOULKJDJmdIkP8V9RnbV3XaK6PAHD3swnFy4tZwtlMDzLKoM/dfNad7ut8Hu3r91wy9uK0WA/9zym5mig==", + "requires": { + "@root/encoding": "^1.0.1" + } + }, + "@root/csr": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@root/csr/-/csr-0.8.1.tgz", + "integrity": "sha512-hKl0VuE549TK6SnS2Yn9nRvKbFZXn/oAg+dZJU/tlKl/f/0yRXeuUzf8akg3JjtJq+9E592zDqeXZ7yyrg8fSQ==", + "requires": { + "@root/asn1": "^1.0.0", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + } + }, + "@root/encoding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@root/encoding/-/encoding-1.0.1.tgz", + "integrity": "sha512-OaEub02ufoU038gy6bsNHQOjIn8nUjGiLcaRmJ40IUykneJkIW5fxDqKxQx48cszuNflYldsJLPPXCrGfHs8yQ==" + }, + "@root/keypairs": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.9.0.tgz", + "integrity": "sha512-NXE2L9Gv7r3iC4kB/gTPZE1vO9Ox/p14zDzAJ5cGpTpytbWOlWF7QoHSJbtVX4H7mRG/Hp7HR3jWdWdb2xaaXg==", + "requires": { + "@root/encoding": "^1.0.1", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + } + }, + "@root/mkdirp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@root/mkdirp/-/mkdirp-1.0.0.tgz", + "integrity": "sha512-hxGAYUx5029VggfG+U9naAhQkoMSXtOeXtbql97m3Hi6/sQSRL/4khKZPyOF6w11glyCOU38WCNLu9nUcSjOfA==" + }, + "@root/pem": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@root/pem/-/pem-1.0.4.tgz", + "integrity": "sha512-rEUDiUsHtild8GfIjFE9wXtcVxeS+ehCJQBwbQQ3IVfORKHK93CFnRtkr69R75lZFjcmKYVc+AXDB+AeRFOULA==" + }, + "@root/request": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz", + "integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw==" + }, + "@root/x509": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@root/x509/-/x509-0.7.2.tgz", + "integrity": "sha512-ENq3LGYORK5NiMFHEVeNMt+fTXaC7DTS6sQXoqV+dFdfT0vmiL5cDLjaXQhaklJQq0NiwicZegzJRl1ZOTp3WQ==", + "requires": { + "@root/asn1": "^1.0.0", + "@root/encoding": "^1.0.1" + } + }, + "acme-dns-01-digitalocean": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acme-dns-01-digitalocean/-/acme-dns-01-digitalocean-3.0.1.tgz", + "integrity": "sha512-LUdOGluDERQWJG4CwlC9HbzUai4mtKzCz8nzpVTirXup2WwH60iRFAcd81hRGaoWbd0Bc0m6RVjN9YFkXB84yA==" + }, + "acme-http-01-standalone": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/acme-http-01-standalone/-/acme-http-01-standalone-3.0.0.tgz", + "integrity": "sha512-lZqVab2UZ1Dp36HemfhGEvdYOcVNg5wyVXNjtPUqGSAOVUOKqwi3gDrTGwqz+FBrEEEEpTngDPaZn2g3hfmPLA==" + }, + "cert-info": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/cert-info/-/cert-info-1.5.1.tgz", + "integrity": "sha512-eoQC/yAgW3gKTKxjzyClvi+UzuY97YCjcl+lSqbsGIy7HeGaWxCPOQFivhUYm27hgsBMhsJJFya3kGvK6PMIcQ==" + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + }, + "greenlock-store-fs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/greenlock-store-fs/-/greenlock-store-fs-3.0.2.tgz", + "integrity": "sha512-t4So75yKs1+7TqmxD5UKdf+zOQU0/4o0lb2auf5zUcAo7fwwNLOAXyWnnZRL3WuFBUiBGh1qXWleuMua0d3LPg==", + "requires": { + "@root/mkdirp": "^1.0.0", + "safe-replace": "^1.1.0" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "safe-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-replace/-/safe-replace-1.1.0.tgz", + "integrity": "sha512-9/V2E0CDsKs9DWOOwJH7jYpSl9S3N05uyevNjvsnDauBqRowBPOyot1fIvV5N2IuZAbYyvrTXrYFVG0RZInfFw==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2383fd --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "@root/greenlock", + "version": "3.0.0-wip.0", + "description": "The easiest Let's Encrypt client for Node.js and Browsers", + "homepage": "https://rootprojects.org/greenlock/", + "main": "greenlock.js", + "browser": {}, + "files": [ + "*.js", + "lib", + "dist" + ], + "scripts": { + "build": "nodex bin/bundle.js", + "lint": "jshint lib bin", + "test": "node server.js", + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "https://git.coolaj86.com/coolaj86/bluecrypt-acme.js.git" + }, + "keywords": [ + "ACME", + "Let's Encrypt", + "browser", + "EC", + "RSA", + "CSR", + "greenlock", + "VanillaJS", + "ZeroSSL" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0", + "dependencies": { + "@root/acme": "^3.0.0-wip.3", + "@root/csr": "^0.8.1", + "@root/keypairs": "^0.9.0", + "@root/mkdirp": "^1.0.0", + "@root/request": "^1.3.10", + "acme-dns-01-digitalocean": "^3.0.1", + "acme-http-01-standalone": "^3.0.0", + "cert-info": "^1.5.1", + "greenlock-store-fs": "^3.0.2", + "safe-replace": "^1.1.0" + }, + "devDependencies": { + "dotenv": "^8.2.0", + "punycode": "^1.4.1" + } +} diff --git a/plugins.js b/plugins.js new file mode 100644 index 0000000..da9bbac --- /dev/null +++ b/plugins.js @@ -0,0 +1,153 @@ +'use strict'; + +var P = module.exports; + +var spawn = require('child_process').spawn; +var spawnSync = require('child_process').spawnSync; +var PKG_DIR = __dirname; + +P._load = function(modname) { + try { + return Promise.resolve(require(modname)); + } catch (e) { + return P._install(modname).then(function() { + return require(modname); + }); + } +}; + +P._loadSync = function(modname) { + var mod; + try { + mod = require(modname); + } catch (e) { + P._installSync(modname); + mod = require(modname); + } + return mod; +}; + +P._installSync = function(moduleName) { + var npm = 'npm'; + var args = ['install', '--save', moduleName]; + var out = ''; + var cmd; + + try { + cmd = spawnSync(npm, args, { + cwd: PKG_DIR, + windowsHide: true + }); + } catch (e) { + console.error( + "Failed to start: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error(e.message); + process.exit(1); + } + + if (!cmd.status) { + return; + } + + out += cmd.stdout.toString('utf8'); + out += cmd.stderr.toString('utf8'); + + if (out) { + console.error(out); + console.error(); + console.error(); + } + + console.error( + "Failed to run: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + + console.error( + 'Try for yourself:\n\tcd ' + PKG_DIR + '\n\tnpm ' + args.join(' ') + ); + + process.exit(1); +}; + +P._install = function(moduleName) { + return new Promise(function(resolve) { + if (!moduleName) { + throw new Error('no module name given'); + } + + var npm = 'npm'; + var args = ['install', '--save', moduleName]; + var out = ''; + var cmd = spawn(npm, args, { + cwd: PKG_DIR, + windowsHide: true + }); + + cmd.stdout.on('data', function(chunk) { + out += chunk.toString('utf8'); + }); + cmd.stdout.on('data', function(chunk) { + out += chunk.toString('utf8'); + }); + + cmd.on('error', function(e) { + console.error( + "Failed to start: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error(e.message); + process.exit(1); + }); + + cmd.on('exit', function(code) { + if (!code) { + resolve(); + return; + } + + if (out) { + console.error(out); + console.error(); + console.error(); + } + console.error( + "Failed to run: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error( + 'Try for yourself:\n\tcd ' + + PKG_DIR + + '\n\tnpm ' + + args.join(' ') + ); + process.exit(1); + }); + }); +}; + +if (require.main === module) { + P._installSync(process.argv[2]); +} diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..561921d --- /dev/null +++ b/tests/index.js @@ -0,0 +1,41 @@ +'use strict'; + +require('dotenv').config(); + +var path = require('path'); +var Greenlock = require('../'); + +var subject = process.env.BASE_DOMAIN; +var altnames = [subject, '*.' + subject, 'foo.bar.' + subject]; +var email = process.env.SUBSCRIBER_EMAIL; +var challenge = JSON.parse(process.env.CHALLENGE_OPTIONS); +challenge.module = process.env.CHALLENGE_MODULE; + +var greenlock = Greenlock.create({ + agreeTos: true, + maintainerEmail: email, + staging: true, + manager: path.join(__dirname, 'manager.js'), + challenges: { + 'dns-01': challenge + } + //configFile: '~/.config/greenlock/certs.json', + //challenges: challenges, + //store: args.storeOpts, + //renewOffset: args.renewOffset || '30d', + //renewStagger: '1d' +}); + +greenlock + .add({ + subject: subject, + altnames: altnames, + subscriberEmail: email + }) + .then(function() { + return greenlock.renew(); + }) + .catch(function(e) { + console.error('yo', e.code); + console.error(e); + }); diff --git a/tests/manager.js b/tests/manager.js new file mode 100644 index 0000000..f7c5517 --- /dev/null +++ b/tests/manager.js @@ -0,0 +1,259 @@ +'use strict'; + +var Manage = module.exports; +var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' }); +var promisify = require('util').promisify; +var fs = require('fs'); +var readFile = promisify(fs.readFile); +var statFile = promisify(fs.stat); +var homedir = require('os').homedir(); +var path = require('path'); +var mkdirp = promisify(require('@root/mkdirp')); + +Manage.create = function(opts) { + if (!opts) { + opts = {}; + } + if (!opts.configFile) { + opts.configFile = '~/.config/greenlock/config.json'; + } + opts.configFile = opts.configFile.replace('~/', homedir + '/'); + + var manage = {}; + + manage.ping = function() { + return Manage._ping(manage, opts); + }; + + manage._txPromise = new Promise(function(resolve) { + resolve(); + }); + + manage._lastStat = { + size: 0, + mtimeMs: 0 + }; + manage._config = {}; + + manage._save = function(config) { + return mkdirp(path.dirname(opts.configFile)).then(function() { + return sfs + .writeFileAsync(opts.configFile, JSON.stringify(config), 'utf8') + .then(function() { + return statFile(opts.configFile).then(function(stat) { + manage._lastStat.size = stat.size; + manage._lastStat.mtimeMs = stat.mtimeMs; + }); + }); + }); + }; + + manage.add = function(args) { + manage._txPromise = manage._txPromise.then(function() { + // if the fs has changed since we last wrote, get the lastest from disk + return Manage._getLatest(manage, opts).then(function(config) { + // TODO move to Greenlock.add + var subject = args.subject || args.domain; + var primary = subject; + var altnames = args.altnames || args.domains; + if ('string' !== typeof primary) { + if (!Array.isArray(altnames) || !altnames.length) { + throw new Error('there needs to be a subject'); + } + primary = altnames.slice(0).sort()[0]; + } + if (!Array.isArray(altnames) || !altnames.length) { + altnames = [primary]; + } + primary = primary.toLowerCase(); + altnames = altnames.map(function(name) { + return name.toLowerCase(); + }); + + if (!config.sites) { + config.sites = {}; + } + + var site = config.sites[primary]; + if (!site) { + site = config.sites[primary] = { altnames: [] }; + } + + // The goal is to make this decently easy to manage by hand without mistakes + // but also reasonably easy to error check and correct + // and to make deterministic auto-corrections + + // TODO added, removed, moved (duplicate), changed + site.subscriberEmail = site.subscriberEmail; + site.subject = subject; + site.altnames = altnames; + site.issuedAt = site.issuedAt || 0; + site.expiresAt = site.expiresAt || 0; + site.lastAttemptAt = site.lastAttemptAt || 0; + // re-add if this was deleted + site.deletedAt = 0; + if ( + site.altnames + .slice(0) + .sort() + .join() !== + altnames + .slice(0) + .sort() + .join() + ) { + site.expiresAt = 0; + site.issuedAt = 0; + } + + // These should usually be empty, for most situations + site.subscriberEmail = args.subscriberEmail; + site.customerEmail = args.customerEmail; + site.challenges = args.challenges; + site.store = args.store; + console.log('[debug] save site', site); + + return manage._save(config).then(function() { + return JSON.parse(JSON.stringify(site)); + }); + }); + }); + return manage._txPromise; + }; + + manage.find = function(args) { + return Manage._getLatest(manage, opts).then(function(config) { + // i.e. find certs more than 30 days old + //args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000; + // i.e. find certs more that will expire in less than 45 days + //args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000; + var issuedBefore = args.issuedBefore || 0; + var expiresBefore = + args.expiresBefore || Date.now() + 21 * 24 * 60 * 60 * 1000; + + // TODO match ANY domain on any cert + var sites = Object.keys(config.sites) + .filter(function(sub) { + var site = config.sites[sub]; + if ( + !site.deletedAt || + site.expiresAt < expiresBefore || + site.issuedAt < issuedBefore + ) { + if (!args.subject || sub === args.subject) { + return true; + } + } + }) + .map(function(name) { + var site = config.sites[name]; + console.debug('debug', site); + return { + subject: site.subject, + altnames: site.altnames, + issuedAt: site.issuedAt, + expiresAt: site.expiresAt, + renewOffset: site.renewOffset, + renewStagger: site.renewStagger, + renewAt: site.renewAt, + subscriberEmail: site.subscriberEmail, + customerEmail: site.customerEmail, + challenges: site.challenges, + store: site.store + }; + }); + + return sites; + }); + }; + + manage.remove = function(args) { + if (!args.subject) { + throw new Error('should have a subject for sites to remove'); + } + manage._txPromise = manage.txPromise.then(function() { + return Manage._getLatest(manage, opts).then(function(config) { + var site = config.sites[args.subject]; + if (!site) { + return {}; + } + site.deletedAt = Date.now(); + + return JSON.parse(JSON.stringify(site)); + }); + }); + return manage._txPromise; + }; + + manage.notifications = function(args) { + // TODO define message types + console.info(args.event, args.message); + }; + + manage.errors = function(err) { + // err.subject + // err.altnames + // err.challenge + // err.challengeOptions + // err.store + // err.storeOptions + console.error('Failure with ', err.subject); + }; + + manage.update = function(args) { + manage._txPromise = manage.txPromise.then(function() { + return Manage._getLatest(manage, opts).then(function(config) { + var site = config.sites[args.subject]; + site.issuedAt = args.issuedAt; + site.expiresAt = args.expiresAt; + site.renewAt = args.renewAt; + // foo + }); + }); + return manage._txPromise; + }; + + return manage; +}; + +Manage._getLatest = function(mng, opts) { + return statFile(opts.configFile) + .catch(function(err) { + if ('ENOENT' === err.code) { + return { + size: 0, + mtimeMs: 0 + }; + } + err.context = 'manager_read'; + throw err; + }) + .then(function(stat) { + if ( + stat.size === mng._lastStat.size && + stat.mtimeMs === mng._lastStat.mtimeMs + ) { + return mng._config; + } + return readFile(opts.configFile, 'utf8').then(function(data) { + mng._lastStat = stat; + mng._config = JSON.parse(data); + return mng._config; + }); + }); +}; + +Manage._ping = function(mng, opts) { + if (mng._pingPromise) { + return mng._pingPromise; + } + + mng._pringPromise = Promise.resolve().then(function() { + // TODO file permissions + if (!opts.configFile) { + throw new Error('no config file location provided'); + } + JSON.parse(fs.readFileSync(opts.configFile, 'utf8')); + }); + return mng._pingPromise; +}; diff --git a/user-events.js b/user-events.js new file mode 100644 index 0000000..8e76b95 --- /dev/null +++ b/user-events.js @@ -0,0 +1,7 @@ +'use strict'; + +var UserEvents = module.exports; + +UserEvents.notify = function() { + // TODO not implemented yet +}; diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..915cc86 --- /dev/null +++ b/utils.js @@ -0,0 +1,267 @@ +'use strict'; + +var U = module.exports; + +var promisify = require('util').promisify; +var resolveSoa = promisify(require('dns').resolveSoa); +var resolveMx = promisify(require('dns').resolveMx); +var punycode = require('punycode'); +var Keypairs = require('@root/keypairs'); +// TODO move to @root +var certParser = require('cert-info'); + +U._parseDuration = function(str) { + if ('number' === typeof str) { + return str; + } + + var pattern = /^(\-?\d+(\.\d+)?)([wdhms]|ms)$/; + var matches = str.match(pattern); + if (!matches || !matches[0]) { + throw new Error('invalid duration string: ' + str); + } + + var n = parseInt(matches[1], 10); + var unit = matches[3]; + + switch (unit) { + case 'w': + n *= 7; + /*falls through*/ + case 'd': + n *= 24; + /*falls through*/ + case 'h': + n *= 60; + /*falls through*/ + case 'm': + n *= 60; + /*falls through*/ + case 's': + n *= 1000; + /*falls through*/ + case 'ms': + n *= 1; // for completeness + } + + return n; +}; + +U._encodeName = function(str) { + return punycode.toASCII(str.toLowerCase(str)); +}; + +U._validName = function(str) { + // A quick check of the 38 and two ½ valid characters + // 253 char max full domain, including dots + // 63 char max each label segment + // Note: * is not allowed, but it's allowable here + // Note: _ (underscore) is only allowed for "domain names", not "hostnames" + // Note: - (hyphen) is not allowed as a first character (but a number is) + return ( + /^(\*\.)?[a-z0-9_\.\-]+$/.test(str) && + str.length < 254 && + str.split('.').every(function(label) { + return label.length > 0 && label.length < 64; + }) + ); +}; + +U._validMx = function(email) { + var host = email.split('@').slice(1)[0]; + // try twice, just because DNS hiccups sometimes + // Note: we don't care if the domain exists, just that it *can* exist + return resolveMx(host).catch(function() { + return U._timeout(1000).then(function() { + return resolveMx(host); + }); + }); +}; + +// should be called after _validName +U._validDomain = function(str) { + // TODO use @root/dns (currently dns-suite) + // because node's dns can't read Authority records + return Promise.resolve(str); + /* + // try twice, just because DNS hiccups sometimes + // Note: we don't care if the domain exists, just that it *can* exist + return resolveSoa(str).catch(function() { + return U._timeout(1000).then(function() { + return resolveSoa(str); + }); + }); + */ +}; + +// foo.example.com and *.example.com overlap +// should be called after _validName +// (which enforces *. or no *) +U._uniqueNames = function(altnames) { + var dups = {}; + var wilds = {}; + if ( + altnames.some(function(w) { + if ('*.' !== w.slice(0, 2)) { + return; + } + if (wilds[w]) { + return true; + } + wilds[w] = true; + }) + ) { + return false; + } + + return altnames.every(function(name) { + var w; + if ('*.' !== name.slice(0, 2)) { + w = + '*.' + + name + .split('.') + .slice(1) + .join('.'); + } else { + return true; + } + + if (!dups[name] && !dups[w]) { + dups[name] = true; + return true; + } + }); +}; + +U._timeout = function(d) { + return new Promise(function(resolve) { + setTimeout(resolve, d); + }); +}; + +U._genKeypair = function(keyType) { + var keyopts; + var len = parseInt(keyType.replace(/.*?(\d)/, '$1') || 0, 10); + if (/RSA/.test(keyType)) { + keyopts = { + kty: 'RSA', + modulusLength: len || 2048 + }; + } else if (/^(EC|P\-?\d)/i.test(keyType)) { + keyopts = { + kty: 'EC', + namedCurve: 'P-' + (len || 256) + }; + } else { + // TODO put in ./errors.js + throw new Error('invalid key type: ' + keyType); + } + + return Keypairs.generate(keyopts).then(function(pair) { + return U._jwkToSet(pair.private); + }); +}; + +// TODO use ACME._importKeypair ?? +U._importKeypair = function(keypair) { + if (keypair.privateKeyJwk) { + return U._jwkToSet(keypair.privateKeyJwk); + } + + if (!keypair.privateKeyPem) { + // TODO put in errors + throw new Error('missing private key'); + } + + return Keypairs.import({ pem: keypair.privateKeyPem }).then(function(pair) { + return U._jwkToSet(pair.private); + }); +}; + +U._jwkToSet = function(jwk) { + var keypair = { + privateKeyJwk: jwk + }; + return Promise.all([ + Keypairs.export({ + jwk: jwk, + encoding: 'pem' + }).then(function(pem) { + keypair.privateKeyPem = pem; + }), + Keypairs.export({ + jwk: jwk, + encoding: 'pem', + public: true + }).then(function(pem) { + keypair.publicKeyPem = pem; + }), + Keypairs.publish({ + jwk: jwk + }).then(function(pub) { + keypair.publicKeyJwk = pub; + }) + ]).then(function() { + return keypair; + }); +}; + +U._attachCertInfo = function(results) { + var certInfo = certParser.info(results.cert); + + // subject, altnames, issuedAt, expiresAt + Object.keys(certInfo).forEach(function(key) { + results[key] = certInfo[key]; + }); + + return results; +}; + +U._certHasDomain = function(certInfo, _domain) { + var names = (certInfo.altnames || []).slice(0); + return names.some(function(name) { + var domain = _domain.toLowerCase(); + name = name.toLowerCase(); + if ('*.' === name.substr(0, 2)) { + name = name.substr(2); + domain = domain + .split('.') + .slice(1) + .join('.'); + } + return name === domain; + }); +}; + +// a bit heavy to be labeled 'utils'... perhaps 'common' would be better? +U._getOrCreateKeypair = function(db, subject, query, keyType, mustExist) { + var exists = false; + return db + .checkKeypair(query) + .then(function(kp) { + if (kp) { + exists = true; + return U._importKeypair(kp); + } + + if (mustExist) { + // TODO put in errors + throw new Error( + 'required keypair not found: ' + + (subject || '') + + ' ' + + JSON.stringify(query) + ); + } + + return U._genKeypair(keyType); + }) + .then(function(keypair) { + return { exists: exists, keypair: keypair }; + }); +}; + +U._getKeypair = function(db, subject, query) { + return U._getOrCreateKeypair(db, subject, query, '', true); +};