@certd/acme-client 0.1.6 → 0.2.0
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/CHANGELOG.md +152 -152
- package/LICENSE +21 -21
- package/README.md +199 -199
- package/package.json +5 -4
- package/src/api.js +243 -243
- package/src/auto.js +203 -199
- package/src/axios.js +40 -40
- package/src/client.js +716 -716
- package/src/crypto/forge.js +454 -445
- package/src/http.js +241 -241
- package/src/index.js +31 -31
- package/src/util.js +173 -172
- package/src/util.log.js +8 -8
- package/src/verify.js +96 -96
- package/types/index.d.ts +141 -141
- package/types/rfc8555.d.ts +127 -127
- package/types/test.ts +70 -70
- package/types/tsconfig.json +11 -11
- package/types/tslint.json +6 -6
package/src/auto.js
CHANGED
|
@@ -1,199 +1,203 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ACME auto helper
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
const Promise = require('bluebird');
|
|
6
|
-
const logger = require('./util.log.js');
|
|
7
|
-
|
|
8
|
-
const debug = logger.info;
|
|
9
|
-
const forge = require('./crypto/forge');
|
|
10
|
-
|
|
11
|
-
const defaultOpts = {
|
|
12
|
-
csr: null,
|
|
13
|
-
email: null,
|
|
14
|
-
preferredChain: null,
|
|
15
|
-
termsOfServiceAgreed: false,
|
|
16
|
-
skipChallengeVerification: false,
|
|
17
|
-
challengePriority: ['http-01', 'dns-01'],
|
|
18
|
-
challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); },
|
|
19
|
-
challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()'); }
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
function sleep(time) {
|
|
24
|
-
return new Promise((resovle) => {
|
|
25
|
-
setTimeout(() => {
|
|
26
|
-
resovle();
|
|
27
|
-
}, time);
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* ACME client auto mode
|
|
32
|
-
*
|
|
33
|
-
* @param {AcmeClient} client ACME client
|
|
34
|
-
* @param {object} userOpts Options
|
|
35
|
-
* @returns {Promise<buffer>} Certificate
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
module.exports = async function(client, userOpts) {
|
|
39
|
-
const opts = Object.assign({}, defaultOpts, userOpts);
|
|
40
|
-
const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed };
|
|
41
|
-
|
|
42
|
-
if (!Buffer.isBuffer(opts.csr)) {
|
|
43
|
-
opts.csr = Buffer.from(opts.csr);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (opts.email) {
|
|
47
|
-
accountPayload.contact = [`mailto:${opts.email}`];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Register account
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
logger.info('[auto] Checking account');
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
client.getAccountUrl();
|
|
59
|
-
logger.info('[auto] Account URL already exists, skipping account registration');
|
|
60
|
-
}
|
|
61
|
-
catch (e) {
|
|
62
|
-
logger.info('[auto] Registering account');
|
|
63
|
-
await client.createAccount(accountPayload);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Parse domains from CSR
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
logger.info('[auto] Parsing domains from Certificate Signing Request');
|
|
72
|
-
const csrDomains = await forge.readCsrDomains(opts.csr);
|
|
73
|
-
const domains = [csrDomains.commonName].concat(csrDomains.altNames);
|
|
74
|
-
|
|
75
|
-
logger.info(`[auto] Resolved ${domains.length} domains from parsing the Certificate Signing Request`);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Place order
|
|
80
|
-
*/
|
|
81
|
-
|
|
82
|
-
logger.info('[auto] Placing new certificate order with ACME provider');
|
|
83
|
-
const orderPayload = { identifiers: domains.map((d) => ({ type: 'dns', value: d })) };
|
|
84
|
-
const order = await client.createOrder(orderPayload);
|
|
85
|
-
const authorizations = await client.getAuthorizations(order);
|
|
86
|
-
|
|
87
|
-
logger.info(`[auto] Placed certificate order successfully, received ${authorizations.length} identity authorizations`);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Resolve and satisfy challenges
|
|
92
|
-
*/
|
|
93
|
-
|
|
94
|
-
logger.info('[auto] Resolving and satisfying authorization challenges');
|
|
95
|
-
|
|
96
|
-
// 获取challenge列表
|
|
97
|
-
const challengePromises = authorizations.map(async (authz) => {
|
|
98
|
-
const d = authz.identifier.value;
|
|
99
|
-
|
|
100
|
-
/* Select challenge based on priority */
|
|
101
|
-
const challenge = authz.challenges.sort((a, b) => {
|
|
102
|
-
const aidx = opts.challengePriority.indexOf(a.type);
|
|
103
|
-
const bidx = opts.challengePriority.indexOf(b.type);
|
|
104
|
-
|
|
105
|
-
if (aidx === -1) return 1;
|
|
106
|
-
if (bidx === -1) return -1;
|
|
107
|
-
return aidx - bidx;
|
|
108
|
-
}).slice(0, 1)[0];
|
|
109
|
-
|
|
110
|
-
if (!challenge) {
|
|
111
|
-
throw new Error(`Unable to select challenge for ${d}, no challenge found`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
logger.info(`[auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}`);
|
|
115
|
-
|
|
116
|
-
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
authz, keyAuthorization, domain: d, challenge
|
|
120
|
-
};
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// 执行获取challenge信息列表
|
|
124
|
-
const challengeInfos = await Promise.all(challengePromises);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// 创建dns records 的promise
|
|
128
|
-
const challengeCreatePromises = challengeInfos.map(async (challengeInfo, index) => {
|
|
129
|
-
const { domain, authz, challenge, keyAuthorization } = challengeInfo;
|
|
130
|
-
try {
|
|
131
|
-
await sleep(index * 2000); // 延迟2秒再请求下一个
|
|
132
|
-
logger.info(`[auto] [${domain}] Trigger challengeCreateFn()`);
|
|
133
|
-
challengeInfo.record = await opts.challengeCreateFn(authz, challenge, keyAuthorization);
|
|
134
|
-
return challengeInfo;
|
|
135
|
-
}
|
|
136
|
-
catch (e) {
|
|
137
|
-
logger.error('challengeCreate error', e);
|
|
138
|
-
return e;
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// 执行创建dns record
|
|
143
|
-
const challengeRecordInfos = await Promise.all(challengeCreatePromises);
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
// 如果创建record有错误,直接报错
|
|
147
|
-
const hasError = challengeRecordInfos.filter((item) => item instanceof Error);
|
|
148
|
-
if (hasError.length > 0) {
|
|
149
|
-
throw new Error(hasError[0]);
|
|
150
|
-
}
|
|
151
|
-
// 等待30秒,尽量等dns记录更新到远端
|
|
152
|
-
logger.info('[auto] 等待30秒');
|
|
153
|
-
await sleep(30000);
|
|
154
|
-
// 开始验证
|
|
155
|
-
const verifys = challengeRecordInfos.map(async (challengeInfo) => {
|
|
156
|
-
const { domain, authz, challenge } = challengeInfo;
|
|
157
|
-
const d = domain;
|
|
158
|
-
/* Challenge verification */
|
|
159
|
-
if (opts.skipChallengeVerification === true) {
|
|
160
|
-
logger.info(`[auto] [${d}] Skipping challenge verification since skipChallengeVerification=true`);
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
logger.info(`[auto] [${d}] Running challenge verification`);
|
|
164
|
-
await client.verifyChallenge(authz, challenge);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/* Complete challenge and wait for valid status */
|
|
168
|
-
logger.info(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`);
|
|
169
|
-
await client.completeChallenge(challenge);
|
|
170
|
-
await client.waitForValidStatus(challenge);
|
|
171
|
-
});
|
|
172
|
-
logger.info('[auto] Waiting for challenge valid status');
|
|
173
|
-
await Promise.all(verifys);
|
|
174
|
-
// 验证成功
|
|
175
|
-
logger.info('[auto] challenge verify success');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ACME auto helper
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const Promise = require('bluebird');
|
|
6
|
+
const logger = require('./util.log.js');
|
|
7
|
+
|
|
8
|
+
const debug = logger.info;
|
|
9
|
+
const forge = require('./crypto/forge');
|
|
10
|
+
|
|
11
|
+
const defaultOpts = {
|
|
12
|
+
csr: null,
|
|
13
|
+
email: null,
|
|
14
|
+
preferredChain: null,
|
|
15
|
+
termsOfServiceAgreed: false,
|
|
16
|
+
skipChallengeVerification: false,
|
|
17
|
+
challengePriority: ['http-01', 'dns-01'],
|
|
18
|
+
challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); },
|
|
19
|
+
challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()'); }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
function sleep(time) {
|
|
24
|
+
return new Promise((resovle) => {
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
resovle();
|
|
27
|
+
}, time);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* ACME client auto mode
|
|
32
|
+
*
|
|
33
|
+
* @param {AcmeClient} client ACME client
|
|
34
|
+
* @param {object} userOpts Options
|
|
35
|
+
* @returns {Promise<buffer>} Certificate
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
module.exports = async function(client, userOpts) {
|
|
39
|
+
const opts = Object.assign({}, defaultOpts, userOpts);
|
|
40
|
+
const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed };
|
|
41
|
+
|
|
42
|
+
if (!Buffer.isBuffer(opts.csr)) {
|
|
43
|
+
opts.csr = Buffer.from(opts.csr);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (opts.email) {
|
|
47
|
+
accountPayload.contact = [`mailto:${opts.email}`];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register account
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
logger.info('[auto] Checking account');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
client.getAccountUrl();
|
|
59
|
+
logger.info('[auto] Account URL already exists, skipping account registration');
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
logger.info('[auto] Registering account');
|
|
63
|
+
await client.createAccount(accountPayload);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse domains from CSR
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
logger.info('[auto] Parsing domains from Certificate Signing Request');
|
|
72
|
+
const csrDomains = await forge.readCsrDomains(opts.csr);
|
|
73
|
+
const domains = [csrDomains.commonName].concat(csrDomains.altNames);
|
|
74
|
+
|
|
75
|
+
logger.info(`[auto] Resolved ${domains.length} domains from parsing the Certificate Signing Request`);
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Place order
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
logger.info('[auto] Placing new certificate order with ACME provider');
|
|
83
|
+
const orderPayload = { identifiers: domains.map((d) => ({ type: 'dns', value: d })) };
|
|
84
|
+
const order = await client.createOrder(orderPayload);
|
|
85
|
+
const authorizations = await client.getAuthorizations(order);
|
|
86
|
+
|
|
87
|
+
logger.info(`[auto] Placed certificate order successfully, received ${authorizations.length} identity authorizations`);
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve and satisfy challenges
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
logger.info('[auto] Resolving and satisfying authorization challenges');
|
|
95
|
+
|
|
96
|
+
// 获取challenge列表
|
|
97
|
+
const challengePromises = authorizations.map(async (authz) => {
|
|
98
|
+
const d = authz.identifier.value;
|
|
99
|
+
|
|
100
|
+
/* Select challenge based on priority */
|
|
101
|
+
const challenge = authz.challenges.sort((a, b) => {
|
|
102
|
+
const aidx = opts.challengePriority.indexOf(a.type);
|
|
103
|
+
const bidx = opts.challengePriority.indexOf(b.type);
|
|
104
|
+
|
|
105
|
+
if (aidx === -1) return 1;
|
|
106
|
+
if (bidx === -1) return -1;
|
|
107
|
+
return aidx - bidx;
|
|
108
|
+
}).slice(0, 1)[0];
|
|
109
|
+
|
|
110
|
+
if (!challenge) {
|
|
111
|
+
throw new Error(`Unable to select challenge for ${d}, no challenge found`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logger.info(`[auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}`);
|
|
115
|
+
|
|
116
|
+
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
authz, keyAuthorization, domain: d, challenge
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 执行获取challenge信息列表
|
|
124
|
+
const challengeInfos = await Promise.all(challengePromises);
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
// 创建dns records 的promise
|
|
128
|
+
const challengeCreatePromises = challengeInfos.map(async (challengeInfo, index) => {
|
|
129
|
+
const { domain, authz, challenge, keyAuthorization } = challengeInfo;
|
|
130
|
+
try {
|
|
131
|
+
await sleep(index * 2000); // 延迟2秒再请求下一个
|
|
132
|
+
logger.info(`[auto] [${domain}] Trigger challengeCreateFn()`);
|
|
133
|
+
challengeInfo.record = await opts.challengeCreateFn(authz, challenge, keyAuthorization);
|
|
134
|
+
return challengeInfo;
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
logger.error('challengeCreate error', e);
|
|
138
|
+
return e;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 执行创建dns record
|
|
143
|
+
const challengeRecordInfos = await Promise.all(challengeCreatePromises);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// 如果创建record有错误,直接报错
|
|
147
|
+
const hasError = challengeRecordInfos.filter((item) => item instanceof Error);
|
|
148
|
+
if (hasError.length > 0) {
|
|
149
|
+
throw new Error(hasError[0]);
|
|
150
|
+
}
|
|
151
|
+
// 等待30秒,尽量等dns记录更新到远端
|
|
152
|
+
logger.info('[auto] 等待30秒');
|
|
153
|
+
await sleep(30000);
|
|
154
|
+
// 开始验证
|
|
155
|
+
const verifys = challengeRecordInfos.map(async (challengeInfo) => {
|
|
156
|
+
const { domain, authz, challenge } = challengeInfo;
|
|
157
|
+
const d = domain;
|
|
158
|
+
/* Challenge verification */
|
|
159
|
+
if (opts.skipChallengeVerification === true) {
|
|
160
|
+
logger.info(`[auto] [${d}] Skipping challenge verification since skipChallengeVerification=true`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
logger.info(`[auto] [${d}] Running challenge verification`);
|
|
164
|
+
await client.verifyChallenge(authz, challenge);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Complete challenge and wait for valid status */
|
|
168
|
+
logger.info(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`);
|
|
169
|
+
await client.completeChallenge(challenge);
|
|
170
|
+
await client.waitForValidStatus(challenge);
|
|
171
|
+
});
|
|
172
|
+
logger.info('[auto] Waiting for challenge valid status');
|
|
173
|
+
await Promise.all(verifys);
|
|
174
|
+
// 验证成功
|
|
175
|
+
logger.info('[auto] challenge verify success');
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
logger.error('申请时出错:', e);
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
// 删除record
|
|
183
|
+
const willRemovePromises = challengeRecordInfos.filter((item) => item && !(item instanceof Error)).map(async (challengeInfo, index) => {
|
|
184
|
+
await sleep(index * 2000); // 延迟2秒再请求下一个
|
|
185
|
+
const { authz, challenge, keyAuthorization, record, domain } = challengeInfo;
|
|
186
|
+
/* Trigger challengeRemoveFn(), suppress errors */
|
|
187
|
+
logger.info(`[auto] [${domain}] Trigger challengeRemoveFn()`);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await opts.challengeRemoveFn(authz, challenge, keyAuthorization, record);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
logger.info(`[auto] [${domain}] challengeRemoveFn threw error: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await Promise.all(willRemovePromises);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logger.info('[auto] Finalizing order and downloading certificate');
|
|
201
|
+
await client.finalizeOrder(order, opts.csr);
|
|
202
|
+
return client.getCertificate(order, opts.preferredChain);
|
|
203
|
+
};
|
package/src/axios.js
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Axios instance
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
const axios = require('axios');
|
|
6
|
-
const adapter = require('axios/lib/adapters/http');
|
|
7
|
-
const pkg = require('./../package.json');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Instance
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const instance = axios.create();
|
|
15
|
-
|
|
16
|
-
/* Default User-Agent */
|
|
17
|
-
instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version}`;
|
|
18
|
-
|
|
19
|
-
/* Default ACME settings */
|
|
20
|
-
instance.defaults.acmeSettings = {
|
|
21
|
-
httpChallengePort: 80,
|
|
22
|
-
bypassCustomDnsResolver: false
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Explicitly set Node as default HTTP adapter
|
|
28
|
-
*
|
|
29
|
-
* https://github.com/axios/axios/issues/1180
|
|
30
|
-
* https://stackoverflow.com/questions/42677387
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
instance.defaults.adapter = adapter;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Export instance
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
module.exports = instance;
|
|
1
|
+
/**
|
|
2
|
+
* Axios instance
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const axios = require('axios');
|
|
6
|
+
const adapter = require('axios/lib/adapters/http');
|
|
7
|
+
const pkg = require('./../package.json');
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Instance
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const instance = axios.create();
|
|
15
|
+
|
|
16
|
+
/* Default User-Agent */
|
|
17
|
+
instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version}`;
|
|
18
|
+
|
|
19
|
+
/* Default ACME settings */
|
|
20
|
+
instance.defaults.acmeSettings = {
|
|
21
|
+
httpChallengePort: 80,
|
|
22
|
+
bypassCustomDnsResolver: false
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Explicitly set Node as default HTTP adapter
|
|
28
|
+
*
|
|
29
|
+
* https://github.com/axios/axios/issues/1180
|
|
30
|
+
* https://stackoverflow.com/questions/42677387
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
instance.defaults.adapter = adapter;
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Export instance
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
module.exports = instance;
|