Browse Source

wip: yeah!

wip
AJ ONeal 5 years ago
parent
commit
7d8674cb7e
  1. 4
      .gitignore
  2. 21
      README.md
  3. 184
      accounts.js
  4. 90
      bin/certonly.js
  5. 58
      bin/plugins.js
  6. 279
      certificates.js
  7. 58
      errors.js
  8. 60
      examples/manage.js
  9. 16
      examples/server.js
  10. 42
      express.js
  11. 541
      greenlock.js
  12. 97
      order.js
  13. 141
      package-lock.json
  14. 52
      package.json
  15. 153
      plugins.js
  16. 41
      tests/index.js
  17. 259
      tests/manager.js
  18. 7
      user-events.js
  19. 267
      utils.js

4
.gitignore

@ -1,3 +1,7 @@
TODO.txt
link.sh
.env
# ---> Node
# Logs
logs

21
README.md

@ -1,3 +1,24 @@
# root-greenlock.js
🔐 Free SSL, Free Wildcard SSL, and Fully Automated HTTPS for Node.js and Browsers, issued by Let's Encrypt v2 via ACME
Typically file propagation is faster and more reliably than DNS propagation.
Therefore, http-01 will be preferred to dns-01 except when wildcards or **private domains** are in use.
http-01 will only be supplied as a defaut if no other challenge is provided.
```
Greenlock.create
Greenlock#add
Greenlock#order... or Greenlock#issue?
Greenlock#renew... or Greenlock#issue?
Greenlock#remove
Greenlock#get
Greenlock#all
```
Better scaling
cluster lazy-load, remote management
`server identifier (for sharding, for manager)`

184
accounts.js

@ -0,0 +1,184 @@
'use strict';
var A = module.exports;
var U = require('./utils.js');
var E = require('./errors.js');
var pending = {};
A._getOrCreate = function(greenlock, db, acme, args) {
console.log('[debug] A get or create', args);
var email = args.subscriberEmail || greenlock._defaults.subscriberEmail;
if (!email) {
console.log('[debug] throw no sub');
throw E.NO_SUBSCRIBER('get account', args.subject);
}
// TODO send welcome message with benefit info
return U._validMx(email)
.catch(function() {
throw E.NO_SUBSCRIBER('get account', args.subcriberEmail);
})
.then(function() {
console.log('[debug] valid email');
if (pending[email]) {
console.log('[debug] return pending');
return pending[email];
}
pending[email] = A._rawGetOrCreate(greenlock, db, acme, args, email)
.catch(function(e) {
delete pending[email];
throw e;
})
.then(function(result) {
delete pending[email];
return result;
});
console.log('[debug] return new');
return pending[email];
});
};
// What we really need out of this is the private key and the ACME "key" id
A._rawGetOrCreate = function(greenlock, db, acme, args, email) {
var p;
if (db.check) {
p = A._checkStore(greenlock, db, acme, args, email);
} else {
p = Promise.resolve(null);
}
return p.then(function(fullAccount) {
console.log('[debug] full account', fullAccount);
if (!fullAccount) {
return A._newAccount(greenlock, db, acme, args, email, null);
}
if (fullAccount.keypair && fullAccount.key && fullAccount.key.kid) {
return fullAccount;
}
return A._newAccount(greenlock, db, acme, args, email, fullAccount);
});
};
A._newAccount = function(greenlock, db, acme, args, email, fullAccount) {
var keyType = args.accountKeyType || greenlock._defaults.accountKeyType;
var query = {
subject: args.subject,
email: email,
account: fullAccount || {}
};
return U._getOrCreateKeypair(db, args.subject, query, keyType).then(
function(kresult) {
var keypair = kresult.keypair;
var accReg = {
subscriberEmail: email,
agreeToTerms:
args.agreeToTerms || greenlock._defaults.agreeToTerms,
accountKeypair: keypair,
debug: args.debug
};
console.log('[debug] create account', accReg);
return acme.accounts.create(accReg).then(function(receipt) {
var reg = {
keypair: keypair,
receipt: receipt,
// shudder... not actually a KeyID... but so it is called anyway...
kid:
receipt &&
receipt.key &&
(receipt.key.kid || receipt.kid),
email: args.email
};
var keyP;
if (kresult.exists) {
keyP = Promise.resolve();
} else {
query.keypair = keypair;
query.receipt = receipt;
keyP = db.setKeypair(query, keypair);
}
return keyP
.then(function() {
if (!db.set) {
return Promise.resolve({
keypair: keypair
});
}
return db.set(
{
// id to be set by Store
email: email,
agreeTos: true
},
reg
);
})
.then(function(fullAccount) {
if (fullAccount && 'object' !== typeof fullAccount) {
throw new Error(
"accounts.set should either return 'null' or an object with an 'id' string"
);
}
if (!fullAccount) {
fullAccount = {};
}
fullAccount.keypair = keypair;
if (!fullAccount.key) {
fullAccount.key = {};
}
fullAccount.key.kid = reg.kid;
return fullAccount;
});
});
}
);
};
A._checkStore = function(greenlock, db, acme, args, email) {
if ((args.domain || args.domains) && !args.subject) {
console.warn("use 'subject' instead of 'domain'");
args.subject = args.domain;
}
var account = args.account;
if (!account) {
account = {};
}
if (args.accountKeypair) {
console.warn(
'rather than passing accountKeypair, put it directly into your account key store'
);
// TODO we probably don't need this
return U._importKeypair(args.accountKeypair);
}
if (!db.check) {
return Promise.resolve(null);
}
return db
.check({
//keypair: undefined,
//receipt: undefined,
email: email,
account: account
})
.then(function(fullAccount) {
if (!fullAccount) {
return null;
}
return fullAccount;
});
};

90
bin/certonly.js

