wip: bring up to date with latest v3

This commit is contained in:
AJ ONeal 2019-04-08 00:14:28 -06:00
parent 317dc3853f
commit 879b278d5f
3 changed files with 235 additions and 175 deletions

View File

@ -1,6 +1,15 @@
# le-store-fs
# [greenlock-store-fs](https://git.coolaj86.com/coolaj86/greenlock-store-fs.js)
A greenlock keypair and certificate storage strategy with wildcard support (simpler successor to le-store-certbot).
| A [Root](https://rootprojects.org) project |
A keypair and certificate storage strategy for Greenlock v2.7+ (and v3).
The (much simpler) successor to le-store-certbot.
Works with all ACME (Let's Encrypt) SSL certificate sytles:
* [x] single domains
* [x] multiple domains (SANs, AltNames)
* [x] wildcards
* [x] private / localhost domains
# Usage
@ -8,7 +17,7 @@ A greenlock keypair and certificate storage strategy with wildcard support (simp
var greenlock = require('greenlock');
var gl = greenlock.create({
configDir: '~/.config/acme'
, store: require('le-store-fs')
, store: require('greenlock-store-fs')
, approveDomains: approveDomains
, ...
});
@ -42,16 +51,17 @@ acme
# Wildcards & AltNames
Working with wildcards and multiple altnames requires greenlock >= v2.7.
Working with wildcards and multiple altnames requires greenlock >= v2.7 (or v3).
To do so you must set `opts.subject` and `opts.domains` within the `approvedomains()` callback.
To do so you must return `{ subject: '...', altnames: ['...', ...] }` within the `approveDomains()` callback.
`subject` refers to "the subject of the ssl certificate" as opposed to `domain` which indicates "the domain servername
used in the current request". For single-domain certificates they're always the same, but for multiple-domain
certificates `subject` must be the name no matter what `domain` is receiving a request. `subject` is used as
part of the name of the file storage path where the certificate will be saved (or retrieved).
`domains` should be the list of "altnames" on the certificate, which should include the `subject`.
`altnames` should be the list of SubjectAlternativeNames (SANs) on the certificate.
The subject and the first altname must be an exact match: `subject === altnames[0]`.
## Simple Example
@ -61,14 +71,13 @@ function approveDomains(opts) {
// foo.example.com => *.example.com
var wild = '*.' + opts.domain.split('.').slice(1).join('.');
if ('example.com' !== opts.domain && '*.example.com' !== wild) {
cb(new Error(opts.domain + " is not allowed"));
}
opts.subject = 'example.com';
opts.domains = [ 'example.com', '*.example.com' ];
return Promise.resolve(opts);
var result = { subject: 'example.com', altnames: [ 'example.com', '*.example.com' ] };
return Promise.resolve(result);
}
```

377
index.js
View File

@ -1,250 +1,276 @@
'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 os = require("os");
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");
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'));
// 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".
// 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) {
// 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 -> checkAccount
// 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.
// 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);
// set and check account keypairs and account data
store.accounts = {};
// set and check domain keypairs and domain certificates
store.certificates = {};
// certificates.checkAsync({ subject, ... }):
// Certificates.check
//
// The first check is that a certificate looked for by its subject (primary domain name).
// If that lookup succeeds, then nothing else needs to happen. Otherwise accounts.checkAsync will happen next.
// The only input you need to be concerned with is opts.subject (which falls back to opts.domains[0] if not set).
// And since this is called after `approveDomains()`, any options that you set there will be available here too.
store.certificates.checkAsync = function (opts) {
// { certificate.id, subject, domains, ... }
// 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.checkAsync for', opts.domain, opts.subject, opts.domains);
//console.log('certificates.check for', opts.certificate, opts.subject);
//console.log(opts);
// Just to show that any options set in approveDomains() will be available here
// 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);
// 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([
// all other PEM files are arrangements of these three
readFileAsync(tameWild(privkeyPath, id), 'ascii') // 0
, readFileAsync(tameWild(certPath, id), 'ascii') // 1
, readFileAsync(tameWild(chainPath, id), 'ascii') // 2
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) {
// Success
////////////////////////
// PAY ATTENTION HERE //
////////////////////////
// This is all you have to return: cert, chain
return {
privkey: all[0]
, cert: all[1]
, chain: all[2]
// When using a database, these should be retrieved too
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
//, altnames: certinfo.altnames
//, issuedAt: certinfo.issuedAt // a.k.a. NotBefore
//, expiresAt: certinfo.expiresAt // a.k.a. NotAfter
//, 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) {
// Non-success
// Treat non-exceptional failures as null returns (not undefined)
if ('ENOENT' === err.code) { return null; }
// Failure
throw err;
throw err; // True exceptions should be thrown
});
};
// accounts.checkAsync({ accountId, email, [...] }): // Optional
//
// This is where you promise an account corresponding to the given the email and ID. All options set in
// approveDomains() are also available. You can ignore them unless your implementation is using them in some way.
//
// 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
//
// 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);
return PromiseA.resolve(null);
};
// accounts.checkKeypairAsync({ email, ... }):
// 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
//
// 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 PromiseA.reject(new Error("'account.id' should have been set in approveDomains()")); }
// 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 is an opaque object that should be treated as blob
return JSON.parse(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.setKeypairAsync({ keypair, email, ... }):
// Accounts.setKeypair({ account, email, keypair, ... }):
//
// The keypair details (RSA, ECDSA, etc) are chosen either by the greenlock defaults, global user defaults,
// or whatever you set in approveDomains(). This is called *after* the account is successfully created.
//
// 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) {
// 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';
//console.log('accounts.setKeypairAsync for', id);
keypair = opts.keypair || keypair;
if (!opts.account.id) { return PromiseA.reject(new Error("'account.id' should have been set in approveDomains()")); }
// 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 () {
// 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');
return writeFileAsync(tameWild(pathname, opts.subject), keyblob, 'utf8');
}).then(function () {
// This is your job: return null, not undefined
return null;
});
};
// accounts.setAsync({ account, keypair, email, ... }):
//
// The account details, from ACME, if everything is successful. Unless you need to do something with those account
// details, this implementation can remain empty.
//
// 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 = opts.receipt || receipt;
//console.log('account.setAsync:', receipt);
return PromiseA.resolve(null);
};
// certificates.checkKeypairAsync({ subject, ... }):
// 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
//
// Same rules as certificates.checkAsync apply, except for the private key of the certificate, not the public
// certificate itself (similar to accounts.checkKeyPairAsync, but for certs).
store.certificates.checkKeypairAsync = function (opts) {
//console.log('certificates.checkKeypairAsync:');
// 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) {
// keypair is normally an opaque object, but here it's a pem for the filesystem
return { privateKeyPem: 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.setKeypairAsync({ domain, keypair, ... }):
// Certificates.setKeypair({ certificate, subject, keypair, ... }):
//
// Same as accounts.setKeypairAsync, but by domains rather than email / accountId
store.certificates.setKeypairAsync = function (opts, keypair) {
keypair = opts.keypair || 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');
// 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 () {
// 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.setAsync({ domain, certs, ... }):
// Certificates.set({ subject, pems, ... }):
//
// 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);
// 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 = {
privkey: opts.pems.privkey
, cert: opts.pems.cert
, chain: opts.pems.chain
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 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 () {
@ -263,13 +289,21 @@ module.exports.create = function (config) {
});
});
}).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')
@ -307,3 +341,20 @@ 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;
}

View File

@ -1,6 +1,6 @@
{
"name": "le-store-fs",
"version": "1.0.3",
"name": "greenlock-store-fs",
"version": "3.0.0",
"description": "A file-based certificate store for greenlock that supports wildcards.",
"homepage": "https://git.coolaj86.com/coolaj86/le-store-fs.js",
"main": "index.js",