'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('Greenlock Manager Config File: ' + opts.configFile); } opts.configFile = opts.configFile.replace('~/', homedir + '/'); var manage = {}; manage._txPromise = Promise.resolve(); manage.defaults = 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.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 subscriberEmail = args.subscriberEmail; var subject = args.subject || args.domain; var primary = subject; var altnames = args.servernames || 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 existing = config.sites[primary]; var site = existing; if (!existing) { site = config.sites[primary] = { altnames: [primary] }; } // 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 if (subscriberEmail) { site.subscriberEmail = subscriberEmail; } site.subject = subject; site.renewAt = args.renewAt || site.renewAt || 0; if ( altnames .slice(0) .sort() .join(' ') !== site.altnames.slice(0).sort().join(' ') ) { // TODO signal to wait for renewal? // it will definitely be renewed on the first request anyway site.renewAt = 0; } site.altnames = altnames; if (!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 if (args.customerEmail) { site.customerEmail = args.customerEmail; } if (args.challenges) { site.challenges = args.challenges; } if (args.store) { site.store = args.store; } return manage._save(config).then(function() { return JSON.parse(JSON.stringify(site)); }); }); }); return manage._txPromise; }; manage.find = function(args) { return _find(args).then(function(existing) { if (!opts.find) { return existing; } return Promise.resolve(opts.find(args)).then(function(results) { // TODO also detect and delete stale (just ignoring them for now) var changed = []; var same = []; results.forEach(function(_newer) { // Check lowercase subject names var subject = (_newer.subject || '').toLowerCase(); // Set the default altnames to the subject, just in case var altnames = _newer.altnames || []; if (!altnames.includes(subject)) { console.warn( "all site configs should include 'subject' and 'altnames': " + subject ); altnames.push(subject); } existing.some(function(_older) { if (subject !== (_older.subject || '').toLowerCase()) { return false; } _newer._exists = true; // Compare the altnames and update if needed if ( altnames .slice(0) .sort() .join(' ') !== (_older.altnames || []) .slice(0) .sort() .join(' ') ) { _older.renewAt = 0; _older.altnames = altnames; // TODO signal waitForRenewal (although it'll update on the first access automatically) changed.push(_older); } else { same.push(_older); } return true; }); if (!_newer._exists) { changed.push({ subject: subject, altnames: altnames, renewAt: 0 }); } }); if (!changed.length) { return same; } // kinda redundant to pull again, but whatever... return Manage._getLatest(manage, opts).then(function(config) { changed.forEach(function(site) { config.sites[site.subject] = site; }); return manage._save(config).then(function() { // everything was either added, updated, or not different // hence, this is everything var all = changed.concat(same); return all; }); }); }); }); }; function _find(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 || Infinity; var expiresBefore = args.expiresBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000; var all = !args.altnames; var altnames = (args.altnames || args.domains || []).slice(0); if (args.servername && !altnames.includes(args.servername)) { altnames.push(args.servername); } if (args.wildname && !altnames.includes(args.wildname)) { altnames.push(args.wildname); } // TODO match ANY domain on any cert var sites = Object.keys(config.sites || {}) .filter(function(sub) { var site = config.sites[sub]; if (site.deletedAt) { return false; } if (site.expiresAt >= expiresBefore) { return false; } if (site.issuedAt >= issuedBefore) { return false; } // if subject is specified, don't return anything else if (args.subject) { if (site.subject === args.subject) { return true; } } // altnames, servername, and wildname all get rolled into one return ( all || (site.altnames || []).some(function(name) { return altnames.includes(name); }) ); }) .map(function(name) { var site = config.sites[name]; 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.notify = opts.notify || _notify; function _notify(ev, args) { if (!args) { args = ev; ev = args.event; delete args.event; } // TODO define message types if (!manage._notify_notice) { console.info( 'set greenlockOptions.notify to override the default logger' ); manage._notify_notice = true; } switch (ev) { case 'error': /* falls through */ case 'warning': console.error( 'Error%s:', (' ' + (args.context || '')).trimRight() ); console.error(args.message); if (args.description) { console.error(args.description); } if (args.code) { console.error('code:', args.code); } break; default: if (/status/.test(ev)) { console.info( ev, args.altname || args.subject || '', args.status || '' ); if (!args.status) { console.info(args); } break; } console.info( ev, '(more info available: ' + Object.keys(args).join(' ') + ')' ); } } 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; return manage._save(config); }); }); return manage._txPromise; }; 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._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; }); }); }); }); }; 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; }); }); };