@ -33,11 +33,17 @@ cli.parse({
' Domain names to apply. For multiple domains you can enter a comma separated list of domains as a parameter. (default: [])',
'string'
],
'renew-offset': [
false,
' Positive (time after issue) or negative (time before expiry) offset, such as 30d or -45d',
'string',
'45d'
],
'renew-within': [
false,
' Renew certificates this many days before expiry',
'int',
7
' (ignored) use renew-offset instead',
'ignore',
undefined
],
'cert-path': [
false,
@ -157,16 +163,18 @@ cli.parse({
],
'rsa-key-size': [
false,
' (ignored) use domain-key-type or account-key-type instead',
' (ignored) use server-key-type or account-key-type instead',
'ignore',
2048
],
'domain-key-path': [
'server-key-path': [
false,
' Path to privkey.pem to use for domain (default: generate new)',
'string'
' Path to privkey.pem to use for certificate (default: generate new)',
'string',
undefined,
'domain-key-path'
],
'domain-key-type': [
'server-key-type': [
false,
" One of 'RSA' (2048), 'RSA-3084', 'RSA-4096', 'ECDSA' (P-256), or 'P-384'. For best compatibility, security, and efficiency use the default (More bits != More security)",
'string',
@ -184,7 +192,7 @@ cli.parse({
'P-256'
],
webroot: [false, ' (ignored) for certbot compatibility', 'ignore', false],
//, 'standalone-supported-challenges': [ false, " Supported challenges, order preferences are randomly chosen. (default: http-01,tls-sni-01)", 'string', 'http-01,tls-sni-01']
//, 'standalone-supported-challenges': [ false, " Supported challenges, order preferences are randomly chosen. (default: http-01,tls-alpn-01)", 'string', 'http-01']
'work-dir': [
false,
' for certbot compatibility (ignored)',
@ -286,12 +294,66 @@ cli.main(function(_, options) {
}
function run() {
var challenges = {};
if (/http.?01/i.test(args.challenge)) {
challenges['http-01'] = args.challengeOpts;
}
if (/dns.?01/i.test(args.challenge)) {
challenges['dns-01'] = args.challengeOpts;
}
if (/alpn.?01/i.test(args.challenge)) {
challenges['tls-alpn-01'] = args.challengeOpts;
}
if (!Object.keys(challenges).length) {
throw new Error(
"Could not determine the challenge type for '" +
args.challengeOpts.module +
"'. Expected a name like @you/acme-xxxx-01-foo. Please name the module with http-01, dns-01, or tls-alpn-01."
);
}
args.challengeOpts.module = args.challenge;
args.storeOpts.module = args.store;
console.log('\ngot to the run step');
process.exit(1);
require('../')
.run(args)
.then(function(status) {
process.exit(status);
require(args.challenge);
require(args.store);
var greenlock = require('../').create({
maintainerEmail: args.maintainerEmail || 'coolaj86@gmail.com',
manager: './manager.js',
configFile: '~/.config/greenlock/certs.json',
challenges: challenges,
store: args.storeOpts,
renewOffset: args.renewOffset || '30d',
renewStagger: '1d'
});
// for long-running processes
if (args.renewEvery) {
setInterval(function() {
greenlock.renew({
period: args.renewEvery
});
}, args.renewEvery);
}
// TODO should greenlock.add simply always include greenlock.renew?
// the concern is conflating error events
return greenlock
.add({
subject: args.subject,
altnames: args.altnames,
subscriberEmail: args.subscriberEmail || args.email
})
.then(function(changes) {
console.info(changes);
// renew should always
return greenlock
.renew({
subject: args.subject,
force: false
})
.then(function() {});
});
}

58
bin/plugins.js

@ -1,9 +1,65 @@
'use strict';
var spawn = require('child_process').spawn;
var spawnSync = require('child_process').spawnSync;
var path = require('path');
var PKG_DIR = path.join(__dirname, '..');
module.exports.installSync = function(moduleName) {
var npm = 'npm';
var args = ['install', '--save', moduleName];
var out = '';
var cmd;
try {
cmd = spawnSync(npm, args, {
cwd: PKG_DIR,
windowsHide: true
});
} catch (e) {
console.error(
"Failed to start: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(e.message);
process.exit(1);
}
if (!cmd.status) {
return;
}
out += cmd.stdout.toString('utf8');
out += cmd.stderr.toString('utf8');
if (out) {
console.error(out);
console.error();
console.error();
}
console.error(
"Failed to run: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(
'Try for yourself:\n\tcd ' + PKG_DIR + '\n\tnpm ' + args.join(' ')
);
process.exit(1);
};
module.exports.install = function(moduleName) {
return new Promise(function(resolve) {
if (!moduleName) {
@ -71,5 +127,5 @@ module.exports.install = function(moduleName) {
};
if (require.main === module) {
module.exports.install(process.argv[2]);
module.exports.installSync(process.argv[2]);
}

279
certificates.js

@ -0,0 +1,279 @@
'use strict';
var C = module.exports;
var U = require('./utils.js');
var CSR = require('@root/csr');
var Enc = require('@root/encoding');
var pending = {};
var rawPending = {};
// Certificates
C._getOrOrder = function(greenlock, db, acme, challenges, account, args) {
var email = args.subscriberEmail || greenlock._defaults.subscriberEmail;
var id = args.altnames.join(' ');
if (pending[id]) {
return pending[id];
}
pending[id] = C._rawGetOrOrder(
greenlock,
db,
acme,
challenges,
account,
email,
args
)
.then(function(pems) {
delete pending[id];
return pems;
})
.catch(function(err) {
delete pending[id];
throw err;
});
return pending[id];
};
// Certificates
C._rawGetOrOrder = function(
greenlock,
db,
acme,
challenges,
account,
email,
args
) {
return C._check(db, args).then(function(pems) {
// No pems? get some!
if (!pems) {
return C._rawOrder(
greenlock,
db,
acme,
challenges,
account,
email,
args
).then(function(newPems) {
// do not wait on notify
greenlock.notify('cert_issue', {
options: args,
subject: args.subject,
altnames: args.altnames,
account: account,
email: email,
pems: newPems
});
return newPems;
});
}
// Nice and fresh? We're done!
if (!C._isStale(greenlock, args, pems)) {
// return existing unexpired (although potentially stale) certificates when available
// there will be an additional .renewing property if the certs are being asynchronously renewed
//pems._type = 'current';
return pems;
}
// Getting stale? Let's renew to freshen up!
var p = C._rawOrder(
greenlock,
db,
acme,
challenges,
account,
email,
args
).then(function(renewedPems) {
// do not wait on notify
greenlock.notify('cert_renewal', {
options: args,
subject: args.subject,
altnames: args.altnames,
account: account,
email: email,
pems: renewedPems
});
return renewedPems;
});
// TODO what should this be?
if (args.waitForRenewal) {
return p;
}
return pems;
});
};
// we have another promise here because it the optional renewal
// may resolve in a different stack than the returned pems
C._rawOrder = function(greenlock, db, acme, challenges, account, email, args) {
var id = args.altnames
.slice(0)
.sort()
.join(' ');
if (rawPending[id]) {
return rawPending[id];
}
var keyType = args.serverKeyType || greenlock._defaults.serverKeyType;
var query = {
subject: args.subject,
certificate: args.certificate || {}
};
rawPending[id] = U._getOrCreateKeypair(db, args.subject, query, keyType)
.then(function(kresult) {
var serverKeypair = kresult.keypair;
var domains = args.altnames.slice(0);
return CSR.csr({
jwk: serverKeypair.privateKeyJwk,
domains: domains,
encoding: 'der'
})
.then(function(csrDer) {
// TODO let CSR support 'urlBase64' ?
return Enc.bufToUrlBase64(csrDer);
})
.then(function(csr) {
function notify() {
greenlock.notify('challenge_status', {
options: args,
subject: args.subject,
altnames: args.altnames,
account: account,
email: email
});
}
var certReq = {
debug: args.debug || greenlock._defaults.debug,
challenges: challenges,
account: account, // only used if accounts.key.kid exists
accountKeypair: account.keypair,
keypair: account.keypair, // TODO
csr: csr,
domains: domains, // because ACME.js v3 uses `domains` still, actually
onChallengeStatus: notify,
notify: notify // TODO
// TODO handle this in acme-v2
//subject: args.subject,
//altnames: args.altnames.slice(0),
};
return acme.certificates
.create(certReq)
.then(U._attachCertInfo);
})
.then(function(pems) {
if (kresult.exists) {
return pems;
}
return db.setKeypair(query, serverKeypair).then(function() {
return pems;
});
});
})
.then(function(pems) {
// TODO put this in the docs
// { cert, chain, privkey, subject, altnames, issuedAt, expiresAt }
// Note: the query has been updated
query.pems = pems;
return db.set(query);
})
.then(function() {
return C._check(db, args);
})
.then(function(bundle) {
// TODO notify Manager
delete rawPending[id];
return bundle;
})
.catch(function(err) {
// Todo notify manager
delete rawPending[id];
throw err;
});
return rawPending[id];
};
// returns pems, if they exist
C._check = function(db, args) {
var query = {
subject: args.subject,
// may contain certificate.id
certificate: args.certificate
};
return db.check(query).then(function(pems) {
if (!pems) {
return null;
}
pems = U._attachCertInfo(pems);
// For eager management
if (args.subject && !U._certHasDomain(pems, args.subject)) {
// TODO report error, but continue the process as with no cert
return null;
}
// For lazy SNI requests
if (args.domain && !U._certHasDomain(pems, args.domain)) {
// TODO report error, but continue the process as with no cert
return null;
}
return U._getKeypair(db, args.subject, query)
.then(function(keypair) {
pems.privkey = keypair.privateKeyPem;
return pems;
})
.catch(function() {
// TODO report error, but continue the process as with no cert
return null;
});
});
};
// Certificates
C._isStale = function(greenlock, args, pems) {
if (args.duplicate) {
return true;
}
var renewAt = C._renewableAt(greenlock, args, pems);
if (Date.now() >= renewAt) {
return true;
}
return false;
};
C._renewableAt = function(greenlock, args, pems) {
if (args.renewAt) {
return args.renewAt;
}
var renewOffset = args.renewOffset || greenlock._defaults.renewOffset || 0;
var week = 1000 * 60 * 60 * 24 * 6;
if (!args.force && Math.abs(renewOffset) < week) {
throw new Error(
'developer error: `renewOffset` should always be at least a week, use `force` to not safety-check renewOffset'
);
}
if (renewOffset > 0) {
return pems.issuedAt + renewOffset;
}
return pems.expiresAt + renewOffset;
};

58
errors.js

@ -0,0 +1,58 @@
'use strict';
var E = module.exports;
function create(code, msg) {
E[code] = function(ctx, msg2) {
var err = new Error(msg);
err.code = code;
err.context = ctx;
if (msg2) {
err.message += ': ' + msg2;
}
/*
Object.keys(extras).forEach(function(k) {
if ('message' === k) {
err.message += ': ' + extras[k];
} else {
err[k] = extras[k];
}
});
*/
return err;
};
}
// TODO open issues and link to them as the error url
create(
'NO_MAINTAINER',
'please supply `maintainerEmail` as a contact for security and critical bug notices'
);
create(
'BAD_ORDER',
'altnames should be in deterministic order, with subject as the first altname'
);
create('NO_SUBJECT', 'no certificate subject given');
create(
'NO_SUBSCRIBER',
'please supply `subscriberEmail` as a contact for failed renewal and certificate revocation'
);
create(
'INVALID_SUBSCRIBER',
'`subscriberEmail` is not a valid address, please check for typos'
);
create(
'INVALID_HOSTNAME',
'valid hostnames must be restricted to a-z0-9_.- and contain at least one "."'
);
create(
'INVALID_DOMAIN',
'one or more domains do not exist on public DNS SOA record'
);
create(
'NOT_UNIQUE',
'found duplicate domains, or a subdomain that overlaps a wildcard'
);
// exported for testing only
E._create = create;

60
examples/manage.js

@ -0,0 +1,60 @@
'use strict';
// tradeoff - lazy load certs vs DOS invalid sni
var Manager = module.exports;
var Cache = {};
Manager.create = function(conf) {
var domains = conf.domains;
var manager = {};
// { servername, wildname }
manager.getSubject = function(opts) {
if (
!opts.domains.includes(opts.domain) &&
!opts.domains.includes(opts.wildname)
) {
throw new Error('not a registered domain');
}
return opts.domains[0];
};
manager.add = function() {};
// { servername, wildname }
manager.configure = function(opts) {};
// { servername }
manager._contexts = {};
};
var manager = Manager.create({
domains: ['example.com', '*.example.com']
});
Cache.getTlsContext = function(servername) {
// TODO exponential fallback certificate renewal
if (Cache._contexts[servername]) {
// may be a context, or a promise for a context
return Cache._contexts[servername];
}
var wildname =
'*.' +
(servername || '')
.split('.')
.slice(1)
.join('.');
var opts = {
servername: servername,
domain: servername,
wildname: wildname
};
manager._contexts[servername] = manager
.orderCertificate(opts)
.then(function() {})
.catch(function(e) {});
};

16
examples/server.js

@ -0,0 +1,16 @@
'use strict';
var http = require('http');
var https = require('http2');
var greenlock = require('../greenlock.js').create({
maintainerEmail: 'jon@example.com'
});
function app(req, res) {
res.end('Hello, Encrypted World!');
}
http.createServer(greenlock.plainMiddleware()).listen(8080);
https
.createServer(greenlock.tlsOptions, greenlock.secureMiddleware(app))
.listen(8443);

42
express.js

@ -0,0 +1,42 @@
'use strict';
var Greenlock = module.exports;
Greenlock.server = function (opts) {
var opts = Greenlock.create(opts);
opts.plainMiddleware = function(req, res) {
return Greenlock._plainMiddleware(opts, req, res);
};
opts.secureMiddleware = function(req, res) {
return Greenlock._secureMiddleware(opts, req, res);
};
opts.tlsOptions = {
SNICallback: function(servername, cb) {
return Greenlock._sniCallback(opts, servername)
.then(function() {
cb(null);
})
.catch(function(err) {
cb(err);
});
}
};
return opts;
};
// must handle http-01 challenges
Greenlock._plainMiddleware = function(opts, req, res) {};
// should check for domain fronting
Greenlock._secureMiddleware = function(opts, req, res) {};
// should check to see if domain is allowed, and if domain should be renewed
// manage should be able to clear the internal cache
Greenlock._sniCallback = function(opts, servername) {};
Greenlock._onSniRejection(function () {
});

541
greenlock.js

@ -0,0 +1,541 @@
'use strict';
var pkg = require('./package.json');
var ACME = require('@root/acme');
var Greenlock = module.exports;
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 promisify = require('util').promisify;
var caches = {};
// { maintainerEmail, directoryUrl, subscriberEmail, store, challenges }
G.create = function(gconf) {
var greenlock = {};
if (!gconf) {
gconf = {};
}
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.maintainer.Email
);
// maybe a little harsh?
process.exit(1);
});
// TODO default servername is GLE only
if (!gconf.manager) {
gconf.manager = 'greenlock-manager-fs';
}
var Manager;
if ('string' === typeof gconf.manager) {
try {
Manager = require(gconf.manager);
} catch (e) {
if ('MODULE_NOT_FOUND' !== e.code) {
throw e;
}
console.error(e.code);
console.error(e.message);
console.error(gconf.manager);
P._installSync(gconf.manager);
Manager = require(gconf.manager);
}
}
// minimal modification to the original object
var defaults = G._defaults(gconf);
greenlock.manager = Manager.create(defaults);
// The goal here is to reduce boilerplate, such as error checking
// and duration parsing, that a manager must implement
greenlock.add = function(args) {
return Promise.resolve().then(function() {
// durations
if (args.renewOffset) {
args.renewOffset = U._parseDuration(args.renewOffset);
}
if (args.renewStagger) {
args.renewStagger = U._parseDuration(args.renewStagger);
}
if (!args.subject) {
throw E.NO_SUBJECT('add');
}
if (!args.altnames) {
args.altnames = [args.subject];
}
if ('string' === typeof args.altnames) {
args.altnames = args.altnames.split(/[,\s]+/);
}
if (args.subject !== args.altnames[0]) {
throw E.BAD_ORDER(
'add',
'(' + args.subject + ") '" + args.altnames.join("' '") + "'"
);
}
args.altnames = args.altnames.map(U._encodeName);
if (
!args.altnames.every(function(d) {
return U._validName(d);
})
) {
throw E.INVALID_HOSTNAME(
'add',
"'" + args.altnames.join("' '") + "'"
);
}
// at this point we know that subject is the first of altnames
return Promise.all(
args.altnames.map(function(d) {
d = d.replace('*.', '');
return U._validDomain(d);
})
).then(function() {
if (!U._uniqueNames(args.altnames)) {
throw E.NOT_UNIQUE(
'add',
"'" + args.altnames.join("' '") + "'"
);
}
return greenlock.manager.add(args);
});
});
};
greenlock._notify = function(ev, params) {
var mng = greenlock.manager;
if (mng.notify) {
try {
var p = mng.notify(ev, params);
if (p && p.catch) {
p.catch(function(e) {
console.error("Error on event '" + ev + "':");
console.error(e);
});
}
} catch (e) {
console.error("Error on event '" + ev + "':");
console.error(e);
}
} else {
if (/error/i.test(ev)) {
console.error("Error event '" + ev + "':");
console.error(params);
}
}
/*
*'cert_issue', {
options: args,
subject: args.subject,
altnames: args.altnames,
account: account,
email: email,
pems: newPems
}
*/
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._maintainerPackage,
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
});
}
};
// needs to get info about the renewal, such as which store and challenge(s) to use
greenlock.renew = function(args) {
if (!args) {
args = {};
}
// durations
if (args.renewOffset) {
args.renewOffset = U._parseDuration(args.renewOffset);
}
if (args.renewStagger) {
args.renewStagger = U._parseDuration(args.renewStagger);
}
if (args.domain) {
// this doesn't have to be the subject, it can be anything
// however, not sure how useful this really is...
args.domain = args.toLowerCase();
}
args.defaults = greenlock.defaults;
return greenlock.manager.find(args).then(function(sites) {
// Note: the manager must guaranteed that these are mutable copies
console.log('[debug] found what?', sites);
var renewedOrFailed = [];
function next() {
var site = sites.shift();
if (!site) {
return null;
}
var order = {
site: site
};
renewedOrFailed.push(order);
// TODO merge args + result?
return greenlock
.order(site)
.then(function(pems) {
order.pems = pems;
})
.catch(function(err) {
order.error = err;
greenlock._notify('order_error', order);
})
.then(function() {
return next();
});
}
return next().then(function() {
return renewedOrFailed;
});
});
};
greenlock._acme = function(args) {
var acme = ACME.create({
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._acme(args).then(function(acme) {
console.log('[debug] acme meta', acme);
var storeConf = args.store || greenlock._defaults.store;
return P._load(storeConf.module).then(function(plugin) {
var store = Greenlock._normalizeStore(
storeConf.module,
plugin.create(storeConf)
);
console.log('[debug] store', storeConf);
return A._getOrCreate(
greenlock,
store.accounts,
acme,
args
).then(function(account) {
console.log('[debug] account', account);
var challengeConfs =
args.challenges || greenlock._defaults.challenges;
console.log('[debug] challenge confs', challengeConfs);
return Promise.all(
Object.keys(challengeConfs).map(function(typ01) {
var chConf = challengeConfs[typ01];
return P._load(chConf.module).then(function(
plugin
) {
var ch = Greenlock._normalizeChallenge(
chConf.module,
plugin.create(chConf)
);
ch._type = typ01;
return ch;
});
})
).then(function(arr) {
var challenges = {};
arr.forEach(function(el) {
challenges[el._type] = el;
});
return C._getOrOrder(
greenlock,
store.certificates,
acme,
challenges,
account,
args
);
});
});
});
});
};
greenlock._options = gconf;
greenlock._defaults = defaults;
if (!gconf.onOrderFailure) {
gconf.onOrderFailure = function(err) {
G._onOrderFailure(gconf, err);
};
}
return greenlock;
};
G._defaults = function(opts) {
var defaults = {};
// [ 'store', 'challenges' ]
Object.keys(opts).forEach(function(k) {
// manage is the only thing that is, potentially, not plain-old JSON
if ('manage' === k && 'string' !== typeof opts[k]) {
return;
}
defaults[k] = opts[k];
});
if (!defaults._maintainerPackage) {
defaults._maintainerPackage = pkg.name;
defaults._maintainerPackageVersion = pkg.version;
}
if (!defaults.directoryUrl) {
if (defaults.staging) {
defaults.directoryUrl =
'https://acme-staging-v02.api.letsencrypt.org/directory';
} else {
defaults.directoryUrl =
'https://acme-v02.api.letsencrypt.org/directory';
}
} else {
if (defaults.staging) {
throw new Error('supply `directoryUrl` or `staging`, but not both');
}
}
console.info('ACME Directory URL:', defaults.directoryUrl);
// Load the default store module
if (!defaults.store) {
defaults.store = {
module: 'greenlock-store-fs',
basePath: '~/.config/greenlock/'
};
}
P._loadSync(defaults.store.module);
//defaults.store = store;
// Load the default challenge modules
var challenges;
if (!defaults.challenges) {
defaults.challenges = {};
}
challenges = defaults.challenges;
// TODO provide http-01 when http-01 and/or(?) dns-01 don't exist
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) {
P._loadSync(challenges['http-01'].module);
}
}
if (challenges['dns-01']) {
if ('string' === typeof challenges['dns-01'].module) {
P._loadSync(challenges['dns-01'].module);
}
}
if (defaults.agreeToTerms === true || defaults.agreeTos === true) {
defaults.agreeToTerms = function(tos) {
return Promise.resolve(tos);
};
}
if (!defaults.accountKeyType) {
defaults.accountKeyType = 'EC-P256';
}
if (!defaults.serverKeyType) {
if (defaults.domainKeyType) {
console.warn('use serverKeyType instead of domainKeyType');
defaults.serverKeyType = defaults.domainKeyType;
}
defaults.serverKeyType = 'RSA-2048';
}
if (defaults.domainKeypair) {
console.warn('use serverKeypair instead of domainKeypair');
defaults.serverKeypair =
defaults.serverKeypair || defaults.domainKeypair;
}
Object.defineProperty(defaults, 'domainKeypair', {
write: false,
get: function() {
console.warn('use serverKeypair instead of domainKeypair');
return defaults.serverKeypair;
}
});
return defaults;
};
Greenlock._normalizeStore = function(name, store) {
var acc = store.accounts;
var crt = store.certificates;
var warned = false;
function warn() {
if (warned) {
return;
}
warned = true;
console.warn(
"'" +
name +
"' may have incorrect function signatures, or contains deprecated use of callbacks"
);
}
// accs
if (acc.check && 2 === acc.check.length) {
warn();
acc._thunk_check = acc.check;
acc.check = promisify(acc._thunk_check);
}
if (acc.set && 3 === acc.set.length) {
warn();
acc._thunk_set = acc.set;
acc.set = promisify(acc._thunk_set);
}
if (2 === acc.checkKeypair.length) {
warn();
acc._thunk_checkKeypair = acc.checkKeypair;
acc.checkKeypair = promisify(acc._thunk_checkKeypair);
}
if (3 === acc.setKeypair.length) {
warn();
acc._thunk_setKeypair = acc.setKeypair;
acc.setKeypair = promisify(acc._thunk_setKeypair);
}
// certs
if (2 === crt.check.length) {
warn();
crt._thunk_check = crt.check;
crt.check = promisify(crt._thunk_check);
}
if (3 === crt.set.length) {
warn();
crt._thunk_set = crt.set;
crt.set = promisify(crt._thunk_set);
}
if (2 === crt.checkKeypair.length) {
warn();
crt._thunk_checkKeypair = crt.checkKeypair;
crt.checkKeypair = promisify(crt._thunk_checkKeypair);
}
if (2 === crt.setKeypair.length) {
warn();
crt._thunk_setKeypair = crt.setKeypair;
crt.setKeypair = promisify(crt._thunk_setKeypair);
}
return store;
};
Greenlock._normalizeChallenge = function(name, ch) {
var warned = false;
function warn() {
if (warned) {
return;
}
warned = true;
console.warn(
"'" +
name +
"' may have incorrect function signatures, or contains deprecated use of callbacks"
);
}
// init, zones, set, get, remove
if (ch.init && 2 === ch.init.length) {
warn();
ch._thunk_init = ch.init;
ch.init = promisify(ch._thunk_init);
}
if (ch.zones && 2 === ch.zones.length) {
warn();
ch._thunk_zones = ch.zones;
ch.zones = promisify(ch._thunk_zones);
}
if (2 === ch.set.length) {
warn();
ch._thunk_set = ch.set;
ch.set = promisify(ch._thunk_set);
}
if (2 === ch.remove.length) {
warn();
ch._thunk_remove = ch.remove;
ch.remove = promisify(ch._thunk_remove);
}
if (ch.get && 2 === ch.get.length) {
warn();
ch._thunk_get = ch.get;
ch.get = promisify(ch._thunk_get);
}
return ch;
};

97
order.js

@ -0,0 +1,97 @@
var accountKeypair = await Keypairs.generate({ kty: accKty });
if (config.debug) {
console.info('Account Key Created');
console.info(JSON.stringify(accountKeypair, null, 2));
console.info();
console.info();
}
var account = await acme.accounts.create({
agreeToTerms: agree,
// TODO detect jwk/pem/der?
accountKeypair: { privateKeyJwk: accountKeypair.private },
subscriberEmail: config.email
});
// TODO top-level agree
function agree(tos) {
if (config.debug) {
console.info('Agreeing to Terms of Service:');
console.info(tos);
console.info();
console.info();
}
agreed = true;
return Promise.resolve(tos);
}
if (config.debug) {
console.info('New Subscriber Account');
console.info(JSON.stringify(account, null, 2));
console.info();
console.info();
}
if (!agreed) {
throw new Error('Failed to ask the user to agree to terms');
}
var certKeypair = await Keypairs.generate({ kty: srvKty });
var pem = await Keypairs.export({
jwk: certKeypair.private,
encoding: 'pem'
});
if (config.debug) {
console.info('Server Key Created');
console.info('privkey.jwk.json');
console.info(JSON.stringify(certKeypair, null, 2));
// This should be saved as `privkey.pem`
console.info();
console.info('privkey.' + srvKty.toLowerCase() + '.pem:');
console.info(pem);
console.info();
}
// 'subject' should be first in list
var domains = randomDomains(rnd);
if (config.debug) {
console.info('Get certificates for random domains:');
console.info(
domains
.map(function(puny) {
var uni = punycode.toUnicode(puny);
if (puny !== uni) {
return puny + ' (' + uni + ')';
}
return puny;
})
.join('\n')
);
console.info();
}
// Create CSR
var csrDer = await CSR.csr({
jwk: certKeypair.private,
domains: domains,
encoding: 'der'
});
var csr = Enc.bufToUrlBase64(csrDer);
var csrPem = PEM.packBlock({
type: 'CERTIFICATE REQUEST',
bytes: csrDer /* { jwk: jwk, domains: opts.domains } */
});
if (config.debug) {
console.info('Certificate Signing Request');
console.info(csrPem);
console.info();
}
var results = await acme.certificates.create({
account: account,
accountKeypair: { privateKeyJwk: accountKeypair.private },
csr: csr,
domains: domains,
challenges: challenges, // must be implemented
customerEmail: null
});

141
package-lock.json

@ -0,0 +1,141 @@
{
"name": "@root/greenlock",
"version": "3.0.0-wip.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@root/acme": {
"version": "3.0.0-wip.3",
"resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.0.0-wip.3.tgz",
"integrity": "sha512-7Fq9FuO0WQgKPgyYmKHst71EbIqH764A3j6vF1aKemgWXXq2Wqy8G+2SJwt3/MSXhQ7X+qLmWRLLJ7U4Zlygsg==",
"requires": {
"@root/csr": "^0.8.1",
"@root/encoding": "^1.0.1",
"@root/keypairs": "^0.9.0",
"@root/pem": "^1.0.4",
"@root/request": "^1.3.11",
"@root/x509": "^0.7.2"
},
"dependencies": {
"@root/csr": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@root/csr/-/csr-0.8.1.tgz",
"integrity": "sha512-hKl0VuE549TK6SnS2Yn9nRvKbFZXn/oAg+dZJU/tlKl/f/0yRXeuUzf8akg3JjtJq+9E592zDqeXZ7yyrg8fSQ==",
"requires": {
"@root/asn1": "^1.0.0",
"@root/pem": "^1.0.4",
"@root/x509": "^0.7.2"
}
},
"@root/keypairs": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.9.0.tgz",
"integrity": "sha512-NXE2L9Gv7r3iC4kB/gTPZE1vO9Ox/p14zDzAJ5cGpTpytbWOlWF7QoHSJbtVX4H7mRG/Hp7HR3jWdWdb2xaaXg==",
"requires": {
"@root/encoding": "^1.0.1",
"@root/pem": "^1.0.4",
"@root/x509": "^0.7.2"
}
}
}
},
"@root/asn1": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@root/asn1/-/asn1-1.0.0.tgz",
"integrity": "sha512-0lfZNuOULKJDJmdIkP8V9RnbV3XaK6PAHD3swnFy4tZwtlMDzLKoM/dfNad7ut8Hu3r91wy9uK0WA/9zym5mig==",
"requires": {
"@root/encoding": "^1.0.1"
}
},
"@root/csr": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@root/csr/-/csr-0.8.1.tgz",
"integrity": "sha512-hKl0VuE549TK6SnS2Yn9nRvKbFZXn/oAg+dZJU/tlKl/f/0yRXeuUzf8akg3JjtJq+9E592zDqeXZ7yyrg8fSQ==",
"requires": {
"@root/asn1": "^1.0.0",
"@root/pem": "^1.0.4",
"@root/x509": "^0.7.2"
}
},
"@root/encoding": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@root/encoding/-/encoding-1.0.1.tgz",
"integrity": "sha512-OaEub02ufoU038gy6bsNHQOjIn8nUjGiLcaRmJ40IUykneJkIW5fxDqKxQx48cszuNflYldsJLPPXCrGfHs8yQ=="
},
"@root/keypairs": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.9.0.tgz",
"integrity": "sha512-NXE2L9Gv7r3iC4kB/gTPZE1vO9Ox/p14zDzAJ5cGpTpytbWOlWF7QoHSJbtVX4H7mRG/Hp7HR3jWdWdb2xaaXg==",
"requires": {
"@root/encoding": "^1.0.1",
"@root/pem": "^1.0.4",
"@root/x509": "^0.7.2"
}
},
"@root/mkdirp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@root/mkdirp/-/mkdirp-1.0.0.tgz",
"integrity": "sha512-hxGAYUx5029VggfG+U9naAhQkoMSXtOeXtbql97m3Hi6/sQSRL/4khKZPyOF6w11glyCOU38WCNLu9nUcSjOfA=="
},
"@root/pem": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@root/pem/-/pem-1.0.4.tgz",
"integrity": "sha512-rEUDiUsHtild8GfIjFE9wXtcVxeS+ehCJQBwbQQ3IVfORKHK93CFnRtkr69R75lZFjcmKYVc+AXDB+AeRFOULA=="
},
"@root/request": {
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz",
"integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw=="
},
"@root/x509": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@root/x509/-/x509-0.7.2.tgz",
"integrity": "sha512-ENq3LGYORK5NiMFHEVeNMt+fTXaC7DTS6sQXoqV+dFdfT0vmiL5cDLjaXQhaklJQq0NiwicZegzJRl1ZOTp3WQ==",
"requires": {
"@root/asn1": "^1.0.0",
"@root/encoding": "^1.0.1"
}
},
"acme-dns-01-digitalocean": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/acme-dns-01-digitalocean/-/acme-dns-01-digitalocean-3.0.1.tgz",
"integrity": "sha512-LUdOGluDERQWJG4CwlC9HbzUai4mtKzCz8nzpVTirXup2WwH60iRFAcd81hRGaoWbd0Bc0m6RVjN9YFkXB84yA=="
},
"acme-http-01-standalone": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/acme-http-01-standalone/-/acme-http-01-standalone-3.0.0.tgz",
"integrity": "sha512-lZqVab2UZ1Dp36HemfhGEvdYOcVNg5wyVXNjtPUqGSAOVUOKqwi3gDrTGwqz+FBrEEEEpTngDPaZn2g3hfmPLA=="
},
"cert-info": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/cert-info/-/cert-info-1.5.1.tgz",
"integrity": "sha512-eoQC/yAgW3gKTKxjzyClvi+UzuY97YCjcl+lSqbsGIy7HeGaWxCPOQFivhUYm27hgsBMhsJJFya3kGvK6PMIcQ=="
},
"dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==",
"dev": true
},
"greenlock-store-fs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/greenlock-store-fs/-/greenlock-store-fs-3.0.2.tgz",
"integrity": "sha512-t4So75yKs1+7TqmxD5UKdf+zOQU0/4o0lb2auf5zUcAo7fwwNLOAXyWnnZRL3WuFBUiBGh1qXWleuMua0d3LPg==",
"requires": {
"@root/mkdirp": "^1.0.0",
"safe-replace": "^1.1.0"
}
},
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
"dev": true
},
"safe-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-replace/-/safe-replace-1.1.0.tgz",
"integrity": "sha512-9/V2E0CDsKs9DWOOwJH7jYpSl9S3N05uyevNjvsnDauBqRowBPOyot1fIvV5N2IuZAbYyvrTXrYFVG0RZInfFw=="
}
}
}

