forked from root/acme.js
v1.8: transitional support for v2.0
This commit is contained in:
parent
f2b6772f5c
commit
fced146928
|
@ -1,32 +1,17 @@
|
||||||
|
.env
|
||||||
|
|
||||||
*.pem
|
*.pem
|
||||||
letsencrypt.work
|
|
||||||
letsencrypt.logs
|
|
||||||
letsencrypt.config
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
lib-cov
|
lib-cov
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directory
|
# Dependency directory
|
||||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||||
node_modules
|
node_modules
|
||||||
|
|
224
README.md
224
README.md
|
@ -2,28 +2,77 @@
|
||||||
| [acme-v2-cli.js](https://git.coolaj86.com/coolaj86/acme-v2-cli.js)
|
| [acme-v2-cli.js](https://git.coolaj86.com/coolaj86/acme-v2-cli.js)
|
||||||
| [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
|
| [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
|
||||||
| [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js)
|
| [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js)
|
||||||
|
|
|
||||||
|
|
||||||
| A [Root](https://therootcompany.com) Project
|
# [acme-v2.js](https://git.coolaj86.com/coolaj86/acme-v2.js) | a [Root](https://therootcompany.com) project
|
||||||
|
|
||||||
# [acme-v2.js](https://git.coolaj86.com/coolaj86/acme-v2.js)
|
A **Zero (External) Dependency**\* library for building
|
||||||
|
Let's Encrypt v2 (ACME draft 18) clients and getting Free SSL certificates.
|
||||||
|
|
||||||
A lightweight, **Low Dependency**\* framework for building
|
The primary goal of this library is to make it easy to
|
||||||
Let's Encrypt v2 (ACME draft 12) clients, successor to `le-acme-core.js`.
|
get Accounts and Certificates through Let's Encrypt.
|
||||||
Built [by request](https://git.coolaj86.com/coolaj86/greenlock.js/issues/5#issuecomment-8).
|
|
||||||
|
|
||||||
\* <small>although `node-forge` and `ursa` are included as `optionalDependencies`
|
# Features
|
||||||
for backwards compatibility with older versions of node, there are no other
|
|
||||||
dependencies except those that I wrote for this (and related) projects.</small>
|
- [x] Let's Encrypt™ v2 / ACME Draft 12
|
||||||
|
- [ ] (in-progress) Let's Encrypt™ v2.1 / ACME Draft 18
|
||||||
|
- [ ] (in-progress) StartTLS Everywhere™
|
||||||
|
- [x] Works with any [generic ACME challenge handler](https://git.rootprojects.org/root/acme-challenge-test.js)
|
||||||
|
- [x] **http-01** for single or multiple domains per certificate
|
||||||
|
- [x] **dns-01** for wildcards, localhost, private networks, etc
|
||||||
|
- [x] VanillaJS
|
||||||
|
- [x] Zero External Dependencies
|
||||||
|
- [x] Safe, Efficient, Maintained
|
||||||
|
- [x] Works in Node v6+
|
||||||
|
- [ ] (v2) Works in Web Browsers (See [Demo](https://greenlock.domains))
|
||||||
|
|
||||||
|
\* <small>The only required dependencies were built by us, specifically for this and related libraries.
|
||||||
|
There are some, truly optional, backwards-compatibility dependencies for node v6.</small>
|
||||||
|
|
||||||
## Looking for Quick 'n' Easy™?
|
## Looking for Quick 'n' Easy™?
|
||||||
|
|
||||||
If you're looking to _build a webserver_, try [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js).
|
If you want something that's more "batteries included" give
|
||||||
If you're looking for an _ACME-enabled webserver_, try [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js).
|
[greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
|
||||||
|
a try.
|
||||||
|
|
||||||
- [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
|
- [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
|
||||||
- [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js)
|
|
||||||
|
|
||||||
|
## v1.7+: Transitional v2 Support
|
||||||
|
|
||||||
|
By the end of June 2019 we expect to have completed the migration to Let's Encrypt v2.1 (ACME draft 18).
|
||||||
|
|
||||||
|
Although the draft 18 changes themselves don't requiring breaking the API,
|
||||||
|
we've been keeping backwards compatibility for a long time and the API has become messy.
|
||||||
|
|
||||||
|
We're taking this **mandatory ACME update** as an opportunity to **clean up** and **greatly simplify**
|
||||||
|
the code with a fresh new release.
|
||||||
|
|
||||||
|
As of **v1.7** we started adding **transitional support** for the **next major version**, v2.0 of acme-v2.js.
|
||||||
|
We've been really good about backwards compatibility for
|
||||||
|
|
||||||
|
## Recommended Example
|
||||||
|
|
||||||
|
Due to the upcoming changes we've removed the old documentation.
|
||||||
|
|
||||||
|
Instead we recommend that you take a look at the
|
||||||
|
[Digital Ocean DNS-01 Example](https://git.rootprojects.org/root/acme-v2.js/src/branch/master/examples/dns-01-digitalocean.js)
|
||||||
|
|
||||||
|
- [examples/dns-01-digitalocean.js](https://git.rootprojects.org/root/acme-v2.js/src/branch/master/examples/dns-01-digitalocean.js)
|
||||||
|
|
||||||
|
That's not exactly the new API, but it's close.
|
||||||
|
|
||||||
|
## Let's Encrypt v02 Directory URLs
|
||||||
|
|
||||||
|
```
|
||||||
|
# Production URL
|
||||||
|
https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
# Staging URL
|
||||||
|
https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--
|
||||||
## How to build ACME clients
|
## How to build ACME clients
|
||||||
|
|
||||||
As this is intended to build ACME clients, there is not a simple 2-line example
|
As this is intended to build ACME clients, there is not a simple 2-line example
|
||||||
|
@ -63,136 +112,75 @@ examples/https-server.js
|
||||||
examples/http-server.js
|
examples/http-server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## Let's Encrypt Directory URLs
|
-->
|
||||||
|
|
||||||
```
|
## API
|
||||||
# Production URL
|
|
||||||
https://acme-v02.api.letsencrypt.org/directory
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
Status: Small, but breaking changes coming in v2
|
||||||
# Staging URL
|
|
||||||
https://acme-staging-v02.api.letsencrypt.org/directory
|
|
||||||
```
|
|
||||||
|
|
||||||
## Two API versions, Two Implementations
|
|
||||||
|
|
||||||
This library (acme-v2.js) supports ACME [_draft 11_](https://tools.ietf.org/html/draft-ietf-acme-acme-11),
|
|
||||||
otherwise known as Let's Encrypt v2 (or v02).
|
|
||||||
|
|
||||||
- ACME draft 11
|
|
||||||
- Let's Encrypt v2
|
|
||||||
- Let's Encrypt v02
|
|
||||||
|
|
||||||
The predecessor (le-acme-core) supports Let's Encrypt v1 (or v01), which was a
|
|
||||||
[hodge-podge of various drafts](https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md)
|
|
||||||
of the ACME spec early on.
|
|
||||||
|
|
||||||
- ACME early draft
|
|
||||||
- Let's Encrypt v1
|
|
||||||
- Let's Encrypt v01
|
|
||||||
|
|
||||||
This library maintains compatibility with le-acme-core so that it can be used as a **drop-in replacement**
|
|
||||||
and requires **no changes to existing code**,
|
|
||||||
but also provides an updated API more congruent with draft 11.
|
|
||||||
|
|
||||||
## le-acme-core-compatible API (recommended)
|
|
||||||
|
|
||||||
Status: Stable, Locked, Bugfix-only
|
|
||||||
|
|
||||||
See Full Documentation at <https://git.coolaj86.com/coolaj86/le-acme-core.js>
|
|
||||||
|
|
||||||
```js
|
|
||||||
var RSA = require('rsa-compat').RSA;
|
|
||||||
var acme = require('acme-v2/compat.js').ACME.create({ RSA: RSA });
|
|
||||||
|
|
||||||
//
|
|
||||||
// Use exactly the same as le-acme-core
|
|
||||||
//
|
|
||||||
```
|
|
||||||
|
|
||||||
## Promise API (dev)
|
|
||||||
|
|
||||||
Status: Almost stable, but **not semver locked**
|
|
||||||
|
|
||||||
This API is a simple evolution of le-acme-core,
|
This API is a simple evolution of le-acme-core,
|
||||||
but tries to provide a better mapping to the new draft 11 APIs.
|
but tries to provide a better mapping to the new draft 11 APIs.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Create Instance (Dependency Injection)
|
|
||||||
var ACME = require('acme-v2').ACME.create({
|
var ACME = require('acme-v2').ACME.create({
|
||||||
RSA: require('rsa-compat').RSA
|
// used for overriding the default user-agent
|
||||||
|
userAgent: 'My custom UA String',
|
||||||
|
getUserAgentString: function(deps) {
|
||||||
|
return 'My custom UA String';
|
||||||
|
},
|
||||||
|
|
||||||
// other overrides
|
// don't try to validate challenges locally
|
||||||
, request: require('request')
|
skipChallengeTest: false,
|
||||||
, promisify: require('util').promisify
|
skipDryRun: false,
|
||||||
|
|
||||||
// used for constructing user-agent
|
// ask if the certificate can be issued up to 10 times before failing
|
||||||
, os: require('os')
|
retryPoll: 8,
|
||||||
, process: require('process')
|
// ask if the certificate has been validated up to 6 times before cancelling
|
||||||
|
retryPending: 4,
|
||||||
// used for overriding the default user-agent
|
// Wait 1000ms between retries
|
||||||
, userAgent: 'My custom UA String'
|
retryInterval: 1000,
|
||||||
, getUserAgentString: function (deps) { return 'My custom UA String'; }
|
// Wait 10,000ms after deauthorizing a challenge before retrying
|
||||||
|
deauthWait: 10 * 1000
|
||||||
|
|
||||||
// don't try to validate challenges locally
|
|
||||||
, skipChallengeTest: false
|
|
||||||
// ask if the certificate can be issued up to 10 times before failing
|
|
||||||
, retryPoll: 8
|
|
||||||
// ask if the certificate has been validated up to 6 times before cancelling
|
|
||||||
, retryPending: 4
|
|
||||||
// Wait 1000ms between retries
|
|
||||||
, retryInterval: 1000
|
|
||||||
// Wait 10,000ms after deauthorizing a challenge before retrying
|
|
||||||
, deauthWait: 10 * 1000
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Discover Directory URLs
|
// Discover Directory URLs
|
||||||
ACME.init(acmeDirectoryUrl) // returns Promise<acmeUrls={keyChange,meta,newAccount,newNonce,newOrder,revokeCert}>
|
ACME.init(acmeDirectoryUrl); // returns Promise<acmeUrls={keyChange,meta,newAccount,newNonce,newOrder,revokeCert}>
|
||||||
|
|
||||||
|
|
||||||
// Accounts
|
// Accounts
|
||||||
ACME.accounts.create(options) // returns Promise<regr> registration data
|
ACME.accounts.create(options); // returns Promise<regr> registration data
|
||||||
|
|
||||||
{ email: '<email>' // valid email (server checks MX records)
|
|
||||||
, accountKeypair: { // privateKeyPem or privateKeyJwt
|
|
||||||
privateKeyPem: '<ASCII PEM>'
|
|
||||||
}
|
|
||||||
, agreeToTerms: fn (tosUrl) {} // returns Promise with tosUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
|
options = {
|
||||||
|
email: '<email>', // valid email (server checks MX records)
|
||||||
|
accountKeypair: {
|
||||||
|
// privateKeyPem or privateKeyJwt
|
||||||
|
privateKeyPem: '<ASCII PEM>'
|
||||||
|
},
|
||||||
|
agreeToTerms: function(tosUrl) {} // should Promise the same `tosUrl` back
|
||||||
|
};
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
ACME.certificates.create(options) // returns Promise<pems={ privkey (key), cert, chain (ca) }>
|
ACME.certificates.create(options); // returns Promise<pems={ privkey (key), cert, chain (ca) }>
|
||||||
|
|
||||||
{ newAuthzUrl: '<url>' // specify acmeUrls.newAuthz
|
options = {
|
||||||
, newCertUrl: '<url>' // specify acmeUrls.newCert
|
domainKeypair: {
|
||||||
|
privateKeyPem: '<ASCII PEM>'
|
||||||
|
},
|
||||||
|
accountKeypair: {
|
||||||
|
privateKeyPem: '<ASCII PEM>'
|
||||||
|
},
|
||||||
|
domains: ['example.com'],
|
||||||
|
|
||||||
, domainKeypair: {
|
getZones: function(opts) {}, // should Promise an array of domain zone names
|
||||||
privateKeyPem: '<ASCII PEM>'
|
setChallenge: function(opts) {}, // should Promise the record id, or name
|
||||||
}
|
removeChallenge: function(opts) {} // should Promise null
|
||||||
, accountKeypair: {
|
};
|
||||||
privateKeyPem: '<ASCII PEM>'
|
|
||||||
}
|
|
||||||
, domains: [ 'example.com' ]
|
|
||||||
|
|
||||||
, setChallenge: fn (hostname, key, val) // return Promise
|
|
||||||
, removeChallenge: fn (hostname, key) // return Promise
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Helpers & Stuff
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Constants
|
|
||||||
ACME.challengePrefixes['http-01']; // '/.well-known/acme-challenge'
|
|
||||||
ACME.challengePrefixes['dns-01']; // '_acme-challenge'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- v1.8
|
||||||
|
- more transitional prepwork for new v2 API
|
||||||
|
- support newer (simpler) dns-01 and http-01 libraries
|
||||||
- v1.5
|
- v1.5
|
||||||
- perform full test challenge first (even before nonce)
|
- perform full test challenge first (even before nonce)
|
||||||
- v1.3
|
- v1.3
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
(function(exports) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// node[0] ./test.js[1] jon.doe@gmail.com[2] example.com,*.example.com[3] xxxxxx[4]
|
||||||
|
var email = process.argv[2] || process.env.ACME_EMAIL;
|
||||||
|
var domains = (process.argv[3] || process.env.ACME_DOMAINS).split(/[,\s]+/);
|
||||||
|
var token = process.argv[4] || process.env.DIGITALOCEAN_API_KEY;
|
||||||
|
|
||||||
|
// git clone https://git.rootprojects.org/root/acme-dns-01-digitalocean.js node_modules/acme-dns-01-digitalocean
|
||||||
|
var dns01 = require('acme-dns-01-digitalocean').create({
|
||||||
|
//baseUrl: 'https://api.digitalocean.com/v2/domains',
|
||||||
|
token: token
|
||||||
|
});
|
||||||
|
|
||||||
|
// This will be replaced with Keypairs.js in the next version
|
||||||
|
var promisify = require('util').promisify;
|
||||||
|
var generateKeypair = promisify(require('rsa-compat').RSA.generateKeypair);
|
||||||
|
|
||||||
|
//var ACME = exports.ACME || require('acme').ACME;
|
||||||
|
var ACME = exports.ACME || require('../').ACME;
|
||||||
|
var acme = ACME.create({});
|
||||||
|
acme
|
||||||
|
.init({
|
||||||
|
//directoryUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
return generateKeypair(null).then(function(accountPair) {
|
||||||
|
return generateKeypair(null).then(function(serverPair) {
|
||||||
|
return acme.accounts
|
||||||
|
.create({
|
||||||
|
// valid email (server checks MX records)
|
||||||
|
email: email,
|
||||||
|
accountKeypair: accountPair,
|
||||||
|
agreeToTerms: function(tosUrl) {
|
||||||
|
// ask user (if user is the host)
|
||||||
|
return tosUrl;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(account) {
|
||||||
|
console.info('Created Account:');
|
||||||
|
console.info(account);
|
||||||
|
|
||||||
|
return acme.certificates
|
||||||
|
.create({
|
||||||
|
domains: domains,
|
||||||
|
challenges: { 'dns-01': dns01 },
|
||||||
|
domainKeypair: serverPair,
|
||||||
|
accountKeypair: accountPair,
|
||||||
|
|
||||||
|
// v2 will be directly compatible with the new ACME modules,
|
||||||
|
// whereas this version needs a shim
|
||||||
|
getZones: dns01.zones,
|
||||||
|
setChallenge: dns01.set,
|
||||||
|
removeChallenge: dns01.remove
|
||||||
|
})
|
||||||
|
.then(function(certs) {
|
||||||
|
console.info('Secured SSL Certificates');
|
||||||
|
console.info(certs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
console.error('Something went wrong:');
|
||||||
|
console.error(e);
|
||||||
|
process.exit(500);
|
||||||
|
});
|
||||||
|
})('undefined' === typeof module ? window : module.exports);
|
|
@ -0,0 +1,3 @@
|
||||||
|
ACME_EMAIL=jon.doe@gmail.com
|
||||||
|
ACME_DOMAINS=example.com,foo.example.com,*.foo.example.com
|
||||||
|
DIGITALOCEAN_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
451
node.js
451
node.js
|
@ -276,7 +276,10 @@ ACME._registerAccount = function(me, options) {
|
||||||
}
|
}
|
||||||
if (1 === options.agreeToTerms.length) {
|
if (1 === options.agreeToTerms.length) {
|
||||||
// newer promise API
|
// newer promise API
|
||||||
return options.agreeToTerms(me._tos).then(agree, reject);
|
return Promise.resolve(options.agreeToTerms(me._tos)).then(
|
||||||
|
agree,
|
||||||
|
reject
|
||||||
|
);
|
||||||
} else if (2 === options.agreeToTerms.length) {
|
} else if (2 === options.agreeToTerms.length) {
|
||||||
// backwards compat cb API
|
// backwards compat cb API
|
||||||
return options.agreeToTerms(me._tos, function(err, tosUrl) {
|
return options.agreeToTerms(me._tos, function(err, tosUrl) {
|
||||||
|
@ -461,6 +464,58 @@ ACME._chooseChallenge = function(options, results) {
|
||||||
|
|
||||||
return challenge;
|
return challenge;
|
||||||
};
|
};
|
||||||
|
ACME._getZones = function(me, options, dnsHosts) {
|
||||||
|
if ('function' !== typeof options.getZones) {
|
||||||
|
options.getZones = function() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
if (options.getZones.length <= 1) {
|
||||||
|
options
|
||||||
|
.getZones({ dnsHosts: dnsHosts })
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
} else if (2 === options.getZones.length) {
|
||||||
|
options.getZones({ dnsHosts: dnsHosts }, function(err, zonenames) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(zonenames);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
'options.getZones should accept opts and Promise an array of zone names'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
function newZoneRegExp(zonename) {
|
||||||
|
// (^|\.)example\.com$
|
||||||
|
// which matches:
|
||||||
|
// foo.example.com
|
||||||
|
// example.com
|
||||||
|
// but not:
|
||||||
|
// fooexample.com
|
||||||
|
return new RegExp('(^|\\.)' + zonename.replace(/\./g, '\\.') + '$');
|
||||||
|
}
|
||||||
|
function pluckZone(zonenames, dnsHost) {
|
||||||
|
return zonenames
|
||||||
|
.filter(function(zonename) {
|
||||||
|
// the only character that needs to be escaped for regex
|
||||||
|
// and is allowed in a domain name is '.'
|
||||||
|
return newZoneRegExp(zonename).test(dnsHost);
|
||||||
|
})
|
||||||
|
.sort(function(a, b) {
|
||||||
|
// longest match first
|
||||||
|
return b.length - a.length;
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
|
ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
|
||||||
// we don't poison the dns cache with our dummy request
|
// we don't poison the dns cache with our dummy request
|
||||||
var dnsPrefix = ACME.challengePrefixes['dns-01'];
|
var dnsPrefix = ACME.challengePrefixes['dns-01'];
|
||||||
|
@ -490,6 +545,7 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
|
||||||
auth[key] = challenge[key];
|
auth[key] = challenge[key];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var zone = pluckZone(options.zonenames || [], auth.identifier.value);
|
||||||
// batteries-included helpers
|
// batteries-included helpers
|
||||||
auth.hostname = auth.identifier.value;
|
auth.hostname = auth.identifier.value;
|
||||||
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
|
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
|
||||||
|
@ -511,7 +567,15 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
|
||||||
.update(auth.keyAuthorization)
|
.update(auth.keyAuthorization)
|
||||||
.digest('base64')
|
.digest('base64')
|
||||||
);
|
);
|
||||||
|
if (zone) {
|
||||||
|
auth.dnsZone = zone;
|
||||||
|
auth.dnsPrefix = auth.dnsHost
|
||||||
|
.replace(newZoneRegExp(zone), '')
|
||||||
|
.replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// for backwards compat
|
||||||
|
auth.challenge = auth;
|
||||||
return auth;
|
return auth;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -997,187 +1061,204 @@ ACME._getCertificate = function(me, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do a little dry-run / self-test
|
var dnsHosts = options.domains.map(function(d) {
|
||||||
return ACME._testChallenges(me, options).then(function() {
|
return (
|
||||||
if (me.debug) {
|
require('crypto')
|
||||||
console.debug('[acme-v2] certificates.create');
|
.randomBytes(2)
|
||||||
}
|
.toString('hex') + d
|
||||||
return ACME._getNonce(me).then(function() {
|
);
|
||||||
var body = {
|
});
|
||||||
// raw wildcard syntax MUST be used here
|
return ACME._getZones(me, options, dnsHosts).then(function(zonenames) {
|
||||||
identifiers: options.domains
|
options.zonenames = zonenames;
|
||||||
.sort(function(a, b) {
|
// Do a little dry-run / self-test
|
||||||
// the first in the list will be the subject of the certificate, I believe (and hope)
|
return ACME._testChallenges(me, options).then(function() {
|
||||||
if (!options.subject) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (options.subject === a) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (options.subject === b) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
.map(function(hostname) {
|
|
||||||
return { type: 'dns', value: hostname };
|
|
||||||
})
|
|
||||||
//, "notBefore": "2016-01-01T00:00:00Z"
|
|
||||||
//, "notAfter": "2016-01-08T00:00:00Z"
|
|
||||||
};
|
|
||||||
|
|
||||||
var payload = JSON.stringify(body);
|
|
||||||
// determine the signing algorithm to use in protected header // TODO isn't that handled by the signer?
|
|
||||||
me._kty =
|
|
||||||
(options.accountKeypair.privateKeyJwk &&
|
|
||||||
options.accountKeypair.privateKeyJwk.kty) ||
|
|
||||||
'RSA';
|
|
||||||
me._alg = 'EC' === me._kty ? 'ES256' : 'RS256'; // TODO vary with bitwidth of key (if not handled)
|
|
||||||
var jws = me.RSA.signJws(
|
|
||||||
options.accountKeypair,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
nonce: me._nonce,
|
|
||||||
alg: me._alg,
|
|
||||||
url: me._directoryUrls.newOrder,
|
|
||||||
kid: me._kid
|
|
||||||
},
|
|
||||||
Buffer.from(payload, 'utf8')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (me.debug) {
|
if (me.debug) {
|
||||||
console.debug('\n[DEBUG] newOrder\n');
|
console.debug('[acme-v2] certificates.create');
|
||||||
}
|
}
|
||||||
me._nonce = null;
|
return ACME._getNonce(me).then(function() {
|
||||||
return me
|
var body = {
|
||||||
._request({
|
// raw wildcard syntax MUST be used here
|
||||||
method: 'POST',
|
identifiers: options.domains
|
||||||
url: me._directoryUrls.newOrder,
|
.sort(function(a, b) {
|
||||||
headers: { 'Content-Type': 'application/jose+json' },
|
// the first in the list will be the subject of the certificate, I believe (and hope)
|
||||||
json: jws
|
if (!options.subject) {
|
||||||
})
|
return 0;
|
||||||
.then(function(resp) {
|
|
||||||
me._nonce = resp.toJSON().headers['replay-nonce'];
|
|
||||||
var location = resp.toJSON().headers.location;
|
|
||||||
var setAuths;
|
|
||||||
var auths = [];
|
|
||||||
if (me.debug) {
|
|
||||||
console.debug(location);
|
|
||||||
} // the account id url
|
|
||||||
if (me.debug) {
|
|
||||||
console.debug(resp.toJSON());
|
|
||||||
}
|
|
||||||
me._authorizations = resp.body.authorizations;
|
|
||||||
me._order = location;
|
|
||||||
me._finalize = resp.body.finalize;
|
|
||||||
//if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return;
|
|
||||||
|
|
||||||
if (!me._authorizations) {
|
|
||||||
return Promise.reject(
|
|
||||||
new Error(
|
|
||||||
"[acme-v2.js] authorizations were not fetched for '" +
|
|
||||||
options.domains.join() +
|
|
||||||
"':\n" +
|
|
||||||
JSON.stringify(resp.body)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (me.debug) {
|
|
||||||
console.debug('[acme-v2] POST newOrder has authorizations');
|
|
||||||
}
|
|
||||||
setAuths = me._authorizations.slice(0);
|
|
||||||
|
|
||||||
function setNext() {
|
|
||||||
var authUrl = setAuths.shift();
|
|
||||||
if (!authUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ACME._getChallenges(me, options, authUrl).then(function(
|
|
||||||
results
|
|
||||||
) {
|
|
||||||
// var domain = options.domains[i]; // results.identifier.value
|
|
||||||
|
|
||||||
// If it's already valid, we're golden it regardless
|
|
||||||
if (
|
|
||||||
results.challenges.some(function(ch) {
|
|
||||||
return 'valid' === ch.status;
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return setNext();
|
|
||||||
}
|
}
|
||||||
|
if (options.subject === a) {
|
||||||
var challenge = ACME._chooseChallenge(options, results);
|
return -1;
|
||||||
if (!challenge) {
|
|
||||||
// For example, wildcards require dns-01 and, if we don't have that, we have to bail
|
|
||||||
return Promise.reject(
|
|
||||||
new Error(
|
|
||||||
"Server didn't offer any challenge we can handle for '" +
|
|
||||||
options.domains.join() +
|
|
||||||
"'."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (options.subject === b) {
|
||||||
var auth = ACME._challengeToAuth(me, options, results, challenge);
|
return 1;
|
||||||
auths.push(auth);
|
|
||||||
return ACME._setChallenge(me, options, auth).then(setNext);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function challengeNext() {
|
|
||||||
var auth = auths.shift();
|
|
||||||
if (!auth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return ACME._postChallenge(me, options, auth).then(challengeNext);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First we set every challenge
|
|
||||||
// Then we ask for each challenge to be checked
|
|
||||||
// Doing otherwise would potentially cause us to poison our own DNS cache with misses
|
|
||||||
return setNext()
|
|
||||||
.then(challengeNext)
|
|
||||||
.then(function() {
|
|
||||||
if (me.debug) {
|
|
||||||
console.debug('[getCertificate] next.then');
|
|
||||||
}
|
}
|
||||||
var validatedDomains = body.identifiers.map(function(ident) {
|
return 0;
|
||||||
return ident.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return ACME._finalizeOrder(me, options, validatedDomains);
|
|
||||||
})
|
})
|
||||||
.then(function(order) {
|
.map(function(hostname) {
|
||||||
if (me.debug) {
|
return { type: 'dns', value: hostname };
|
||||||
console.debug('acme-v2: order was finalized');
|
})
|
||||||
|
//, "notBefore": "2016-01-01T00:00:00Z"
|
||||||
|
//, "notAfter": "2016-01-08T00:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
var payload = JSON.stringify(body);
|
||||||
|
// determine the signing algorithm to use in protected header // TODO isn't that handled by the signer?
|
||||||
|
me._kty =
|
||||||
|
(options.accountKeypair.privateKeyJwk &&
|
||||||
|
options.accountKeypair.privateKeyJwk.kty) ||
|
||||||
|
'RSA';
|
||||||
|
me._alg = 'EC' === me._kty ? 'ES256' : 'RS256'; // TODO vary with bitwidth of key (if not handled)
|
||||||
|
var jws = me.RSA.signJws(
|
||||||
|
options.accountKeypair,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
nonce: me._nonce,
|
||||||
|
alg: me._alg,
|
||||||
|
url: me._directoryUrls.newOrder,
|
||||||
|
kid: me._kid
|
||||||
|
},
|
||||||
|
Buffer.from(payload, 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug('\n[DEBUG] newOrder\n');
|
||||||
|
}
|
||||||
|
me._nonce = null;
|
||||||
|
return me
|
||||||
|
._request({
|
||||||
|
method: 'POST',
|
||||||
|
url: me._directoryUrls.newOrder,
|
||||||
|
headers: { 'Content-Type': 'application/jose+json' },
|
||||||
|
json: jws
|
||||||
|
})
|
||||||
|
.then(function(resp) {
|
||||||
|
me._nonce = resp.toJSON().headers['replay-nonce'];
|
||||||
|
var location = resp.toJSON().headers.location;
|
||||||
|
var setAuths;
|
||||||
|
var auths = [];
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug(location);
|
||||||
|
} // the account id url
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug(resp.toJSON());
|
||||||
|
}
|
||||||
|
me._authorizations = resp.body.authorizations;
|
||||||
|
me._order = location;
|
||||||
|
me._finalize = resp.body.finalize;
|
||||||
|
//if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return;
|
||||||
|
|
||||||
|
if (!me._authorizations) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
"[acme-v2.js] authorizations were not fetched for '" +
|
||||||
|
options.domains.join() +
|
||||||
|
"':\n" +
|
||||||
|
JSON.stringify(resp.body)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug('[acme-v2] POST newOrder has authorizations');
|
||||||
|
}
|
||||||
|
setAuths = me._authorizations.slice(0);
|
||||||
|
|
||||||
|
function setNext() {
|
||||||
|
var authUrl = setAuths.shift();
|
||||||
|
if (!authUrl) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return me
|
|
||||||
._request({ method: 'GET', url: me._certificate, json: true })
|
return ACME._getChallenges(me, options, authUrl).then(function(
|
||||||
.then(function(resp) {
|
results
|
||||||
if (me.debug) {
|
) {
|
||||||
console.debug('acme-v2: csr submitted and cert received:');
|
// var domain = options.domains[i]; // results.identifier.value
|
||||||
}
|
|
||||||
// https://github.com/certbot/certbot/issues/5721
|
// If it's already valid, we're golden it regardless
|
||||||
var certsarr = ACME.splitPemChain(
|
if (
|
||||||
ACME.formatPemChain(resp.body || '')
|
results.challenges.some(function(ch) {
|
||||||
|
return 'valid' === ch.status;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return setNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
var challenge = ACME._chooseChallenge(options, results);
|
||||||
|
if (!challenge) {
|
||||||
|
// For example, wildcards require dns-01 and, if we don't have that, we have to bail
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
"Server didn't offer any challenge we can handle for '" +
|
||||||
|
options.domains.join() +
|
||||||
|
"'."
|
||||||
|
)
|
||||||
);
|
);
|
||||||
// cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */
|
}
|
||||||
var certs = {
|
|
||||||
expires: order.expires,
|
var auth = ACME._challengeToAuth(
|
||||||
identifiers: order.identifiers,
|
me,
|
||||||
//, authorizations: order.authorizations
|
options,
|
||||||
cert: certsarr.shift(),
|
results,
|
||||||
//, privkey: privkeyPem
|
challenge
|
||||||
chain: certsarr.join('\n')
|
);
|
||||||
};
|
auths.push(auth);
|
||||||
if (me.debug) {
|
return ACME._setChallenge(me, options, auth).then(setNext);
|
||||||
console.debug(certs);
|
});
|
||||||
}
|
}
|
||||||
return certs;
|
|
||||||
|
function challengeNext() {
|
||||||
|
var auth = auths.shift();
|
||||||
|
if (!auth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return ACME._postChallenge(me, options, auth).then(challengeNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First we set every challenge
|
||||||
|
// Then we ask for each challenge to be checked
|
||||||
|
// Doing otherwise would potentially cause us to poison our own DNS cache with misses
|
||||||
|
return setNext()
|
||||||
|
.then(challengeNext)
|
||||||
|
.then(function() {
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug('[getCertificate] next.then');
|
||||||
|
}
|
||||||
|
var validatedDomains = body.identifiers.map(function(ident) {
|
||||||
|
return ident.value;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
return ACME._finalizeOrder(me, options, validatedDomains);
|
||||||
|
})
|
||||||
|
.then(function(order) {
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug('acme-v2: order was finalized');
|
||||||
|
}
|
||||||
|
return me
|
||||||
|
._request({ method: 'GET', url: me._certificate, json: true })
|
||||||
|
.then(function(resp) {
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug(
|
||||||
|
'acme-v2: csr submitted and cert received:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// https://github.com/certbot/certbot/issues/5721
|
||||||
|
var certsarr = ACME.splitPemChain(
|
||||||
|
ACME.formatPemChain(resp.body || '')
|
||||||
|
);
|
||||||
|
// cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */
|
||||||
|
var certs = {
|
||||||
|
expires: order.expires,
|
||||||
|
identifiers: order.identifiers,
|
||||||
|
//, authorizations: order.authorizations
|
||||||
|
cert: certsarr.shift(),
|
||||||
|
//, privkey: privkeyPem
|
||||||
|
chain: certsarr.join('\n')
|
||||||
|
};
|
||||||
|
if (me.debug) {
|
||||||
|
console.debug(certs);
|
||||||
|
}
|
||||||
|
return certs;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1190,7 +1271,7 @@ ACME.create = function create(me) {
|
||||||
me.challengePrefixes = ACME.challengePrefixes;
|
me.challengePrefixes = ACME.challengePrefixes;
|
||||||
me.RSA = me.RSA || require('rsa-compat').RSA;
|
me.RSA = me.RSA || require('rsa-compat').RSA;
|
||||||
//me.Keypairs = me.Keypairs || require('keypairs');
|
//me.Keypairs = me.Keypairs || require('keypairs');
|
||||||
me.request = me.request || require('@coolaj86/urequest');
|
me.request = me.request || require('@root/request');
|
||||||
me._dig = function(query) {
|
me._dig = function(query) {
|
||||||
// TODO use digd.js
|
// TODO use digd.js
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
|
@ -1241,7 +1322,27 @@ ACME.create = function create(me) {
|
||||||
}
|
}
|
||||||
|
|
||||||
me.init = function(_directoryUrl) {
|
me.init = function(_directoryUrl) {
|
||||||
me.directoryUrl = me.directoryUrl || _directoryUrl;
|
if (_directoryUrl) {
|
||||||
|
_directoryUrl = _directoryUrl.directoryUrl || _directoryUrl;
|
||||||
|
}
|
||||||
|
if ('string' === typeof _directoryUrl) {
|
||||||
|
me.directoryUrl = _directoryUrl;
|
||||||
|
}
|
||||||
|
if (!me.directoryUrl) {
|
||||||
|
me.directoryUrl =
|
||||||
|
'https://acme-staging-v02.api.letsencrypt.org/directory';
|
||||||
|
console.warn();
|
||||||
|
console.warn(
|
||||||
|
"No ACME `directoryUrl` was specified. Using Let's Encrypt's staging environment as the default, which will issue invalid certs."
|
||||||
|
);
|
||||||
|
console.warn('\t' + me.directoryUrl);
|
||||||
|
console.warn();
|
||||||
|
console.warn(
|
||||||
|
"To get valid certificates you will need to switch to a production URL. You might like Let's Encrypt v2:"
|
||||||
|
);
|
||||||
|
console.warn('\t' + me.directoryUrl.replace('-staging', ''));
|
||||||
|
console.warn();
|
||||||
|
}
|
||||||
return ACME._directory(me).then(function(resp) {
|
return ACME._directory(me).then(function(resp) {
|
||||||
me._directoryUrls = resp.body;
|
me._directoryUrls = resp.body;
|
||||||
me._tos = me._directoryUrls.meta.termsOfService;
|
me._tos = me._directoryUrls.meta.termsOfService;
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "acme-v2",
|
"name": "acme-v2",
|
||||||
"version": "1.7.6",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coolaj86/urequest": {
|
"@root/request": {
|
||||||
"version": "1.3.7",
|
"version": "1.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz",
|
||||||
"integrity": "sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA=="
|
"integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw=="
|
||||||
|
},
|
||||||
|
"dotenv": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-30xVGqjLjiUOArT4+M5q9sYdvuR4riM6yK9wMcas9Vbp6zZa+ocC9dp6QoftuhTPhFAiLK/0C5Ni2nou/Bk8lg==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"eckles": {
|
"eckles": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
|
@ -29,9 +35,9 @@
|
||||||
"integrity": "sha512-KxtX+/fBk+wM7O3CNgwjSh5elwFilLvqWajhr6wFr2Hd63JnKTTi43Tw+Jb1hxJQWOwoya+NZWR2xztn3hCrTw=="
|
"integrity": "sha512-KxtX+/fBk+wM7O3CNgwjSh5elwFilLvqWajhr6wFr2Hd63JnKTTi43Tw+Jb1hxJQWOwoya+NZWR2xztn3hCrTw=="
|
||||||
},
|
},
|
||||||
"rsa-compat": {
|
"rsa-compat": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/rsa-compat/-/rsa-compat-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/rsa-compat/-/rsa-compat-2.0.8.tgz",
|
||||||
"integrity": "sha512-bQmpscAQec9442RaghDybrHMy1twQ3nUZOgTlqntio1yru+rMnDV64uGRzKp7dJ4VVhNv3mLh3X4MNON+YM0dA==",
|
"integrity": "sha512-BFiiSEbuxzsVdaxpejbxfX07qs+rtous49Y6mL/zw6YHh9cranDvm2BvBmqT3rso84IsxNlP5BXnuNvm1Wn3Tw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"keypairs": "^1.2.14"
|
"keypairs": "^1.2.14"
|
||||||
}
|
}
|
||||||
|
|
15
package.json
15
package.json
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "acme-v2",
|
"name": "acme-v2",
|
||||||
"version": "1.7.7",
|
"version": "1.8.0",
|
||||||
"description": "Free SSL. A framework for building Let's Encrypt v2 clients, and other ACME v2 (draft 11) clients. Successor to le-acme-core.js",
|
"description": "A lightweight library for getting Free SSL certifications through Let's Encrypt, using the ACME protocol.",
|
||||||
"homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js",
|
"homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js",
|
||||||
"main": "node.js",
|
"main": "node.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node ./test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -23,10 +23,13 @@
|
||||||
"automated https",
|
"automated https",
|
||||||
"letsencrypt"
|
"letsencrypt"
|
||||||
],
|
],
|
||||||
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
|
"author": "AJ ONeal <coolaj86@gmail.com> (https://solderjs.com/)",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coolaj86/urequest": "^1.3.6",
|
"@root/request": "^1.3.11",
|
||||||
"rsa-compat": "^2.0.6"
|
"rsa-compat": "^2.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue