greenlock.js-ARCHIVED/greenlock.js

694 lines
22 KiB
JavaScript

'use strict';
var pkg = require('./package.json');
var ACME = require('@root/acme');
var Greenlock = module.exports;
var request = require('@root/request');
var G = Greenlock;
var U = require('./utils.js');
var E = require('./errors.js');
var P = require('./plugins.js');
var A = require('./accounts.js');
var C = require('./certificates.js');
var UserEvents = require('./user-events.js');
var caches = {};
// { maintainerEmail, directoryUrl, subscriberEmail, store, challenges }
G.create = function(gconf) {
var greenlock = {};
var gdefaults = {};
if (!gconf) {
gconf = {};
}
var manager;
greenlock._create = function() {
if (!gconf._bin_mode) {
if (!gconf.maintainerEmail) {
throw E.NO_MAINTAINER('create');
}
// TODO send welcome message with benefit info
U._validMx(gconf.maintainerEmail).catch(function() {
console.error(
'invalid maintainer contact info:',
gconf.maintainerEmail
);
// maybe move this to init and don't exit the process, just in case
process.exit(1);
});
}
if ('function' === typeof gconf.notify) {
gdefaults.notify = gconf.notify;
} else {
gdefaults.notify = _notify;
}
if (gconf.directoryUrl) {
gdefaults = gconf.directoryUrl;
if (gconf.staging) {
throw new Error(
'supply `directoryUrl` or `staging`, but not both'
);
}
} else if (gconf.staging) {
gdefaults.directoryUrl =
'https://acme-staging-v02.api.letsencrypt.org/directory';
} else {
gdefaults.directoryUrl =
'https://acme-v02.api.letsencrypt.org/directory';
}
console.info('ACME Directory URL:', gdefaults.directoryUrl);
manager = normalizeManager(gconf);
// Wraps each of the following with appropriate error checking
// greenlock.manager.defaults
// greenlock.manager.add
// greenlock.manager.update
// greenlock.manager.remove
// greenlock.manager.find
require('./manager-underlay.js').wrap(greenlock, manager, gconf);
// The goal here is to reduce boilerplate, such as error checking
// and duration parsing, that a manager must implement
greenlock.sites.add = greenlock.add = greenlock.manager.add;
// Exports challenges.get for Greenlock Express HTTP-01,
// and whatever odd use case pops up, I suppose
// greenlock.challenges.get
require('./challenges-underlay.js').wrap(greenlock, manager, gconf);
greenlock._defaults = gdefaults;
greenlock._defaults.debug = gconf.debug;
if (!gconf._bin_mode) {
// renew every 90-ish minutes (random for staggering)
// the weak setTimeout (unref) means that when run as a CLI process this
// will still finish as expected, and not wait on the timeout
(function renew() {
setTimeout(function() {
greenlock.renew({});
renew();
}, Math.PI * 30 * 60 * 1000).unref();
})();
}
};
// The purpose of init is to make MCONF the source of truth
greenlock._init = function() {
var p;
greenlock._init = function() {
return p;
};
if (manager.init) {
// TODO punycode?
p = manager.init({
request: request
//punycode: require('punycode')
});
} else {
p = Promise.resolve();
}
p = p
.then(function() {
return manager.defaults().then(function(MCONF) {
mergeDefaults(MCONF, gconf);
if (true === MCONF.agreeToTerms) {
gdefaults.agreeToTerms = function(tos) {
return Promise.resolve(tos);
};
}
return manager.defaults(MCONF);
});
})
.catch(function(err) {
console.error('Fatal error during greenlock init:');
console.error(err);
process.exit(1);
});
return p;
};
greenlock.notify = greenlock._notify = function(ev, params) {
var mng = greenlock.manager;
if ('_' === String(ev)[0]) {
if ('_cert_issue' === ev) {
try {
mng.update({
subject: params.subject,
renewAt: params.renewAt
}).catch(function(e) {
e.context = '_cert_issue';
greenlock._notify('error', e);
});
} catch (e) {
e.context = '_cert_issue';
greenlock._notify('error', e);
}
}
// trap internal events internally
return;
}
try {
var p = greenlock._defaults.notify(ev, params);
if (p && p.catch) {
p.catch(function(e) {
console.error("Promise Rejection on event '" + ev + "':");
console.error(e);
});
}
} catch (e) {
console.error("Thrown Exception on event '" + ev + "':");
console.error(e);
console.error(params);
}
if (-1 !== ['cert_issue', 'cert_renewal'].indexOf(ev)) {
// We will notify all greenlock users of mandatory and security updates
// We'll keep track of versions and os so we can make sure things work well
// { name, version, email, domains, action, communityMember, telemetry }
// TODO look at the other one
UserEvents.notify({
/*
// maintainer should be only on pre-publish, or maybe install, I think
maintainerEmail: greenlock._defaults._maintainerEmail,
name: greenlock._defaults._packageAgent,
version: greenlock._defaults._maintainerPackageVersion,
//action: params.pems._type,
domains: params.altnames,
subscriberEmail: greenlock._defaults._subscriberEmail,
// TODO enable for Greenlock Pro
//customerEmail: args.customerEmail
telemetry: greenlock._defaults.telemetry
*/
});
}
};
// certs.get
greenlock.get = function(args) {
return greenlock
._single(args)
.then(function() {
args._includePems = true;
return greenlock.renew(args);
})
.then(function(results) {
if (!results || !results.length) {
// TODO throw an error here?
return null;
}
// just get the first one
var result = results[0];
// (there should be only one, ideally)
if (results.length > 1) {
var err = new Error(
"a search for '" +
args.servername +
"' returned multiple certificates"
);
err.context = 'duplicate_certs';
err.servername = args.servername;
err.subjects = results.map(function(r) {
return (r.site || {}).subject || 'N/A';
});
greenlock._notify('warning', err);
}
if (result.error) {
return Promise.reject(result.error);
}
// site for plugin options, such as http-01 challenge
// pems for the obvious reasons
return result;
});
};
greenlock._single = function(args) {
if ('string' !== typeof args.servername) {
return Promise.reject(new Error('no `servername` given'));
}
// www.example.com => *.example.com
args.wildname =
'*.' +
args.servername
.split('.')
.slice(1)
.join('.');
if (
args.servernames ||
args.subject ||
args.renewBefore ||
args.issueBefore ||
args.expiresBefore
) {
return Promise.reject(
new Error(
'bad arguments, did you mean to call greenlock.renew()?'
)
);
}
// duplicate, force, and others still allowed
return Promise.resolve(args);
};
greenlock._config = function(args) {
return greenlock
._single(args)
.then(function() {
return greenlock._find(args);
})
.then(function(sites) {
if (!sites || !sites.length) {
return null;
}
var site = sites[0];
site = JSON.parse(JSON.stringify(site));
if (site.store && site.challenges) {
return site;
}
return manager.defaults().then(function(mconf) {
if (!site.store) {
site.store = mconf.store;
}
if (!site.challenges) {
site.challenges = mconf.challenges;
}
return site;
});
});
};
// needs to get info about the renewal, such as which store and challenge(s) to use
greenlock.renew = function(args) {
return greenlock._init().then(function() {
return manager.defaults().then(function(mconf) {
return greenlock._renew(mconf, args);
});
});
};
greenlock._renew = function(mconf, args) {
if (!args) {
args = {};
}
var renewedOrFailed = [];
//console.log('greenlock._renew find', args);
return greenlock._find(args).then(function(sites) {
// Note: the manager must guaranteed that these are mutable copies
//console.log('greenlock._renew found', sites);;
if (!Array.isArray(sites)) {
throw new Error(
'Developer Error: not an array of sites returned from find: ' +
JSON.stringify(sites)
);
}
function next() {
var site = sites.shift();
if (!site) {
return Promise.resolve(null);
}
var order = { site: site };
renewedOrFailed.push(order);
// TODO merge args + result?
return greenlock
._order(mconf, site)
.then(function(pems) {
if (args._includePems) {
order.pems = pems;
}
})
.catch(function(err) {
order.error = err;
// For greenlock express serialization
err.toJSON = errorToJSON;
err.context = err.context || 'cert_order';
err.subject = site.subject;
if (args.servername) {
err.servername = args.servername;
}
// for debugging, but not to be relied on
err._site = site;
// TODO err.context = err.context || 'renew_certificate'
greenlock._notify('error', err);
})
.then(function() {
return next();
});
}
return next().then(function() {
return renewedOrFailed;
});
});
};
greenlock._acme = function(args) {
var packageAgent = gconf.packageAgent || '';
// because Greenlock_Express/v3.x Greenlock/v3 is redundant
if (!/greenlock/i.test(packageAgent)) {
packageAgent = (packageAgent + ' Greenlock/' + pkg.version).trim();
}
var acme = ACME.create({
maintainerEmail: gconf.maintainerEmail,
packageAgent: packageAgent,
notify: greenlock._notify,
debug: greenlock._defaults.debug || args.debug
});
var dirUrl = args.directoryUrl || greenlock._defaults.directoryUrl;
var dir = caches[dirUrl];
// don't cache more than an hour
if (dir && Date.now() - dir.ts < 1 * 60 * 60 * 1000) {
return dir.promise;
}
return acme
.init(dirUrl)
.then(function(/*meta*/) {
caches[dirUrl] = {
promise: Promise.resolve(acme),
ts: Date.now()
};
return acme;
})
.catch(function(err) {
// TODO
// let's encrypt is possibly down for maintenaince...
// this is a special kind of failure mode
throw err;
});
};
greenlock.order = function(args) {
return greenlock._init().then(function() {
return manager.defaults().then(function(mconf) {
return greenlock._order(mconf, args);
});
});
};
greenlock._order = function(mconf, args) {
// packageAgent, maintainerEmail
return greenlock._acme(args).then(function(acme) {
var storeConf = args.store || mconf.store;
return P._loadStore(storeConf).then(function(store) {
return A._getOrCreate(
greenlock,
mconf,
store.accounts,
acme,
args
).then(function(account) {
var challengeConfs = args.challenges || mconf.challenges;
return Promise.all(
Object.keys(challengeConfs).map(function(typ01) {
return P._loadChallenge(challengeConfs, typ01);
})
).then(function(arr) {
var challenges = {};
arr.forEach(function(el) {
challenges[el._type] = el;
});
return C._getOrOrder(
greenlock,
mconf,
store.certificates,
acme,
challenges,
account,
args
).then(function(pems) {
if (!pems) {
throw new Error('no order result');
}
if (!pems.privkey) {
throw new Error(
'missing private key, which is kinda important'
);
}
return pems;
});
});
});
});
});
};
greenlock._create();
return greenlock;
};
G._loadChallenge = P._loadChallenge;
function errorToJSON(e) {
var error = {};
Object.getOwnPropertyNames(e).forEach(function(k) {
error[k] = e[k];
});
return error;
}
function normalizeManager(gconf) {
var m;
// 1. Get the manager
// 2. Figure out if we need to wrap it
if (!gconf.manager) {
gconf.manager = 'greenlock-manager-fs';
if (gconf.find) {
// { manager: 'greenlock-manager-fs', find: function () { } }
warpFind(gconf);
}
}
if ('string' === typeof gconf.manager) {
try {
// wrap this to be safe for greenlock-manager-fs
m = require(gconf.manager).create(gconf);
} catch (e) {
console.error(e.code);
console.error(e.message);
}
} else {
m = gconf.manager;
}
if (!m) {
console.error();
console.error(
'Failed to load manager plugin ',
JSON.stringify(gconf.manager)
);
console.error();
process.exit(1);
}
if (
['set', 'remove', 'find', 'defaults'].every(function(k) {
return 'function' === typeof m[k];
})
) {
return m;
}
// { manager: { find: function () { } } }
if (m.find) {
warpFind(m);
}
// m.configFile could also be set
m = require('greenlock-manager-fs').create(m);
if ('function' !== typeof m.find) {
console.error();
console.error(
JSON.stringify(gconf.manager),
'must implement `find()` and should implement `set()`, `remove()`, `defaults()`, and `init()`'
);
console.error();
process.exit(1);
}
return m;
}
function warpFind(gconf) {
gconf.__gl_find = gconf.find;
gconf.find = function(args) {
// the incoming args will be normalized by greenlock
return gconf.__gl_find(args).then(function(sites) {
// we also need to error check the incoming sites,
// as if they were being passed through `add()` or `set()`
// (effectively they are) because the manager assumes that
// they're not bad
sites.forEach(function(s) {
if (!s || 'string' !== typeof s.subject) {
throw new Error('missing subject');
}
if (
!Array.isArray(s.altnames) ||
!s.altnames.length ||
!s.altnames[0] ||
s.altnames[0] !== s.subject
) {
throw new Error('missing or malformed altnames');
}
['renewAt', 'issuedAt', 'expiresAt'].forEach(function(k) {
if (s[k]) {
throw new Error(
'`' +
k +
'` should be updated by `set()`, not by `find()`'
);
}
});
});
return sites;
});
};
}
function mergeDefaults(MCONF, gconf) {
if (
gconf.agreeToTerms === true ||
MCONF.agreeToTerms === true ||
// TODO deprecate
gconf.agreeTos === true ||
MCONF.agreeTos === true
) {
MCONF.agreeToTerms = true;
}
if (!MCONF.subscriberEmail && gconf.subscriberEmail) {
MCONF.subscriberEmail = gconf.subscriberEmail;
}
var homedir;
// Load the default store module
if (!MCONF.store) {
if (gconf.store) {
MCONF.store = gconf.store;
} else {
homedir = require('os').homedir();
MCONF.store = {
module: 'greenlock-store-fs',
basePath: homedir + '/.config/greenlock/'
};
}
}
// just to test that it loads
P._loadSync(MCONF.store.module);
// Load the default challenge modules
var challenges = MCONF.challenges || gconf.challenges;
if (!challenges) {
challenges = {};
}
if (!challenges['http-01'] && !challenges['dns-01']) {
challenges['http-01'] = { module: 'acme-http-01-standalone' };
}
if (challenges['http-01']) {
if ('string' !== typeof challenges['http-01'].module) {
throw new Error(
'bad challenge http-01 module config:' +
JSON.stringify(challenges['http-01'])
);
}
P._loadSync(challenges['http-01'].module);
}
if (challenges['dns-01']) {
if ('string' !== typeof challenges['dns-01'].module) {
throw new Error(
'bad challenge dns-01 module config' +
JSON.stringify(challenges['dns-01'])
);
}
P._loadSync(challenges['dns-01'].module);
}
MCONF.challenges = challenges;
if (!MCONF.renewOffset) {
MCONF.renewOffset = gconf.renewOffset || '-45d';
}
if (!MCONF.renewStagger) {
MCONF.renewStagger = gconf.renewStagger || '3d';
}
if (!MCONF.accountKeyType) {
MCONF.accountKeyType = gconf.accountKeyType || 'EC-P256';
}
if (!MCONF.serverKeyType) {
MCONF.serverKeyType = gconf.serverKeyType || 'RSA-2048';
}
}
function _notify(ev, args) {
if (!args) {
args = ev;
ev = args.event;
delete args.event;
}
// TODO define message types
if (!_notify._notice) {
console.info(
'set greenlockOptions.notify to override the default logger'
);
_notify._notice = true;
}
var prefix = 'Warning';
switch (ev) {
case 'error':
prefix = 'Error';
/* falls through */
case 'warning':
console.error(
prefix + '%s:',
(' ' + (args.context || '')).trimRight()
);
console.error(args.message);
if (args.description) {
console.error(args.description);
}
if (args.code) {
console.error('code:', args.code);
}
if (args.stack) {
console.error(args.stack);
}
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(' ') + ')'
);
}
}