52
package.json

@ -0,0 +1,52 @@
{
"name": "@root/greenlock",
"version": "3.0.0-wip.0",
"description": "The easiest Let's Encrypt client for Node.js and Browsers",
"homepage": "https://rootprojects.org/greenlock/",
"main": "greenlock.js",
"browser": {},
"files": [
"*.js",
"lib",
"dist"
],
"scripts": {
"build": "nodex bin/bundle.js",
"lint": "jshint lib bin",
"test": "node server.js",
"start": "node server.js"
},
"repository": {
"type": "git",
"url": "https://git.coolaj86.com/coolaj86/bluecrypt-acme.js.git"
},
"keywords": [
"ACME",
"Let's Encrypt",
"browser",
"EC",
"RSA",
"CSR",
"greenlock",
"VanillaJS",
"ZeroSSL"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"dependencies": {
"@root/acme": "^3.0.0-wip.3",
"@root/csr": "^0.8.1",
"@root/keypairs": "^0.9.0",
"@root/mkdirp": "^1.0.0",
"@root/request": "^1.3.10",
"acme-dns-01-digitalocean": "^3.0.1",
"acme-http-01-standalone": "^3.0.0",
"cert-info": "^1.5.1",
"greenlock-store-fs": "^3.0.2",
"safe-replace": "^1.1.0"
},
"devDependencies": {
"dotenv": "^8.2.0",
"punycode": "^1.4.1"
}
}

153
plugins.js

@ -0,0 +1,153 @@
'use strict';
var P = module.exports;
var spawn = require('child_process').spawn;
var spawnSync = require('child_process').spawnSync;
var PKG_DIR = __dirname;
P._load = function(modname) {
try {
return Promise.resolve(require(modname));
} catch (e) {
return P._install(modname).then(function() {
return require(modname);
});
}
};
P._loadSync = function(modname) {
var mod;
try {
mod = require(modname);
} catch (e) {
P._installSync(modname);
mod = require(modname);
}
return mod;
};
P._installSync = function(moduleName) {
var npm = 'npm';
var args = ['install', '--save', moduleName];
var out = '';
var cmd;
try {
cmd = spawnSync(npm, args, {
cwd: PKG_DIR,
windowsHide: true
});
} catch (e) {
console.error(
"Failed to start: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(e.message);
process.exit(1);
}
if (!cmd.status) {
return;
}
out += cmd.stdout.toString('utf8');
out += cmd.stderr.toString('utf8');
if (out) {
console.error(out);
console.error();
console.error();
}
console.error(
"Failed to run: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(
'Try for yourself:\n\tcd ' + PKG_DIR + '\n\tnpm ' + args.join(' ')
);
process.exit(1);
};
P._install = function(moduleName) {
return new Promise(function(resolve) {
if (!moduleName) {
throw new Error('no module name given');
}
var npm = 'npm';
var args = ['install', '--save', moduleName];
var out = '';
var cmd = spawn(npm, args, {
cwd: PKG_DIR,
windowsHide: true
});
cmd.stdout.on('data', function(chunk) {
out += chunk.toString('utf8');
});
cmd.stdout.on('data', function(chunk) {
out += chunk.toString('utf8');
});
cmd.on('error', function(e) {
console.error(
"Failed to start: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(e.message);
process.exit(1);
});
cmd.on('exit', function(code) {
if (!code) {
resolve();
return;
}
if (out) {
console.error(out);
console.error();
console.error();
}
console.error(
"Failed to run: '" +
npm +
' ' +
args.join(' ') +
"' in '" +
PKG_DIR +
"'"
);
console.error(
'Try for yourself:\n\tcd ' +
PKG_DIR +
'\n\tnpm ' +
args.join(' ')
);
process.exit(1);
});
});
};
if (require.main === module) {
P._installSync(process.argv[2]);
}

41
tests/index.js

@ -0,0 +1,41 @@
'use strict';
require('dotenv').config();
var path = require('path');
var Greenlock = require('../');
var subject = process.env.BASE_DOMAIN;
var altnames = [subject, '*.' + subject, 'foo.bar.' + subject];
var email = process.env.SUBSCRIBER_EMAIL;
var challenge = JSON.parse(process.env.CHALLENGE_OPTIONS);
challenge.module = process.env.CHALLENGE_MODULE;
var greenlock = Greenlock.create({
agreeTos: true,
maintainerEmail: email,
staging: true,
manager: path.join(__dirname, 'manager.js'),
challenges: {
'dns-01': challenge
}
//configFile: '~/.config/greenlock/certs.json',
//challenges: challenges,
//store: args.storeOpts,
//renewOffset: args.renewOffset || '30d',
//renewStagger: '1d'
});
greenlock
.add({
subject: subject,
altnames: altnames,
subscriberEmail: email
})
.then(function() {
return greenlock.renew();
})
.catch(function(e) {
console.error('yo', e.code);
console.error(e);
});

259
tests/manager.js

@ -0,0 +1,259 @@
'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 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/config.json';
}
opts.configFile = opts.configFile.replace('~/', homedir + '/');
var manage = {};
manage.ping = function() {
return Manage._ping(manage, opts);
};
manage._txPromise = new Promise(function(resolve) {
resolve();
});
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, JSON.stringify(config), 'utf8')
.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.notifications = function(args) {
// TODO define message types
console.info(args.event, args.message);
};
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;
};

