361 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			361 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| var os = require("os");
 | |
| var fs = require('fs');
 | |
| var path = require('path');
 | |
| var sfs = require('safe-replace');
 | |
| var PromiseA = getPromise();
 | |
| var readFileAsync = PromiseA.promisify(fs.readFile);
 | |
| var writeFileAsync = PromiseA.promisify(fs.writeFile);
 | |
| // TODO replace with zero-depenency version
 | |
| var mkdirpAsync = PromiseA.promisify(require('mkdirp'));
 | |
| 
 | |
| 
 | |
| // How Storage Works in Greenlock: High-Level Call Stack
 | |
| //
 | |
| // nested === skipped if parent succeeds (or has cached result)
 | |
| //
 | |
| // tls.SNICallback()                                      // TLS connection with SNI kicks of the request
 | |
| //
 | |
| //   greenlock.approveDomains(opts)                       // Greenlokc does some housekeeping, checks for a cert in
 | |
| //                                                        // an internal cash, and only asks you to approve new
 | |
| //                                                        // certificate // registration if it doesn't find anything.
 | |
| //                                                        // In `opts` you'll receive `domain` and a few other things.
 | |
| //                                                        // You should return { subject: '...', altnames: ['...'] }
 | |
| //                                                        // Anything returned by approveDomains() will be received
 | |
| //                                                        // by all plugins at all stages
 | |
| //
 | |
| //     greenlock.store.certificates.check()               // Certificate checking happens after approval for several
 | |
| //                                                        // reasons, including preventing duplicate registrations
 | |
| //                                                        // but most importantly because you can dynamically swap the
 | |
| //                                                        // storage plugin right from approveDomains().
 | |
| //     greenlock.store.certificates.checkKeypair()        // Check for a keypair associated with the domain
 | |
| //
 | |
| //     greenlock.store.accounts.check()                   // Optional. If you need it, look at other Greenlock docs
 | |
| //
 | |
| //     greenlock.store.accounts.checkKeypair()            // Check storage for registered account key
 | |
| //       (opts.generateKeypair||RSA.generateKeypair)()    // Generates a new keypair
 | |
| //       greenlock.core.accounts.register()               // Registers the keypair as an ACME account
 | |
| //       greenlock.store.accounts.setKeypair()            // Saves the keypair of the registered account
 | |
| //       greenlock.store.accounts.set()                   // Optional. Saves superfluous ACME account metadata
 | |
| //
 | |
| //     greenlock.core.certificates.register()             // Begin certificate registration process & housekeeping
 | |
| //       (opts.generateKeypair||RSA.generateKeypair)()    // Generates a new certificate keypair
 | |
| //       greenlock.acme.certificates.register()           // Performs the ACME challenge processes
 | |
| //       greenlock.store.certificates.setKeypair()        // Saves the keypair for the valid certificate
 | |
| //       greenlock.store.certificates.set()               // Saves the valid certificate
 | |
| 
 | |
| 
 | |
| ////////////////////////////////////////////
 | |
| // Recap of the high-level overview above //
 | |
| ////////////////////////////////////////////
 | |
| //
 | |
| //  None of this ever gets called except if there's not a cert already cached.
 | |
| //  That only happens on service boot, and about every 75 days for each cert's renewal.
 | |
| //
 | |
| //  Therefore, none of this needs to be fast, fancy, or clever
 | |
| //
 | |
| //  For any type of customization, whatever is set in `approveDomains()` is available everywhere else.
 | |
| 
 | |
| 
 | |
| 
 | |
| // Either your user calls create with specific options, or greenlock calls it for you with a big options blob
 | |
