diff --git a/manager.js b/manager.js new file mode 100644 index 0000000..affc0ab --- /dev/null +++ b/manager.js @@ -0,0 +1,341 @@ +'use strict'; + +var Manage = module.exports; +var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' }); +var promisify = require('util').promisify; +var fs = require('fs'); +var readFile = promisify(fs.readFile); +var statFile = promisify(fs.stat); +var chmodFile = promisify(fs.chmod); +var homedir = require('os').homedir(); +var path = require('path'); +var mkdirp = promisify(require('@root/mkdirp')); + +Manage.create = function(opts) { + if (!opts) { + opts = {}; + } + if (!opts.configFile) { + opts.configFile = '~/.config/greenlock/manager.json'; + console.info( + "[Manager] using default config file:\n\t'" + opts.configFile + "'" + ); + } + opts.configFile = opts.configFile.replace('~/', homedir + '/'); + + var manage = {}; + + manage.ping = function() { + return Manage._ping(manage, opts); + }; + + manage._txPromise = new Promise(function(resolve) { + resolve(); + }); + + manage.config = function(conf) { + // get / set default site settings such as + // subscriberEmail, store, challenges, renewOffset, renewStagger + return Manage._getLatest(manage, opts).then(function(config) { + if (!conf) { + conf = JSON.parse(JSON.stringify(config)); + delete conf.sites; + return conf; + } + + // TODO set initial sites + if (conf.sites) { + throw new Error('cannot set sites as global config'); + } + + // TODO whitelist rather than blacklist? + if ( + [ + 'subject', + 'altnames', + 'lastAttemptAt', + 'expiresAt', + 'issuedAt', + 'renewAt' + ].some(function(k) { + if (k in conf) { + throw new Error( + '`' + k + '` not allowed as a default setting' + ); + } + }) + ) { + } + + Object.keys(conf).forEach(function(k) { + if (-1 !== ['sites', 'module', 'manager'].indexOf(k)) { + return; + } + + if ('undefined' === typeof k) { + throw new Error( + "'" + + k + + "' should be set to a value, or `null`, but not left `undefined`" + ); + } + + if (null === k) { + delete config[k]; + } + + config[k] = conf[k]; + }); + + return manage.save(config); + }); + }; + + manage._lastStat = { + size: 0, + mtimeMs: 0 + }; + manage._config = {}; + + manage._save = function(config) { + return mkdirp(path.dirname(opts.configFile)).then(function() { + return sfs + .writeFileAsync( + opts.configFile, + // pretty-print the config file + JSON.stringify(config, null, 2), + 'utf8' + ) + .then(function() { + // this file may contain secrets, so keep it safe + return chmodFile(opts.configFile, parseInt('0600', 8)) + .catch(function() { + /*ignore for Windows */ + }) + .then(function() { + return statFile(opts.configFile).then(function( + stat + ) { + manage._lastStat.size = stat.size; + manage._lastStat.mtimeMs = stat.mtimeMs; + }); + }); + }); + }); + }; + + manage.add = function(args) { + manage._txPromise = manage._txPromise.then(function() { + // if the fs has changed since we last wrote, get the lastest from disk + return Manage._getLatest(manage, opts).then(function(config) { + // TODO move to Greenlock.add + var subject = args.subject || args.domain; + var primary = subject; + var altnames = args.altnames || args.domains; + if ('string' !== typeof primary) { + if (!Array.isArray(altnames) || !altnames.length) { + throw new Error('there needs to be a subject'); + } + primary = altnames.slice(0).sort()[0]; + } + if (!Array.isArray(altnames) || !altnames.length) { + altnames = [primary]; + } + primary = primary.toLowerCase(); + altnames = altnames.map(function(name) { + return name.toLowerCase(); + }); + + if (!config.sites) { + config.sites = {}; + } + + var site = config.sites[primary]; + if (!site) { + site = config.sites[primary] = { altnames: [] }; + } + + // The goal is to make this decently easy to manage by hand without mistakes + // but also reasonably easy to error check and correct + // and to make deterministic auto-corrections + + // TODO added, removed, moved (duplicate), changed + site.subscriberEmail = site.subscriberEmail; + site.subject = subject; + site.altnames = altnames; + site.issuedAt = site.issuedAt || 0; + site.expiresAt = site.expiresAt || 0; + site.lastAttemptAt = site.lastAttemptAt || 0; + // re-add if this was deleted + site.deletedAt = 0; + if ( + site.altnames + .slice(0) + .sort() + .join() !== + altnames + .slice(0) + .sort() + .join() + ) { + site.expiresAt = 0; + site.issuedAt = 0; + } + + // These should usually be empty, for most situations + site.subscriberEmail = args.subscriberEmail; + site.customerEmail = args.customerEmail; + site.challenges = args.challenges; + site.store = args.store; + console.log('[debug] save site', site); + + return manage._save(config).then(function() { + return JSON.parse(JSON.stringify(site)); + }); + }); + }); + return manage._txPromise; + }; + + manage.find = function(args) { + return Manage._getLatest(manage, opts).then(function(config) { + // i.e. find certs more than 30 days old + //args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000; + // i.e. find certs more that will expire in less than 45 days + //args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000; + var issuedBefore = args.issuedBefore || 0; + var expiresBefore = + args.expiresBefore || Date.now() + 21 * 24 * 60 * 60 * 1000; + + // TODO match ANY domain on any cert + var sites = Object.keys(config.sites) + .filter(function(sub) { + var site = config.sites[sub]; + if ( + !site.deletedAt || + site.expiresAt < expiresBefore || + site.issuedAt < issuedBefore + ) { + if (!args.subject || sub === args.subject) { + return true; + } + } + }) + .map(function(name) { + var site = config.sites[name]; + console.debug('debug', site); + return { + subject: site.subject, + altnames: site.altnames, + issuedAt: site.issuedAt, + expiresAt: site.expiresAt, + renewOffset: site.renewOffset, + renewStagger: site.renewStagger, + renewAt: site.renewAt, + subscriberEmail: site.subscriberEmail, + customerEmail: site.customerEmail, + challenges: site.challenges, + store: site.store + }; + }); + + return sites; + }); + }; + + manage.remove = function(args) { + if (!args.subject) { + throw new Error('should have a subject for sites to remove'); + } + manage._txPromise = manage.txPromise.then(function() { + return Manage._getLatest(manage, opts).then(function(config) { + var site = config.sites[args.subject]; + if (!site) { + return {}; + } + site.deletedAt = Date.now(); + + return JSON.parse(JSON.stringify(site)); + }); + }); + return manage._txPromise; + }; + + manage.notify = manage.notifications = function(ev, args) { + if (!args) { + args = ev; + ev = args.event; + delete args.event; + } + + // TODO define message types + console.info(ev, args); + }; + + manage.errors = function(err) { + // err.subject + // err.altnames + // err.challenge + // err.challengeOptions + // err.store + // err.storeOptions + console.error('Failure with ', err.subject); + }; + + manage.update = function(args) { + manage._txPromise = manage.txPromise.then(function() { + return Manage._getLatest(manage, opts).then(function(config) { + var site = config.sites[args.subject]; + site.issuedAt = args.issuedAt; + site.expiresAt = args.expiresAt; + site.renewAt = args.renewAt; + // foo + }); + }); + return manage._txPromise; + }; + + return manage; +}; + +Manage._getLatest = function(mng, opts) { + return statFile(opts.configFile) + .catch(function(err) { + if ('ENOENT' === err.code) { + return { + size: 0, + mtimeMs: 0 + }; + } + err.context = 'manager_read'; + throw err; + }) + .then(function(stat) { + if ( + stat.size === mng._lastStat.size && + stat.mtimeMs === mng._lastStat.mtimeMs + ) { + return mng._config; + } + return readFile(opts.configFile, 'utf8').then(function(data) { + mng._lastStat = stat; + mng._config = JSON.parse(data); + return mng._config; + }); + }); +}; + +Manage._ping = function(mng, opts) { + if (mng._pingPromise) { + return mng._pingPromise; + } + + mng._pringPromise = Promise.resolve().then(function() { + // TODO file permissions + if (!opts.configFile) { + throw new Error('no config file location provided'); + } + JSON.parse(fs.readFileSync(opts.configFile, 'utf8')); + }); + return mng._pingPromise; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f9d809 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "@greenlock/manager-fs", + "version": "0.1.0", + "description": "A simple file-based management strategy for Greenlock", + "main": "manager.js", + "scripts": { + "test": "node tests" + }, + "repository": { + "type": "git", + "url": "https://git.coolaj86.com/coolaj86/greenlock-manager-fs.js.git" + }, + "keywords": [ + "Greenlock", + "manager", + "letsencrypt", + "acme" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0" +}