Merge branch 'v2.x' of github.com:Daplie/node-letsencrypt into v2.x
This commit is contained in:
commit
c246c196b9
72
README.md
72
README.md
|
@ -7,23 +7,12 @@
|
||||||
| [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi)
|
| [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi)
|
||||||
|
|
|
|
||||||
|
|
||||||
letsencrypt (v2)
|
letsencrypt
|
||||||
===========
|
===========
|
||||||
|
|
||||||
Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificates for node.js
|
Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificates for node.js
|
||||||
|
|
||||||
* [Automatic HTTPS with ExpressJS](https://github.com/Daplie/letsencrypt-express)
|
Free SLL with [90-day](https://letsencrypt.org/2015/11/09/why-90-days.html) HTTPS / TLS Certificates
|
||||||
* [Automatic live renewal](https://github.com/Daplie/letsencrypt-express#how-automatic)
|
|
||||||
* On-the-fly HTTPS certificates for Dynamic DNS (in-process, no server restart)
|
|
||||||
* Works with node cluster out of the box
|
|
||||||
* usable [via commandline](https://github.com/Daplie/letsencrypt-cli) as well
|
|
||||||
* Free SSL (HTTPS Certificates for TLS)
|
|
||||||
* [90-day certificates](https://letsencrypt.org/2015/11/09/why-90-days.html)
|
|
||||||
|
|
||||||
**See Also**
|
|
||||||
|
|
||||||
* [Let's Encrypt in (exactly) 90 seconds with Caddy](https://daplie.com/articles/lets-encrypt-in-literally-90-seconds/)
|
|
||||||
* [lego](https://github.com/xenolf/lego): Let's Encrypt for golang
|
|
||||||
|
|
||||||
STOP
|
STOP
|
||||||
====
|
====
|
||||||
|
@ -71,7 +60,7 @@ It's very simple and easy to use, but also very complete and easy to extend and
|
||||||
|
|
||||||
### Overly Simplified Example
|
### Overly Simplified Example
|
||||||
|
|
||||||
Against my better judgement I'm providing a terribly oversimplified exmaple
|
Against my better judgement I'm providing a terribly oversimplified example
|
||||||
of how to use this library:
|
of how to use this library:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
@ -148,37 +137,36 @@ le = LE.create({
|
||||||
|
|
||||||
|
|
||||||
// Check in-memory cache of certificates for the named domain
|
// Check in-memory cache of certificates for the named domain
|
||||||
le.exists({ domain: 'example.com' }).then(function (results) {
|
le.check({ domain: 'example.com' }).then(function (results) {
|
||||||
if (results) {
|
if (results) {
|
||||||
// we already have certificates
|
// we already have certificates
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Register Certificate manually
|
// Register Certificate manually
|
||||||
le.register(
|
le.get({
|
||||||
|
|
||||||
{ domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS)
|
domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS)
|
||||||
, email: 'user@email.com' // CHANGE TO YOUR EMAIL
|
, email: 'user@email.com' // CHANGE TO YOUR EMAIL
|
||||||
, agreeTos: '' // set to tosUrl string to pre-approve (and skip agreeToTerms)
|
, agreeTos: '' // set to tosUrl string (or true) to pre-approve (and skip agreeToTerms)
|
||||||
, rsaKeySize: 2048 // 1024 or 2048
|
, rsaKeySize: 2048 // 2048 or higher
|
||||||
, challengeType: 'http-01' // http-01, tls-sni-01, or dns-01
|
, challengeType: 'http-01' // http-01, tls-sni-01, or dns-01
|
||||||
}
|
|
||||||
|
|
||||||
, function (err, results) {
|
}).then(function (results) {
|
||||||
if (err) {
|
|
||||||
// Note: you must either use le.middleware() with express,
|
|
||||||
// manually use le.getChallenge(domain, key, val, done)
|
|
||||||
// or have a webserver running and responding
|
|
||||||
// to /.well-known/acme-challenge at `webrootPath`
|
|
||||||
console.error('[Error]: node-letsencrypt/examples/standalone');
|
|
||||||
console.error(err.stack);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('success');
|
console.log('success');
|
||||||
}
|
|
||||||
|
|
||||||
);
|
}, function (err) {
|
||||||
|
|
||||||
|
// Note: you must either use le.middleware() with express,
|
||||||
|
// manually use le.getChallenge(domain, key, val, done)
|
||||||
|
// or have a webserver running and responding
|
||||||
|
// to /.well-known/acme-challenge at `webrootPath`
|
||||||
|
console.error('[Error]: node-letsencrypt/examples/standalone');
|
||||||
|
console.error(err.stack);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
@ -200,6 +188,12 @@ API
|
||||||
|
|
||||||
The full end-user API is exposed in the example above and includes all relevant options.
|
The full end-user API is exposed in the example above and includes all relevant options.
|
||||||
|
|
||||||
|
```
|
||||||
|
le.register
|
||||||
|
le.get // checkAndRegister
|
||||||
|
le.check
|
||||||
|
```
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
We do expose a few helper functions:
|
We do expose a few helper functions:
|
||||||
|
@ -241,7 +235,7 @@ TODO double check and finish
|
||||||
* accounts.get
|
* accounts.get
|
||||||
* accounts.exists
|
* accounts.exists
|
||||||
* certs
|
* certs
|
||||||
* certs.byDomain
|
* certs.byAccount
|
||||||
* certs.all
|
* certs.all
|
||||||
* certs.get
|
* certs.get
|
||||||
* certs.exists
|
* certs.exists
|
||||||
|
@ -250,9 +244,9 @@ TODO double check and finish
|
||||||
|
|
||||||
TODO finish
|
TODO finish
|
||||||
|
|
||||||
* setChallenge(opts, domain, key, value, done); // opts will be saved with domain/key
|
* `.set(opts, domain, key, value, done);` // opts will be saved with domain/key
|
||||||
* getChallenge(domain, key, done); // opts will be retrieved by domain/key
|
* `.get(opts, domain, key, done);` // opts will be retrieved by domain/key
|
||||||
* removeChallenge(domain, key, done); // opts will be retrieved by domain/key
|
* `.remove(opts, domain, key, done);` // opts will be retrieved by domain/key
|
||||||
|
|
||||||
Change History
|
Change History
|
||||||
==============
|
==============
|
||||||
|
|
171
index.js
171
index.js
|
@ -1,21 +1,20 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// TODO handle www and no-www together somehow?
|
var ACME = require('le-acme-core').ACME;
|
||||||
|
|
||||||
var PromiseA = require('bluebird');
|
|
||||||
var leCore = require('letiny-core');
|
|
||||||
|
|
||||||
var LE = module.exports;
|
var LE = module.exports;
|
||||||
|
LE.LE = LE;
|
||||||
|
// in-process cache, shared between all instances
|
||||||
|
var ipc = {};
|
||||||
|
|
||||||
LE.defaults = {
|
LE.defaults = {
|
||||||
server: leCore.productionServerUrl
|
productionServerUrl: ACME.productionServerUrl
|
||||||
, stagingServer: leCore.stagingServerUrl
|
, stagingServerUrl: ACME.stagingServerUrl
|
||||||
, liveServer: leCore.productionServerUrl
|
|
||||||
|
|
||||||
, productionServerUrl: leCore.productionServerUrl
|
, rsaKeySize: ACME.rsaKeySize || 2048
|
||||||
, stagingServerUrl: leCore.stagingServerUrl
|
, challengeType: ACME.challengeType || 'http-01'
|
||||||
|
|
||||||
, acmeChallengePrefix: leCore.acmeChallengePrefix
|
, acmeChallengePrefix: ACME.acmeChallengePrefix
|
||||||
};
|
};
|
||||||
|
|
||||||
// backwards compat
|
// backwards compat
|
||||||
|
@ -23,58 +22,108 @@ Object.keys(LE.defaults).forEach(function (key) {
|
||||||
LE[key] = LE.defaults[key];
|
LE[key] = LE.defaults[key];
|
||||||
});
|
});
|
||||||
|
|
||||||
LE.create = function (defaults, handlers, backend) {
|
// show all possible options
|
||||||
var Core = require('./lib/core');
|
var u; // undefined
|
||||||
var core;
|
LE._undefined = {
|
||||||
if (!backend) { backend = require('./lib/pycompat'); }
|
acme: u
|
||||||
if (!handlers) { handlers = {}; }
|
, store: u
|
||||||
if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; }
|
, challenger: u
|
||||||
if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; }
|
, register: u
|
||||||
if (!handlers.sniRegisterCallback) {
|
, check: u
|
||||||
handlers.sniRegisterCallback = function (args, cache, cb) {
|
, renewWithin: u
|
||||||
// TODO when we have ECDSA, just do this automatically
|
, memorizeFor: u
|
||||||
cb(null, null);
|
, acmeChallengePrefix: u
|
||||||
};
|
, rsaKeySize: u
|
||||||
}
|
, challengeType: u
|
||||||
|
, server: u
|
||||||
if (backend.create) {
|
, agreeToTerms: u
|
||||||
backend = backend.create(defaults);
|
, _ipc: u
|
||||||
}
|
};
|
||||||
backend = PromiseA.promisifyAll(backend);
|
LE._undefine = function (le) {
|
||||||
core = Core.create(defaults, handlers, backend);
|
Object.keys(LE._undefined).forEach(function (key) {
|
||||||
|
if (!(key in le)) {
|
||||||
var le = {
|
le[key] = u;
|
||||||
backend: backend
|
|
||||||
, core: core
|
|
||||||
// register
|
|
||||||
, create: function (args, cb) {
|
|
||||||
return core.registerAsync(args).then(function (pems) {
|
|
||||||
cb(null, pems);
|
|
||||||
}, cb);
|
|
||||||
}
|
}
|
||||||
// fetch
|
});
|
||||||
, domain: function (args, cb) {
|
|
||||||
// TODO must return email, domains, tos, pems
|
return le;
|
||||||
return core.fetchAsync(args).then(function (certInfo) {
|
};
|
||||||
cb(null, certInfo);
|
LE.create = function (le) {
|
||||||
}, cb);
|
var PromiseA = require('bluebird');
|
||||||
}
|
|
||||||
, domains: function (args, cb) {
|
le.acme = le.acme || ACME.create({ debug: le.debug });
|
||||||
// TODO show all domains or limit by account
|
le.store = le.store || require('le-store-certbot').create({ debug: le.debug });
|
||||||
throw new Error('not implemented');
|
le.challenger = le.challenger || require('le-store-certbot').create({ debug: le.debug });
|
||||||
}
|
le.core = require('./lib/core');
|
||||||
, accounts: function (args, cb) {
|
|
||||||
// TODO show all accounts or limit by domain
|
le = LE._undefine(le);
|
||||||
throw new Error('not implemented');
|
le.acmeChallengePrefix = LE.acmeChallengePrefix;
|
||||||
}
|
le.rsaKeySize = le.rsaKeySize || LE.rsaKeySize;
|
||||||
, account: function (args, cb) {
|
le.challengeType = le.challengeType || LE.challengeType;
|
||||||
// TODO return one account
|
le._ipc = ipc;
|
||||||
throw new Error('not implemented');
|
|
||||||
}
|
if (!le.renewWithin) { le.renewWithin = 3 * 24 * 60 * 60 * 1000; }
|
||||||
};
|
if (!le.memorizeFor) { le.memorizeFor = 1 * 24 * 60 * 60 * 1000; }
|
||||||
|
|
||||||
// exists
|
if (!le.server) {
|
||||||
// get
|
throw new Error("opts.server must be set to 'staging' or a production url, such as LE.productionServerUrl'");
|
||||||
|
}
|
||||||
|
if ('staging' === le.server) {
|
||||||
|
le.server = LE.stagingServerUrl;
|
||||||
|
}
|
||||||
|
else if ('production' === le.server) {
|
||||||
|
le.server = LE.productionServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (le.acme.create) {
|
||||||
|
le.acme = le.acme.create(le);
|
||||||
|
}
|
||||||
|
le.acme = PromiseA.promisifyAll(le.acme);
|
||||||
|
le._acmeOpts = le.acme.getOptions();
|
||||||
|
Object.keys(le._acmeOpts).forEach(function (key) {
|
||||||
|
if (!(key in le)) {
|
||||||
|
le[key] = le._acmeOpts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (le.store.create) {
|
||||||
|
le.store = le.store.create(le);
|
||||||
|
}
|
||||||
|
le.store = PromiseA.promisifyAll(le.store);
|
||||||
|
le._storeOpts = le.store.getOptions();
|
||||||
|
Object.keys(le._storeOpts).forEach(function (key) {
|
||||||
|
if (!(key in le)) {
|
||||||
|
le[key] = le._storeOpts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (le.challenger.create) {
|
||||||
|
le.challenger = le.challenger.create(le);
|
||||||
|
}
|
||||||
|
le.challenger = PromiseA.promisifyAll(le.challenger);
|
||||||
|
le._challengerOpts = le.challenger.getOptions();
|
||||||
|
Object.keys(le._challengerOpts).forEach(function (key) {
|
||||||
|
if (!(key in le)) {
|
||||||
|
le[key] = le._challengerOpts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (le.core.create) {
|
||||||
|
le.core = le.core.create(le);
|
||||||
|
}
|
||||||
|
|
||||||
|
le.register = function (args) {
|
||||||
|
return le.core.certificates.getAsync(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
le.check = function (args) {
|
||||||
|
// TODO must return email, domains, tos, pems
|
||||||
|
return le.core.certificates.checkAsync(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
le.middleware = function () {
|
||||||
|
return require('./lib/middleware')(le);
|
||||||
|
};
|
||||||
|
|
||||||
return le;
|
return le;
|
||||||
};
|
};
|
||||||
|
|
469
lib/core.js
469
lib/core.js
|
@ -1,280 +1,287 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var LE = require('../');
|
module.exports.create = function (le) {
|
||||||
var ipc = {}; // in-process cache
|
|
||||||
|
|
||||||
module.exports.create = function (defaults, handlers, backend) {
|
|
||||||
var backendDefaults = backend.getDefaults && backend.getDefaults || backend.defaults || {};
|
|
||||||
|
|
||||||
defaults.server = defaults.server || LE.liveServer;
|
|
||||||
handlers.merge = require('./common').merge;
|
|
||||||
handlers.tplCopy = require('./common').tplCopy;
|
|
||||||
|
|
||||||
var PromiseA = require('bluebird');
|
var PromiseA = require('bluebird');
|
||||||
|
var utils = require('./utils'); // merge, tplCopy;
|
||||||
var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA);
|
var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA);
|
||||||
var LeCore = PromiseA.promisifyAll(require('letiny-core'));
|
|
||||||
var crypto = require('crypto');
|
var crypto = require('crypto');
|
||||||
|
|
||||||
function attachCertInfo(results) {
|
var core = {
|
||||||
var getCertInfo = require('./cert-info').getCertInfo;
|
//
|
||||||
// XXX Note: Parsing the certificate info comes at a great cost (~500kb)
|
// Helpers
|
||||||
var certInfo = getCertInfo(results.cert);
|
//
|
||||||
|
getAcmeUrlsAsync: function (args) {
|
||||||
|
var now = Date.now();
|
||||||
|
|
||||||
//results.issuedAt = arr[3].mtime.valueOf()
|
// TODO check response header on request for cache time
|
||||||
results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now()
|
if ((now - le._ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) {
|
||||||
results.expiresAt = Date(certInfo.notAfter.value).valueOf();
|
return PromiseA.resolve(le._ipc.acmeUrls);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return le.acme.getAcmeUrlsAsync(args.server).then(function (data) {
|
||||||
}
|
le._ipc.acmeUrlsUpdatedAt = Date.now();
|
||||||
|
le._ipc.acmeUrls = data;
|
||||||
|
|
||||||
function createAccount(args, handlers) {
|
return le._ipc.acmeUrls;
|
||||||
args.rsaKeySize = args.rsaKeySize || 2048;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) {
|
|
||||||
|
|
||||||
return LeCore.registerNewAccountAsync({
|
//
|
||||||
email: args.email
|
// The Main Enchilada
|
||||||
, newRegUrl: args._acmeUrls.newReg
|
//
|
||||||
, agreeToTerms: function (tosUrl, agree) {
|
|
||||||
// args.email = email; // already there
|
//
|
||||||
args.tosUrl = tosUrl;
|
// Accounts
|
||||||
handlers.agreeToTerms(args, agree);
|
//
|
||||||
|
, accounts: {
|
||||||
|
registerAsync: function (args) {
|
||||||
|
var err;
|
||||||
|
|
||||||
|
if (!args.email || !args.agreeTos || (parseInt(args.rsaKeySize, 10) < 2048)) {
|
||||||
|
err = new Error(
|
||||||
|
"In order to register an account both 'email' and 'agreeTos' must be present"
|
||||||
|
+ " and 'rsaKeySize' must be 2048 or greater."
|
||||||
|
);
|
||||||
|
err.code = 'E_ARGS';
|
||||||
|
return PromiseA.reject(err);
|
||||||
}
|
}
|
||||||
, accountKeypair: keypair
|
|
||||||
|
|
||||||
, debug: defaults.debug || args.debug || handlers.debug
|
return utils.testEmail(args.email).then(function () {
|
||||||
}).then(function (body) {
|
|
||||||
// TODO XXX use sha256 (the python client uses md5)
|
|
||||||
// TODO ssh fingerprint (noted on rsa-compat issues page, I believe)
|
|
||||||
keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex');
|
|
||||||
keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex');
|
|
||||||
|
|
||||||
var accountId = keypair.publicKeyMd5;
|
return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) {
|
||||||
var regr = { body: body };
|
// Note: the ACME urls are always fetched fresh on purpose
|
||||||
var account = {};
|
// TODO is this the right place for this?
|
||||||
|
return core.getAcmeUrlsAsync(args).then(function (urls) {
|
||||||
|
args._acmeUrls = urls;
|
||||||
|
|
||||||
args.accountId = accountId;
|
return le.acme.registerNewAccountAsync({
|
||||||
|
email: args.email
|
||||||
|
, newRegUrl: args._acmeUrls.newReg
|
||||||
|
, agreeToTerms: function (tosUrl, agreeCb) {
|
||||||
|
if (true === args.agreeTos || tosUrl === args.agreeTos || tosUrl === le.agreeToTerms) {
|
||||||
|
agreeCb(null, tosUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
account.keypair = keypair;
|
// args.email = email; // already there
|
||||||
account.regr = regr;
|
// args.domains = domains // already there
|
||||||
account.accountId = accountId;
|
args.tosUrl = tosUrl;
|
||||||
account.id = accountId;
|
le.agreeToTerms(args, agreeCb);
|
||||||
|
}
|
||||||
|
, accountKeypair: keypair
|
||||||
|
|
||||||
args.account = account;
|
, debug: le.debug || args.debug
|
||||||
|
}).then(function (body) {
|
||||||
|
// TODO XXX use sha256 (the python client uses md5)
|
||||||
|
// TODO ssh fingerprint (noted on rsa-compat issues page, I believe)
|
||||||
|
keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex');
|
||||||
|
keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex');
|
||||||
|
|
||||||
|
var accountId = keypair.publicKeyMd5;
|
||||||
|
var regr = { body: body };
|
||||||
|
var account = {};
|
||||||
|
|
||||||
|
args.accountId = accountId;
|
||||||
|
|
||||||
|
account.keypair = keypair;
|
||||||
|
account.regr = regr;
|
||||||
|
account.accountId = accountId;
|
||||||
|
account.id = accountId;
|
||||||
|
|
||||||
|
args.account = account;
|
||||||
|
|
||||||
|
return le.store.accounts.setAsync(args, account).then(function () {
|
||||||
|
return account;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
, getAsync: function (args) {
|
||||||
|
return core.accounts.checkAsync(args).then(function (account) {
|
||||||
|
if (account) {
|
||||||
|
return account;
|
||||||
|
} else {
|
||||||
|
return core.accounts.registerAsync(args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
, checkAsync: function (args) {
|
||||||
|
var requiredArgs = ['accountId', 'email', 'domains', 'domain'];
|
||||||
|
if (!requiredArgs.some(function (key) { return -1 !== Object.keys(args).indexOf(key) })) {
|
||||||
|
return PromiseA.reject(new Error(
|
||||||
|
"In order to register or retrieve an account one of '" + requiredArgs.join("', '") + "' must be present"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
var copy = utils.merge(args, le);
|
||||||
|
args = utils.tplCopy(copy);
|
||||||
|
|
||||||
|
return le.store.accounts.checkAsync(args).then(function (account) {
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
args.account = account;
|
||||||
|
args.accountId = account.id;
|
||||||
|
|
||||||
return backend.setAccountAsync(args, account).then(function () {
|
|
||||||
return account;
|
return account;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAcmeUrls(args) {
|
|
||||||
var now = Date.now();
|
|
||||||
|
|
||||||
// TODO check response header on request for cache time
|
|
||||||
if ((now - ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) {
|
|
||||||
return PromiseA.resolve(ipc.acmeUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
return LeCore.getAcmeUrlsAsync(args.server).then(function (data) {
|
|
||||||
ipc.acmeUrlsUpdatedAt = Date.now();
|
|
||||||
ipc.acmeUrls = data;
|
|
||||||
|
|
||||||
return ipc.acmeUrls;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCertificateAsync(args, defaults, handlers) {
|
|
||||||
args.rsaKeySize = args.rsaKeySize || 2048;
|
|
||||||
args.challengeType = args.challengeType || 'http-01';
|
|
||||||
|
|
||||||
function log() {
|
|
||||||
if (args.debug || defaults.debug) {
|
|
||||||
console.log.apply(console, arguments);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var account = args.account;
|
, certificates: {
|
||||||
var promise;
|
registerAsync: function (args) {
|
||||||
var keypairOpts = { public: true, pem: true };
|
var err;
|
||||||
|
var copy = utils.merge(args, le);
|
||||||
|
args = utils.tplCopy(copy);
|
||||||
|
|
||||||
promise = backend.getPrivatePem(args).then(function (pem) {
|
if (!Array.isArray(args.domains)) {
|
||||||
return RSA.import({ privateKeyPem: pem });
|
return PromiseA.reject(new Error('args.domains should be an array of domains'));
|
||||||
}, function (/*err*/) {
|
|
||||||
return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) {
|
|
||||||
keypair.privateKeyPem = RSA.exportPrivatePem(keypair);
|
|
||||||
keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair);
|
|
||||||
return backend.setPrivatePem(args, keypair);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return promise.then(function (domainKeypair) {
|
|
||||||
log("[le/core.js] get certificate");
|
|
||||||
|
|
||||||
args.domainKeypair = domainKeypair;
|
|
||||||
//args.registration = domainKey;
|
|
||||||
|
|
||||||
return LeCore.getCertificateAsync({
|
|
||||||
debug: args.debug
|
|
||||||
|
|
||||||
, newAuthzUrl: args._acmeUrls.newAuthz
|
|
||||||
, newCertUrl: args._acmeUrls.newCert
|
|
||||||
|
|
||||||
, accountKeypair: RSA.import(account.keypair)
|
|
||||||
, domainKeypair: domainKeypair
|
|
||||||
, domains: args.domains
|
|
||||||
, challengeType: args.challengeType
|
|
||||||
|
|
||||||
//
|
|
||||||
// IMPORTANT
|
|
||||||
//
|
|
||||||
// setChallenge and removeChallenge are handed defaults
|
|
||||||
// instead of args because getChallenge does not have
|
|
||||||
// access to args
|
|
||||||
// (args is per-request, defaults is per instance)
|
|
||||||
//
|
|
||||||
, setChallenge: function (domain, key, value, done) {
|
|
||||||
var copy = handlers.merge({ domains: [domain] }, defaults, backendDefaults);
|
|
||||||
handlers.tplCopy(copy);
|
|
||||||
|
|
||||||
//args.domains = [domain];
|
|
||||||
args.domains = args.domains || [domain];
|
|
||||||
|
|
||||||
if (5 !== handlers.setChallenge.length) {
|
|
||||||
done(new Error("handlers.setChallenge receives the wrong number of arguments."
|
|
||||||
+ " You must define setChallenge as function (opts, domain, key, val, cb) { }"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handlers.setChallenge(copy, domain, key, value, done);
|
|
||||||
}
|
}
|
||||||
, removeChallenge: function (domain, key, done) {
|
|
||||||
var copy = handlers.merge({ domains: [domain] }, defaults, backendDefaults);
|
|
||||||
handlers.tplCopy(copy);
|
|
||||||
|
|
||||||
if (4 !== handlers.removeChallenge.length) {
|
if (!(args.domains.length && args.domains.every(utils.isValidDomain))) {
|
||||||
done(new Error("handlers.removeChallenge receives the wrong number of arguments."
|
// NOTE: this library can't assume to handle the http loopback
|
||||||
+ " You must define removeChallenge as function (opts, domain, key, cb) { }"));
|
// (or dns-01 validation may be used)
|
||||||
return;
|
// so we do not check dns records or attempt a loopback here
|
||||||
}
|
err = new Error("invalid domain name(s): '" + args.domains + "'");
|
||||||
|
err.code = "INVALID_DOMAIN";
|
||||||
handlers.removeChallenge(copy, domain, key, done);
|
return PromiseA.reject(err);
|
||||||
}
|
}
|
||||||
}).then(attachCertInfo);
|
|
||||||
}).then(function (results) {
|
|
||||||
// { cert, chain, fullchain, privkey }
|
|
||||||
|
|
||||||
args.pems = results;
|
return core.accounts.getAsync(copy).then(function (account) {
|
||||||
return backend.setRegistration(args, defaults, handlers);
|
copy.account = account;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateDomainCertificate(args, defaults, handlers) {
|
//var account = args.account;
|
||||||
if (args.duplicate) {
|
var keypairOpts = { public: true, pem: true };
|
||||||
// we're forcing a refresh via 'dupliate: true'
|
|
||||||
return getCertificateAsync(args, defaults, handlers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapped.fetchAsync(args).then(function (certs) {
|
var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) {
|
||||||
var halfLife = (certs.expiresAt - certs.issuedAt) / 2;
|
return RSA.import(keypair);
|
||||||
|
}, function (/*err*/) {
|
||||||
|
return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) {
|
||||||
|
keypair.privateKeyPem = RSA.exportPrivatePem(keypair);
|
||||||
|
keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair);
|
||||||
|
return le.store.certificates.setKeypairAsync(args, keypair);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (!certs || (Date.now() - certs.issuedAt) > halfLife) {
|
return promise.then(function (domainKeypair) {
|
||||||
// There is no cert available
|
args.domainKeypair = domainKeypair;
|
||||||
// Or the cert is more than half-expired
|
//args.registration = domainKey;
|
||||||
return getCertificateAsync(args, defaults, handlers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return PromiseA.reject(new Error(
|
// Note: the ACME urls are always fetched fresh on purpose
|
||||||
"[ERROR] Certificate issued at '"
|
// TODO is this the right place for this?
|
||||||
+ new Date(certs.issuedAt).toISOString() + "' and expires at '"
|
return core.getAcmeUrlsAsync(args).then(function (urls) {
|
||||||
+ new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '"
|
args._acmeUrls = urls;
|
||||||
+ new Date(certs.issuedA + halfLife).toISOString() + "'. Set { duplicate: true } to force."
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) }
|
return le.acme.getCertificateAsync({
|
||||||
function getOrCreateAcmeAccount(args, defaults, handlers) {
|
debug: args.debug || le.debug
|
||||||
function log() {
|
|
||||||
if (args.debug) {
|
|
||||||
console.log.apply(console, arguments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return backend.getAccountId(args).then(function (accountId) {
|
, newAuthzUrl: args._acmeUrls.newAuthz
|
||||||
|
, newCertUrl: args._acmeUrls.newCert
|
||||||
|
|
||||||
// Note: the ACME urls are always fetched fresh on purpose
|
, accountKeypair: RSA.import(account.keypair)
|
||||||
return getAcmeUrls(args).then(function (urls) {
|
, domainKeypair: domainKeypair
|
||||||
args._acmeUrls = urls;
|
, domains: args.domains
|
||||||
|
, challengeType: args.challengeType
|
||||||
|
|
||||||
if (accountId) {
|
//
|
||||||
log('[le/core.js] use account');
|
// IMPORTANT
|
||||||
|
//
|
||||||
|
// setChallenge and removeChallenge are handed defaults
|
||||||
|
// instead of args because getChallenge does not have
|
||||||
|
// access to args
|
||||||
|
// (args is per-request, defaults is per instance)
|
||||||
|
//
|
||||||
|
, setChallenge: function (domain, key, value, done) {
|
||||||
|
var copy = utils.merge({ domains: [domain] }, le);
|
||||||
|
utils.tplCopy(copy);
|
||||||
|
|
||||||
args.accountId = accountId;
|
//args.domains = [domain];
|
||||||
return backend.getAccount(args, handlers);
|
args.domains = args.domains || [domain];
|
||||||
} else {
|
|
||||||
log('[le/core.js] create account');
|
|
||||||
return createAccount(args, handlers);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var wrapped = {
|
if (5 !== le.challenger.set.length) {
|
||||||
registerAsync: function (args) {
|
done(new Error("le.challenger.set receives the wrong number of arguments."
|
||||||
var utils = require('./lib/common');
|
+ " You must define setChallenge as function (opts, domain, key, val, cb) { }"));
|
||||||
var err;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(args.domains)) {
|
le.challenger.set(copy, domain, key, value, done);
|
||||||
return PromiseA.reject(new Error('args.domains should be an array of domains'));
|
}
|
||||||
}
|
, removeChallenge: function (domain, key, done) {
|
||||||
|
var copy = utils.merge({ domains: [domain] }, le);
|
||||||
|
utils.tplCopy(copy);
|
||||||
|
|
||||||
if (!(args.domains.length && args.domains.every(utils.isValidDomain))) {
|
if (4 !== le.challenger.remove.length) {
|
||||||
// NOTE: this library can't assume to handle the http loopback
|
done(new Error("le.challenger.remove receives the wrong number of arguments."
|
||||||
// (or dns-01 validation may be used)
|
+ " You must define removeChallenge as function (opts, domain, key, cb) { }"));
|
||||||
// so we do not check dns records or attempt a loopback here
|
return;
|
||||||
err = new Error("invalid domain name(s): '" + args.domains + "'");
|
}
|
||||||
err.code = "INVALID_DOMAIN";
|
|
||||||
return PromiseA.reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
var copy = handlers.merge(args, defaults, backendDefaults);
|
le.challenger.remove(copy, domain, key, done);
|
||||||
handlers.tplCopy(copy);
|
}
|
||||||
|
}).then(utils.attachCertInfo);
|
||||||
|
});
|
||||||
|
}).then(function (results) {
|
||||||
|
// { cert, chain, privkey }
|
||||||
|
|
||||||
return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) {
|
args.pems = results;
|
||||||
copy.account = account;
|
return le.store.certificates.setAsync(args).then(function () {
|
||||||
|
return results;
|
||||||
return backend.getOrCreateRenewal(copy).then(function (pyobj) {
|
});
|
||||||
|
});
|
||||||
copy.pyobj = pyobj;
|
|
||||||
return getOrCreateDomainCertificate(copy, defaults, handlers);
|
|
||||||
});
|
});
|
||||||
}).then(function (result) {
|
}
|
||||||
return result;
|
, renewAsync: function (args) {
|
||||||
}, function (err) {
|
// TODO fetch email address if not present
|
||||||
return PromiseA.reject(err);
|
return core.certificates.registerAsync(args);
|
||||||
});
|
}
|
||||||
}
|
, checkAsync: function (args) {
|
||||||
, getOrCreateAccount: function (args) {
|
var copy = utils.merge(args, le);
|
||||||
return createAccount(args, handlers);
|
utils.tplCopy(copy);
|
||||||
}
|
|
||||||
, configureAsync: function (hargs) {
|
|
||||||
var copy = handlers.merge(hargs, defaults, backendDefaults);
|
|
||||||
handlers.tplCopy(copy);
|
|
||||||
|
|
||||||
return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) {
|
// returns pems
|
||||||
copy.account = account;
|
return le.store.certificates.checkAsync(copy).then(utils.attachCertInfo);
|
||||||
return backend.getOrCreateRenewal(copy);
|
}
|
||||||
});
|
, getAsync: function (args) {
|
||||||
}
|
var copy = utils.merge(args, le);
|
||||||
, fetchAsync: function (args) {
|
args = utils.tplCopy(copy);
|
||||||
var copy = handlers.merge(args, defaults);
|
|
||||||
handlers.tplCopy(copy);
|
|
||||||
|
|
||||||
return backend.fetchAsync(copy).then(attachCertInfo);
|
return core.certificates.checkAsync(args).then(function (certs) {
|
||||||
|
if (!certs) {
|
||||||
|
// There is no cert available
|
||||||
|
return core.certificates.registerAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
var renewableAt = certs.expiresAt - le.renewWithin;
|
||||||
|
//var halfLife = (certs.expiresAt - certs.issuedAt) / 2;
|
||||||
|
//var renewable = (Date.now() - certs.issuedAt) > halfLife;
|
||||||
|
|
||||||
|
if (args.duplicate || Date.now() >= renewableAt) {
|
||||||
|
// The cert is more than half-expired
|
||||||
|
// We're forcing a refresh via 'dupliate: true'
|
||||||
|
return core.certificates.renewAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PromiseA.reject(new Error(
|
||||||
|
"[ERROR] Certificate issued at '"
|
||||||
|
+ new Date(certs.issuedAt).toISOString() + "' and expires at '"
|
||||||
|
+ new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '"
|
||||||
|
+ new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force."
|
||||||
|
));
|
||||||
|
}).then(function (results) {
|
||||||
|
// returns pems
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrapped;
|
return core;
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function (le) {
|
||||||
|
return function () {
|
||||||
|
var prefix = le.acmeChallengePrefix; // /.well-known/acme-challenge/:token
|
||||||
|
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (0 !== req.url.indexOf(prefix)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = req.url.slice(prefix.length);
|
||||||
|
var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:*/, '');
|
||||||
|
|
||||||
|
// TODO tpl copy?
|
||||||
|
le.challenger.getAsync(le, hostname, key).then(function (token) {
|
||||||
|
if (!token) {
|
||||||
|
res.status = 404;
|
||||||
|
res.send("Error: These aren't the tokens you're looking for. Move along.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(token);
|
||||||
|
}, function (/*err*/) {
|
||||||
|
res.status = 404;
|
||||||
|
res.send("Error: These aren't the tokens you're looking for. Move along.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
|
@ -4,6 +4,20 @@ var path = require('path');
|
||||||
var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")");
|
var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")");
|
||||||
var re = /^[a-zA-Z0-9\.\-]+$/;
|
var re = /^[a-zA-Z0-9\.\-]+$/;
|
||||||
var punycode = require('punycode');
|
var punycode = require('punycode');
|
||||||
|
var PromiseA = require('bluebird');
|
||||||
|
var dns = PromiseA.promisifyAll(require('dns'));
|
||||||
|
|
||||||
|
module.exports.attachCertInfo = function (results) {
|
||||||
|
var getCertInfo = require('./cert-info').getCertInfo;
|
||||||
|
// XXX Note: Parsing the certificate info comes at a great cost (~500kb)
|
||||||
|
var certInfo = getCertInfo(results.cert);
|
||||||
|
|
||||||
|
//results.issuedAt = arr[3].mtime.valueOf()
|
||||||
|
results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now()
|
||||||
|
results.expiresAt = Date(certInfo.notAfter.value).valueOf();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
module.exports.isValidDomain = function (domain) {
|
module.exports.isValidDomain = function (domain) {
|
||||||
if (re.test(domain)) {
|
if (re.test(domain)) {
|
||||||
|
@ -21,7 +35,7 @@ module.exports.isValidDomain = function (domain) {
|
||||||
|
|
||||||
module.exports.merge = function (/*defaults, args*/) {
|
module.exports.merge = function (/*defaults, args*/) {
|
||||||
var allDefaults = Array.prototype.slice.apply(arguments);
|
var allDefaults = Array.prototype.slice.apply(arguments);
|
||||||
var args = args.shift();
|
var args = allDefaults.shift();
|
||||||
var copy = {};
|
var copy = {};
|
||||||
|
|
||||||
allDefaults.forEach(function (defaults) {
|
allDefaults.forEach(function (defaults) {
|
||||||
|
@ -63,4 +77,31 @@ module.exports.tplCopy = function (copy) {
|
||||||
copy[key] = copy[key].replace(':' + tplname, tpls[tplname]);
|
copy[key] = copy[key].replace(':' + tplname, tpls[tplname]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.testEmail = function (email) {
|
||||||
|
var parts = (email||'').split('@');
|
||||||
|
var err;
|
||||||
|
|
||||||
|
if (2 !== parts.length || !parts[0] || !parts[1]) {
|
||||||
|
err = new Error("malformed email address '" + email + "'");
|
||||||
|
err.code = 'E_EMAIL';
|
||||||
|
return PromiseA.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dns.resolveMxAsync(parts[1]).then(function (records) {
|
||||||
|
// records only returns when there is data
|
||||||
|
if (!records.length) {
|
||||||
|
throw new Error("sanity check fail: success, but no MX records returned");
|
||||||
|
}
|
||||||
|
return email;
|
||||||
|
}, function (err) {
|
||||||
|
if ('ENODATA' === err.code) {
|
||||||
|
err = new Error("no MX records found for '" + parts[1] + "'");
|
||||||
|
err.code = 'E_EMAIL';
|
||||||
|
return PromiseA.reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
|
@ -0,0 +1,117 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var LE = require('../').LE;
|
||||||
|
var le = LE.create({
|
||||||
|
server: 'staging'
|
||||||
|
, acme: require('le-acme-core').ACME.create()
|
||||||
|
, store: require('le-store-certbot').create({
|
||||||
|
configDir: '~/letsencrypt.test/etc/'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
var testId = Math.round(Date.now() / 1000).toString();
|
||||||
|
var fakeEmail = 'coolaj86+le.' + testId + '@example.com';
|
||||||
|
var testEmail = 'coolaj86+le.' + testId + '@example.com';
|
||||||
|
var testAccount;
|
||||||
|
|
||||||
|
var tests = [
|
||||||
|
function () {
|
||||||
|
return le.core.accounts.checkAsync({
|
||||||
|
email: testEmail
|
||||||
|
}).then(function (account) {
|
||||||
|
if (account) {
|
||||||
|
console.error(account);
|
||||||
|
throw new Error("Test account should not exist.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
return le.core.accounts.registerAsync({
|
||||||
|
email: testEmail
|
||||||
|
, agreeTos: false
|
||||||
|
, rsaKeySize: 2048
|
||||||
|
}).then(function (/*account*/) {
|
||||||
|
throw new Error("Should not register if 'agreeTos' is not truthy.");
|
||||||
|
}, function (err) {
|
||||||
|
if (err.code !== 'E_ARGS') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
return le.core.accounts.registerAsync({
|
||||||
|
email: testEmail
|
||||||
|
, agreeTos: true
|
||||||
|
, rsaKeySize: 1024
|
||||||
|
}).then(function (/*account*/) {
|
||||||
|
throw new Error("Should not register if 'rsaKeySize' is less than 2048.");
|
||||||
|
}, function (err) {
|
||||||
|
if (err.code !== 'E_ARGS') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
return le.core.accounts.registerAsync({
|
||||||
|
email: fakeEmail
|
||||||
|
, agreeTos: true
|
||||||
|
, rsaKeySize: 2048
|
||||||
|
}).then(function (/*account*/) {
|
||||||
|
// TODO test mx record
|
||||||
|
throw new Error("Registration should NOT succeed with a bad email address.");
|
||||||
|
}, function (err) {
|
||||||
|
if (err.code !== 'E_EMAIL') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
throw new Error('NOT IMPLEMENTED');
|
||||||
|
return le.core.accounts.registerAsync({
|
||||||
|
email: 'coolaj86+le.' + testId + '@example.com'
|
||||||
|
, agreeTos: true
|
||||||
|
, rsaKeySize: 2048
|
||||||
|
}).then(function (account) {
|
||||||
|
testAccount = account;
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Registration should always return a new account.");
|
||||||
|
}
|
||||||
|
if (!account.email) {
|
||||||
|
throw new Error("Registration should return the email.");
|
||||||
|
}
|
||||||
|
if (!account.id) {
|
||||||
|
throw new Error("Registration should return the account id.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
return le.core.accounts.checkAsync({
|
||||||
|
email: testAccount.email
|
||||||
|
}).then(function (account) {
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Test account should exist when searched by email.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
, function () {
|
||||||
|
return le.core.accounts.checkAsync({
|
||||||
|
accountId: testAccount.id
|
||||||
|
}).then(function (account) {
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Test account should exist when searched by account id.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
var test = tests.shift();
|
||||||
|
if (!test) {
|
||||||
|
console.info('All tests passed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
test().then(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
Loading…
Reference in New Issue