@abtnode/certificate-manager 1.6.7 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +5 -0
- package/libs/acme-manager.js +211 -0
- package/libs/acme-wrapper.js +129 -0
- package/libs/constant.js +13 -0
- package/libs/http-01.js +62 -0
- package/libs/logger.js +1 -0
- package/libs/queue.js +12 -0
- package/libs/util.js +90 -0
- package/package.json +13 -11
- package/routes/index.js +15 -0
- package/routes/well-known.js +17 -0
- package/sdk/manager.js +180 -0
- package/states/account.js +9 -0
- package/states/base.js +15 -0
- package/states/certificate.js +29 -0
- package/states/http-challenge.js +9 -0
- package/states/index.js +26 -0
- package/validators/cert.js +31 -0
package/index.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { Certificate } = require('@fidm/x509');
|
|
5
|
+
const moment = require('moment');
|
|
6
|
+
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
const AcmeWrapper = require('./acme-wrapper');
|
|
9
|
+
const { CERT_STATUS, CERT_SOURCE } = require('./constant');
|
|
10
|
+
const createQueue = require('./queue');
|
|
11
|
+
const { md5 } = require('./util');
|
|
12
|
+
const logger = require('./logger');
|
|
13
|
+
const states = require('../states');
|
|
14
|
+
|
|
15
|
+
const http01 = require('./http-01').create({});
|
|
16
|
+
|
|
17
|
+
const DEFAULT_AGENT_NAME = 'blocklet-server';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RENEWAL_OFFSET_IN_DAY = 30;
|
|
20
|
+
|
|
21
|
+
class Manager extends EventEmitter {
|
|
22
|
+
constructor({ dataDir, maintainerEmail, staging = false, renewalOffsetInDay = DEFAULT_RENEWAL_OFFSET_IN_DAY }) {
|
|
23
|
+
super();
|
|
24
|
+
logger.info('initialize manager in data dir:', { dataDir });
|
|
25
|
+
this.acme = new AcmeWrapper({
|
|
26
|
+
packageAgent: `${DEFAULT_AGENT_NAME}/${pkg.version}`,
|
|
27
|
+
staging,
|
|
28
|
+
maintainerEmail,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.maintainerEmail = maintainerEmail;
|
|
32
|
+
this.renewalOffsetInDay = renewalOffsetInDay;
|
|
33
|
+
this.dataDir = dataDir;
|
|
34
|
+
this.queue = createQueue({
|
|
35
|
+
name: 'create-cert-queue',
|
|
36
|
+
dataDir,
|
|
37
|
+
onJob: async ({ domain }) => {
|
|
38
|
+
const data = await states.certificate.findOne({ domain });
|
|
39
|
+
if (data) {
|
|
40
|
+
await this._createOrRenewCert({
|
|
41
|
+
domain: data.domain,
|
|
42
|
+
subscriberEmail: this.subscriberEmail,
|
|
43
|
+
challenges: { 'http-01': http01 },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
options: {
|
|
48
|
+
maxRetries: 5,
|
|
49
|
+
retryDelay: 60 * 1000,
|
|
50
|
+
maxTimeout: 60 * 1000 * 5, // throw timeout error after 5 minutes
|
|
51
|
+
id: (job) => (job ? md5(`${job.domain}-${job.challenge}`) : ''),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getJobSchedular() {
|
|
57
|
+
return {
|
|
58
|
+
name: 'check-renewal-cert-job',
|
|
59
|
+
time: process.env.NODE_ENV === 'development' ? '0 * * * * *' : '0 */5 * * * *', // every 5 minutes
|
|
60
|
+
fn: this.checkRenewalCerts.bind(this),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async add(domain) {
|
|
65
|
+
if (!domain) {
|
|
66
|
+
throw new Error('domain is required when add domain');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await states.certificate.insert({
|
|
70
|
+
domain,
|
|
71
|
+
source: CERT_SOURCE.letsEncrypt,
|
|
72
|
+
status: CERT_STATUS.waiting,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.queue.push({ domain });
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCertState(domain) {
|
|
80
|
+
const certDir = path.join(this.acme.certDir, domain);
|
|
81
|
+
if (fs.existsSync(certDir)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
readCert(domain) {
|
|
89
|
+
const certDir = path.join(this.acme.certDir, domain);
|
|
90
|
+
if (!fs.existsSync(certDir)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const chain = fs.readFileSync(path.join(certDir, 'fullchain.pem')).toString();
|
|
95
|
+
const privkey = fs.readFileSync(path.join(certDir, 'privkey.pem')).toString();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
domain,
|
|
99
|
+
chain,
|
|
100
|
+
privkey,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async _createOrRenewCert({ domain, subscriberEmail, force = false, challenges }) {
|
|
105
|
+
try {
|
|
106
|
+
if (!domain) {
|
|
107
|
+
throw new Error('domain is required when create certificate');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cert = await states.certificate.findOne({ domain });
|
|
111
|
+
if (!cert) {
|
|
112
|
+
logger.warn(`create certificate failed: the cert ${domain} does not exist`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let status = CERT_STATUS.creating;
|
|
117
|
+
|
|
118
|
+
if (cert.certificate) {
|
|
119
|
+
const info = Certificate.fromPEM(cert.fullchain);
|
|
120
|
+
const days = moment(info.validTo).diff(moment(), 'days');
|
|
121
|
+
|
|
122
|
+
if (force === false && days > this.renewalOffsetInDay) {
|
|
123
|
+
logger.info(`no need to renewal ${cert.domain}, the certificate will expires more than ${days} days`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
status = CERT_STATUS.renewaling;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await states.certificate.updateStatus(cert.domain, status);
|
|
131
|
+
await this.acme.create({
|
|
132
|
+
subject: cert.domain,
|
|
133
|
+
subscriberEmail,
|
|
134
|
+
challenges,
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logger.error(`create certificate for ${domain} job failed`, error);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async checkRenewalCerts() {
|
|
143
|
+
logger.info('run generate certificate job');
|
|
144
|
+
const certs = await states.certificate.find({
|
|
145
|
+
$or: [
|
|
146
|
+
{
|
|
147
|
+
source: CERT_SOURCE.letsEncrypt,
|
|
148
|
+
status: { $in: [CERT_STATUS.waiting, CERT_STATUS.error] },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
source: CERT_SOURCE.letsEncrypt,
|
|
152
|
+
status: CERT_STATUS.generated,
|
|
153
|
+
validTo: { $lte: moment().add(this.renewalOffsetInDay, 'days') },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (certs.length > 0) {
|
|
159
|
+
certs.forEach(({ domain }) => this.queue.push({ domain }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let instance = null;
|
|
165
|
+
|
|
166
|
+
Manager.getInstance = async ({ maintainerEmail, baseDataDir }) => {
|
|
167
|
+
if (instance) {
|
|
168
|
+
return instance;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const dataDir = path.join(baseDataDir, '.data');
|
|
172
|
+
|
|
173
|
+
instance = new Manager({
|
|
174
|
+
packageRoot: baseDataDir,
|
|
175
|
+
dataDir,
|
|
176
|
+
maintainerEmail,
|
|
177
|
+
staging: typeof process.env.STAGING === 'undefined' ? process.env.NODE_ENV !== 'production' : !!process.env.STAGING,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await instance.acme.init();
|
|
181
|
+
|
|
182
|
+
instance.acme.on('cert.issued', async (data) => {
|
|
183
|
+
await states.certificate.update(
|
|
184
|
+
{ domain: data.subject },
|
|
185
|
+
{
|
|
186
|
+
$set: {
|
|
187
|
+
status: CERT_STATUS.generated,
|
|
188
|
+
certificate: data.fullchain,
|
|
189
|
+
privateKey: data.privkey,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const cert = await states.certificate.findOne({ domain: data.subject });
|
|
195
|
+
instance.emit('cert.issued', { id: cert.id, domain: cert.domain });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
instance.acme.on('cert.error', async (data) => {
|
|
199
|
+
if (data.domain) {
|
|
200
|
+
await states.certificate.updateStatus(data.domain, CERT_STATUS.error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
instance.emit('cert.error', data);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return instance;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
Manager.initInstance = Manager.getInstance;
|
|
210
|
+
|
|
211
|
+
module.exports = Manager;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const ACME = require('@root/acme');
|
|
3
|
+
const Keypairs = require('@root/keypairs');
|
|
4
|
+
const CSR = require('@root/csr');
|
|
5
|
+
const PEM = require('@root/pem');
|
|
6
|
+
const punycode = require('punycode/');
|
|
7
|
+
const states = require('../states');
|
|
8
|
+
const logger = require('./logger');
|
|
9
|
+
|
|
10
|
+
const DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory';
|
|
11
|
+
const DIRECTORY_URL_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
|
|
12
|
+
|
|
13
|
+
class AcmeWrapper extends EventEmitter {
|
|
14
|
+
constructor({ maintainerEmail, packageAgent, staging = false }) {
|
|
15
|
+
super();
|
|
16
|
+
|
|
17
|
+
this.acme = ACME.create({
|
|
18
|
+
maintainerEmail,
|
|
19
|
+
packageAgent,
|
|
20
|
+
// events list:
|
|
21
|
+
// 1. error
|
|
22
|
+
// 2. warning
|
|
23
|
+
// 3. certificate_order
|
|
24
|
+
// 4. challenge_select
|
|
25
|
+
// 5. challenge_status
|
|
26
|
+
notify: (event, details) => {
|
|
27
|
+
if (event === 'error') {
|
|
28
|
+
logger.error('issue with error:', details);
|
|
29
|
+
this.emit('cert.error', { error: details });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (event === 'warning') {
|
|
34
|
+
logger.warn('issue with warning', { details });
|
|
35
|
+
this.emit('cert.warning', { details });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (['challenge_status', 'cert_issue'].includes(event)) {
|
|
40
|
+
logger.info(`notify ${event} details:`, details);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.emit('cert.event:', event);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.staging = staging;
|
|
48
|
+
this.directoryUrl = this.staging === true ? DIRECTORY_URL_STAGING : DIRECTORY_URL;
|
|
49
|
+
this.maintainerEmail = maintainerEmail;
|
|
50
|
+
|
|
51
|
+
logger.info('directory url', { url: this.directoryUrl });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async init() {
|
|
55
|
+
await this.acme.init(this.directoryUrl);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async create({ subject, subscriberEmail, agreeToTerms = true, challenges }) {
|
|
59
|
+
const domains = [subject].map((name) => punycode.toASCII(name));
|
|
60
|
+
|
|
61
|
+
const encoding = 'der';
|
|
62
|
+
const typ = 'CERTIFICATE REQUEST';
|
|
63
|
+
|
|
64
|
+
const serverKeypair = await Keypairs.generate({ kty: 'RSA', format: 'jwk' });
|
|
65
|
+
const serverKey = serverKeypair.private;
|
|
66
|
+
const serverPem = await Keypairs.export({ jwk: serverKey, encoding: 'pem' });
|
|
67
|
+
|
|
68
|
+
const csrDer = await CSR.csr({ jwk: serverKey, domains, encoding });
|
|
69
|
+
const csr = PEM.packBlock({ type: typ, bytes: csrDer });
|
|
70
|
+
logger.info(`validating domain authorization for ${domains.join(' ')}`);
|
|
71
|
+
|
|
72
|
+
const dbAccount = await this._createAccount(subscriberEmail, agreeToTerms);
|
|
73
|
+
const accountKey = dbAccount.private_key;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const pems = await this.acme.certificates.create({
|
|
77
|
+
account: dbAccount.account,
|
|
78
|
+
accountKey,
|
|
79
|
+
csr,
|
|
80
|
+
domains,
|
|
81
|
+
challenges,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const fullchain = `${pems.cert}\n${pems.chain}\n`;
|
|
85
|
+
|
|
86
|
+
logger.info('certificates generated!');
|
|
87
|
+
|
|
88
|
+
this.emit('cert.issued', {
|
|
89
|
+
subject,
|
|
90
|
+
privkey: serverPem,
|
|
91
|
+
cert: pems.cert,
|
|
92
|
+
chain: pems.chain,
|
|
93
|
+
fullchain,
|
|
94
|
+
challenges,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
logger.error('create certificate error', { domain: subject, error });
|
|
98
|
+
this.emit('cert.error', { domain: subject, error_message: error.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async _createAccount(subscriberEmail, agreeToTerms) {
|
|
103
|
+
const dbAccount = await states.account.findOne({ directoryUrl: this.directoryUrl });
|
|
104
|
+
if (dbAccount) {
|
|
105
|
+
return dbAccount;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// TODO: kty 可以是 RSA? 和 EC 有什么区别?
|
|
109
|
+
const accountKeypair = await Keypairs.generate({ kty: 'EC', format: 'jwk' });
|
|
110
|
+
const accountKey = accountKeypair.private;
|
|
111
|
+
|
|
112
|
+
const account = await this.acme.accounts.create({
|
|
113
|
+
subscriberEmail,
|
|
114
|
+
agreeToTerms,
|
|
115
|
+
accountKey,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await states.account.update(
|
|
119
|
+
{ directoryUrl: this.directoryUrl },
|
|
120
|
+
{ directoryUrl: this.directoryUrl, private_key: accountKey, account, maintainer_email: this.maintainerEmail },
|
|
121
|
+
{ upsert: true }
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
logger.info('account was created', { directoryUrl: this.directoryUrl, maintainerEmail: this.maintainerEmail });
|
|
125
|
+
return states.account.findOne({ directoryUrl: this.directoryUrl });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = AcmeWrapper;
|
package/libs/constant.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module.exports = Object.freeze({
|
|
2
|
+
CERT_STATUS: {
|
|
3
|
+
waiting: 'waiting',
|
|
4
|
+
creating: 'creating',
|
|
5
|
+
generated: 'generated',
|
|
6
|
+
renewaling: 'renewaling',
|
|
7
|
+
error: 'error',
|
|
8
|
+
},
|
|
9
|
+
CERT_SOURCE: {
|
|
10
|
+
letsEncrypt: 'lets_encrypt',
|
|
11
|
+
upload: 'upload',
|
|
12
|
+
},
|
|
13
|
+
});
|
package/libs/http-01.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base on: https://www.npmjs.com/package/acme-http-01-standalone
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const states = require('../states');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
const _memdb = {};
|
|
9
|
+
|
|
10
|
+
const create = () => {
|
|
11
|
+
return {
|
|
12
|
+
init() {
|
|
13
|
+
return Promise.resolve(null);
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
set(data) {
|
|
17
|
+
return Promise.resolve().then(async () => {
|
|
18
|
+
const ch = data.challenge;
|
|
19
|
+
const key = ch.token;
|
|
20
|
+
logger.info('set key:', { key });
|
|
21
|
+
await states.httpChallenge.update({ key }, { key, value: ch.keyAuthorization }, { upsert: true });
|
|
22
|
+
logger.info('setted key:', { key });
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
get(data) {
|
|
29
|
+
return Promise.resolve().then(async () => {
|
|
30
|
+
const ch = data.challenge;
|
|
31
|
+
const key = ch.token;
|
|
32
|
+
logger.info('get key', { key });
|
|
33
|
+
|
|
34
|
+
const challengeResult = await states.httpChallenge.findOne({ key });
|
|
35
|
+
if (challengeResult) {
|
|
36
|
+
logger.info('got key', { key });
|
|
37
|
+
return { keyAuthorization: challengeResult.value };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logger.info('key not found', { key });
|
|
41
|
+
return null;
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
remove(data) {
|
|
46
|
+
return Promise.resolve().then(async () => {
|
|
47
|
+
const ch = data.challenge;
|
|
48
|
+
const key = ch.token;
|
|
49
|
+
logger.info('remove key', { key });
|
|
50
|
+
await states.httpChallenge.remove({ key });
|
|
51
|
+
logger.info('removed key', { key });
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
create,
|
|
61
|
+
db: _memdb,
|
|
62
|
+
};
|
package/libs/logger.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('@abtnode/logger')(require('../package.json').name);
|
package/libs/queue.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const createQueue = require('@abtnode/queue');
|
|
3
|
+
|
|
4
|
+
module.exports = ({ name, dataDir, onJob, options = {} }) => {
|
|
5
|
+
const queue = createQueue({
|
|
6
|
+
file: path.join(dataDir, `${name}.db`),
|
|
7
|
+
onJob,
|
|
8
|
+
options,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return queue;
|
|
12
|
+
};
|
package/libs/util.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const get = require('lodash.get');
|
|
4
|
+
const { Certificate, PrivateKey } = require('@fidm/x509');
|
|
5
|
+
|
|
6
|
+
const md5 = (str) => crypto.createHash('md5').update(str).digest('hex');
|
|
7
|
+
|
|
8
|
+
const ensureDir = (dir) => {
|
|
9
|
+
if (fs.existsSync(dir)) {
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
return dir;
|
|
15
|
+
};
|
|
16
|
+
const getFingerprint = (data, alg) => {
|
|
17
|
+
const shasum = crypto.createHash(alg);
|
|
18
|
+
// eslint-disable-next-line newline-per-chained-call
|
|
19
|
+
return shasum.update(data).digest('hex').match(/.{2}/g).join(':').toUpperCase();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const validateCertificate = (cert, domain) => {
|
|
23
|
+
const certificate = Certificate.fromPEM(cert.certificate);
|
|
24
|
+
const privateKey = PrivateKey.fromPEM(cert.privateKey);
|
|
25
|
+
|
|
26
|
+
const data = Buffer.allocUnsafe(100);
|
|
27
|
+
const signature = privateKey.sign(data, 'sha512');
|
|
28
|
+
if (!certificate.publicKey.verify(data, signature, 'sha512')) {
|
|
29
|
+
throw new Error('Invalid certificate: signature verify failed');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const certDomain = get(certificate, 'subject.commonName', '');
|
|
33
|
+
if (domain && domain !== certDomain) {
|
|
34
|
+
throw new Error('Invalid certificate: domain does not match');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validFrom = get(certificate, 'validFrom', '');
|
|
38
|
+
if (!validFrom || new Date(validFrom).getTime() > Date.now()) {
|
|
39
|
+
throw new Error('Invalid certificate: not in valid period');
|
|
40
|
+
}
|
|
41
|
+
const validTo = get(certificate, 'validTo', '');
|
|
42
|
+
if (!validTo || new Date(validTo).getTime() < Date.now()) {
|
|
43
|
+
throw new Error('Invalid certificate: not in valid period');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return certificate;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getCertInfo = (certificate) => {
|
|
50
|
+
const info = Certificate.fromPEM(certificate);
|
|
51
|
+
|
|
52
|
+
const domain = info.subject.commonName.valueOf();
|
|
53
|
+
const validFrom = info.validFrom.valueOf();
|
|
54
|
+
const validTo = info.validTo.valueOf();
|
|
55
|
+
const issuer = {
|
|
56
|
+
countryName: info.issuer.countryName.valueOf(),
|
|
57
|
+
organizationName: info.issuer.organizationName.valueOf(),
|
|
58
|
+
commonName: info.issuer.commonName.valueOf(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
domain,
|
|
63
|
+
validFrom,
|
|
64
|
+
validTo,
|
|
65
|
+
issuer,
|
|
66
|
+
serialNumber: info.serialNumber,
|
|
67
|
+
sans: info.dnsNames,
|
|
68
|
+
validityPeriod: info.validTo - info.validFrom,
|
|
69
|
+
fingerprintAlg: 'SHA256',
|
|
70
|
+
fingerprint: getFingerprint(info.raw, 'sha256'),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const attachCertInfo = (certificates = []) => {
|
|
75
|
+
if (!certificates) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(certificates)) {
|
|
80
|
+
return certificates.map((cert) => {
|
|
81
|
+
const info = cert.certificate ? getCertInfo(cert.certificate) : {};
|
|
82
|
+
return { ...cert, ...info };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const info = certificates.certificate ? getCertInfo(certificates.certificate) : {};
|
|
87
|
+
return { ...certificates, ...info };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
module.exports = { md5, ensureDir, getFingerprint, validateCertificate, attachCertInfo };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abtnode/certificate-manager",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.8",
|
|
4
4
|
"description": "Manage ABT Node SSL certificates",
|
|
5
5
|
"author": "polunzh <polunzh@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/ArcBlock/blocklet-server#readme",
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"directories": {
|
|
13
|
-
"lib": "lib",
|
|
14
|
-
"test": "__tests__"
|
|
15
|
-
},
|
|
16
12
|
"files": [
|
|
17
|
-
"
|
|
13
|
+
"libs",
|
|
14
|
+
"routes",
|
|
15
|
+
"sdk",
|
|
16
|
+
"states",
|
|
17
|
+
"validators",
|
|
18
|
+
"index.js",
|
|
19
|
+
"README.md"
|
|
18
20
|
],
|
|
19
21
|
"repository": {
|
|
20
22
|
"type": "git",
|
|
@@ -29,10 +31,10 @@
|
|
|
29
31
|
"url": "https://github.com/ArcBlock/blocklet-server/issues"
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|
|
32
|
-
"@abtnode/cron": "1.6.
|
|
33
|
-
"@abtnode/db": "1.6.
|
|
34
|
-
"@abtnode/logger": "1.6.
|
|
35
|
-
"@abtnode/queue": "1.6.
|
|
34
|
+
"@abtnode/cron": "1.6.8",
|
|
35
|
+
"@abtnode/db": "1.6.8",
|
|
36
|
+
"@abtnode/logger": "1.6.8",
|
|
37
|
+
"@abtnode/queue": "1.6.8",
|
|
36
38
|
"@fidm/x509": "^1.2.1",
|
|
37
39
|
"@greenlock/manager": "^3.1.0",
|
|
38
40
|
"@nedb/core": "^1.1.0",
|
|
@@ -52,5 +54,5 @@
|
|
|
52
54
|
"punycode": "^2.1.1",
|
|
53
55
|
"ursa-optional": "^0.10.2"
|
|
54
56
|
},
|
|
55
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "f97ec3a44250e034ff4b5e3f088c1417f448a7bb"
|
|
56
58
|
}
|
package/routes/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const wellKnownRouter = require('./well-known');
|
|
3
|
+
const states = require('../states');
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
const createRoutes = (dataDir) => {
|
|
8
|
+
states.init(dataDir);
|
|
9
|
+
|
|
10
|
+
router.use(wellKnownRouter);
|
|
11
|
+
|
|
12
|
+
return router;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
module.exports = createRoutes;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const logger = require('../libs/logger');
|
|
3
|
+
const states = require('../states');
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get('/.well-known/acme-challenge/:token', async (req, res) => {
|
|
8
|
+
const challenge = await states.httpChallenge.findOne({ key: req.params.token });
|
|
9
|
+
logger.debug('acme challenge', { token: req.params.token, challenge });
|
|
10
|
+
if (!challenge) {
|
|
11
|
+
return res.status(404).send();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return res.send(challenge.value);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
module.exports = router;
|
package/sdk/manager.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const EventEmitter = require('events');
|
|
2
|
+
const { Certificate } = require('@fidm/x509');
|
|
3
|
+
const get = require('lodash.get');
|
|
4
|
+
const Cron = require('@abtnode/cron');
|
|
5
|
+
|
|
6
|
+
const AcmeManager = require('../libs/acme-manager');
|
|
7
|
+
const { CERT_SOURCE, CERT_STATUS } = require('../libs/constant');
|
|
8
|
+
const states = require('../states');
|
|
9
|
+
const { validateAdd, validateUpdate, validateUpsertByDomain } = require('../validators/cert');
|
|
10
|
+
const logger = require('../libs/logger');
|
|
11
|
+
const { validateCertificate } = require('../libs/util');
|
|
12
|
+
|
|
13
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const CERTIFICATE_EXPIRES_WARNING_OFFSET = 7 * DAY_IN_MS;
|
|
15
|
+
|
|
16
|
+
class Manager extends EventEmitter {
|
|
17
|
+
constructor({ daysBeforeExpireToRenewal, maintainerEmail, dataDir }) {
|
|
18
|
+
super();
|
|
19
|
+
if (!dataDir) {
|
|
20
|
+
throw new Error('dataDir is required');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.daysBeforeExpireToRenewal = daysBeforeExpireToRenewal;
|
|
24
|
+
this.maintainerEmail = maintainerEmail;
|
|
25
|
+
this.acmeManager = null;
|
|
26
|
+
this.dataDir = dataDir;
|
|
27
|
+
|
|
28
|
+
states.init(dataDir);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async start() {
|
|
32
|
+
const acmeManager = await AcmeManager.initInstance({
|
|
33
|
+
maintainerEmail: this.maintainerEmail,
|
|
34
|
+
daysBeforeExpireToRenewal: this.daysBeforeExpireToRenewal,
|
|
35
|
+
baseDataDir: this.dataDir,
|
|
36
|
+
});
|
|
37
|
+
this.acmeManager = acmeManager;
|
|
38
|
+
|
|
39
|
+
acmeManager.on('cert.issued', (...args) => {
|
|
40
|
+
this.emit('cert.issued', ...args);
|
|
41
|
+
});
|
|
42
|
+
acmeManager.on('cert.error', (...args) => this.emit('cert.error', ...args));
|
|
43
|
+
|
|
44
|
+
Cron.init({
|
|
45
|
+
context: {},
|
|
46
|
+
jobs: [
|
|
47
|
+
acmeManager.getJobSchedular(),
|
|
48
|
+
{
|
|
49
|
+
name: 'check-expired-certificates',
|
|
50
|
+
time: '0 0 9 * * *', // check on 09:00 every day
|
|
51
|
+
fn: this.checkCertificatesExpiration.bind(this),
|
|
52
|
+
options: { runOnInit: true },
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async checkCertificatesExpiration() {
|
|
59
|
+
try {
|
|
60
|
+
logger.info('check expired certificates');
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
|
|
63
|
+
const certificates = await this.getAllNormal();
|
|
64
|
+
for (let i = 0; i < certificates.length; i++) {
|
|
65
|
+
const cert = certificates[i];
|
|
66
|
+
const alreadyExpired = now >= cert.validTo;
|
|
67
|
+
const aboutToExpire = cert.validTo - now > 0 && cert.validTo - now < CERTIFICATE_EXPIRES_WARNING_OFFSET;
|
|
68
|
+
|
|
69
|
+
const expireInDays = Math.ceil((cert.validTo - now) / DAY_IN_MS);
|
|
70
|
+
const data = { id: cert.id, domain: cert.domain, expireInDays, validTo: cert.validTo };
|
|
71
|
+
|
|
72
|
+
if (alreadyExpired) {
|
|
73
|
+
this.emit('cert.expired', data);
|
|
74
|
+
} else if (aboutToExpire) {
|
|
75
|
+
this.emit('cert.about_to_expire', data);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.error('check expired certificates failed', { error });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getAll() {
|
|
84
|
+
return states.certificate.find({});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getAllNormal() {
|
|
88
|
+
return states.certificate.find({
|
|
89
|
+
$or: [
|
|
90
|
+
{ status: CERT_STATUS.generated },
|
|
91
|
+
{
|
|
92
|
+
source: CERT_SOURCE.upload,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getByDomain(domain) {
|
|
99
|
+
return states.certificate.findOne({ domain });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async add(data) {
|
|
103
|
+
await validateAdd(data);
|
|
104
|
+
|
|
105
|
+
validateCertificate(data);
|
|
106
|
+
|
|
107
|
+
const existed = await states.certificate.findOne({ name: data.name });
|
|
108
|
+
if (existed) {
|
|
109
|
+
throw new Error(`The name ${data.name} already exists!`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!data.source) {
|
|
113
|
+
data.source = CERT_SOURCE.upload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const info = Certificate.fromPEM(data.certificate);
|
|
117
|
+
data.domain = get(info, 'subject.commonName', '');
|
|
118
|
+
|
|
119
|
+
const result = await states.certificate.insert(data);
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 生成域名,certificate-manager 会自动生成并更新该证书
|
|
125
|
+
* @param {string} domain
|
|
126
|
+
* @returns
|
|
127
|
+
*/
|
|
128
|
+
async issue(domain) {
|
|
129
|
+
if (!domain) {
|
|
130
|
+
throw new Error('domain is required');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existed = await states.certificate.findOne({ domain });
|
|
134
|
+
if (existed) {
|
|
135
|
+
throw new Error(`The name ${domain} already exists!`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return this.acmeManager.add(domain);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async upsertByDomain(data) {
|
|
142
|
+
await validateUpsertByDomain(data);
|
|
143
|
+
|
|
144
|
+
if (!data.source) {
|
|
145
|
+
data.source = CERT_SOURCE.upload;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return states.certificate.update({ domain: data.domain }, data, { upsert: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async update(id, data) {
|
|
152
|
+
await validateUpdate(data);
|
|
153
|
+
|
|
154
|
+
const existed = await states.certificate.findOne({ _id: id });
|
|
155
|
+
if (!existed) {
|
|
156
|
+
throw new Error(`The name ${data.name} does not exists`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (existed.source !== CERT_SOURCE.upload) {
|
|
160
|
+
throw new Error('Can not update non-upload certificate');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return states.certificate.update({ _id: id }, { $set: data });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async remove(id) {
|
|
167
|
+
const existed = await states.certificate.findOne({ _id: id });
|
|
168
|
+
if (!existed) {
|
|
169
|
+
throw new Error(`The certificate ${id} does not exists!`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return states.certificate.remove({ _id: id });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
addWithoutValidations(data) {
|
|
176
|
+
return states.certificate.insert(data);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = Manager;
|
package/states/base.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const DB = require('@abtnode/db');
|
|
2
|
+
|
|
3
|
+
module.exports = class BaseDB extends DB {
|
|
4
|
+
async find(...args) {
|
|
5
|
+
const dbEntity = await super.find(...args);
|
|
6
|
+
|
|
7
|
+
return DB.renameIdFiledName(dbEntity);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async findOne(...args) {
|
|
11
|
+
const dbEntity = await super.findOne(...args);
|
|
12
|
+
|
|
13
|
+
return DB.renameIdFiledName(dbEntity);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { CERT_STATUS } = require('../libs/constant');
|
|
2
|
+
const BaseSate = require('./base');
|
|
3
|
+
const { attachCertInfo } = require('../libs/util');
|
|
4
|
+
|
|
5
|
+
class Certificate extends BaseSate {
|
|
6
|
+
constructor(dataDir) {
|
|
7
|
+
super(dataDir, { filename: 'certificate.db' });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async updateStatus(domain, status) {
|
|
11
|
+
if (!Object.values(CERT_STATUS).includes(status)) {
|
|
12
|
+
throw new Error('invalid domain status');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return super.update({ domain }, { $set: { status } });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async find(...args) {
|
|
19
|
+
const result = await super.find(...args);
|
|
20
|
+
return attachCertInfo(result);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findOne(...args) {
|
|
24
|
+
const result = await super.findOne(...args);
|
|
25
|
+
return attachCertInfo(result);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = Certificate;
|
package/states/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const stateFactory = require('@abtnode/db/lib/factory');
|
|
4
|
+
|
|
5
|
+
const Account = require('./account');
|
|
6
|
+
const Certificate = require('./certificate');
|
|
7
|
+
const HttpChallenge = require('./http-challenge');
|
|
8
|
+
|
|
9
|
+
const init = (dataDir) => {
|
|
10
|
+
const dbDir = path.join(dataDir, 'db');
|
|
11
|
+
if (!fs.existsSync(dbDir)) {
|
|
12
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const account = new Account(dbDir);
|
|
16
|
+
const certificate = new Certificate(dbDir);
|
|
17
|
+
const httpChallenge = new HttpChallenge(dbDir);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
account,
|
|
21
|
+
certificate,
|
|
22
|
+
httpChallenge,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = stateFactory(init);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const Joi = require('joi');
|
|
2
|
+
|
|
3
|
+
const nameSchema = Joi.string().trim().max(64);
|
|
4
|
+
const publicSchema = Joi.bool().default(false);
|
|
5
|
+
|
|
6
|
+
const addSchema = Joi.object({
|
|
7
|
+
name: nameSchema,
|
|
8
|
+
certificate: Joi.string().required(),
|
|
9
|
+
privateKey: Joi.string().required(),
|
|
10
|
+
public: publicSchema,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const updateSchema = Joi.object({
|
|
14
|
+
name: nameSchema.required(),
|
|
15
|
+
public: publicSchema,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const upsertByDomainSchema = Joi.object({
|
|
19
|
+
name: nameSchema,
|
|
20
|
+
domain: Joi.string().required(),
|
|
21
|
+
certificate: Joi.string().required(),
|
|
22
|
+
privateKey: Joi.string().required(),
|
|
23
|
+
public: publicSchema,
|
|
24
|
+
isProtected: Joi.boolean(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
validateAdd: (entity) => addSchema.validateAsync(entity),
|
|
29
|
+
validateUpdate: (entity) => updateSchema.validateAsync(entity),
|
|
30
|
+
validateUpsertByDomain: (entity) => upsertByDomainSchema.validateAsync(entity),
|
|
31
|
+
};
|