7
user-events.js

@ -0,0 +1,7 @@
'use strict';
var UserEvents = module.exports;
UserEvents.notify = function() {
// TODO not implemented yet
};

267
utils.js

@ -0,0 +1,267 @@
'use strict';
var U = module.exports;
var promisify = require('util').promisify;
var resolveSoa = promisify(require('dns').resolveSoa);
var resolveMx = promisify(require('dns').resolveMx);
var punycode = require('punycode');
var Keypairs = require('@root/keypairs');
// TODO move to @root
var certParser = require('cert-info');
U._parseDuration = function(str) {
if ('number' === typeof str) {
return str;
}
var pattern = /^(\-?\d+(\.\d+)?)([wdhms]|ms)$/;
var matches = str.match(pattern);
if (!matches || !matches[0]) {
throw new Error('invalid duration string: ' + str);
}
var n = parseInt(matches[1], 10);
var unit = matches[3];
switch (unit) {
case 'w':
n *= 7;
/*falls through*/
case 'd':
n *= 24;
/*falls through*/
case 'h':
n *= 60;
/*falls through*/
case 'm':
n *= 60;
/*falls through*/
case 's':
n *= 1000;
/*falls through*/
case 'ms':
n *= 1; // for completeness
}
return n;
};
U._encodeName = function(str) {
return punycode.toASCII(str.toLowerCase(str));
};
U._validName = function(str) {
// A quick check of the 38 and two ½ valid characters
// 253 char max full domain, including dots
// 63 char max each label segment
// Note: * is not allowed, but it's allowable here
// Note: _ (underscore) is only allowed for "domain names", not "hostnames"
// Note: - (hyphen) is not allowed as a first character (but a number is)
return (
/^(\*\.)?[a-z0-9_\.\-]+$/.test(str) &&
str.length < 254 &&
str.split('.').every(function(label) {
return label.length > 0 && label.length < 64;
})
);
};
U._validMx = function(email) {
var host = email.split('@').slice(1)[0];
// try twice, just because DNS hiccups sometimes
// Note: we don't care if the domain exists, just that it *can* exist
return resolveMx(host).catch(function() {
return U._timeout(1000).then(function() {
return resolveMx(host);
});
});
};
// should be called after _validName
U._validDomain = function(str) {
// TODO use @root/dns (currently dns-suite)
// because node's dns can't read Authority records
return Promise.resolve(str);
/*
// try twice, just because DNS hiccups sometimes
// Note: we don't care if the domain exists, just that it *can* exist
return resolveSoa(str).catch(function() {
return U._timeout(1000).then(function() {
return resolveSoa(str);
});
});
*/
};
// foo.example.com and *.example.com overlap
// should be called after _validName
// (which enforces *. or no *)
U._uniqueNames = function(altnames) {
var dups = {};
var wilds = {};
if (
altnames.some(function(w) {
if ('*.' !== w.slice(0, 2)) {
return;
}
if (wilds[w]) {
return true;
}
wilds[w] = true;
})
) {
return false;
}
return altnames.every(function(name) {
var w;
if ('*.' !== name.slice(0, 2)) {
w =
'*.' +
name
.split('.')
.slice(1)
.join('.');
} else {
return true;
}
if (!dups[name] && !dups[w]) {
dups[name] = true;
return true;
}
});
};
U._timeout = function(d) {
return new Promise(function(resolve) {
setTimeout(resolve, d);
});
};
U._genKeypair = function(keyType) {
var keyopts;
var len = parseInt(keyType.replace(/.*?(\d)/, '$1') || 0, 10);
if (/RSA/.test(keyType)) {
keyopts = {
kty: 'RSA',
modulusLength: len || 2048
};
} else if (/^(EC|P\-?\d)/i.test(keyType)) {
keyopts = {
kty: 'EC',
namedCurve: 'P-' + (len || 256)
};
} else {
// TODO put in ./errors.js
throw new Error('invalid key type: ' + keyType);
}
return Keypairs.generate(keyopts).then(function(pair) {
return U._jwkToSet(pair.private);
});
};
// TODO use ACME._importKeypair ??
U._importKeypair = function(keypair) {
if (keypair.privateKeyJwk) {
return U._jwkToSet(keypair.privateKeyJwk);
}
if (!keypair.privateKeyPem) {
// TODO put in errors
throw new Error('missing private key');
}
return Keypairs.import({ pem: keypair.privateKeyPem }).then(function(pair) {
return U._jwkToSet(pair.private);
});
};
U._jwkToSet = function(jwk) {
var keypair = {
privateKeyJwk: jwk
};
return Promise.all([
Keypairs.export({
jwk: jwk,
encoding: 'pem'
}).then(function(pem) {
keypair.privateKeyPem = pem;
}),
Keypairs.export({
jwk: jwk,
encoding: 'pem',
public: true
}).then(function(pem) {
keypair.publicKeyPem = pem;
}),
Keypairs.publish({
jwk: jwk
}).then(function(pub) {
keypair.publicKeyJwk = pub;
})
]).then(function() {
return keypair;
});
};
U._attachCertInfo = function(results) {
var certInfo = certParser.info(results.cert);
// subject, altnames, issuedAt, expiresAt
Object.keys(certInfo).forEach(function(key) {
results[key] = certInfo[key];
});
return results;
};
U._certHasDomain = function(certInfo, _domain) {
var names = (certInfo.altnames || []).slice(0);
return names.some(function(name) {
var domain = _domain.toLowerCase();
name = name.toLowerCase();
if ('*.' === name.substr(0, 2)) {
name = name.substr(2);
domain = domain
.split('.')
.slice(1)
.join('.');
}
return name === domain;
});
};
// a bit heavy to be labeled 'utils'... perhaps 'common' would be better?
U._getOrCreateKeypair = function(db, subject, query, keyType, mustExist) {
var exists = false;
return db
.checkKeypair(query)
.then(function(kp) {
if (kp) {
exists = true;
return U._importKeypair(kp);
}
if (mustExist) {
// TODO put in errors
throw new Error(
'required keypair not found: ' +
(subject || '') +
' ' +
JSON.stringify(query)
);
}
return U._genKeypair(keyType);
})
.then(function(keypair) {
return { exists: exists, keypair: keypair };
});
};
U._getKeypair = function(db, subject, query) {
return U._getOrCreateKeypair(db, subject, query, '', true);
};
Loading…
Cancel
Save