2015-12-11 11:23:47 +00:00
|
|
|
letsencrypt
|
|
|
|
===========
|
|
|
|
|
2015-12-14 19:37:22 +00:00
|
|
|
Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS Certificates for node.js
|
2015-12-11 11:23:47 +00:00
|
|
|
|
2015-12-13 06:40:03 +00:00
|
|
|
* Automatic HTTPS with ExpressJS
|
|
|
|
* Automatic live renewal (in-process)
|
2015-12-13 08:05:24 +00:00
|
|
|
* On-the-fly HTTPS certificates for Dynamic DNS (in-process, no server restart)
|
|
|
|
* Works with node cluster out of the box
|
2015-12-13 06:39:37 +00:00
|
|
|
* usable via commandline as well
|
2015-12-13 06:40:03 +00:00
|
|
|
* Free SSL (HTTPS Certificates for TLS)
|
2015-12-13 08:05:24 +00:00
|
|
|
* [90-day certificates](https://letsencrypt.org/2015/11/09/why-90-days.html)
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
**See Also**
|
|
|
|
|
|
|
|
* See the node-letsencrypt [Examples](https://github.com/Daplie/node-letsencrypt/tree/master/examples)
|
|
|
|
* [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
|
|
|
|
|
2015-12-12 13:11:05 +00:00
|
|
|
Install
|
|
|
|
=======
|
|
|
|
|
|
|
|
```bash
|
|
|
|
npm install --save letsencrypt
|
|
|
|
```
|
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
Usage
|
|
|
|
=====
|
2015-12-12 22:16:02 +00:00
|
|
|
|
2015-12-16 09:19:08 +00:00
|
|
|
See [letsencrypt-cli](https://github.com/Daplie/node-letsencrypt-cli)
|
|
|
|
and [letsencrypt-express](https://github.com/Daplie/letsencrypt-express)
|
2015-12-12 22:16:02 +00:00
|
|
|
|
2015-12-13 11:09:06 +00:00
|
|
|
```javascript
|
2015-12-13 09:04:44 +00:00
|
|
|
var config = require('./examples/config-minimal');
|
|
|
|
|
2015-12-15 02:15:11 +00:00
|
|
|
config.le.webrootPath = __dirname + '/tests/acme-challenge';
|
2015-12-13 09:04:44 +00:00
|
|
|
|
2015-12-16 09:19:08 +00:00
|
|
|
var le = require('letsencrypt').create(config.le);
|
2015-12-12 22:16:02 +00:00
|
|
|
le.register({
|
2015-12-13 09:04:44 +00:00
|
|
|
agreeTos: true
|
|
|
|
, domains: ['example.com'] // CHANGE TO YOUR DOMAIN
|
|
|
|
, email: 'user@email.com' // CHANGE TO YOUR EMAIL
|
2015-12-16 09:19:08 +00:00
|
|
|
, standalone: true
|
2015-12-13 09:04:44 +00:00
|
|
|
}, function (err) {
|
|
|
|
if (err) {
|
|
|
|
console.error('[Error]: node-letsencrypt/examples/standalone');
|
|
|
|
console.error(err.stack);
|
|
|
|
} else {
|
|
|
|
console.log('success');
|
|
|
|
}
|
|
|
|
|
|
|
|
plainServer.close();
|
|
|
|
tlsServer.close();
|
2015-12-12 22:16:02 +00:00
|
|
|
});
|
2015-12-13 09:04:44 +00:00
|
|
|
|
|
|
|
// IMPORTANT
|
2015-12-13 09:11:57 +00:00
|
|
|
// you also need BOTH an http AND https server that serve directly
|
|
|
|
// from webrootPath, which might as well be a special folder reserved
|
|
|
|
// only for acme/letsencrypt challenges
|
2015-12-13 09:04:44 +00:00
|
|
|
//
|
|
|
|
// app.use('/', express.static(config.le.webrootPath))
|
2015-12-12 22:16:02 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
**However**, due to the nature of what this library does, it has a few more "moving parts"
|
2015-12-12 22:16:44 +00:00
|
|
|
than what makes sense to show in a minimal snippet.
|
2015-12-12 22:16:02 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
Examples
|
|
|
|
========
|
2015-12-13 08:05:24 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
### One-Time Registration
|
2015-12-13 06:39:37 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
Register a 90-day certificate manually, on a whim
|
|
|
|
|
|
|
|
#### Snippets
|
2015-12-13 08:30:47 +00:00
|
|
|
|
|
|
|
[`commandline-minimal`](https://github.com/Daplie/node-letsencrypt/blob/master/examples/commandline-minimal.js):
|
2015-12-13 09:04:44 +00:00
|
|
|
|
|
|
|
**Part 1: the Let's Encrypt client**:
|
2015-12-13 08:05:24 +00:00
|
|
|
```javascript
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
var LE = require('letsencrypt');
|
|
|
|
var config = require('./config-minimal');
|
|
|
|
|
|
|
|
// Note: you should make this special dir in your product and leave it empty
|
|
|
|
config.le.webrootPath = __dirname + '/../tests/acme-challenge';
|
|
|
|
config.le.server = LE.stagingServer;
|
|
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
// Manual Registration
|
|
|
|
//
|
|
|
|
var le = LE.create(config.backend, config.le);
|
|
|
|
le.register({
|
|
|
|
agreeTos: true
|
|
|
|
, domains: ['example.com'] // CHANGE TO YOUR DOMAIN
|
|
|
|
, email: 'user@email.com' // CHANGE TO YOUR EMAIL
|
|
|
|
}, function (err) {
|
|
|
|
if (err) {
|
|
|
|
console.error('[Error]: node-letsencrypt/examples/standalone');
|
|
|
|
console.error(err.stack);
|
|
|
|
} else {
|
|
|
|
console.log('success');
|
|
|
|
}
|
|
|
|
|
|
|
|
plainServer.close();
|
|
|
|
tlsServer.close();
|
|
|
|
});
|
2015-12-13 09:04:44 +00:00
|
|
|
```
|
2015-12-13 08:05:24 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
**Part 2: Express Web Server**:
|
|
|
|
```javascript
|
2015-12-13 08:05:24 +00:00
|
|
|
//
|
|
|
|
// Express App
|
|
|
|
//
|
|
|
|
var app = require('express')();
|
|
|
|
app.use('/', le.middleware());
|
|
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
// HTTP & HTTPS servers
|
|
|
|
// (required for domain validation)
|
|
|
|
//
|
|
|
|
var plainServer = require('http').createServer(app).listen(config.plainPort, function () {
|
|
|
|
console.log('Listening http', this.address());
|
|
|
|
});
|
|
|
|
|
|
|
|
var tlsServer = require('https').createServer({
|
|
|
|
key: config.tlsKey
|
|
|
|
, cert: config.tlsCert
|
|
|
|
, SNICallback: le.sniCallback
|
|
|
|
}, app).listen(config.tlsPort, function () {
|
|
|
|
console.log('Listening http', this.address());
|
|
|
|
});
|
|
|
|
```
|
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
#### Runnable Demo
|
|
|
|
|
|
|
|
* [commandline (standalone with "webroot")](https://github.com/Daplie/node-letsencrypt/blob/master/examples/commandline.js)
|
|
|
|
|
|
|
|
```bash
|
|
|
|
# manual standalone registration via commandline
|
|
|
|
# (runs against testing server on tls port 5001)
|
|
|
|
node examples/commandline.js example.com,www.example.com user@example.net agree
|
|
|
|
```
|
|
|
|
|
2015-12-13 08:05:24 +00:00
|
|
|
### Express
|
|
|
|
|
|
|
|
Fully Automatic HTTPS with ExpressJS using Free SSL certificates from Let's Encrypt
|
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
#### Snippets
|
|
|
|
|
2015-12-13 08:05:24 +00:00
|
|
|
* [Minimal ExpressJS Example](https://github.com/Daplie/node-letsencrypt/blob/master/examples/express-minimal.js)
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
var LE = require('letsencrypt');
|
|
|
|
var config = require('./config-minimal');
|
|
|
|
|
|
|
|
// Note: you should make this special dir in your product and leave it empty
|
|
|
|
config.le.webrootPath = __dirname + '/../tests/acme-challenge';
|
|
|
|
config.le.server = LE.stagingServer;
|
|
|
|
|
|
|
|
//
|
|
|
|
// Automatically Register / Renew Domains
|
|
|
|
//
|
|
|
|
var le = LE.create(config.backend, config.le, {
|
|
|
|
sniRegisterCallback: function (args, expiredCert, cb) {
|
|
|
|
// Security: check that this is actually a subdomain we allow
|
|
|
|
// (otherwise an attacker can cause you to rate limit against the LE server)
|
|
|
|
|
|
|
|
var hostname = args.domains[0];
|
|
|
|
if (!/\.example\.com$/.test(hostname)) {
|
|
|
|
console.error("bad domain '" + hostname + "', not a subdomain of example.com");
|
|
|
|
cb(nul, null);
|
|
|
|
}
|
|
|
|
|
|
|
|
// agree to the LE TOS for this domain
|
|
|
|
args.agreeTos = true;
|
|
|
|
args.email = 'user@example.com';
|
|
|
|
|
|
|
|
// use the cert even though it's expired
|
|
|
|
if (expiredCert) {
|
|
|
|
cb(null, expiredCert);
|
|
|
|
cb = function () { /*ignore*/ };
|
|
|
|
}
|
|
|
|
|
|
|
|
// register / renew the certificate in the background
|
2015-12-13 08:32:25 +00:00
|
|
|
le.register(args, cb);
|
2015-12-13 08:05:24 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
// Express App
|
|
|
|
//
|
|
|
|
var app = require('express')();
|
|
|
|
app.use('/', le.middleware());
|
|
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
// HTTP & HTTPS servers
|
|
|
|
//
|
|
|
|
require('http').createServer(app).listen(config.plainPort, function () {
|
|
|
|
console.log('Listening http', this.address());
|
|
|
|
});
|
|
|
|
|
|
|
|
require('https').createServer({
|
|
|
|
key: config.tlsKey
|
|
|
|
, cert: config.tlsCert
|
|
|
|
, SNICallback: le.sniCallback
|
|
|
|
}, app).listen(config.tlsPort, function () {
|
|
|
|
console.log('Listening http', this.address());
|
|
|
|
});
|
|
|
|
```
|
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
#### Runnable Example
|
|
|
|
|
2015-12-13 08:05:24 +00:00
|
|
|
* [Full ExpressJS Example](https://github.com/Daplie/node-letsencrypt/blob/master/examples/express.js)
|
2015-12-13 06:45:21 +00:00
|
|
|
|
2015-12-13 06:39:37 +00:00
|
|
|
```bash
|
2015-12-13 06:45:21 +00:00
|
|
|
# clear out the certificates
|
|
|
|
rm -rf tests/letsencrypt.*
|
|
|
|
|
2015-12-13 06:39:37 +00:00
|
|
|
# automatic registration and renewal (certs install as you visit the site for the first time)
|
|
|
|
# (runs against testing server on tls port 5001)
|
2015-12-13 06:45:21 +00:00
|
|
|
node examples/express.js example.com,www.example.com user@example.net agree
|
2015-12-13 06:39:37 +00:00
|
|
|
```
|
|
|
|
|
2015-12-13 06:45:21 +00:00
|
|
|
```bash
|
|
|
|
# this will take a moment because it won't respond to the tls sni header until it gets the certs
|
|
|
|
curl https://example.com/
|
|
|
|
```
|
2015-12-12 22:16:02 +00:00
|
|
|
|
2015-12-13 06:00:30 +00:00
|
|
|
### non-root
|
|
|
|
|
|
|
|
If you want to run this as non-root, you can.
|
|
|
|
|
|
|
|
You just have to set node to be allowed to use root ports
|
|
|
|
|
|
|
|
```
|
|
|
|
# node
|
|
|
|
sudo setcap cap_net_bind_service=+ep /usr/local/bin/node
|
|
|
|
```
|
|
|
|
|
|
|
|
and then make sure to set all of of the following to a directory that your user is permitted to write to
|
|
|
|
|
|
|
|
* `webrootPath`
|
|
|
|
* `configDir`
|
|
|
|
|
|
|
|
|
2015-12-12 22:16:02 +00:00
|
|
|
API
|
|
|
|
===
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-13 09:04:44 +00:00
|
|
|
```javascript
|
2015-12-16 09:11:31 +00:00
|
|
|
LetsEncrypt.init(leConfig, handlers) // wraps a given
|
|
|
|
LetsEncrypt.create(backend, leConfig, handlers) // wraps a given "backend" (the python or node client)
|
|
|
|
LetsEncrypt.stagingServer // string of staging server for testing
|
|
|
|
|
|
|
|
le.middleware() // middleware for serving webrootPath to /.well-known/acme-challenge
|
|
|
|
le.sniCallback(hostname, function (err, tlsContext) {}) // uses fetch (below) and formats for https.SNICallback
|
|
|
|
le.register({ domains, email, agreeTos, ... }, cb) // registers or renews certs for a domain
|
|
|
|
le.fetch({domains, email, agreeTos, ... }, cb) // fetches certs from in-memory cache, occasionally refreshes from disk
|
|
|
|
le.validate(domains, cb) // do some sanity checks before attempting to register
|
|
|
|
le.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet)
|
2015-12-13 09:04:44 +00:00
|
|
|
```
|
2015-12-12 15:38:14 +00:00
|
|
|
|
2015-12-16 09:11:31 +00:00
|
|
|
### `LetsEncrypt.create(backend, leConfig, handlers)`
|
2015-12-12 22:06:36 +00:00
|
|
|
|
2015-12-16 09:11:31 +00:00
|
|
|
#### leConfig
|
2015-12-12 22:06:36 +00:00
|
|
|
|
|
|
|
The arguments passed here (typically `webpathRoot`, `configDir`, etc) will be merged with
|
2015-12-12 22:19:28 +00:00
|
|
|
any `args` (typically `domains`, `email`, and `agreeTos`) and passed to the backend whenever
|
|
|
|
it is called.
|
2015-12-12 22:06:36 +00:00
|
|
|
|
|
|
|
Typically the backend wrapper will already merge any necessary backend-specific arguments.
|
|
|
|
|
|
|
|
**Example**:
|
2015-12-12 13:11:05 +00:00
|
|
|
```javascript
|
2015-12-12 22:06:36 +00:00
|
|
|
{ webrootPath: __dirname, '/acme-challenge'
|
2015-12-12 13:11:05 +00:00
|
|
|
, fullchainTpl: '/live/:hostname/fullchain.pem'
|
|
|
|
, privkeyTpl: '/live/:hostname/fullchain.pem'
|
|
|
|
, configDir: '/etc/letsencrypt'
|
2015-12-12 22:06:36 +00:00
|
|
|
}
|
|
|
|
```
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-13 01:04:12 +00:00
|
|
|
Note: `webrootPath` can be set as a default, semi-locally with `webrootPathTpl`, or per
|
2015-12-16 09:11:31 +00:00
|
|
|
registration as `webrootPath` (which overwrites `leConfig.webrootPath`).
|
2015-12-13 01:04:12 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
#### handlers *optional*
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
`h.setChallenge(hostnames, name, value, cb)`:
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
default is to write to fs
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
`h.getChallenge(hostnames, value cb)`
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
default is to read from fs
|
|
|
|
|
|
|
|
`h.sniRegisterCallback(args, currentCerts, cb)`
|
|
|
|
|
|
|
|
The default is to immediately call `cb(null, null)` and register (or renew) in the background
|
|
|
|
during the `SNICallback` phase. Right now it isn't reasonable to renew during SNICallback,
|
|
|
|
but around February when it is possible to use ECDSA keys (as opposed to RSA at present),
|
|
|
|
registration will take very little time.
|
|
|
|
|
|
|
|
This will not be called while another registration is already in progress.
|
|
|
|
|
|
|
|
**SECURITY WARNING**: If you use this option with a custom `h.validate()`, make sure that `args.domains`
|
|
|
|
refers to domains you expect, otherwise an attacker will spoof SNI and cause your server to rate-limit
|
|
|
|
letsencrypt.org and get blocked. Note that `le.validate()` will check A records before attempting to
|
|
|
|
register to help prevent such possible attacks.
|
2015-12-12 13:11:05 +00:00
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
`h.validate(domains, cb)`
|
|
|
|
|
|
|
|
When specified this will override `le.validate()`. You will need to do this if the ip address of this
|
|
|
|
server is not one specified in the A records for your domain.
|
|
|
|
|
|
|
|
### `le.middleware()`
|
|
|
|
|
|
|
|
An express handler for `/.well-known/acme-challenge/<challenge>`.
|
|
|
|
Will call `getChallenge([hostname], key, cb)` if present or otherwise read `challenge` from disk.
|
|
|
|
|
|
|
|
Example:
|
|
|
|
```javascript
|
|
|
|
app.use('/', le.middleware())
|
2015-12-12 13:11:05 +00:00
|
|
|
```
|
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
### `le.sniCallback(hostname, function (err, tlsContext) {});`
|
|
|
|
|
|
|
|
Will call `fetch`. If fetch does not return certificates or returns expired certificates
|
|
|
|
it will call `sniRegisterCallback(args, currentCerts, cb)` and then return the error,
|
|
|
|
the new certificates, or call `fetch` a final time.
|
|
|
|
|
|
|
|
Example:
|
|
|
|
```javascript
|
2015-12-12 22:21:02 +00:00
|
|
|
var server = require('https').createServer({ SNICallback: le.sniCallback, cert: '...', key: '...' });
|
2015-12-12 22:06:36 +00:00
|
|
|
server.on('request', app);
|
2015-12-12 13:11:05 +00:00
|
|
|
```
|
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
### `le.register({ domains, email, agreeTos, ... }, cb)`
|
|
|
|
|
|
|
|
Get certificates for a domain
|
|
|
|
|
|
|
|
Example:
|
2015-12-12 22:17:32 +00:00
|
|
|
```javascript
|
2015-12-12 22:06:36 +00:00
|
|
|
le.register({
|
|
|
|
domains: ['example.com', 'www.example.com']
|
|
|
|
, email: 'user@example.com'
|
2015-12-13 01:04:12 +00:00
|
|
|
, webrootPath: '/srv/www/example.com/public'
|
2015-12-12 22:06:36 +00:00
|
|
|
, agreeTos: true
|
|
|
|
}, function (err, certs) {
|
|
|
|
// err is some error
|
|
|
|
|
|
|
|
console.log(certs);
|
|
|
|
/*
|
|
|
|
{ cert: "contents of fullchain.pem"
|
|
|
|
, key: "contents of privkey.pem"
|
|
|
|
, renewedAt: <date in milliseconds>
|
|
|
|
, duration: <duration in milliseconds (90-days)>
|
2015-12-12 13:11:05 +00:00
|
|
|
}
|
2015-12-12 22:06:36 +00:00
|
|
|
*/
|
|
|
|
});
|
2015-12-12 13:11:05 +00:00
|
|
|
```
|
|
|
|
|
2015-12-12 22:06:36 +00:00
|
|
|
### `le.isValidDomain(hostname)`
|
|
|
|
|
|
|
|
returns `true` if `hostname` is a valid ascii or punycode domain name.
|
|
|
|
|
|
|
|
(also exposed on the main exported module as `LetsEncrypt.isValidDomain()`)
|
|
|
|
|
|
|
|
### `le.validate(args, cb)`
|
|
|
|
|
|
|
|
Used internally, but exposed for convenience. Checks `LetsEncrypt.isValidDomain()`
|
2015-12-13 06:40:03 +00:00
|
|
|
and then checks to see that the current server
|
2015-12-12 22:06:36 +00:00
|
|
|
|
|
|
|
Called before `backend.register()` to validate the following:
|
|
|
|
|
|
|
|
* the hostnames don't use any illegal characters
|
|
|
|
* the server's actual public ip (via api.apiify.org)
|
2015-12-13 06:40:03 +00:00
|
|
|
* the A records for said hostnames
|
2015-12-12 22:06:36 +00:00
|
|
|
|
|
|
|
### `le.fetch(args, cb)`
|
|
|
|
|
|
|
|
Used internally, but exposed for convenience.
|
|
|
|
|
|
|
|
Checks in-memory cache of certificates for `args.domains` and calls then calls `backend.fetch(args, cb)`
|
|
|
|
**after** merging `args` if necessary.
|
|
|
|
|
2015-12-13 05:03:48 +00:00
|
|
|
### `le.registrationFailureCallback(err, args, certInfo, cb)`
|
|
|
|
|
|
|
|
Not yet implemented
|
|
|
|
|
2015-12-12 13:11:05 +00:00
|
|
|
|
|
|
|
This is what `args` looks like:
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
{ domains: ['example.com', 'www.example.com']
|
|
|
|
, email: 'user@email.com'
|
|
|
|
, agreeTos: true
|
|
|
|
, configDir: '/etc/letsencrypt'
|
|
|
|
, fullchainTpl: '/live/:hostname/fullchain.pem' // :hostname will be replaced with the domainname
|
2015-12-13 01:04:12 +00:00
|
|
|
, privkeyTpl: '/live/:hostname/privkey.pem'
|
|
|
|
, webrootPathTpl: '/srv/www/:hostname/public'
|
|
|
|
, webrootPath: '/srv/www/example.com/public' // templated from webrootPathTpl
|
2015-12-12 13:11:05 +00:00
|
|
|
}
|
|
|
|
```
|
|
|
|
|
|
|
|
This is what the implementation should look like:
|
|
|
|
|
|
|
|
(it's expected that the client will follow the same conventions as
|
|
|
|
the python client, but it's not necessary)
|
|
|
|
|
2015-12-13 11:09:06 +00:00
|
|
|
Change History
|
|
|
|
==============
|
|
|
|
|
2015-12-16 09:19:08 +00:00
|
|
|
* v1.1.0 Added letiny-core, removed node-letsencrypt-python
|
|
|
|
* v1.0.2 Works with node-letsencrypt-python
|
|
|
|
* v1.0.0 Thar be dragons
|
2015-12-13 11:09:06 +00:00
|
|
|
|
2015-12-11 11:23:47 +00:00
|
|
|
LICENSE
|
|
|
|
=======
|
|
|
|
|
|
|
|
Dual-licensed MIT and Apache-2.0
|
|
|
|
|
|
|
|
See LICENSE
|