'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 || '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; }