-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathssl.mjs
173 lines (156 loc) · 6.43 KB
/
ssl.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// @todo by @Leask: need to foollow up on this:
// https://github.com/publishlab/node-acme-client/blob/HEAD/docs/upgrade-v5.md
import {
__, basename, ensureString, insensitiveCompare, log as _log, need, toString,
} from './utilitas.mjs';
import {
getConfig as _getConfig, readFile, setConfig as _setConfig
} from './storage.mjs'
import { boardcast, isPrimary, on } from './callosum.mjs';
import { join } from 'node:path';
import { loop } from './event.mjs';
import http from 'node:http';
import tls from 'node:tls';
const _NEED = ['acme-client'];
const __keys = __(import.meta.url, '../keys');
const [acmeChallenge, curCert] = [{ url: null, key: null }, {}];
const assertChlnge = t => assert(t === HTTP01, 'Only HTTP-auth is supported.');
const getPathByToken = token => `/.well-known/acme-challenge/${token}`;
const isLocalhost = host => ['127.0.0.1', '::1', 'localhost'].includes(host);
const log = content => _log(content, import.meta.url);
const resetCurCert = () => Object.assign(curCert, { key: null, cert: null });
const _challengeCreate = (url, key) => Object.assign(acmeChallenge, { url, key });
const _challengeRemove = url => Object.assign(acmeChallenge, { url: '', key: '' });
const [GET, HTTP_PORT, HTTPS, SSL_RESET, HTTP01, termsOfServiceAgreed]
= ['GET', 80, 'https', 'SSL_RESET', 'http-01', true];
const assertDomain = domain => {
domain = ensureString(domain, { case: 'LOW', trim: true });
assert(domain, 'Invalid domain.', 400);
return domain;
};
const getPack = (name, options) => ({
...options || {},
// pack: { name }, // inject pack name to custom storage path
// @todo, by @Leask: need to support custom storage function
});
const getConfig = async (domain, options) => (await _getConfig(
getPack(domain, options)
)).config;
const setConfig = async (domain, data, options) => (await _setConfig(
{ ...data || {}, domain }, getPack(domain, options)
)).config;
const _getCert = async (name, force) => {
force || (name = assertDomain(name));
const { csr, key, cert, domain } = await getConfig(name);
return (force ? true : insensitiveCompare(domain, name))
? { csr, key, cert, domain } : null;
};
const getCert = async (name) => {
if (curCert.key && curCert.cert) { return curCert; }
let { key, cert } = (await _getCert(name, true)) || {};
if (!key || !cert) {
[key, cert] = await Promise.all([
readFile(join(__keys, 'private.key')),
readFile(join(__keys, 'certificate.crt')),
]);
}
if (key && cert) { return Object.assign(curCert, { key, cert }); }
return { key, cert };
};
const createCsr = async (commonName, forge) => {
commonName = assertDomain(commonName);
const [key, csr] = await forge.createCsr({ commonName });
return { key: key.toString(), csr: csr.toString() };
};
const packChallengeFunc = fn => async (authz, challenge, keyAuthorization) => {
assertChlnge(challenge.type);
return await fn(getPathByToken(challenge.token), keyAuthorization);
};
const ensureCert = async (domain, challengeCreate, challengeRemove, options) => {
const { Client, directory, forge } = await need('acme-client');
domain = assertDomain(domain);
let { csr, key, cert } = (await _getCert(domain)) || {};
if (csr && key) { log('Found private-key and CSR.'); } else {
log('Creating new private-key and CSR...');
const newCsr = await createCsr(domain, forge);
await setConfig(domain, { csr, key, cert: cert = null });
resetCurCert(); boardcast(SSL_RESET);
csr = newCsr.csr; key = newCsr.key; log('Done.');
}
if (cert) {
log('Found certificate.');
const curCrt = await forge.readCertificateInfo(cert);
if (curCrt.notAfter.getTime() - Date.now() > 1000 * 60 * 60 * 24 * 30) {
log(`Certificate is still valid until ${curCrt.notAfter}.`);
} else { cert = null; log('Certificate will expire soon.'); }
}
if (!cert) {
log('Updating certificate...');
const client = new Client({
directoryUrl: directory.letsencrypt[
options?.debug ? 'staging' : 'production'
], accountKey: await forge.createPrivateKey(),
});
cert = await client.auto({
csr, email: `i@${domain}`, termsOfServiceAgreed,
challengePriority: [HTTP01],
challengeCreateFn: packChallengeFunc(challengeCreate),
challengeRemoveFn: packChallengeFunc(challengeRemove),
});
assert(cert, 'Failed to update certificate.', 500);
await setConfig(domain, { csr, key, cert: cert = cert.toString() });
resetCurCert(); boardcast(SSL_RESET);
log('Done.');
}
return { csr, key, cert, domain };
};
const httpsServerOptions = async (options) => ({
...await getCert(), SNICallback: async (domain, cbf) => cbf(
null, tls.createSecureContext(await getCert(domain))
), ...options || {},
});
const request = (domain) => async (req, res) =>
req.method === GET && acmeChallenge.key
&& acmeChallenge.url && acmeChallenge.url === req.url
? res.end(acmeChallenge.key)
: res.writeHead(301, { Location: `${HTTPS}://${domain}${req.url}` }).end();
const init = async (domain, options) => {
// https://letsencrypt.org/docs/rate-limits/
// https://letsencrypt.org/docs/staging-environment/
assert(
(domain = assertDomain(domain)) && !isLocalhost(domain),
'A public domain is required to get an ACME certs.'
);
if (isPrimary) {
const httpd = http.createServer(request(domain));
httpd.listen(HTTP_PORT, options?.address || '', () => {
log(`Challenge HTTP Server started: ${toString(httpd.address())}`);
});
const checkCert = async () => await ensureCert(domain,
options?.challengeCreate || _challengeCreate,
options?.challengeRemove || _challengeRemove, options
);
if (options.instant) {
const result = await checkCert();
options.noFallback && await new Promise(resolve => httpd.close(resolve));
return result;
}
const timer = await loop(
checkCert, 60 * 60 * 24 * 7, 60 * 10, 0,
basename(import.meta.url), { silent: false }
);
return { httpd, timer };
}
return on(SSL_RESET, resetCurCert);
};
export default init;
export {
_NEED,
SSL_RESET,
createCsr,
ensureCert,
getCert,
httpsServerOptions,
init,
isLocalhost,
};