| module.exports.create = function (config) {
 | |
|   // Bear in mind that the only time any of this gets called is on first access after startup, new registration, and
 | |
|   // renewal - so none of this needs to be particularly fast. It may need to be memory efficient, however - if you have
 | |
|   // more than 10,000 domains, for example.
 | |
| 
 | |
|   // basic setup
 | |
|   var store = { accounts: {}, certificates: {} };
 | |
| 
 | |
|   // For you store.options should probably start empty and get a minimal set of options copied from `config` above.
 | |
|   // Example:
 | |
|   //store.options = {};
 | |
|   //store.options.databaseUrl = config.databaseUrl;
 | |
| 
 | |
|   // In the case of greenlock-store-fs there's a bunch of legacy stuff that goes on, so we just clobber it all on.
 | |
|   // Don't be like greenlock-store-fs (see note above).
 | |
|   store.options = mergeOptions(config);
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Certificates.check
 | |
|   //
 | |
|   // Use certificate.id, or subject, if id hasn't been set, to find a certificate.
 | |
|   // Return an object with string PEMs for cert and chain (or null, not undefined)
 | |
|   store.certificates.check = function (opts) {
 | |
|     // { certificate.id, subject, ... }
 | |
|     var id = opts.certificate && opts.certificate.id || opts.subject;
 | |
|     //console.log('certificates.check for', opts.certificate, opts.subject);
 | |
|     //console.log(opts);
 | |
| 
 | |
|     // For advanced use cases:
 | |
|     // This just goes to show that any options set in approveDomains() will be available here
 | |
|     // (the same is true for all of the hooks in this file)
 | |
|     if (opts.exampleThrowError) { return PromiseA.reject(new Error("You want an error? You got it!")); }
 | |
|     if (opts.exampleReturnNull) { return PromiseA.resolve(null); }
 | |
|     if (opts.exampleReturnCerts) { return PromiseA.resolve(opts.exampleReturnCerts); }
 | |
| 
 | |
| 
 | |
|     // Ignore this first bit, it's just file system template / compatibility stuff
 | |
|     var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
 | |
|     var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem');
 | |
|     var certPath = opts.certPath || path.join(liveDir, 'cert.pem');
 | |
|     var chainPath = opts.chainPath || path.join(liveDir, 'chain.pem');
 | |
|     return PromiseA.all([
 | |
|       readFileAsync(tameWild(privkeyPath, id), 'ascii')   // 0 // all other PEM types are just
 | |
|     , readFileAsync(tameWild(certPath, id), 'ascii')      // 1 // some arrangement of these 3
 | |
|     , readFileAsync(tameWild(chainPath, id), 'ascii')     // 2 // (bundle, combined, fullchain, etc)
 | |
|     ]).then(function (all) {
 | |
| 
 | |
|       ////////////////////////
 | |
|       // PAY ATTENTION HERE //
 | |
|       ////////////////////////
 | |
|       // This is all you have to return: cert, chain
 | |
|       return {
 | |
|         cert: all[1]      // string PEM. the bare cert, half of the concatonated fullchain.pem you need
 | |
|       , chain: all[2]     // string PEM. the bare chain, the second half of the fullchain.pem
 | |
|       , privkey: all[0]   // string PEM. optional, allows checkKeypair to be skipped
 | |
| 
 | |
|       // These can be useful to store in your database,
 | |
|       // but otherwise they're easy to derive from the cert.
 | |
|       // (when not available they'll be generated from cert-info)
 | |
|       //, subject: certinfo.subject     // string domain name
 | |
|       //, altnames: certinfo.altnames   // array of domain name strings
 | |
|       //, issuedAt: certinfo.issuedAt   // number in ms (a.k.a. NotBefore)
 | |
|       //, expiresAt: certinfo.expiresAt // number in ms (a.k.a. NotAfter)
 | |
|       };
 | |
|     }).catch(function (err) {
 | |
|       // Treat non-exceptional failures as null returns (not undefined)
 | |
|       if ('ENOENT' === err.code) { return null; }
 | |
|       throw err; // True exceptions should be thrown
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Implement if you need the ACME account metadata elsewhere in the chain of events
 | |
|   //store.accounts.check = function (opts) {
 | |
|   //  console.log('accounts.check for', opts.account, opts.email);
 | |
|   //  return PromiseA.resolve(null);
 | |
|   //};
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Accounts.checkKeypair
 | |
|   //
 | |
|   // Use account.id, or email, if id hasn't been set, to find an account keypair.
 | |
|   // Return an object with string privateKeyPem and/or object privateKeyJwk (or null, not undefined)
 | |
|   store.accounts.checkKeypair = function (opts) {
 | |
|     var id = opts.account.id || opts.email || 'single-user';
 | |
|     //console.log('accounts.checkKeypair for', id);
 | |
| 
 | |
|     var pathname = path.join(tameWild(opts.accountsDir, opts.subject), sanitizeFilename(id) + '.json');
 | |
|     return readFileAsync(tameWild(pathname, opts.subject), 'utf8').then(function (blob) {
 | |
|       // keypair can treated as an opaque object and just passed along,
 | |
|       // but just to show you what it is...
 | |
|       var keypair = JSON.parse(blob);
 | |
|       return {
 | |
|         privateKeyPem: keypair.privateKeyPem // string PEM private key
 | |
|       , privateKeyJwk: keypair.privateKeyJwk // object JWK private key
 | |
|       };
 | |
|     }).catch(function (err) {
 | |
|       if ('ENOENT' === err.code) { return null; }
 | |
|       throw err;
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Accounts.setKeypair({ account, email, keypair, ... }):
 | |
|   //
 | |
|   // Use account.id (or email if no id is present) to save an account keypair
 | |
|   // Return null (not undefined) on success, or throw on error
 | |
|   store.accounts.setKeypair = function (opts) {
 | |
|     //console.log('accounts.setKeypair for', opts.account, opts.email, opts.keypair);
 | |
|     var id = opts.account.id || opts.email || 'single-user';
 | |
| 
 | |
|     // you can just treat the keypair as opaque and save and retrieve it as JSON
 | |
|     var keyblob = JSON.stringify({
 | |
|       privateKeyPem: opts.keypair.privateKeyPem // string PEM
 | |
|     , privateKeyJwk: opts.keypair.privateKeyJwk // object JWK
 | |
|     });
 | |
| 
 | |
|     // Ignore.
 | |
|     // Just implementation specific details here.
 | |
|     return mkdirpAsync(tameWild(opts.accountsDir, opts.subject)).then(function () {
 | |
|       var pathname = tameWild(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), opts.subject);
 | |
|       return writeFileAsync(tameWild(pathname, opts.subject), keyblob, 'utf8');
 | |
|     }).then(function () {
 | |
|       // This is your job: return null, not undefined
 | |
|       return null;
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Implement if you need the ACME account metadata elsewhere in the chain of events
 | |
|   //store.accounts.set = function (opts) {
 | |
|   //  console.log('account.set:', opts.account, opts.email, opts.receipt);
 | |
|   //  return PromiseA.resolve(null);
 | |
|   //};
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Certificates.checkKeypair
 | |
|   //
 | |
|   // Use certificate.kid, certificate.id, or subject to find a certificate keypair
 | |
|   // Return an object with string privateKeyPem and/or object privateKeyJwk (or null, not undefined)
 | |
|   store.certificates.checkKeypair = function (opts) {
 | |
|     //console.log('certificates.checkKeypair:', opts.certificate, opts.subject);
 | |
| 
 | |
|     // Ignore this. It's just special stuff for file system compat with the old le-store-certbot
 | |
|     var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
 | |
|     var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem');
 | |
|     return readFileAsync(tameWild(privkeyPath, opts.subject), 'ascii').then(function (key) {
 | |
|       ////////////////////////
 | |
|       // PAY ATTENTION HERE //
 | |
|       ////////////////////////
 | |
|       return {
 | |
|         privateKeyPem: key      // In this case we only saved privateKeyPem, so we only return it
 | |
|       //privateKeyJwk: null     // (but it's fine, just different encodings of the same thing)
 | |
|       };
 | |
|     }).catch(function (err) {
 | |
|       if ('ENOENT' === err.code) { return null; }
 | |
|       throw err;
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Certificates.setKeypair({ certificate, subject, keypair, ... }):
 | |
|   //
 | |
|   // Use certificate.kid (or certificate.id or subject if no kid is present) to find a certificate keypair
 | |
|   // Return null (not undefined) on success, or throw on error
 | |
|   store.certificates.setKeypair = function (opts) {
 | |
|     var keypair = opts.keypair || keypair;
 | |
| 
 | |
|     // Ignore.
 | |
|     // Just specific implementation details.
 | |
|     var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
 | |
|     var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem');
 | |
|     return mkdirpAsync(tameWild(path.dirname(privkeyPath), opts.subject)).then(function () {
 | |
|       // keypair is normally an opaque object, but here it's a PEM for the FS (for things like Apache and Nginx)
 | |
|       return writeFileAsync(tameWild(privkeyPath, opts.subject), keypair.privateKeyPem, 'ascii').then(function () {
 | |
|         return null;
 | |
|       });
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   // Certificates.set({ subject, pems, ... }):
 | |
|   //
 | |
|   // Use certificate.id (or subject if no ki is present) to save a certificate
 | |
|   // Return null (not undefined) on success, or throw on error
 | |
|   store.certificates.set = function (opts) {
 | |
|     //console.log('certificates.set:', opts.subject, opts.pems);
 | |
|     var pems = {
 | |
|       cert: opts.pems.cert        // string PEM the first half of the concatonated fullchain.pem cert
 | |
|     , chain: opts.pems.chain      // string PEM the second half (yes, you need this too)
 | |
|     , privkey: opts.pems.privkey  // Ignore. string PEM, useful if you have to create bundle.pem
 | |
|     };
 | |
| 
 | |
|     // Ignore
 | |
|     // Just implementation specific details (writing lots of combinatons of files)
 | |
|     var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
 | |
|     var certPath = opts.certPath || path.join(liveDir, 'cert.pem');
 | |
|     var fullchainPath = opts.fullchainPath || path.join(liveDir, 'fullchain.pem');
 | |
|     var chainPath = opts.chainPath || path.join(liveDir, 'chain.pem');
 | |
|     var bundlePath = opts.bundlePath || path.join(liveDir, 'bundle.pem');
 | |
|     return mkdirpAsync(path.dirname(tameWild(certPath, opts.subject))).then(function () {
 | |
|       return mkdirpAsync(path.dirname(tameWild(chainPath, opts.subject))).then(function () {
 | |
|         return mkdirpAsync(path.dirname(tameWild(fullchainPath, opts.subject))).then(function () {
 | |
|           return mkdirpAsync(path.dirname(tameWild(bundlePath, opts.subject))).then(function () {
 | |
|             var fullchainPem = [ pems.cert, pems.chain ].join('\n'); // for Apache, Nginx, etc
 | |
|             var bundlePem = [ pems.privkey, pems.cert, pems.chain ].join('\n'); // for HAProxy
 | |
|             return PromiseA.all([
 | |
|               sfs.writeFileAsync(tameWild(certPath, opts.subject), pems.cert, 'ascii')
 | |
|             , sfs.writeFileAsync(tameWild(chainPath, opts.subject), pems.chain, 'ascii')
 | |
|               // Most web servers need these two
 | |
|             , sfs.writeFileAsync(tameWild(fullchainPath, opts.subject), fullchainPem, 'ascii')
 | |
|               // HAProxy needs "bundle.pem" aka "combined.pem"
 | |
|             , sfs.writeFileAsync(tameWild(bundlePath, opts.subject), bundlePem, 'ascii')
 | |
|             ]);
 | |
|           });
 | |
|         });
 | |
|       });
 | |
|     }).then(function () {
 | |
|       // That's your job: return null
 | |
|       return null;
 | |
|     });
 | |
|   };
 | |
| 
 | |
| 
 | |
| 
 | |
|   return store;
 | |
| };
 | |
| 
 | |
| ///////////////////////////////////////////////////////////////////////////////
 | |
| //                                  Ignore                                   //
 | |
| ///////////////////////////////////////////////////////////////////////////////
 | |
| //
 | |
| // Everything below this line is just implementation specific
 | |
| var defaults = {
 | |
|   configDir: path.join(os.homedir(), 'acme', 'etc')
 | |
| 
 | |
| , accountsDir: path.join(':configDir', 'accounts', ':serverDir')
 | |
| , serverDirGet: function (copy) {
 | |
|     return (copy.server || '').replace('https://', '').replace(/(\/)$/, '').replace(/\//g, path.sep);
 | |
|   }
 | |
| , privkeyPath: path.join(':configDir', 'live', ':hostname', 'privkey.pem')
 | |
| , fullchainPath: path.join(':configDir', 'live', ':hostname', 'fullchain.pem')
 | |
| , certPath: path.join(':configDir', 'live', ':hostname', 'cert.pem')
 | |
| , chainPath: path.join(':configDir', 'live', ':hostname', 'chain.pem')
 | |
| , bundlePath: path.join(':configDir', 'live', ':hostname', 'bundle.pem')
 | |
| };
 | |
| 
 | |
| function mergeOptions(configs) {
 | |
|   if (!configs.domainKeyPath) {
 | |
|     configs.domainKeyPath = configs.privkeyPath || defaults.privkeyPath;
 | |
|   }
 | |
| 
 | |
|   Object.keys(defaults).forEach(function (key) {
 | |
|     if (!configs[key]) {
 | |
|       configs[key] = defaults[key];
 | |
|     }
 | |
|   });
 | |
| 
 | |
|   return configs;
 | |
| }
 | |
| 
 | |
| function sanitizeFilename(id) {
 | |
|   return id.replace(/(\.\.)|\\|\//g, '_').replace(/[^!-~]/g, '_');
 | |
| }
 | |
| 
 | |
| // because not all file systems like '*' in a name (and they're scary)
 | |
| function tameWild(path, wild) {
 | |
|   var tame = wild.replace(/\*/g, '_');
 | |
|   return path.replace(wild, tame);
 | |
| }
 | |
| 
 | |
| function getPromise() {
 | |
|   var util = require('util');
 | |
|   var PromiseA;
 | |
|   if (util.promisify && global.Promise) {
 | |
|     PromiseA = global.Promise;
 | |
|     PromiseA.promisify = util.promisify;
 | |
|   } else {
 | |
|     try {
 | |
|       PromiseA = require('bluebird');
 | |
|     } catch(e) {
 | |
|       console.error("Your version of node is missing Promise. Please run `npm install --save bluebird` in your project to fix");
 | |
|       process.exit(10);
 | |
|     }
 | |
|   }
 | |
|   return PromiseA;
 | |
| }
 |