initial commit

This commit is contained in:
AJ ONeal 2019-04-01 01:56:41 -06:00
commit 2b4b714126
6 changed files with 565 additions and 0 deletions

61
.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env

41
LICENSE Normal file
View File

@ -0,0 +1,41 @@
Copyright 2019 AJ ONeal
This is open source software; you can redistribute it and/or modify it under the
terms of either:
a) the "MIT License"
b) the "Apache-2.0 License"
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Apache-2.0 License Summary
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

107
README.md Normal file
View File

@ -0,0 +1,107 @@
# le-store-fs
A greenlock keypair and certificate storage strategy with wildcard support (simpler successor to le-store-certbot).
# Usage
```js
var greenlock = require('greenlock');
var gl = greenlock.create({
configDir: '~/.config/acme'
, store: require('le-store-fs')
, approveDomains: approveDomains
, ...
});
```
# File System
The default file system layout mirrors that of le-store-certbot in order to make transitioning effortless,
in most situations:
```
acme
├── accounts
│   └── acme-staging-v02.api.letsencrypt.org
│   └── directory
│   └── sites@example.com.json
└── live
├── example.com
│   ├── bundle.pem
│   ├── cert.pem
│   ├── chain.pem
│   ├── fullchain.pem
│   └── privkey.pem
└── www.example.com
├── bundle.pem
├── cert.pem
├── chain.pem
├── fullchain.pem
└── privkey.pem
```
# Wildcards & AltNames
Working with wildcards and multiple altnames requires greenlock >= v2.7.
To do so you must set `opts.subject` and `opts.domains` 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`.
## Simple Example
```js
function approveDomains(opts, certs, cb) {
// foo.example.com => *.example.com
var wild = '*.' + opts.domain.split('.').slice(1).join('.');
if ('*.example.com' !== wild) { cb(new Error(opts.domain + " is not allowed")); }
opts.subject = '*.example.com';
opts.domains = ['*.example.com'];
cb({ options: opts, certs: certs });
}
```
## Realistic Example
```js
function approveDomains(opts, certs, cb) {
var related = getRelated(opts.domain);
if (!related) { cb(new Error(opts.domain + " is not allowed")); };
opts.subject = related.subject;
opts.domains = related.domains;
cb({ options: opts, certs: certs });
}
```
```js
function getRelated(domain) {
var related;
var wild = '*.' + domain.split('.').slice(1).join('.');
if (Object.keys(allAllowedDomains).some(function (k) {
return allAllowedDomains[k].some(function (name) {
if (domain === name || wild === name) {
related = { subject: k, altnames: allAllowedDomains[k] };
return true;
}
});
})) {
return related;
}
}
```
```js
var allAllowedDomains = {
'example.com': ['example.com', '*.example.com']
, 'example.net': ['example.net', '*.example.net']
}
```

299
index.js Normal file
View File

@ -0,0 +1,299 @@
'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
// greenlock.approveDomains)
// greenlock.store.certificates.checkAsync()
// greenlock.store.accounts.checkAsync()
// greenlock.store.accounts.setKeypairAsync()
// greenlock.store.accounts.setAsync()
// greenlock.store.certificates.checkKeypairAsync()
// 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(privkeyPath, 'ascii') // 0
, readFileAsync(certPath, 'ascii') // 1
, readFileAsync(chainPath, '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()")); }
return readFileAsync(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), '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(opts.accountsDir).then(function () {
// keypair is an opaque object that should be treated as blob
return writeFileAsync(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), 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(privkeyPath, '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(path.dirname(privkeyPath)).then(function () {
return writeFileAsync(privkeyPath, 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(certPath)).then(function () {
return mkdirpAsync(path.dirname(chainPath)).then(function () {
return mkdirpAsync(path.dirname(fullchainPath)).then(function () {
return mkdirpAsync(path.dirname(bundlePath)).then(function () {
return PromiseA.all([
sfs.writeFileAsync(certPath, pems.cert, 'ascii')
, sfs.writeFileAsync(chainPath, pems.chain, 'ascii')
// Most platforms need these two
, sfs.writeFileAsync(fullchainPath, [ pems.cert, pems.chain ].join('\n'), 'ascii')
//, sfs.writeFileAsync(privkeyPath, pems.privkey, 'ascii')
// HAProxy needs "bundle.pem" aka "combined.pem"
, sfs.writeFileAsync(bundlePath, [ pems.privkey, pems.cert, pems.chain ].join('\n'), '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, '_');
}

26
package-lock.json generated Normal file
View File

@ -0,0 +1,26 @@
{
"name": "le-store-json",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
},
"mkdirp": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"requires": {
"minimist": "0.0.8"
}
},
"safe-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-replace/-/safe-replace-1.1.0.tgz",
"integrity": "sha512-9/V2E0CDsKs9DWOOwJH7jYpSl9S3N05uyevNjvsnDauBqRowBPOyot1fIvV5N2IuZAbYyvrTXrYFVG0RZInfFw=="
}
}
}

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "le-store-fs",
"version": "0.9.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",
"directories": {
"test": "tests"
},
"scripts": {
"test": "node tests"
},
"repository": {
"type": "git",
"url": "https://git.coolaj86.com/coolaj86/le-store-fs.js.git"
},
"keywords": [
"greenlock",
"json",
"keypairs",
"certificates",
"store",
"database"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"dependencies": {
"mkdirp": "^0.5.1",
"safe-replace": "^1.1.0"
}
}