mirror of
https://git.coolaj86.com/coolaj86/greenlock-store-memory.js.git
synced 2025-04-20 13:00:36 +00:00
v1.0.0: a stupid-simple reference implementation for storage strategies
This commit is contained in:
commit
cbe20c4d00
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Typescript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
|
41
LICENSE
Normal file
41
LICENSE
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
Copyright 2019 AJ ONeal
|
||||||
|
|
||||||
|
This is open source software; you can redistribute it and/or modify it under the
|
||||||
|
terms of either:
|
||||||
|
|
||||||
|
a) the "MIT License"
|
||||||
|
b) the "Apache-2.0 License"
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Apache-2.0 License Summary
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
245
README.md
Normal file
245
README.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# le-store-memory
|
||||||
|
|
||||||
|
An in-memory reference implementation of a Certificate and Keypair storage strategy for Greenlock v2.7+ (and v3)
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
var greenlock = require('greenlock');
|
||||||
|
|
||||||
|
// This in-memory plugin has only one option: 'cache'.
|
||||||
|
// We could have It's used so that we can peek and poke at the store.
|
||||||
|
var cache = {};
|
||||||
|
var gl = greenlock.create({
|
||||||
|
store: require('le-store-memory').create({ cache: cache })
|
||||||
|
, approveDomains: approveDomains
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# How to build your own module:
|
||||||
|
|
||||||
|
**TL;DR**: Just take a look the code here, and don't over think it.
|
||||||
|
|
||||||
|
Also, you have the flexibility to get really fancy. _Don't!_
|
||||||
|
You probably don't need to (unless you already know that you do).
|
||||||
|
|
||||||
|
In most cases you're just implementing dumb storage.
|
||||||
|
If all you do is `JSON.stringify()` on `set` (save) and `JSON.parse()` after `check` (get)
|
||||||
|
and just treat it as a blob with an ID, you'll do just fine. You can always optimize later.
|
||||||
|
|
||||||
|
**Promises** vs **Thunks** ("node callbacks") vs **Synchronous** returns:
|
||||||
|
You can use whatever style you like best. Everything is promisified under the hood.
|
||||||
|
|
||||||
|
Whenever you have neither a result, nor an error, you must **always return null** (instead of 'undefined').
|
||||||
|
|
||||||
|
### storage strategy vs approveDomains()
|
||||||
|
|
||||||
|
The _most_ important thing to keep in mind: `approveDomains()` is where all of the implementation-specific logic goes.
|
||||||
|
|
||||||
|
If you're writing a storage strategy (presumably why you're here), it's because you have logic in `approveDomains()`
|
||||||
|
that isn't supported by existing strategies. That makes it tempting to start thinking about things backwards, letting
|
||||||
|
your implementation-specific logic creep into your storage strategy. DON'T DO IT.
|
||||||
|
|
||||||
|
Keep in mind that, ultimately, **it takes human decision** / interaction / configuration to add, remove, or
|
||||||
|
**modify the collection of domains** that are allowed, and how many / which domains are listed on each certificate -
|
||||||
|
all of which is a _completely_ separate process that lives outside of Greenlock (i.e uploading a site to a new folder).
|
||||||
|
|
||||||
|
The coupling between the method chosen for storage and the method chosen for approval is inherint, but keep it loose.
|
||||||
|
|
||||||
|
Lastly, it would be appropriate to include an example `approveDomains()` with your implementation for reference.
|
||||||
|
|
||||||
|
### 0. approveDomains() is the kick off
|
||||||
|
|
||||||
|
`approveDomains()` is called only when there is no certificate for a given domain in Greenlock's internal cache
|
||||||
|
and when that certificate is "renewable" (typically 15 days before expiration, which is configurable).
|
||||||
|
|
||||||
|
The user (perhaps you) will have checked in their database (or config file or file system) and retrieved relevant
|
||||||
|
details (email associated with the domain, related domains that belong as altnames on the certificate, etc).
|
||||||
|
|
||||||
|
Those options will be available to _all_ storage and challenge strategies. In fact, they can even change which
|
||||||
|
strategy is used (i.e. some users using a Digital Ocean strategy for DNS challenges, others using Route53).
|
||||||
|
|
||||||
|
```js
|
||||||
|
function approveDomains(opts) {
|
||||||
|
var info = userDb.getInfo(opts.domain);
|
||||||
|
if (!info) { throw new Error("ignoring junk request, bad domain"); }
|
||||||
|
|
||||||
|
opts.email = info.certificateOwner;
|
||||||
|
opts.subject = info.certificateSubject
|
||||||
|
opts.domains = info.certificateAltnames;
|
||||||
|
|
||||||
|
return opts; // or Promise.resolve(opts);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Implement `accounts.setKeypair`
|
||||||
|
|
||||||
|
First, you should implement `accounts.setKeypair()`. Just treat it like dumb storage.
|
||||||
|
|
||||||
|
This only gets called after a new account has already been created successfully.
|
||||||
|
That will only happen when a completely new certificate is going to be issued (not renewal),
|
||||||
|
and there's no user account already associate with that set of domains.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.accounts.setKeypair = function (opts) {
|
||||||
|
console.log('accounts.setKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
var keypair = opts.keypair;
|
||||||
|
|
||||||
|
cache.accountKeypairs[id] = JSON.stringify({
|
||||||
|
privateKeyPem: keypair.privateKeyPem
|
||||||
|
, privateKeyJwk: keypair.privateKeyJwk
|
||||||
|
});
|
||||||
|
|
||||||
|
return null; // or Promise.resolve(null);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Implement `accounts.checkKeypair`
|
||||||
|
|
||||||
|
Whatever you did above, you just do the opposite instead. Tada!
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.accounts.checkKeypair = function (opts) {
|
||||||
|
console.log('accounts.checkKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
var keyblob = cache.accountKeypairs[id];
|
||||||
|
|
||||||
|
if (!keyblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(keyblob);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. (and 4.) Optionally save ACME account metadata
|
||||||
|
|
||||||
|
You should probably skip this and not worry about it.
|
||||||
|
|
||||||
|
However, if you have a special need for it, or if you want to shave off an ACME API call,
|
||||||
|
you can save the account `kid` (a misnomer intended to mean "key id", but actually refers
|
||||||
|
to an arbitrary ACME URL, used to identify the account).
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.accounts.set = function (opts) {
|
||||||
|
console.log('accounts.set:', opts);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.accounts.check = function (opts) {
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
console.log('accounts.check:', opts);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
If you don't implement these the account key will be used to "recover" the `kid` as necessary.
|
||||||
|
You don't have to worry though, it doesn't create a duplicate accounts or have any other negative
|
||||||
|
side affects other than an additional API call as needed.
|
||||||
|
|
||||||
|
### 5. Implement a method to save certificate keypairs
|
||||||
|
|
||||||
|
Each certificate is supposed to have a unique keypair, which **must not** be the same as the account keypair.
|
||||||
|
|
||||||
|
Again, just treat it like a blob in dumb storage and you'll be fine.
|
||||||
|
|
||||||
|
This is the same as `accounts.setKeypair()`, but using a different idea.
|
||||||
|
You could even use the same data store in most cases because the IDs aren't likely to clash.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.certificates.setKeypair = function (opts) {
|
||||||
|
console.log('certificates.setKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.kid || opts.certificate.id || opts.subject;
|
||||||
|
var keypair = opts.keypair;
|
||||||
|
|
||||||
|
cache.certificateKeypairs[id] = JSON.stringify({
|
||||||
|
privateKeyPem: keypair.privateKeyPem
|
||||||
|
, privateKeyJwk: keypair.privateKeyJwk
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Implement a method to get certificate keypairs
|
||||||
|
|
||||||
|
You know the drill. Same as `accounts.checkKeypair()`, but a different ID.
|
||||||
|
|
||||||
|
This isn't called until after the certificate retrieval is successful.
|
||||||
|
|
||||||
|
Note: Every account **must have** a unique account key and account keys are
|
||||||
|
**not allowed** to be used as certificate keys. However, you could use the
|
||||||
|
same certificate key for all domains on a device (i.e. a server) or an account.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.certificates.checkKeypair = function (opts) {
|
||||||
|
console.log('certificates.checkKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.kid || opts.certificate.id || opts.subject;
|
||||||
|
var keyblob = cache.certificateKeypairs[id];
|
||||||
|
|
||||||
|
if (!keyblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(keyblob);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Implement a method to save certificates
|
||||||
|
|
||||||
|
Whenever the ACME process completes successfully, you get a shiny new certificate with all of the domains you requested.
|
||||||
|
|
||||||
|
It's a good idea to save them - otherwise you run the risk of running up your rate limit and getting blocked
|
||||||
|
as your server restarts, respawns, auto-scales, etc.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.certificates.set = function (opts) {
|
||||||
|
console.log('certificates.set:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.id || opts.subject;
|
||||||
|
var pems = opts.pems;
|
||||||
|
cache.certificates[id] = JSON.stringify({
|
||||||
|
cert: pems.cert
|
||||||
|
, chain: pems.chain
|
||||||
|
, subject: pems.subject
|
||||||
|
, altnames: pems.altnames
|
||||||
|
, issuedAt: pems.issuedAt // a.k.a. NotBefore
|
||||||
|
, expiresAt: pems.expiresAt // a.k.a. NotAfter
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `chain` is likely to be the same for all certificates issued by a service,
|
||||||
|
but there's no guarantee. The service may rotate which keys do the signing, for example.
|
||||||
|
|
||||||
|
### 8. Implement a method to get certificates
|
||||||
|
|
||||||
|
Lastly, you just need a way to fetch the result of all the work you've done.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.certificates.check = function (opts) {
|
||||||
|
console.log('certificates.check:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.id || opts.subject;
|
||||||
|
var certblob = cache.certificates[id];
|
||||||
|
|
||||||
|
if (!certblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(certblob);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
# Huzzah!
|
||||||
|
|
||||||
|
There you go - you basically just have 8 setter and getter functions that usually act as dumb storage,
|
||||||
|
but that you can tweak with custom options if you need to.
|
||||||
|
|
||||||
|
Remember: Keep It Stupid-Simple
|
||||||
|
|
||||||
|
:D
|
144
index.js
Normal file
144
index.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports.create = function (opts) {
|
||||||
|
// pass in database url, connection string, filepath,
|
||||||
|
// or whatever it is you need to get your job done well
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This is our in-memory storage.
|
||||||
|
// We take it from the outside to make testing the dummy module easier.
|
||||||
|
var cache = opts.cache || {};
|
||||||
|
if (!cache.accounts) { cache.accounts = {}; }
|
||||||
|
if (!cache.certificates) { cache.certificates = {}; }
|
||||||
|
// Although we could have two collections of keypairs,
|
||||||
|
// it's also fine to store both types together.
|
||||||
|
if (!cache.keypairs) { cache.keypairs = {}; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var store = {};
|
||||||
|
// any options you need per instance
|
||||||
|
// (probably okay to leave empty)
|
||||||
|
store.options = {};
|
||||||
|
store.accounts = {};
|
||||||
|
store.certificates = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Whenever a new keypair is used to successfully create an account, we need to save its keypair
|
||||||
|
store.accounts.setKeypair = function (opts) {
|
||||||
|
console.log('accounts.setKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
var keypair = opts.keypair;
|
||||||
|
|
||||||
|
cache.keypairs[id] = JSON.stringify({
|
||||||
|
privateKeyPem: keypair.privateKeyPem
|
||||||
|
, privateKeyJwk: keypair.privateKeyJwk
|
||||||
|
});
|
||||||
|
|
||||||
|
return null; // or Promise.resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// We need a way to retrieve a prior account's keypair for renewals and additional ACME certificate "orders"
|
||||||
|
store.accounts.checkKeypair = function (opts) {
|
||||||
|
console.log('accounts.checkKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
var keyblob = cache.keypairs[id];
|
||||||
|
|
||||||
|
if (!keyblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(keyblob);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// We can optionally implement ACME account storage and retrieval
|
||||||
|
// (to reduce API calls), but it's really not necessary.
|
||||||
|
/*
|
||||||
|
store.accounts.set = function (opts) {
|
||||||
|
console.log('accounts.set:', opts);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
store.accounts.check = function (opts) {
|
||||||
|
var id = opts.account.id || opts.email || 'default';
|
||||||
|
console.log('accounts.check:', opts);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// The certificate keypairs must not be the same as any account keypair
|
||||||
|
store.certificates.setKeypair = function (opts) {
|
||||||
|
console.log('certificates.setKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.kid || opts.certificate.id || opts.subject;
|
||||||
|
var keypair = opts.keypair;
|
||||||
|
|
||||||
|
cache.keypairs[id] = JSON.stringify({
|
||||||
|
privateKeyPem: keypair.privateKeyPem
|
||||||
|
, privateKeyJwk: keypair.privateKeyJwk
|
||||||
|
});
|
||||||
|
// Note: you can use the "keypairs" package to convert between
|
||||||
|
// public and private for jwk and pem, as well as convert JWK <-> PEM
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// You won't be able to use a certificate without it's private key, gotta save it
|
||||||
|
store.certificates.checkKeypair = function (opts) {
|
||||||
|
console.log('certificates.checkKeypair:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.kid || opts.certificate.id || opts.subject;
|
||||||
|
var keyblob = cache.keypairs[id];
|
||||||
|
|
||||||
|
if (!keyblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(keyblob);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// And you'll also need to save certificates. You may find the metadata useful to save
|
||||||
|
// (perhaps to delete expired keys), but the same information can also be redireved from
|
||||||
|
// the key using the "cert-info" package.
|
||||||
|
store.certificates.set = function (opts) {
|
||||||
|
console.log('certificates.set:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.id || opts.subject;
|
||||||
|
var pems = opts.pems;
|
||||||
|
cache.certificates[id] = JSON.stringify({
|
||||||
|
cert: pems.cert
|
||||||
|
, chain: pems.chain
|
||||||
|
, subject: pems.subject
|
||||||
|
, altnames: pems.altnames
|
||||||
|
, issuedAt: pems.issuedAt // a.k.a. NotBefore
|
||||||
|
, expiresAt: pems.expiresAt // a.k.a. NotAfter
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This is actually the first thing to be called after approveDomins(),
|
||||||
|
// but it's easiest to implement last since it's not useful until there
|
||||||
|
// are certs that can actually be loaded from storage.
|
||||||
|
store.certificates.check = function (opts) {
|
||||||
|
console.log('certificates.check:', opts);
|
||||||
|
|
||||||
|
var id = opts.certificate.id || opts.subject;
|
||||||
|
var certblob = cache.certificates[id];
|
||||||
|
|
||||||
|
if (!certblob) { return null; }
|
||||||
|
|
||||||
|
return JSON.parse(certblob);
|
||||||
|
};
|
||||||
|
};
|
5
package-lock.json
generated
Normal file
5
package-lock.json
generated
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "le-store-memory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 1
|
||||||
|
}
|
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "le-store-memory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An in-memory reference implementation for account, certificate, and keypair storage strategies in Greenlock",
|
||||||
|
"homepage": "https://git.coolaj86.com/coolaj86/le-store-memory.js",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"test": "tests"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node tests"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.coolaj86.com/coolaj86/le-store-memory.js.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"greenlock",
|
||||||
|
"acme",
|
||||||
|
"json",
|
||||||
|
"keypairs",
|
||||||
|
"certificates",
|
||||||
|
"store",
|
||||||
|
"database"
|
||||||
|
],
|
||||||
|
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user