greenlock-store-fs.js/index.js

319 lines
16 KiB
JavaScript

'use strict';
/*global Promise*/
var PromiseA;
var util = require('util');
if (!util.promisify) {
try {
PromiseA = require('bluebird');
util.promisify = PromiseA.promisify;
} 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);
}
}
if ('undefined' !== typeof Promise) { PromiseA = Promise; }
var fs = require('fs');
var path = require('path');
var readFileAsync = util.promisify(fs.readFile);
var writeFileAsync = util.promisify(fs.writeFile);
var sfs = require('safe-replace');
var mkdirpAsync = util.promisify(require('mkdirp'));
var os = require("os");
// create():
// Your storage plugin may take special options, or it may not.
// If it does, document to your users that they must call create() with those options.
// If you user does not call create(), greenlock will call it for you with the options it has.
// It's kind of stupid, but it's done this way so that it can be more convenient for users to not repeat shared options
// (such as the config directory), but sometimes configs would clash. I hate having ambiguity, so I may change this in
// a future version, but it's very much an issue of "looks cleaner" vs "behaves cleaner".
module.exports.create = function (config) {
// This file has been laid out in the order that options are used and calls are made
// SNICallback() // le-sni-auto has a cache
// greenlock.approveDomains()
// // you get opts.domain passed to you from SNI
// // you should set opts.subject as the cert "id" domain
// // you should set opts.domains as all domains on the cert
// // you should set opts.account.id, otherwise opts.email will be used
// greenlock.store.certificates.checkAsync() // on success -> SNI cache, on fail \/
// greenlock.store.accounts.checkAsync() // optional (you can always return null)
// greenlock.store.accounts.checkKeypairAsync()
// greenlock.core.RSA.generateKeypair() // TODO double check name
// greenlock.core.accounts.register() // TODO double check name
// greenlock.store.accounts.setKeypairAsync() // TODO make sure this only happens on generate
// greenlock.store.accounts.setAsync() // optional
// greenlock.store.certificates.checkKeypairAsync()
// greenlock.core.RSA.generateKeypair() // TODO double check name
// greenlock.core.certificates.register() // TODO double check name
// greenlock.store.certificates.setKeypairAsync()
// greenlock.store.certificates.setAsync()
// store
// 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).
var store = {};
// options:
//
// If your module requires options (i.e. file paths or database urls) you should check what you get from create()
// and copy over the things you'll use into this options object. You should also merge in any defaults for options
// that have not been set. This object should not be circular, should not be changed after it is set, and should
// contain every property that you can use, using falsey JSON-able values like 0, null, false, or '' for "unset"
// values.
// See the note on create() above.
store.options = mergeOptions(config);
// getOptions():
// This must be implemented for backwards compatibility. That is all.
store.getOptions = function () { return store.options; };
// set and check account keypairs and account data
store.accounts = {};
// set and check domain keypairs and domain certificates
store.certificates = {};
// certificates.checkAsync({ subject, ... }):
//
// The first check is that a certificate looked for by domain name.
// If that lookup succeeds, then nothing else needs to happen. Otherwise accounts.checkAsync will happen next.
// What should happen here is a lookup in a database (or filesystem). Generally the pattern will be to see if the
// domain is an exact match for a single-subject (single domain) or multi-subject (many domains via SANS/AltName)
// and then stripping the first part of the domain to see if there's a wildcard match. If you're clever you could
// also do these checks in parallel, but this only happens at startup and before renewal, so you don't have to get
// unless you want to for fun.
// The only input you need to be concerned with is opts.subject (which falls back to opts.domains[0] if not set).
// However, this is called after `approveDomains)`, so any options that you set there will be available here too,
// as well as any other config you might need to access from other modules, if you're doing something special.
//
// On Success: Promise.resolve({ ... }) - the pem or jwk for the certificate
// On Failure: Promise.resolve(null) - do not return undefined, do not throw, do not reject
// On Error: Promise.reject(new Error("something descriptive for the user"))
store.certificates.checkAsync = function (opts) {
// { domain, ... }
console.log('certificates.checkAsync for', opts.domain, opts.subject, opts.domains);
console.log(opts);
console.log(new Error("just for the stack trace:").stack);
// Just 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 Promise.reject(new Error("You want an error? You got it!")); }
if (opts.exampleReturnNull) { return Promise.resolve(null); }
if (opts.exampleReturnCerts) { return Promise.resolve(opts.exampleReturnCerts); }
var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
// TODO this shouldn't be necessary here (we should get it from checkKeypairAsync)
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, opts.subject), 'ascii') // 0
, readFileAsync(tameWild(certPath, opts.subject), 'ascii') // 1
, readFileAsync(tameWild(chainPath, opts.subject), 'ascii') // 2
]).then(function (all) {
return {
privkey: all[0]
, cert: all[1]
, chain: all[2]
// When using a database, these should be retrieved
// (as is they'll be read via cert-info)
//, subject: certinfo.subject
//, altnames: certinfo.altnames
//, issuedAt: certinfo.issuedAt // a.k.a. NotBefore
//, expiresAt: certinfo.expiresAt // a.k.a. NotAfter
};
}).catch(function (err) {
if ('ENOENT' === err.code) { return null; }
throw err;
});
};
// accounts.checkAsync({ accountId, email, [...] }): // Optional
//
// This is where you promise an account corresponding to the given the email and ID. All instance options
// (i.e. 'options' above, merged with other "override" or per-use options, such as from 'approveDomains)')
// are also available. You can ignore them unless your implementation is using them in some way.
// You should error if the account cannot be found (otherwise an unexpected error will be thrown)
// Although you can supply a 'check' thunk (node-style callback) here, it's going to be converted to a proper
// promise, so just go ahead and use that from the get-go.
//
// On Success: Promise.resolve({ id, keypair, ... }) - an id and, for backwards compatibility, the abstract keypair
// On Failure: Promise.resolve(null) - do not return undefined, do not throw, do not reject
// On Error: Promise.reject(new Error("something descriptive for the user"))
store.accounts.checkAsync = function (opts) {
var id = opts.account.id || 'single-user';
console.log('accounts.checkAsync for', id);
// Since accounts are based on public key, the act of creating a new account or returning an existing account
// are the same in regards to the API and so we don't really need to store the account id or retrieve it.
// This method only needs to be implemented if you need it for your own purposes
return Promise.resolve(null);
};
// accounts.checkKeypairAsync({ email, ... }):
//
// Same rules as above apply, except for the private key of the account, not the account object itself.
//
// On Success: Promise.resolve({ ... }) - the abstract object representing the keypair
// On Failure: Promise.resolve(null) - do not return undefined, do not throw, do not reject
// On Error: Promise.reject(new Error("something descriptive for the user"))
store.accounts.checkKeypairAsync = function (opts) {
var id = opts.account.id || 'single-user';
console.log('accounts.checkKeypairAsync for', id);
if (!opts.account.id) { return Promise.reject(new Error("'account.id' should have been set in approveDomains()")); }
var pathname = path.join(tameWild(opts.accountsDir, opts.subject), sanitizeFilename(id) + '.json');
return readFileAsync(tameWild(pathname, opts.subject), 'utf8').then(function (blob) {
// keypair is an opaque object that should be treated as blob
return JSON.parse(blob);
}).catch(function (err) {
if ('ENOENT' === err.code) { return null; }
throw err;
});
};
// accounts.setKeypairAsync({ keypair, email, ... }):
//
// The keypair details (RSA, ECDSA, etc) are chosen either by the greenlock defaults, global user defaults,
// or whatever you set in approveDomains)
//
// On Success: Promise.resolve(null) - just knowing the operation is successful will do
// On Error: Promise.reject(new Error("something descriptive for the user"))
store.accounts.setKeypairAsync = function (opts, keypair) {
var id = opts.account.id || 'single-user';
console.log('accounts.setKeypairAsync for', id);
keypair = opts.keypair || keypair;
if (!opts.account.id) { return Promise.reject(new Error("'account.id' should have been set in approveDomains()")); }
return mkdirpAsync(tameWild(opts.accountsDir, opts.subject)).then(function () {
// keypair is an opaque object that should be treated as blob
var pathname = tameWild(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), opts.subject);
return writeFileAsync(tameWild(pathname, opts.subject), JSON.stringify(keypair), 'utf8');
});
};
// accounts.setAsync({ account, keypair, email, ... }):
//
// The account details, from ACME, if everything is successful.
//
// On Success: Promise.resolve(null||{ id }) - do not return undefined, do not throw, do not reject
// On Error: Promise.reject(new Error("something descriptive for the user"))
store.accounts.setAsync = function (opts, receipt) {
receipt = opts.receipt || receipt;
console.log('account.setAsync:', receipt);
return Promise.resolve(null);
};
// certificates.checkKeypairAsync({ subject, ... }):
//
// Same rules as above apply, except for the private key of the certificate, not the public certificate itself.
store.certificates.checkKeypairAsync = function (opts) {
console.log('certificates.checkKeypairAsync:');
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) {
// keypair is normally an opaque object, but here it's a pem for the filesystem
return { privateKeyPem: key };
}).catch(function (err) {
if ('ENOENT' === err.code) { return null; }
throw err;
});
};
// certificates.setKeypairAsync({ domain, keypair, ... }):
//
// Same as accounts.setKeypairAsync, but by domains rather than email / accountId
store.certificates.setKeypairAsync = function (opts, keypair) {
keypair = opts.keypair || keypair;
var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject);
var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem');
// keypair is normally an opaque object, but here it's a PEM for the FS
return mkdirpAsync(tameWild(path.dirname(privkeyPath), opts.subject)).then(function () {
return writeFileAsync(tameWild(privkeyPath, opts.subject), keypair.privateKeyPem, 'ascii').then(function () {
return null;
});
});
};
// certificates.setAsync({ domain, certs, ... }):
//
// This is where certificates are set, as well as certinfo
store.certificates.setAsync = function (opts) {
console.log('certificates.setAsync:');
console.log(opts.domain, '<=', opts.subject);
var pems = {
privkey: opts.pems.privkey
, cert: opts.pems.cert
, chain: opts.pems.chain
};
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 privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.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 () {
return null;
});
};
return store;
};
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);
}