468 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			468 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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 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 (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;
 | |
| 			});
 | |
| 		});
 | |
| };
 |