From 0404a2743c865b2b055844c62124ff200e849d53 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 03:41:42 -0400 Subject: [PATCH] initial commit --- README.md | 69 ++++++- index.js | 536 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 index.js diff --git a/README.md b/README.md index 2241646..e33b3ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,69 @@ -# le-store-certbot +le-store-certbot +================ + The "certbot" storage strategy for node-letsencrypt. + +This le storage strategy aims to maintain compatibility with the +configuration files and file structure of the official certbot client. + +Note: You cannot use this strategy on ephemeral instances (heroku, aws elastic). + +Usage +----- + +```bash +npm install --save le-store-certbot +``` + +```bash +var leStore = require('le-store-certbot').create({ + configDir: require('homedir')() + '/letsencrypt/etc' // or /etc/letsencrypt or wherever +, privkeyPath: ':config/live/:hostname/privkey.pem' // +, fullchainPath: ':config/live/:hostname/fullchain.pem' // Note: both that :config and :hostname +, certPath: ':config/live/:hostname/cert.pem' // will be templated as expected by +, chainPath: ':config/live/:hostname/chain.pem' // node-letsencrypt + +, workDir: require('homedir')() + '/letsencrypt/var/lib' +, logsDir: require('homedir')() + '/letsencrypt/var/log' + +, debug: false +}); + +var LE = require('letsencrypt'); + +LE.create({ + server: LE.stagingServerUrl // Change to LE.productionServerUrl in production +, store: leStore +}); +``` + +Example File Structure +---------------------- + +``` +~/letsencrypt/ +└── etc + ├── accounts + │   └── acme-staging.api.letsencrypt.org + │   └── directory + │   └── cd96ac4889ddfa47bfc66300ab223342 + │   ├── meta.json + │   ├── private_key.json + │   └── regr.json + ├── archive + │   └── example.daplie.me + │   ├── cert0.pem + │   ├── chain0.pem + │   ├── fullchain0.pem + │   └── privkey0.pem + ├── live + │   └── example.daplie.me + │   ├── cert.pem + │   ├── chain.pem + │   ├── fullchain.pem + │   ├── privkey.pem + │   └── privkey.pem.bak + └── renewal + ├── example.daplie.me.conf + └── example.daplie.me.conf.bak +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..427253c --- /dev/null +++ b/index.js @@ -0,0 +1,536 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var mkdirpAsync = PromiseA.promisify(require('mkdirp')); +var path = require('path'); +var fs = PromiseA.promisifyAll(require('fs')); +var sfs = require('safe-replace'); +var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")"); + +var fetchFromConfigLiveDir = function (args) { + // TODO NO HARD-CODED DEFAULTS + if (!args.fullchainPath || !args.privkeyPath || !args.certPath || !args.chainPath) { + console.warn("missing one or more of args.privkeyPath, args.fullchainPath, args.certPath, args.chainPath"); + console.warn("hard-coded conventional pathnames were for debugging and are not a stable part of the API"); + } + + //, fs.readFileAsync(fullchainPath, 'ascii') + // note: if this ^^ gets added back in, the arrays below must change + return PromiseA.all([ + fs.readFileAsync(args.privkeyPath, 'ascii') // 0 + , fs.readFileAsync(args.certPath, 'ascii') // 1 + , fs.readFileAsync(args.chainPath, 'ascii') // 2 + + // stat the file, not the link + , fs.statAsync(args.certPath) // 3 + ]).then(function (arr) { + return { + key: arr[0] // privkey.pem + , privkey: arr[0] // privkey.pem + + , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem + , cert: arr[1] // cert.pem + /* + , issuedAt: arr[3].mtime.valueOf() + , expiresAt: arr[3].mtime.valueOf() + (90 * 24 * 60 * 60 * 100) + */ + + , chain: arr[2] // chain.pem + , ca: arr[2] // chain.pem + + , privkeyPath: args.privkeyPath + , fullchainPath: args.fullchainPath + , certPath: args.certPath + , chainPath: args.chainPath + }; + }, function (err) { + if (args.debug) { + console.error("[letsencrypt/lib/common.js] fetchFromDisk"); + console.error(err.stack); + } + return null; + }); +}; + +function getAccount(args) { + var accountId = args.accountId; + var accountDir = path.join(args.accountsDir, accountId); + var files = {}; + var configs = [ 'meta.json', 'private_key.json', 'regr.json' ]; + + return PromiseA.all(configs.map(function (filename) { + var keyname = filename.slice(0, -5); + + return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { + var data; + + try { + data = JSON.parse(text); + } catch(e) { + files[keyname] = { error: e }; + return; + } + + files[keyname] = data; + }, function (err) { + files[keyname] = { error: err }; + }); + })).then(function () { + var err; + + if (!Object.keys(files).every(function (key) { + return !files[key].error; + }) || !files.private_key || !files.private_key.n) { + err = new Error("Account '" + accountId + "' was corrupt. No big deal (I think?). Creating a new one..."); + err.code = 'E_ACCOUNT_CORRUPT'; + err.data = files; + return PromiseA.reject(err); + } + + //files.private_key; + //files.regr; + //files.meta; + files.accountId = accountId; // preserve current account id + files.id = accountId; + files.keypair = { privateKeyJwk: files.private_key }; + + return files; + }); +} + +function getAccountIdByEmail(args) { + // If we read 10,000 account directories looking for + // just one email address, that could get crazy. + // We should have a folder per email and list + // each account as a file in the folder + // TODO + var email = args.email; + if ('string' !== typeof email) { + if (args.debug) { + console.log("[LE] No email given"); + } + return PromiseA.resolve(null); + } + return fs.readdirAsync(args.accountsDir).then(function (nodes) { + if (args.debug) { + console.log("[LE] arg.accountsDir success"); + } + + return PromiseA.all(nodes.map(function (node) { + return fs.readFileAsync(path.join(args.accountsDir, node, 'regr.json'), 'utf8').then(function (text) { + var regr = JSON.parse(text); + regr.__accountId = node; + + return regr; + }); + })).then(function (regrs) { + var accountId; + + /* + if (args.debug) { + console.log('read many regrs'); + console.log('regrs', regrs); + } + */ + + regrs.some(function (regr) { + return regr.body.contact.some(function (contact) { + var match = contact.toLowerCase() === 'mailto:' + email.toLowerCase(); + if (match) { + accountId = regr.__accountId; + return true; + } + }); + }); + + if (!accountId) { + return null; + } + + return accountId; + }); + }).then(function (accountId) { + return accountId; + }, function (err) { + if ('ENOENT' === err.code) { + // ignore error + return null; + } + + return PromiseA.reject(err); + }); +} + +function readRenewalConfig(args) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) { + return pyobj; + }, function () { + return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) { + return pyobj; + }); + }); +} + +function writeRenewalConfig(args) { + function log() { + if (args.debug) { + console.log.apply(console, arguments); + } + } + + var pyobj = args.pyobj; + pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0; + + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); + + var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem'); + var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem'); + var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem'); + var privkeyPath = args.privkeyPath || pyobj.privkey + //|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath + || path.join(liveDir, 'privkey.pem'); + + log('[le/core.js] privkeyPath', privkeyPath); + + var updates = { + account: args.account.id + , configDir: args.configDir + , domains: args.domains + + , email: args.email + , tos: args.agreeTos && true + // yes, it's an array. weird, right? + , webrootPath: args.webrootPath && [args.webrootPath] || [] + , server: args.server || args.acmeDiscoveryUrl + + , privkey: privkeyPath + , fullchain: fullchainPath + , cert: certPath + , chain: chainPath + + , http01Port: args.http01Port + , keyPath: args.domainPrivateKeyPath || args.privkeyPath + , rsaKeySize: args.rsaKeySize + , checkpoints: pyobj.checkpoints + /* // TODO XXX what's the deal with these? they don't make sense + // are they just old junk? or do they have a meaning that I don't know about? + , fullchainPath: path.join(args.configDir, 'chain.pem') + , certPath: path.join(args.configDir, 'cert.pem') + , chainPath: path.join(args.configDir, 'chain.pem') + */ // TODO XXX end + , workDir: args.workDir + , logsDir: args.logsDir + }; + + // final section is completely dynamic + // :hostname = :webroot_path + args.domains.forEach(function (hostname) { + updates[hostname] = args.webrootPath; + }); + + // must write back to the original pyobject or + // annotations will be lost + Object.keys(updates).forEach(function (key) { + pyobj[key] = updates[key]; + }); + + return mkdirpAsync(path.dirname(args.renewalPath)).then(function () { + return pyconf.writeFileAsync(args.renewalPath, pyobj); + }).then(function () { + // NOTE + // writing twice seems to causes a bug, + // so instead we re-read the file from the disk + return pyconf.readFileAsync(args.renewalPath); + }); +} + +function getOrCreateRenewal(args) { + return readRenewalConfig(args).then(function (pyobj) { + var minver = pyobj.checkpoints >= 0; + + args.pyobj = pyobj; + + if (!minver) { + args.checkpoints = 0; + pyobj.checkpoints = 0; + return writeRenewalConfig(args); + } + + // args.account.id = pyobj.account + // args.configDir = args.configDir || pyobj.configDir; + + args.checkpoints = pyobj.checkpoints; + + args.agreeTos = (args.agreeTos || pyobj.tos) && true; + args.email = args.email || pyobj.email; + args.domains = args.domains || pyobj.domains; + + // yes, it's an array. weird, right? + args.webrootPath = args.webrootPath || pyobj.webrootPath[0]; + args.server = args.server || args.acmeDiscoveryUrl || pyobj.server; + + args.certPath = args.certPath || pyobj.cert; + args.privkeyPath = args.privkeyPath || pyobj.privkey; + args.chainPath = args.chainPath || pyobj.chain; + args.fullchainPath = args.fullchainPath || pyobj.fullchain; + + //, workDir: args.workDir + //, logsDir: args.logsDir + args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize; + args.http01Port = args.http01Port || pyobj.http01Port; + args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath; + + return writeRenewalConfig(args); + }); +} + +function writeCertificateAsync(args) { + function log() { + if (args.debug) { + console.log.apply(console, arguments); + } + } + + log("[le/core.js] got certificate!"); + + var obj = args.pyobj; + var pems = args.pems; + + pems.fullchain = pems.cert + '\n' + (pems.chain || pems.ca); + obj.checkpoints = parseInt(obj.checkpoints, 10) || 0; + + var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); + + var certPath = args.certPath || obj.cert || path.join(liveDir, 'cert.pem'); + var fullchainPath = args.fullchainPath || obj.fullchain || path.join(liveDir, 'fullchain.pem'); + var chainPath = args.chainPath || obj.chain || path.join(liveDir, 'chain.pem'); + var privkeyPath = args.privkeyPath || obj.privkey + //|| args.domainPrivateKeyPath || args.domainKeyPath || obj.keyPath + || path.join(liveDir, 'privkey.pem'); + + log('[le/core.js] privkeyPath', privkeyPath); + + var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); + + var checkpoints = obj.checkpoints.toString(); + var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem'); + var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem'); + var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem'); + var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem'); + + return mkdirpAsync(archiveDir).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certArchive, pems.cert, 'ascii') + , sfs.writeFileAsync(chainArchive, (pems.chain || pems.ca), 'ascii') + , sfs.writeFileAsync(fullchainArchive, pems.fullchain, 'ascii') + , sfs.writeFileAsync( + privkeyArchive + // TODO nix args.key, args.domainPrivateKeyPem ?? + , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) + , 'ascii' + ) + ]); + }).then(function () { + return mkdirpAsync(liveDir); + }).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certPath, pems.cert, 'ascii') + , sfs.writeFileAsync(chainPath, (pems.chain || pems.ca), 'ascii') + , sfs.writeFileAsync(fullchainPath, pems.fullchain, 'ascii') + , sfs.writeFileAsync( + privkeyPath + // TODO nix args.key, args.domainPrivateKeyPem ?? + , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) + , 'ascii' + ) + ]); + }).then(function () { + obj.checkpoints += 1; + args.checkpoints += 1; + + return writeRenewalConfig(args); + }).then(function () { + return { + certPath: certPath + , chainPath: chainPath + , fullchainPath: fullchainPath + , privkeyPath: privkeyPath + + // TODO nix keypair + , keypair: args.domainKeypair + + // TODO nix args.key, args.domainPrivateKeyPem ?? + // some ambiguity here... + , privkey: (pems.privkey || pems.key) //|| RSA.exportPrivatePem(args.domainKeypair) + , fullchain: pems.fullchain || (pems.cert + '\n' + pems.chain) + , chain: (pems.chain || pems.ca) + // especially this one... might be cert only, might be fullchain + , cert: pems.cert + + /* + , issuedAt: Date.now() + , expiresAt: Date.now() + (90 * 24 * 60 * 60 * 100) + */ + }; + }); +} + +function pyToJson(pyobj) { + if (!pyobj) { + return null; + } + + var jsobj = {}; + Object.keys(pyobj).forEach(function (key) { + jsobj[key] = pyobj[key]; + }); + jsobj.__lines = undefined; + jsobj.__keys = undefined; + + return jsobj; +} + +var defaults = { + configDir: '~/letsencrypt/etc' // /etc/letsencrypt/ +, logsDir: '~/letsencrypt/var/log' // /var/log/letsencrypt/ +, workDir: '~/letsencrypt/var/lib' // /var/lib/letsencrypt/ + +, accountsDir: ':config/accounts/:server' +, renewalPath: ':config/renewal/:hostname.conf' +, renewalDir: ':config/renewal/' + +, privkeyPath: ':config/live/:hostname/privkey.pem' +, fullchainPath: ':config/live/:hostname/fullchain.pem' +, certPath: ':config/live/:hostname/cert.pem' +, chainPath: ':config/live/:hostname/chain.pem' + +, rsaKeySize: 2048 +, webrootPath: '~/letsencrypt/srv/www/:hostname/.well-known/acme-challenge' +}; + +module.exports.create = function (configs) { + var mergedConfigs; + + function getConfigAsync(copy) { + copy.domains = []; + + return readRenewalConfig(copy).then(function (pyobj) { + var exists = pyobj.checkpoints >= 0; + if (!exists) { + return null; + } + + return pyToJson(pyobj); + }); + } + + return { + getDefaults: function () { + if (mergedConfigs) { + return configs; + } + + Object.keys(defaults).forEach(function (key) { + if (!(key in configs)) { + configs[key] = defaults[key]; + } + }); + + mergedConfigs = configs; + return configs; + } + , getPrivatePemAsync: function (args) { + return fs.readFileAsync(args.domainKeyPath, 'ascii'); + } + , setPrivatePemAsync: function (args, keypair) { + return mkdirpAsync(path.dirname(args.domainKeyPath)).then(function () { + return fs.writeFileAsync(args.domainKeyPath, keypair.privateKeyPem, 'ascii').then(function () { + return keypair; + }); + }); + } + , setRegistrationAsync: function (args) { + return writeCertificateAsync(args); + } + + , getRegistrationAsync: function (args) { + return fetchFromConfigLiveDir(args); + } + , getOrCreateRenewalAsync: function (args) { + return getOrCreateRenewal(args); + } + , getConfigAsync: getConfigAsync + , getConfigsAsync: function (copy) { + copy.domains = []; + + return fs.readdirAsync(copy.renewalDir).then(function (nodes) { + nodes = nodes.filter(function (node) { + return /^[a-z0-9]+.*\.conf$/.test(node); + }); + + return PromiseA.all(nodes.map(function (node) { + copy.domains = [node.replace(/\.conf$/, '')]; + return getConfigAsync(copy); + })); + }); + } + , fetchAsync: function (args) { + return fetchFromConfigLiveDir(args); + } + , getAccountIdByEmailAsync: getAccountIdByEmail + , getAccountAsync: getAccount + , setAccountAsync: function (args, account) { + var isoDate = new Date().toISOString(); + var os = require("os"); + var localname = os.hostname(); + var accountDir = path.join(args.accountsDir, account.accountId); + + account.meta = account.meta || { + creation_host: localname + , creation_dt: isoDate + }; + + return mkdirpAsync(accountDir).then(function () { + var RSA = require('rsa-compat').RSA; + + // TODO abstract file writing + return PromiseA.all([ + // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} + fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(account.meta), 'utf8') + // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } + , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(RSA.exportPrivateJwk(account.keypair)), 'utf8') + // regr.json: + /* + { body: { contact: [ 'mailto:coolaj86@gmail.com' ], + agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', + key: { e: 'AQAB', kty: 'RSA', n: '...' } }, + uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', + new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', + terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } + */ + , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify(account.regr), 'utf8') + ]); + }); + } + , getAccountIdAsync: function (args) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { + var accountId = renewal.account; + renewal = renewal.account; + + return accountId; + }, function (err) { + if ("ENOENT" === err.code) { + return getAccountIdByEmail(args); + } + + return PromiseA.reject(err); + }); + } + }; +};