roster-server 2.4.14 → 3.0.1

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.
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+
3
+ const { it, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const net = require('node:net');
9
+ const tls = require('node:tls');
10
+ const http = require('node:http');
11
+ const https = require('node:https');
12
+ const { once } = require('node:events');
13
+ const { execFileSync, spawn } = require('node:child_process');
14
+ const Roster = require('../index.js');
15
+ const { parseClientHello } = require('../lib/client-hello.js');
16
+ const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
17
+ const shim = require('../vendor/greenlock-express/greenlock-shim.js');
18
+ const greenlock = require('../vendor/greenlock-express/greenlock-express.js');
19
+
20
+ const REPORT_SITE = `module.exports = () => (req, res) => res.end(JSON.stringify({
21
+ pid: process.pid, address: req.socket.remoteAddress, header: req.headers['x-test'] || null, host: req.headers.host
22
+ }));`;
23
+
24
+ let pki;
25
+ before(() => {
26
+ pki = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-isolation-pki-'));
27
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
28
+ '-keyout', path.join(pki, 'key.pem'), '-out', path.join(pki, 'cert.pem'),
29
+ '-subj', '/CN=roster.test', '-days', '1'], { stdio: 'ignore' });
30
+ });
31
+ after(() => fs.rmSync(pki, { recursive: true, force: true }));
32
+
33
+ function installCertificate(store, subject) {
34
+ const directory = path.join(store, 'live', subject);
35
+ fs.mkdirSync(directory, { recursive: true });
36
+ fs.copyFileSync(path.join(pki, 'key.pem'), path.join(directory, 'privkey.pem'));
37
+ fs.copyFileSync(path.join(pki, 'cert.pem'), path.join(directory, 'cert.pem'));
38
+ fs.writeFileSync(path.join(directory, 'chain.pem'), '');
39
+ }
40
+
41
+ function createSite(wwwPath, domain, source) {
42
+ fs.mkdirSync(path.join(wwwPath, domain), { recursive: true });
43
+ fs.writeFileSync(path.join(wwwPath, domain, 'index.cjs'), source);
44
+ }
45
+
46
+ function createWww(t, sites) {
47
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-isolation-'));
48
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
49
+ const wwwPath = path.join(root, 'www');
50
+ fs.mkdirSync(wwwPath);
51
+ for (const [domain, source] of Object.entries(sites)) createSite(wwwPath, domain, source);
52
+ return { root, wwwPath };
53
+ }
54
+
55
+ async function startProduction(t, sites, { certificates = [], issue = async () => {}, configure = () => {} } = {}) {
56
+ const { root, wwwPath } = createWww(t, sites);
57
+ const store = path.join(root, 'greenlock.d');
58
+ for (const subject of certificates) installCertificate(store, subject);
59
+ const roster = new Roster({ wwwPath, greenlockStorePath: store, isolation: { restartDelayMs: 50 }, closeTimeoutMs: 5000 });
60
+ t.mock.method(shim, 'create', () => ({ get: issue }));
61
+ t.mock.method(greenlock, 'init', () => ({
62
+ ready(callback) {
63
+ setImmediate(() => callback({
64
+ httpServer: () => http.createServer(),
65
+ httpsServer: (options, handler) => https.createServer({ ...options, SNICallback: roster.sniCallback() }, handler)
66
+ }));
67
+ return this;
68
+ }
69
+ }));
70
+ const listen = roster._listen.bind(roster);
71
+ roster._listen = server => listen(server, 0, '127.0.0.1');
72
+ configure(roster);
73
+ t.after(() => roster.close().catch(() => {}));
74
+ await roster.start();
75
+ return { roster, port: roster.portServers[443].address().port, store };
76
+ }
77
+
78
+ function get(port, servername, { pathname = '/', headers = {} } = {}) {
79
+ return new Promise((resolve, reject) => {
80
+ https.get({
81
+ hostname: '127.0.0.1', port, servername, path: pathname, agent: false,
82
+ rejectUnauthorized: false, headers: { host: servername, ...headers }
83
+ }, res => {
84
+ let body = '';
85
+ res.on('data', chunk => { body += chunk; });
86
+ res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
87
+ res.on('error', reject);
88
+ }).on('error', reject);
89
+ });
90
+ }
91
+
92
+ function localGet(port, pathname = '/') {
93
+ return new Promise((resolve, reject) => {
94
+ http.get({ hostname: 'localhost', port, path: pathname, agent: false }, res => {
95
+ let body = '';
96
+ res.on('data', chunk => { body += chunk; });
97
+ res.on('end', () => resolve({ status: res.statusCode, body }));
98
+ }).on('error', reject);
99
+ });
100
+ }
101
+
102
+ function isRunning(pid) {
103
+ try {
104
+ process.kill(pid, 0);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ it('routes each SNI host to its own process with the client address and headers intact', { timeout: 20000 }, async t => {
112
+ const { port } = await startProduction(t, {
113
+ 'a.example.com': REPORT_SITE,
114
+ 'b.example.com': REPORT_SITE,
115
+ '*.wild.example.com': REPORT_SITE
116
+ }, {
117
+ certificates: ['a.example.com', 'b.example.com', 'www.a.example.com', 'shop.wild.example.com', 'main.example.com'],
118
+ configure: roster => roster.register('main.example.com', () => (req, res) => res.end(JSON.stringify({ pid: process.pid })))
119
+ });
120
+ const a = JSON.parse((await get(port, 'a.example.com', { headers: { 'x-test': 'kept' } })).body);
121
+ const b = JSON.parse((await get(port, 'b.example.com')).body);
122
+ const wildcard = JSON.parse((await get(port, 'shop.wild.example.com')).body);
123
+ const main = JSON.parse((await get(port, 'main.example.com')).body);
124
+ assert.equal(new Set([a.pid, b.pid, wildcard.pid, process.pid]).size, 4);
125
+ assert.equal(main.pid, process.pid);
126
+ assert.match(a.address, /127\.0\.0\.1$/);
127
+ assert.equal(a.header, 'kept');
128
+ assert.equal(wildcard.host, 'shop.wild.example.com');
129
+ const redirect = await get(port, 'www.a.example.com', { pathname: '/path' });
130
+ assert.equal(redirect.status, 301);
131
+ assert.equal(redirect.headers.location, 'https://a.example.com/path');
132
+ });
133
+
134
+ it('keeps serving in the same worker when a connection arrives without a socket', { timeout: 20000 }, async t => {
135
+ const { roster, port } = await startProduction(t, {
136
+ 'disconnected.example.com': `${REPORT_SITE}
137
+ process.on('message', message => {
138
+ if (message.type === 'connection' && message.head === '') process.send({ type: 'observed' });
139
+ });`
140
+ }, { certificates: ['disconnected.example.com'] });
141
+ const first = JSON.parse((await get(port, 'disconnected.example.com')).body);
142
+ const child = roster._isolatedSites[0].child;
143
+ const observed = Promise.race([
144
+ once(child, 'message').then(([message]) => message.type),
145
+ once(child, 'exit').then(() => 'exited')
146
+ ]);
147
+ await new Promise((resolve, reject) => {
148
+ child.send({ type: 'connection', port: 443, head: '' }, error => error ? reject(error) : resolve());
149
+ });
150
+ assert.equal(await observed, 'observed', 'worker exited on a connection without a socket');
151
+ const response = await get(port, 'disconnected.example.com');
152
+ assert.equal(response.status, 200);
153
+ assert.equal(JSON.parse(response.body).pid, first.pid);
154
+ assert.equal(child.exitCode, null);
155
+ });
156
+
157
+ it('keeps serving other sites while one blocks its event loop', { timeout: 20000 }, async t => {
158
+ const { port } = await startProduction(t, {
159
+ 'busy.example.com': `module.exports = () => (req, res) => {
160
+ const end = Date.now() + 1500;
161
+ while (Date.now() < end) {}
162
+ res.end('done');
163
+ };`,
164
+ 'calm.example.com': REPORT_SITE
165
+ }, { certificates: ['busy.example.com', 'calm.example.com'] });
166
+ const blocked = get(port, 'busy.example.com');
167
+ await new Promise(resolve => setTimeout(resolve, 200));
168
+ const startedAt = Date.now();
169
+ assert.equal((await get(port, 'calm.example.com')).status, 200);
170
+ assert.ok(Date.now() - startedAt < 1000, 'calm site waited for the busy site');
171
+ assert.equal((await blocked).body, 'done');
172
+ });
173
+
174
+ it('restarts a crashed site while the others keep serving', { timeout: 20000 }, async t => {
175
+ const { port } = await startProduction(t, {
176
+ 'fragile.example.com': `module.exports = () => (req, res) => {
177
+ if (req.url === '/crash') setImmediate(() => { throw new Error('site crashed'); });
178
+ else res.end(String(process.pid));
179
+ };`,
180
+ 'steady.example.com': REPORT_SITE
181
+ }, { certificates: ['fragile.example.com', 'steady.example.com'] });
182
+ const firstPid = Number((await get(port, 'fragile.example.com')).body);
183
+ await assert.rejects(get(port, 'fragile.example.com', { pathname: '/crash' }));
184
+ assert.equal((await get(port, 'steady.example.com')).status, 200);
185
+ let restartedPid;
186
+ for (let attempt = 0; attempt < 50 && !restartedPid; attempt++) {
187
+ await new Promise(resolve => setTimeout(resolve, 100));
188
+ restartedPid = await get(port, 'fragile.example.com').then(res => Number(res.body), () => null);
189
+ }
190
+ assert.ok(restartedPid, 'crashed site was not restarted');
191
+ assert.notEqual(restartedPid, firstPid);
192
+ });
193
+
194
+ it('delivers WebSocket upgrades to the isolated site', { timeout: 20000 }, async t => {
195
+ const { port } = await startProduction(t, {
196
+ 'ws.example.com': `module.exports = server => {
197
+ server.on('upgrade', (req, socket) => {
198
+ socket.write('HTTP/1.1 101 Switching Protocols\\r\\nConnection: Upgrade\\r\\nUpgrade: test\\r\\n\\r\\n');
199
+ socket.on('data', data => socket.write(process.pid + ':' + data));
200
+ });
201
+ return (req, res) => res.end('http');
202
+ };`
203
+ }, { certificates: ['ws.example.com'] });
204
+ const socket = tls.connect({ host: '127.0.0.1', port, servername: 'ws.example.com', rejectUnauthorized: false });
205
+ t.after(() => socket.destroy());
206
+ await once(socket, 'secureConnect');
207
+ socket.write('GET / HTTP/1.1\r\nHost: ws.example.com\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n');
208
+ const [handshake] = await once(socket, 'data');
209
+ assert.match(String(handshake), /^HTTP\/1\.1 101/);
210
+ socket.write('ping');
211
+ const [echo] = await once(socket, 'data');
212
+ const [pid, payload] = String(echo).split(':');
213
+ assert.notEqual(Number(pid), process.pid);
214
+ assert.equal(payload, 'ping');
215
+ });
216
+
217
+ it('closes isolated sites after their hooks run, reports hook errors, and stops their processes', { timeout: 20000 }, async t => {
218
+ const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-isolation-marker-')), 'closed');
219
+ t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
220
+ const { roster, port } = await startProduction(t, {
221
+ 'clean.example.com': `module.exports = server => {
222
+ server.onClose(async () => require('fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)));
223
+ return (req, res) => res.end(String(process.pid));
224
+ };`,
225
+ 'failing.example.com': `module.exports = server => {
226
+ server.onClose(() => { throw new Error('cleanup failed'); });
227
+ return (req, res) => res.end(String(process.pid));
228
+ };`
229
+ }, { certificates: ['clean.example.com', 'failing.example.com'] });
230
+ const pids = [
231
+ Number((await get(port, 'clean.example.com')).body),
232
+ Number((await get(port, 'failing.example.com')).body)
233
+ ];
234
+ await assert.rejects(roster.close(), error => {
235
+ assert.ok(error instanceof AggregateError);
236
+ assert.deepEqual(error.errors.map(item => item.message), ['cleanup failed']);
237
+ return true;
238
+ });
239
+ assert.equal(fs.readFileSync(marker, 'utf8'), String(pids[0]));
240
+ assert.deepEqual(pids.map(isRunning), [false, false]);
241
+ });
242
+
243
+ it('rejects startup when an isolated site factory throws', { timeout: 20000 }, async t => {
244
+ await assert.rejects(startProduction(t, {
245
+ 'broken.example.com': `module.exports = () => { throw new Error('factory failed'); };`,
246
+ 'fine.example.com': REPORT_SITE
247
+ }), /factory failed/);
248
+ });
249
+
250
+ it('issues missing certificates for isolated sites through the main process', { timeout: 20000 }, async t => {
251
+ let store;
252
+ const issued = [];
253
+ const { port } = await startProduction(t, { 'fresh.example.com': REPORT_SITE }, {
254
+ issue: async ({ servername }) => {
255
+ issued.push(servername);
256
+ installCertificate(store, servername);
257
+ },
258
+ configure: roster => { store = roster.greenlockStorePath; }
259
+ });
260
+ assert.equal((await get(port, 'fresh.example.com')).status, 200);
261
+ assert.deepEqual(issued, ['fresh.example.com']);
262
+ });
263
+
264
+ it('isolates directory sites in local mode, including aliases and request plugins', { timeout: 20000 }, async t => {
265
+ const { wwwPath } = createWww(t, { 'site.example': REPORT_SITE, 'other.example': REPORT_SITE });
266
+ const roster = new Roster({ local: true, wwwPath, minLocalPort: 19300, maxLocalPort: 19349 });
267
+ t.after(() => roster.close().catch(() => {}));
268
+ roster.use(createScannerBlocker());
269
+ roster.register('alias.example', path.join(wwwPath, 'site.example'));
270
+ await roster.start();
271
+ const port = domain => new URL(roster.getUrl(domain)).port;
272
+ const site = JSON.parse((await localGet(port('site.example'))).body);
273
+ const alias = JSON.parse((await localGet(port('alias.example'))).body);
274
+ const other = JSON.parse((await localGet(port('other.example'))).body);
275
+ assert.notEqual(site.pid, process.pid);
276
+ assert.equal(alias.pid, site.pid);
277
+ assert.notEqual(other.pid, site.pid);
278
+ assert.deepEqual(await localGet(port('other.example'), '/wp-login.php'), { status: 404, body: 'Not Found' });
279
+ });
280
+
281
+ it('serves a site module already loaded by the main process from that process', { timeout: 20000 }, async t => {
282
+ const { wwwPath } = createWww(t, { 'loaded.example': REPORT_SITE });
283
+ require(path.join(wwwPath, 'loaded.example', 'index.cjs'));
284
+ const roster = new Roster({ local: true, wwwPath, minLocalPort: 19350, maxLocalPort: 19359 });
285
+ t.after(() => roster.close().catch(() => {}));
286
+ await roster.start();
287
+ const { port } = new URL(roster.getUrl('loaded.example'));
288
+ assert.equal(JSON.parse((await localGet(port)).body).pid, process.pid);
289
+ });
290
+
291
+ it('keeps every site in the main process when isolation is disabled', { timeout: 20000 }, async t => {
292
+ const { wwwPath } = createWww(t, { 'shared.example': REPORT_SITE });
293
+ const roster = new Roster({ local: true, wwwPath, isolation: false, minLocalPort: 19360, maxLocalPort: 19369 });
294
+ t.after(() => roster.close().catch(() => {}));
295
+ await roster.start();
296
+ const { port } = new URL(roster.getUrl('shared.example'));
297
+ assert.equal(JSON.parse((await localGet(port)).body).pid, process.pid);
298
+ });
299
+
300
+ it('rejects request plugins that cannot be recreated in isolated processes', { timeout: 20000 }, async t => {
301
+ const { wwwPath } = createWww(t, { 'plugin.example': REPORT_SITE });
302
+ const roster = new Roster({ local: true, wwwPath, minLocalPort: 19370, maxLocalPort: 19379 });
303
+ t.after(() => roster.close().catch(() => {}));
304
+ roster.use(function auditPlugin() { return false; });
305
+ await assert.rejects(roster.start(), /auditPlugin cannot be recreated/);
306
+ });
307
+
308
+ function spawnMain(t, wwwPath, minLocalPort) {
309
+ const script = `
310
+ const http = require('node:http');
311
+ const Roster = require(${JSON.stringify(require.resolve('../index.js'))});
312
+ const roster = new Roster({ local: true, handleSignals: true, wwwPath: ${JSON.stringify(wwwPath)},
313
+ minLocalPort: ${minLocalPort}, maxLocalPort: ${minLocalPort + 9} });
314
+ roster.start().then(() => {
315
+ http.get(roster.getUrl('hook.example'), res => {
316
+ let body = '';
317
+ res.on('data', chunk => { body += chunk; });
318
+ res.on('end', () => process.send(Number(body)));
319
+ });
320
+ });
321
+ `;
322
+ const main = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
323
+ let stderr = '';
324
+ main.stderr.on('data', chunk => { stderr += chunk; });
325
+ t.after(() => { if (main.exitCode === null && main.signalCode === null) main.kill('SIGKILL'); });
326
+ return { main, stderr: () => stderr };
327
+ }
328
+
329
+ function hookSite(marker) {
330
+ return `module.exports = server => {
331
+ server.onClose(() => require('fs').writeFileSync(${JSON.stringify(marker)}, 'closed'));
332
+ return (req, res) => res.end(String(process.pid));
333
+ };`;
334
+ }
335
+
336
+ async function waitForExit(pid) {
337
+ for (let attempt = 0; attempt < 100 && isRunning(pid); attempt++) {
338
+ await new Promise(resolve => setTimeout(resolve, 50));
339
+ }
340
+ return !isRunning(pid);
341
+ }
342
+
343
+ it('drains isolated sites on a signal sent to the whole process group', { timeout: 20000 }, async t => {
344
+ const { root, wwwPath } = createWww(t, {});
345
+ const marker = path.join(root, 'closed');
346
+ createSite(wwwPath, 'hook.example', hookSite(marker));
347
+ const { main, stderr } = spawnMain(t, wwwPath, 19380);
348
+ const [sitePid] = await once(main, 'message');
349
+ process.kill(sitePid, 'SIGTERM');
350
+ main.kill('SIGTERM');
351
+ const [code, signal] = await once(main, 'close');
352
+ assert.equal(signal, null, stderr());
353
+ assert.equal(code, 0, stderr());
354
+ assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
355
+ assert.equal(await waitForExit(sitePid), true);
356
+ });
357
+
358
+ it('closes isolated sites when the main process dies without closing', { timeout: 20000 }, async t => {
359
+ const { root, wwwPath } = createWww(t, {});
360
+ const marker = path.join(root, 'closed');
361
+ createSite(wwwPath, 'hook.example', hookSite(marker));
362
+ const { main } = spawnMain(t, wwwPath, 19390);
363
+ const [sitePid] = await once(main, 'message');
364
+ main.kill('SIGKILL');
365
+ assert.equal(await waitForExit(sitePid), true);
366
+ assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
367
+ });
368
+
369
+ it('rejects combining isolation with the legacy cluster launcher', () => {
370
+ assert.throws(() => new Roster({ cluster: true, isolation: true }), /cannot be combined/);
371
+ assert.equal(new Roster({ cluster: true }).isolation, null);
372
+ });
373
+
374
+ it('extracts the SNI host from a ClientHello delivered in fragments', async t => {
375
+ const server = net.createServer();
376
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
377
+ t.after(() => server.close());
378
+ const capture = async servername => {
379
+ const accepted = once(server, 'connection');
380
+ const client = tls.connect({ host: '127.0.0.1', port: server.address().port, servername, rejectUnauthorized: false });
381
+ client.on('error', () => {});
382
+ const [socket] = await accepted;
383
+ const [hello] = await once(socket, 'data');
384
+ client.destroy();
385
+ socket.destroy();
386
+ return hello;
387
+ };
388
+ const hello = await capture('Shop.Example.com');
389
+ for (let length = 0; length < hello.length; length++) {
390
+ assert.equal(parseClientHello(hello.subarray(0, length)), undefined);
391
+ }
392
+ assert.equal(parseClientHello(hello), 'shop.example.com');
393
+ assert.equal(parseClientHello(await capture('')), null);
394
+ assert.equal(parseClientHello(Buffer.from('GET / HTTP/1.1\r\n\r\n')), null);
395
+ });