405 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			405 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| var Manage = module.exports;
 | |
| var doctor = {};
 | |
| 
 | |
| 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'));
 | |
| 
 | |
| // NOTE
 | |
| // this is over-complicated to account for people
 | |
| // doing weird things, and this just being a file system
 | |
| // and wanting to be fairly sure it works and produces
 | |
| // meaningful errors
 | |
| 
 | |
| // For your use case you'll probably find a better example
 | |
| // in greenlock-manager-test
 | |
| 
 | |
| Manage.create = function(CONF) {
 | |
| 	if (!CONF) {
 | |
| 		CONF = {};
 | |
| 	}
 | |
| 	if (!CONF.configFile) {
 | |
| 		CONF.configFile = '~/.config/greenlock/manager.json';
 | |
| 		console.info('Greenlock Manager Config File: ' + CONF.configFile);
 | |
| 	}
 | |
| 	CONF.configFile = CONF.configFile.replace('~/', homedir + '/');
 | |
| 
 | |
| 	var manage = {};
 | |
| 
 | |
| 	manage._txPromise = Promise.resolve();
 | |
| 
 | |
| 	// Note: all of these top-level methods are effectively mutexed
 | |
| 	// You cannot call them from each other or they will deadlock
 | |
| 
 | |
| 	manage.defaults = manage.config = async function(conf) {
 | |
| 		manage._txPromise = manage._txPromise.then(async function() {
 | |
| 			var config = await Manage._getLatest(manage, CONF);
 | |
| 
 | |
| 			// act as a getter
 | |
| 			if (!conf) {
 | |
| 				conf = JSON.parse(JSON.stringify(config.defaults));
 | |
| 				return conf;
 | |
| 			}
 | |
| 
 | |
| 			// act as a setter
 | |
| 			Object.keys(conf).forEach(function(k) {
 | |
| 				// challenges are either both overwritten, or not set
 | |
| 				// this is as it should be
 | |
| 				config.defaults[k] = conf[k];
 | |
| 			});
 | |
| 
 | |
| 			return manage._save(config);
 | |
| 		});
 | |
| 
 | |
| 		return manage._txPromise;
 | |
| 	};
 | |
| 
 | |
| 	manage.set = async function(args) {
 | |
| 		manage._txPromise = manage._txPromise.then(async function() {
 | |
| 			var config = await Manage._getLatest(manage, CONF);
 | |
| 
 | |
| 			manage._merge(config, config.sites[args.subject], args);
 | |
| 
 | |
| 			await manage._save(config);
 | |
| 			return JSON.parse(JSON.stringify(config.sites[args.subject]));
 | |
| 		});
 | |
| 
 | |
| 		return manage._txPromise;
 | |
| 	};
 | |
| 
 | |
| 	manage._merge = function(config, current, args) {
 | |
| 		if (!current || current.deletedAt) {
 | |
| 			current = config.sites[args.subject] = {
 | |
| 				subject: args.subject,
 | |
| 				altnames: [],
 | |
| 				renewAt: 1
 | |
| 			};
 | |
| 		}
 | |
| 
 | |
| 		current.renewAt = parseInt(args.renewAt || current.renewAt, 10) || 1;
 | |
| 		var oldAlts;
 | |
| 		var newAlts;
 | |
| 		if (args.altnames) {
 | |
| 			// copy as to not disturb order, which matters
 | |
| 			oldAlts = current.altnames.slice(0).sort();
 | |
| 			newAlts = args.altnames.slice(0).sort();
 | |
| 
 | |
| 			if (newAlts.join() !== oldAlts.join()) {
 | |
| 				// this will cause immediate renewal
 | |
| 				args.renewAt = 1;
 | |
| 				current.altnames = args.altnames.slice(0);
 | |
| 			}
 | |
| 		}
 | |
| 		Object.keys(args).forEach(function(k) {
 | |
| 			if ('altnames' === k) {
 | |
| 				return;
 | |
| 			}
 | |
| 			current[k] = args[k];
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	// no transaction promise here because it calls set
 | |
| 	manage.find = async function(args) {
 | |
| 		var ours = await _find(args);
 | |
| 		if (!CONF.find) {
 | |
| 			return ours;
 | |
| 		}
 | |
| 
 | |
| 		// if the user has an overlay find function we'll do a diff
 | |
| 		// between the managed state and the overlay, and choose
 | |
| 		// what was found.
 | |
| 		var theirs = await CONF.find(args);
 | |
| 		var config = await Manage._getLatest(manage, CONF);
 | |
| 		return _mergeFind(config, ours, theirs);
 | |
| 	};
 | |
| 
 | |
| 	function _find(args) {
 | |
| 		manage._txPromise = manage._txPromise.then(async function() {
 | |
| 			var config = await Manage._getLatest(manage, CONF);
 | |
| 			// 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 renewBefore = args.renewBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000;
 | |
| 
 | |
| 			// if there's anything to match, only return matches
 | |
| 			// if there's nothing to match, return everything
 | |
| 			var nameKeys = ['subject', 'altnames'];
 | |
| 			var matchAll = !nameKeys.some(function(k) {
 | |
| 				return k in args;
 | |
| 			});
 | |
| 
 | |
| 			var querynames = (args.altnames || []).slice(0);
 | |
| 
 | |
| 			var sites = Object.keys(config.sites)
 | |
| 				.filter(function(subject) {
 | |
| 					var site = config.sites[subject];
 | |
| 					if (site.deletedAt) {
 | |
| 						return false;
 | |
| 					}
 | |
| 					if (site.expiresAt >= expiresBefore) {
 | |
| 						return false;
 | |
| 					}
 | |
| 					if (site.issuedAt >= issuedBefore) {
 | |
| 						return false;
 | |
| 					}
 | |
| 					if (site.renewAt >= renewBefore) {
 | |
| 						return false;
 | |
| 					}
 | |
| 
 | |
| 					// after attribute filtering, before cert filtering
 | |
| 					if (matchAll) {
 | |
| 						return true;
 | |
| 					}
 | |
| 
 | |
| 					// if subject is specified, don't return anything else
 | |
| 					if (site.subject === args.subject) {
 | |
| 						return true;
 | |
| 					}
 | |
| 
 | |
| 					// altnames, servername, and wildname all get rolled into one
 | |
| 					return site.altnames.some(function(altname) {
 | |
| 						return querynames.includes(altname);
 | |
| 					});
 | |
| 				})
 | |
| 				.map(function(name) {
 | |
| 					return doctor.site(config.sites, name);
 | |
| 				});
 | |
| 
 | |
| 			return sites;
 | |
| 		});
 | |
| 
 | |
| 		return manage._txPromise;
 | |
| 	}
 | |
| 
 | |
| 	function _mergeFind(config, ours, theirs) {
 | |
| 		theirs.forEach(function(_newer) {
 | |
| 			var hasCurrent = ours.some(function(_older) {
 | |
| 				if (_newer.subject !== _older.subject) {
 | |
| 					return false;
 | |
| 				}
 | |
| 
 | |
| 				// BE SURE TO SET THIS UNDEFINED AFTERWARDS
 | |
| 				_older._exists = true;
 | |
| 
 | |
| 				manage._merge(config, _older, _newer);
 | |
| 				_newer = config.sites[_older.subject];
 | |
| 
 | |
| 				// handled the (only) match
 | |
| 				return true;
 | |
| 			});
 | |
| 			if (hasCurrent) {
 | |
| 				manage._merge(config, null, _newer);
 | |
| 			}
 | |
| 		});
 | |
| 
 | |
| 		// delete the things that are gone
 | |
| 		ours.forEach(function(_older) {
 | |
| 			if (!_older._exists) {
 | |
| 				delete config.sites[_older.subject];
 | |
| 			}
 | |
| 			_older._exists = undefined;
 | |
| 		});
 | |
| 
 | |
| 		manage._txPromise = manage._txPromise.then(async function() {
 | |
| 			// kinda redundant to pull again, but whatever...
 | |
| 			var config = await Manage._getLatest(manage, CONF);
 | |
| 			await manage._save(config);
 | |
| 			// everything was either added, updated, or not different
 | |
| 			// hence, this is everything
 | |
| 			var copy = JSON.parse(JSON.stringify(config.sites));
 | |
| 			return Object.keys(copy).map(function(k) {
 | |
| 				return copy[k];
 | |
| 			});
 | |
| 		});
 | |
| 
 | |
| 		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(async function() {
 | |
| 			var config = await Manage._getLatest(manage, CONF);
 | |
| 			var site = config.sites[args.subject];
 | |
| 			if (!site || site.deletedAt) {
 | |
| 				return null;
 | |
| 			}
 | |
| 			site.deletedAt = Date.now();
 | |
| 			await manage._save(config);
 | |
| 			return JSON.parse(JSON.stringify(site));
 | |
| 		});
 | |
| 		return manage._txPromise;
 | |
| 	};
 | |
| 
 | |
| 	manage._config = {};
 | |
| 	// (wrong type #1) specifically the wrong type (null)
 | |
| 	manage._lastStat = { size: null, mtimeMs: null };
 | |
| 
 | |
| 	manage._save = async function(config) {
 | |
| 		await mkdirp(path.dirname(CONF.configFile));
 | |
| 		// pretty-print the config file
 | |
| 		var data = JSON.stringify(config, null, 2);
 | |
| 		await sfs.writeFileAsync(CONF.configFile, data, 'utf8');
 | |
| 
 | |
| 		// this file may contain secrets, so keep it safe
 | |
| 		return chmodFile(CONF.configFile, parseInt('0600', 8))
 | |
| 			.catch(function() {
 | |
| 				/*ignore for Windows */
 | |
| 			})
 | |
| 			.then(async function() {
 | |
| 				var stat = await statFile(CONF.configFile);
 | |
| 				manage._lastStat.size = stat.size;
 | |
| 				manage._lastStat.mtimeMs = stat.mtimeMs;
 | |
| 			});
 | |
| 	};
 | |
| 
 | |
| 	manage.init = async function(deps) {
 | |
| 		var request = deps.request;
 | |
| 		// how nice...
 | |
| 	};
 | |
| 
 | |
| 	return manage;
 | |
| };
 | |
| 
 | |
| Manage._getLatest = function(MNG, CONF) {
 | |
| 	return statFile(CONF.configFile)
 | |
| 		.catch(async function(err) {
 | |
| 			if ('ENOENT' !== err.code) {
 | |
| 				err.context = 'manager_read';
 | |
| 				throw err;
 | |
| 			}
 | |
| 			await MNG._save(doctor.config());
 | |
| 			// (wrong type #2) specifically the wrong type (bool)
 | |
| 			return { size: false, mtimeMs: false };
 | |
| 		})
 | |
| 		.then(async function(stat) {
 | |
| 			if (
 | |
| 				stat.size === MNG._lastStat.size &&
 | |
| 				stat.mtimeMs === MNG._lastStat.mtimeMs
 | |
| 			) {
 | |
| 				return MNG._config;
 | |
| 			}
 | |
| 			var data = await readFile(CONF.configFile, 'utf8');
 | |
| 			MNG._lastStat = stat;
 | |
| 			MNG._config = JSON.parse(data);
 | |
| 			return doctor.config(MNG._config);
 | |
| 		});
 | |
| };
 | |
| 
 | |
| // users muck up config files, so we try to handle it gracefully.
 | |
| doctor.config = function(config) {
 | |
| 	if (!config) {
 | |
| 		config = {};
 | |
| 	}
 | |
| 	if (!config.defaults) {
 | |
| 		config.defaults = {};
 | |
| 	}
 | |
| 
 | |
| 	doctor.sites(config);
 | |
| 
 | |
| 	Object.keys(config).forEach(function(key) {
 | |
| 		if (['defaults', 'routes', 'sites'].includes(key)) {
 | |
| 			return;
 | |
| 		}
 | |
| 		config.defaults[key] = config[key];
 | |
| 		delete config[key];
 | |
| 	});
 | |
| 
 | |
| 	doctor.challenges(config.defaults);
 | |
| 
 | |
| 	return config;
 | |
| };
 | |
| doctor.sites = function(config) {
 | |
| 	var sites = config.sites;
 | |
| 	if (!sites) {
 | |
| 		sites = {};
 | |
| 	}
 | |
| 	if (Array.isArray(config.sites)) {
 | |
| 		sites = {};
 | |
| 		config.sites.forEach(function(site) {
 | |
| 			sites[site.subject] = site;
 | |
| 		});
 | |
| 	}
 | |
| 	Object.keys(sites).forEach(function(k) {
 | |
| 		doctor.site(sites, k);
 | |
| 	});
 | |
| 	config.sites = sites;
 | |
| };
 | |
| doctor.site = function(sconfs, subject) {
 | |
| 	var site = sconfs[subject];
 | |
| 	if (!site) {
 | |
| 		delete sconfs[subject];
 | |
| 		site = {};
 | |
| 	}
 | |
| 
 | |
| 	if ('string' !== typeof site.subject) {
 | |
| 		console.warn('warning: deleted malformed site from config file:');
 | |
| 		console.warn(JSON.stringify(site));
 | |
| 		delete sconfs[subject];
 | |
| 		site.subject = 'greenlock-error.example.com';
 | |
| 	}
 | |
| 	if (!Array.isArray(site.altnames)) {
 | |
| 		site.altnames = [site.subject];
 | |
| 	}
 | |
| 	if (!site.renewAt) {
 | |
| 		site.renewAt = 1;
 | |
| 	}
 | |
| 
 | |
| 	return site;
 | |
| };
 | |
| 
 | |
| doctor.challenges = function(defaults) {
 | |
| 	var challenges = defaults.challenges;
 | |
| 	if (!challenges) {
 | |
| 		challenges = {};
 | |
| 	}
 | |
| 	if (Array.isArray(defaults.challenges)) {
 | |
| 		defaults.challenges.forEach(function(challenge) {
 | |
| 			var typ = doctor.challengeType(challenge);
 | |
| 			challenges[typ] = challenge;
 | |
| 		});
 | |
| 	}
 | |
| 	Object.keys(challenges).forEach(function(k) {
 | |
| 		doctor.challenge(challenges, k);
 | |
| 	});
 | |
| 	defaults.challenges = challenges;
 | |
| 	if (!Object.keys(defaults.challenges).length) {
 | |
| 		delete defaults.challenges;
 | |
| 	}
 | |
| };
 | |
| doctor.challengeType = function(challenge) {
 | |
| 	var typ = challenge.type;
 | |
| 	if (!typ) {
 | |
| 		if (/\bhttp-01\b/.test(challenge.module)) {
 | |
| 			typ = 'http-01';
 | |
| 		} else if (/\bdns-01\b/.test(challenge.module)) {
 | |
| 			typ = 'dns-01';
 | |
| 		} else if (/\btls-alpn-01\b/.test(challenge.module)) {
 | |
| 			typ = 'tls-alpn-01';
 | |
| 		} else {
 | |
| 			typ = 'error-01';
 | |
| 		}
 | |
| 	}
 | |
| 	delete challenge.type;
 | |
| 	return typ;
 | |
| };
 | |
| doctor.challenge = function(chconfs, typ) {
 | |
| 	var ch = chconfs[typ];
 | |
| 	if (!ch) {
 | |
| 		delete chconfs[typ];
 | |
| 	}
 | |
| 	return;
 | |
| };
 |