roster-server 2.4.12 → 2.4.14
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/README.md +198 -398
- package/index.js +434 -126
- package/lib/static-site-handler.js +36 -27
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +50 -338
- package/test/https-integration.test.js +149 -0
- package/test/lifecycle.test.js +469 -0
- package/test/roster-server.test.js +22 -22
- package/test/scanner-blocker.test.js +3 -6
- package/test/signals.test.js +183 -0
- package/test/static-and-tls.test.js +273 -0
- package/vendor/greenlock-express/greenlock-express.js +1 -1
- package/vendor/greenlock-express/servers.js +2 -1
- package/vendor/greenlock-express/single.js +1 -1
- package/vendor/greenlock-express/worker.js +2 -2
|
@@ -16,14 +16,8 @@ const {
|
|
|
16
16
|
buildCertLookupCandidates
|
|
17
17
|
} = require('../index.js');
|
|
18
18
|
|
|
19
|
-
function closePortServers(roster) {
|
|
20
|
-
|
|
21
|
-
for (const server of Object.values(roster.portServers)) {
|
|
22
|
-
try {
|
|
23
|
-
server.close();
|
|
24
|
-
} catch (_) {}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
19
|
+
async function closePortServers(roster) {
|
|
20
|
+
await roster.close();
|
|
27
21
|
}
|
|
28
22
|
|
|
29
23
|
function httpGet(host, port, pathname = '/') {
|
|
@@ -490,7 +484,7 @@ describe('Roster local mode (local: true)', () => {
|
|
|
490
484
|
assert.strictEqual(probe.statusCode, 404);
|
|
491
485
|
assert.strictEqual(probe.body, 'Not Found');
|
|
492
486
|
} finally {
|
|
493
|
-
closePortServers(roster);
|
|
487
|
+
await closePortServers(roster);
|
|
494
488
|
}
|
|
495
489
|
});
|
|
496
490
|
|
|
@@ -507,7 +501,7 @@ describe('Roster local mode (local: true)', () => {
|
|
|
507
501
|
assert.ok(url && url.startsWith('http://localhost:'));
|
|
508
502
|
assert.ok(roster.domainPorts['geturltest.example'] !== undefined);
|
|
509
503
|
} finally {
|
|
510
|
-
closePortServers(roster);
|
|
504
|
+
await closePortServers(roster);
|
|
511
505
|
}
|
|
512
506
|
});
|
|
513
507
|
});
|
|
@@ -533,26 +527,32 @@ describe('Roster loadSites', () => {
|
|
|
533
527
|
try {
|
|
534
528
|
await roster.init();
|
|
535
529
|
|
|
536
|
-
const
|
|
530
|
+
const { Writable } = require('stream');
|
|
531
|
+
const dispatch = async (port) => {
|
|
537
532
|
let statusCode;
|
|
538
533
|
let body = '';
|
|
539
|
-
const res = {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
};
|
|
534
|
+
const res = new Writable({
|
|
535
|
+
write(chunk, encoding, done) { body += chunk.toString(); done(); }
|
|
536
|
+
});
|
|
537
|
+
res.writeHead = status => { statusCode = status; };
|
|
538
|
+
const finished = new Promise((resolve, reject) => {
|
|
539
|
+
res.once('finish', resolve);
|
|
540
|
+
res.once('error', reject);
|
|
541
|
+
});
|
|
543
542
|
roster.requestHandler(port)(
|
|
544
543
|
{ headers: { host: 'persist.example.com' }, method: 'GET', url: '/' },
|
|
545
544
|
res
|
|
546
545
|
);
|
|
546
|
+
await finished;
|
|
547
547
|
return { statusCode, body };
|
|
548
548
|
};
|
|
549
549
|
|
|
550
|
-
assert.deepStrictEqual(dispatch(443), {
|
|
550
|
+
assert.deepStrictEqual(await dispatch(443), {
|
|
551
551
|
statusCode: 200,
|
|
552
552
|
body: '<html>static-443</html>'
|
|
553
553
|
});
|
|
554
|
-
assert.deepStrictEqual(dispatch(8880), { statusCode: 200, body: 'app-8880' });
|
|
555
|
-
assert.deepStrictEqual(dispatch(8882), { statusCode: 200, body: 'socket-8882' });
|
|
554
|
+
assert.deepStrictEqual(await dispatch(8880), { statusCode: 200, body: 'app-8880' });
|
|
555
|
+
assert.deepStrictEqual(await dispatch(8882), { statusCode: 200, body: 'socket-8882' });
|
|
556
556
|
} finally {
|
|
557
557
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
558
558
|
}
|
|
@@ -691,7 +691,7 @@ describe('Roster loadSites', () => {
|
|
|
691
691
|
assert.strictEqual(result.statusCode, 200);
|
|
692
692
|
assert.ok(result.body.includes('static ok'));
|
|
693
693
|
} finally {
|
|
694
|
-
closePortServers(roster);
|
|
694
|
+
await closePortServers(roster);
|
|
695
695
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
696
696
|
}
|
|
697
697
|
});
|
|
@@ -711,7 +711,7 @@ describe('Roster loadSites', () => {
|
|
|
711
711
|
const result = await httpGet('localhost', port, '/nonexistent.html');
|
|
712
712
|
assert.strictEqual(result.statusCode, 404);
|
|
713
713
|
} finally {
|
|
714
|
-
closePortServers(roster);
|
|
714
|
+
await closePortServers(roster);
|
|
715
715
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
716
716
|
}
|
|
717
717
|
});
|
|
@@ -738,7 +738,7 @@ describe('Roster loadSites', () => {
|
|
|
738
738
|
assert.strictEqual(resultNoSlash.statusCode, 200);
|
|
739
739
|
assert.ok(resultNoSlash.body.includes('en page'));
|
|
740
740
|
} finally {
|
|
741
|
-
closePortServers(roster);
|
|
741
|
+
await closePortServers(roster);
|
|
742
742
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
743
743
|
}
|
|
744
744
|
});
|
|
@@ -860,7 +860,7 @@ describe('Roster init() (cluster-friendly API)', () => {
|
|
|
860
860
|
assert.strictEqual(result.statusCode, 200);
|
|
861
861
|
assert.strictEqual(result.body, 'after-init');
|
|
862
862
|
} finally {
|
|
863
|
-
closePortServers(roster);
|
|
863
|
+
await closePortServers(roster);
|
|
864
864
|
}
|
|
865
865
|
});
|
|
866
866
|
});
|
|
@@ -191,11 +191,8 @@ describe('Roster request plugins', () => {
|
|
|
191
191
|
asyncRoster.use(async () => false);
|
|
192
192
|
asyncRoster.register('example.com', () => () => {});
|
|
193
193
|
await asyncRoster.init();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
{ writeHead() {}, end() {} }
|
|
198
|
-
);
|
|
199
|
-
}, /must be synchronous/);
|
|
194
|
+
const result = invoke(asyncRoster.requestHandler(), { headers: { host: 'example.com' } });
|
|
195
|
+
assert.strictEqual(result.statusCode, 500);
|
|
196
|
+
assert.strictEqual(result.body, 'Internal Server Error');
|
|
200
197
|
});
|
|
201
198
|
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { once } = require('node:events');
|
|
7
|
+
|
|
8
|
+
async function runChild(t, body, onMessage = () => {}) {
|
|
9
|
+
const script = `
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const Roster = require(${JSON.stringify(require.resolve('../index.js'))});
|
|
13
|
+
const options = { local: true, wwwPath: '/tmp/roster-signals-' + process.pid };
|
|
14
|
+
const signals = ['SIGINT', 'SIGTERM'];
|
|
15
|
+
(async () => { ${body} })().catch(error => {
|
|
16
|
+
console.error(error);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}).finally(() => process.disconnect());
|
|
19
|
+
`;
|
|
20
|
+
const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
|
|
21
|
+
const messages = [];
|
|
22
|
+
let stderr = '';
|
|
23
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
24
|
+
child.on('message', message => {
|
|
25
|
+
messages.push(message);
|
|
26
|
+
onMessage(message, child);
|
|
27
|
+
});
|
|
28
|
+
t.after(() => {
|
|
29
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
30
|
+
});
|
|
31
|
+
const [code, signal] = await once(child, 'close');
|
|
32
|
+
return { code, signal, messages, stderr };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSuccess(result) {
|
|
36
|
+
assert.equal(result.signal, null, result.stderr);
|
|
37
|
+
assert.equal(result.code, 0, result.stderr);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('does not install signal listeners unless explicitly enabled', { timeout: 10000 }, async t => {
|
|
41
|
+
assertSuccess(await runChild(t, `
|
|
42
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
43
|
+
for (const handleSignals of [undefined, false]) {
|
|
44
|
+
const roster = new Roster({ ...options, handleSignals });
|
|
45
|
+
await roster.init();
|
|
46
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
47
|
+
await roster.close();
|
|
48
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
49
|
+
}
|
|
50
|
+
`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('installs listeners once at initialization and removes only the closing instance listeners', { timeout: 10000 }, async t => {
|
|
54
|
+
assertSuccess(await runChild(t, `
|
|
55
|
+
const existing = () => {};
|
|
56
|
+
signals.forEach(signal => process.on(signal, existing));
|
|
57
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
58
|
+
const first = new Roster({ ...options, handleSignals: true });
|
|
59
|
+
const second = new Roster({ ...options, handleSignals: true });
|
|
60
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
61
|
+
await Promise.all([first.init(), first.init(), second.init()]);
|
|
62
|
+
signals.forEach((signal, i) => assert.equal(process.listenerCount(signal), before[i].length + 2));
|
|
63
|
+
await Promise.all([first.close(), first.close()]);
|
|
64
|
+
signals.forEach((signal, i) => {
|
|
65
|
+
assert.equal(process.listenerCount(signal), before[i].length + 1);
|
|
66
|
+
assert.ok(process.listeners(signal).includes(existing));
|
|
67
|
+
});
|
|
68
|
+
await second.close();
|
|
69
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
70
|
+
`));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('removes signal listeners after initialization and cleanup both fail', { timeout: 10000 }, async t => {
|
|
74
|
+
assertSuccess(await runChild(t, `
|
|
75
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
76
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
77
|
+
roster.register('example.com', virtual => {
|
|
78
|
+
virtual.onClose(() => { throw new Error('cleanup failed'); });
|
|
79
|
+
throw new Error('factory failed');
|
|
80
|
+
});
|
|
81
|
+
await assert.rejects(roster.init(), AggregateError);
|
|
82
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
83
|
+
`));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
87
|
+
it(`drains HTTP and runs hooks once on real ${signal}, including repeated signals`, { timeout: 10000 }, async t => {
|
|
88
|
+
const result = await runChild(t, `
|
|
89
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
90
|
+
roster.assignPortToDomain = () => 0;
|
|
91
|
+
let cleaned = 0;
|
|
92
|
+
let finished = false;
|
|
93
|
+
let response;
|
|
94
|
+
const repeated = new Promise(resolve => process.once(${JSON.stringify(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT')}, resolve));
|
|
95
|
+
roster.register('example.com', virtual => {
|
|
96
|
+
virtual.on('close', () => {
|
|
97
|
+
process.send('closing');
|
|
98
|
+
setImmediate(() => { finished = true; response.end(' drained'); });
|
|
99
|
+
});
|
|
100
|
+
virtual.onClose(async () => {
|
|
101
|
+
assert.equal(finished, true);
|
|
102
|
+
await repeated;
|
|
103
|
+
cleaned++;
|
|
104
|
+
});
|
|
105
|
+
return (req, res) => {
|
|
106
|
+
response = res;
|
|
107
|
+
res.write('active');
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
await Promise.all([roster.start(), roster.start()]);
|
|
111
|
+
const server = roster.portServers[0];
|
|
112
|
+
const body = await new Promise((resolve, reject) => {
|
|
113
|
+
http.get({ hostname: 'localhost', port: server.address().port, agent: false }, res => {
|
|
114
|
+
let body = '';
|
|
115
|
+
res.once('data', () => process.send('ready'));
|
|
116
|
+
res.on('data', chunk => { body += chunk; });
|
|
117
|
+
res.on('end', () => resolve(body));
|
|
118
|
+
res.on('error', reject);
|
|
119
|
+
}).on('error', reject);
|
|
120
|
+
});
|
|
121
|
+
await roster.close();
|
|
122
|
+
assert.equal(body, 'active drained');
|
|
123
|
+
assert.equal(cleaned, 1);
|
|
124
|
+
assert.equal(server.listening, false);
|
|
125
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
126
|
+
process.send('complete');
|
|
127
|
+
`, (message, child) => {
|
|
128
|
+
if (message === 'ready') child.kill(signal);
|
|
129
|
+
if (message === 'closing') child.kill(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT');
|
|
130
|
+
});
|
|
131
|
+
assertSuccess(result);
|
|
132
|
+
assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('reports signal-triggered cleanup failure with exit code 1 and removes listeners', { timeout: 10000 }, async t => {
|
|
137
|
+
const result = await runChild(t, `
|
|
138
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
139
|
+
roster.assignPortToDomain = () => 0;
|
|
140
|
+
let entered;
|
|
141
|
+
const closing = new Promise(resolve => { entered = resolve; });
|
|
142
|
+
roster.register('example.com', virtual => {
|
|
143
|
+
virtual.on('close', entered);
|
|
144
|
+
virtual.onClose(() => { throw new Error('cleanup failed'); });
|
|
145
|
+
return (req, res) => res.end();
|
|
146
|
+
});
|
|
147
|
+
await roster.start();
|
|
148
|
+
process.send('ready');
|
|
149
|
+
await closing;
|
|
150
|
+
await assert.rejects(roster.close(), AggregateError);
|
|
151
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
152
|
+
assert.equal(process.exitCode, 1);
|
|
153
|
+
process.send('complete');
|
|
154
|
+
`, (message, child) => { if (message === 'ready') child.kill('SIGTERM'); });
|
|
155
|
+
assert.equal(result.signal, null, result.stderr);
|
|
156
|
+
assert.equal(result.code, 1, result.stderr);
|
|
157
|
+
assert.deepEqual(result.messages, ['ready', 'complete']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('handles signals during initialization and cleans up its listeners', { timeout: 10000 }, async t => {
|
|
161
|
+
const result = await runChild(t, `
|
|
162
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
163
|
+
let release;
|
|
164
|
+
roster.loadSites = () => new Promise(resolve => { release = resolve; });
|
|
165
|
+
let called = false;
|
|
166
|
+
roster.register('example.com', () => { called = true; });
|
|
167
|
+
const initializing = assert.rejects(roster.init(), /closing or closed/);
|
|
168
|
+
process.once('message', () => release());
|
|
169
|
+
const close = roster.close.bind(roster);
|
|
170
|
+
roster.close = () => { const result = close(); process.send('closing'); return result; };
|
|
171
|
+
process.send('ready');
|
|
172
|
+
await initializing;
|
|
173
|
+
await close();
|
|
174
|
+
assert.equal(called, false);
|
|
175
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
176
|
+
process.send('complete');
|
|
177
|
+
`, (message, child) => {
|
|
178
|
+
if (message === 'ready') child.kill('SIGTERM');
|
|
179
|
+
if (message === 'closing') child.send('release');
|
|
180
|
+
});
|
|
181
|
+
assertSuccess(result);
|
|
182
|
+
assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
|
|
183
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { it } = 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 http = require('node:http');
|
|
9
|
+
const { Writable } = require('node:stream');
|
|
10
|
+
const { execFileSync } = require('node:child_process');
|
|
11
|
+
const { createStaticHandler } = require('../lib/static-site-handler.js');
|
|
12
|
+
const Roster = require('../index.js');
|
|
13
|
+
|
|
14
|
+
function temporaryDirectory(t) {
|
|
15
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-files-'));
|
|
16
|
+
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
17
|
+
return directory;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function response() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
const res = new Writable({ write(chunk, encoding, done) { chunks.push(chunk); done(); } });
|
|
23
|
+
res.writeHead = (status, headers) => { res.status = status; res.headers = headers; res.headersSent = true; };
|
|
24
|
+
return { res, body: () => Buffer.concat(chunks) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('streams a static file without synchronous filesystem calls', async t => {
|
|
28
|
+
const root = temporaryDirectory(t);
|
|
29
|
+
const content = Buffer.alloc(1024 * 1024, 'a');
|
|
30
|
+
fs.writeFileSync(path.join(root, 'index.html'), content);
|
|
31
|
+
const handler = createStaticHandler(root)();
|
|
32
|
+
const result = response();
|
|
33
|
+
for (const method of ['readFileSync', 'statSync', 'existsSync']) {
|
|
34
|
+
t.mock.method(fs, method, () => { throw new Error(`Unexpected ${method}`); });
|
|
35
|
+
}
|
|
36
|
+
await handler({ method: 'GET', url: '/' }, result.res);
|
|
37
|
+
assert.equal(result.res.status, 200);
|
|
38
|
+
assert.equal(result.res.headers['Content-Length'], content.length);
|
|
39
|
+
assert.deepEqual(result.body(), content);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('serves HEAD using metadata without reading file contents', async t => {
|
|
43
|
+
const root = temporaryDirectory(t);
|
|
44
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'head content');
|
|
45
|
+
const open = fs.promises.open.bind(fs.promises);
|
|
46
|
+
t.mock.method(fs.promises, 'open', async (...args) => {
|
|
47
|
+
const file = await open(...args);
|
|
48
|
+
file.createReadStream = () => { throw new Error('HEAD must not stream'); };
|
|
49
|
+
file.readFile = () => { throw new Error('HEAD must not read'); };
|
|
50
|
+
return file;
|
|
51
|
+
});
|
|
52
|
+
const result = response();
|
|
53
|
+
await createStaticHandler(root)()({ method: 'HEAD', url: '/' }, result.res);
|
|
54
|
+
assert.equal(result.res.status, 200);
|
|
55
|
+
assert.equal(result.res.headers['Content-Length'], 12);
|
|
56
|
+
assert.equal(result.body().length, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles directory indexes, missing files, and an open race', async t => {
|
|
60
|
+
const root = temporaryDirectory(t);
|
|
61
|
+
fs.mkdirSync(path.join(root, 'en'));
|
|
62
|
+
fs.writeFileSync(path.join(root, 'en', 'index.html'), 'directory');
|
|
63
|
+
const handler = createStaticHandler(root)();
|
|
64
|
+
const directory = response();
|
|
65
|
+
await handler({ method: 'GET', url: '/en/?a=b' }, directory.res);
|
|
66
|
+
assert.equal(directory.body().toString(), 'directory');
|
|
67
|
+
const missing = response();
|
|
68
|
+
await handler({ method: 'GET', url: '/missing' }, missing.res);
|
|
69
|
+
assert.equal(missing.res.status, 404);
|
|
70
|
+
t.mock.method(fs.promises, 'open', async () => { throw Object.assign(new Error('removed'), { code: 'ENOENT' }); });
|
|
71
|
+
const removed = response();
|
|
72
|
+
await handler({ method: 'GET', url: '/en/' }, removed.res);
|
|
73
|
+
assert.equal(removed.res.status, 404);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('closes static file handles when the client disconnects mid-stream', async t => {
|
|
77
|
+
const root = temporaryDirectory(t);
|
|
78
|
+
fs.writeFileSync(path.join(root, 'large.bin'), Buffer.alloc(8 * 1024 * 1024));
|
|
79
|
+
const handler = createStaticHandler(root)();
|
|
80
|
+
const handles = [];
|
|
81
|
+
const open = fs.promises.open.bind(fs.promises);
|
|
82
|
+
t.mock.method(fs.promises, 'open', async (...args) => {
|
|
83
|
+
const file = await open(...args);
|
|
84
|
+
handles.push(file);
|
|
85
|
+
return file;
|
|
86
|
+
});
|
|
87
|
+
let handled;
|
|
88
|
+
const complete = new Promise(resolve => { handled = resolve; });
|
|
89
|
+
const server = http.createServer((req, res) => {
|
|
90
|
+
handler(req, res).then(handled);
|
|
91
|
+
});
|
|
92
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
93
|
+
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path: '/large.bin' }, res => {
|
|
96
|
+
res.once('data', () => { res.destroy(); resolve(); });
|
|
97
|
+
});
|
|
98
|
+
req.on('error', reject);
|
|
99
|
+
});
|
|
100
|
+
await complete;
|
|
101
|
+
assert.equal(handles.length, 1);
|
|
102
|
+
assert.equal(handles[0].fd, -1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects non-regular files before opening them', async t => {
|
|
106
|
+
const root = temporaryDirectory(t);
|
|
107
|
+
t.mock.method(fs.promises, 'stat', async () => ({ isDirectory: () => false, isFile: () => false }));
|
|
108
|
+
t.mock.method(fs.promises, 'open', () => { throw new Error('Must not open special files'); });
|
|
109
|
+
const result = response();
|
|
110
|
+
await createStaticHandler(root)()({ method: 'GET', url: '/pipe' }, result.res);
|
|
111
|
+
assert.equal(result.res.status, 404);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('maps filesystem failures to HTTP errors without exposing internal details', async t => {
|
|
115
|
+
const root = temporaryDirectory(t);
|
|
116
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'content');
|
|
117
|
+
for (const method of ['stat', 'open']) {
|
|
118
|
+
for (const [code, status, body] of [
|
|
119
|
+
['ENOTDIR', 404, 'Not Found'],
|
|
120
|
+
['EACCES', 403, 'Forbidden'],
|
|
121
|
+
['EIO', 500, 'Internal Server Error']
|
|
122
|
+
]) {
|
|
123
|
+
await t.test(`${method}: ${code}`, async t => {
|
|
124
|
+
t.mock.method(fs.promises, method, async () => {
|
|
125
|
+
throw Object.assign(new Error('internal filesystem detail'), { code });
|
|
126
|
+
});
|
|
127
|
+
const result = response();
|
|
128
|
+
await createStaticHandler(root)()({ method: 'GET', url: '/' }, result.res);
|
|
129
|
+
assert.equal(result.res.status, status);
|
|
130
|
+
assert.equal(result.body().toString(), body);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('uses opened-file metadata and closes the handle when the file changes before open', async t => {
|
|
137
|
+
const root = temporaryDirectory(t);
|
|
138
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'old');
|
|
139
|
+
const open = fs.promises.open.bind(fs.promises);
|
|
140
|
+
let handle;
|
|
141
|
+
t.mock.method(fs.promises, 'open', async (...args) => {
|
|
142
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'replacement content');
|
|
143
|
+
handle = await open(...args);
|
|
144
|
+
return handle;
|
|
145
|
+
});
|
|
146
|
+
const result = response();
|
|
147
|
+
await createStaticHandler(root)()({ method: 'GET', url: '/' }, result.res);
|
|
148
|
+
assert.equal(result.res.headers['Content-Length'], Buffer.byteLength('replacement content'));
|
|
149
|
+
assert.equal(result.body().toString(), 'replacement content');
|
|
150
|
+
assert.equal(handle.fd, -1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('closes opened files if fstat fails or finds a non-regular replacement', async t => {
|
|
154
|
+
const root = temporaryDirectory(t);
|
|
155
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'content');
|
|
156
|
+
const open = fs.promises.open.bind(fs.promises);
|
|
157
|
+
for (const fail of [false, true]) {
|
|
158
|
+
await t.test(fail ? 'fstat failure' : 'non-regular replacement', async t => {
|
|
159
|
+
let handle;
|
|
160
|
+
t.mock.method(fs.promises, 'open', async (...args) => {
|
|
161
|
+
handle = await open(...args);
|
|
162
|
+
t.mock.method(handle, 'stat', async () => {
|
|
163
|
+
if (fail) throw Object.assign(new Error('stat failed'), { code: 'EIO' });
|
|
164
|
+
return { isFile: () => false };
|
|
165
|
+
});
|
|
166
|
+
return handle;
|
|
167
|
+
});
|
|
168
|
+
const result = response();
|
|
169
|
+
await createStaticHandler(root)()({ method: 'GET', url: '/' }, result.res);
|
|
170
|
+
assert.equal(result.res.status, fail ? 500 : 404);
|
|
171
|
+
assert.equal(handle.fd, -1);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('closes a file opened after its response was already destroyed', async t => {
|
|
177
|
+
const root = temporaryDirectory(t);
|
|
178
|
+
fs.writeFileSync(path.join(root, 'index.html'), 'content');
|
|
179
|
+
const result = response();
|
|
180
|
+
const open = fs.promises.open.bind(fs.promises);
|
|
181
|
+
let handle;
|
|
182
|
+
t.mock.method(fs.promises, 'open', async (...args) => {
|
|
183
|
+
result.res.destroy();
|
|
184
|
+
handle = await open(...args);
|
|
185
|
+
return handle;
|
|
186
|
+
});
|
|
187
|
+
await createStaticHandler(root)()({ method: 'GET', url: '/' }, result.res);
|
|
188
|
+
assert.equal(result.res.headersSent, undefined);
|
|
189
|
+
assert.equal(handle.fd, -1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('reuses TLS contexts, coalesces concurrent loads, and reloads changed certificates', async t => {
|
|
193
|
+
const root = temporaryDirectory(t);
|
|
194
|
+
const directory = path.join(root, 'live', 'example.com');
|
|
195
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
196
|
+
const key = path.join(directory, 'privkey.pem');
|
|
197
|
+
const cert = path.join(directory, 'cert.pem');
|
|
198
|
+
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', key,
|
|
199
|
+
'-out', cert, '-subj', '/CN=example.com', '-days', '1'], { stdio: 'ignore' });
|
|
200
|
+
fs.writeFileSync(path.join(directory, 'chain.pem'), '');
|
|
201
|
+
const roster = new Roster({ local: true, wwwPath: root, greenlockStorePath: root });
|
|
202
|
+
t.after(() => roster.close());
|
|
203
|
+
const read = fs.promises.readFile.bind(fs.promises);
|
|
204
|
+
let reads = 0;
|
|
205
|
+
t.mock.method(fs.promises, 'readFile', (...args) => { reads++; return read(...args); });
|
|
206
|
+
const contexts = await Promise.all(Array.from({ length: 8 }, () => roster._resolveSecureContext('example.com')));
|
|
207
|
+
assert.ok(contexts[0]);
|
|
208
|
+
assert.ok(contexts.every(context => context === contexts[0]));
|
|
209
|
+
assert.equal(reads, 3);
|
|
210
|
+
assert.equal(await roster._resolveSecureContext('example.com'), contexts[0]);
|
|
211
|
+
assert.equal(reads, 3);
|
|
212
|
+
execFileSync('openssl', ['req', '-x509', '-key', key, '-out', cert,
|
|
213
|
+
'-subj', '/CN=example.com', '-days', '1', '-set_serial', '2'], { stdio: 'ignore' });
|
|
214
|
+
assert.notEqual(await roster._resolveSecureContext('example.com'), contexts[0]);
|
|
215
|
+
assert.equal(reads, 6);
|
|
216
|
+
fs.unlinkSync(cert);
|
|
217
|
+
assert.equal(await roster._resolveSecureContext('example.com'), null);
|
|
218
|
+
assert.equal(roster._secureContexts.size, 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('coalesces certificate checks and prevents new issuance after close', async t => {
|
|
222
|
+
const root = temporaryDirectory(t);
|
|
223
|
+
const roster = new Roster({ local: true, wwwPath: root });
|
|
224
|
+
let calls = 0;
|
|
225
|
+
let release;
|
|
226
|
+
roster._greenlockRuntime = { get: () => { calls++; return new Promise(resolve => { release = resolve; }); } };
|
|
227
|
+
const first = roster._checkCertificate('example.com');
|
|
228
|
+
assert.equal(first, roster._checkCertificate('example.com'));
|
|
229
|
+
await Promise.resolve();
|
|
230
|
+
assert.equal(calls, 1);
|
|
231
|
+
release();
|
|
232
|
+
await first;
|
|
233
|
+
assert.equal(roster._certificateChecks.size, 0);
|
|
234
|
+
await roster.close();
|
|
235
|
+
assert.throws(() => roster._checkCertificate('example.com'), /closing or closed/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('waits for in-flight certificate work before running close hooks', async t => {
|
|
239
|
+
const root = temporaryDirectory(t);
|
|
240
|
+
const roster = new Roster({ local: true, wwwPath: root });
|
|
241
|
+
t.after(() => roster.close());
|
|
242
|
+
let cleaned = false;
|
|
243
|
+
roster.register('example.com', virtual => {
|
|
244
|
+
virtual.onClose(() => { cleaned = true; });
|
|
245
|
+
return () => {};
|
|
246
|
+
});
|
|
247
|
+
await roster.init();
|
|
248
|
+
let release;
|
|
249
|
+
roster._greenlockRuntime = { get: () => new Promise(resolve => { release = resolve; }) };
|
|
250
|
+
const check = roster._checkCertificate('example.com');
|
|
251
|
+
await Promise.resolve();
|
|
252
|
+
const closing = roster.close();
|
|
253
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
254
|
+
assert.equal(cleaned, false);
|
|
255
|
+
release();
|
|
256
|
+
await Promise.all([check, closing]);
|
|
257
|
+
assert.equal(cleaned, true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not repopulate TLS caches when close interrupts a context load', async t => {
|
|
261
|
+
const root = temporaryDirectory(t);
|
|
262
|
+
const roster = new Roster({ local: true, wwwPath: root });
|
|
263
|
+
t.after(() => roster.close());
|
|
264
|
+
let release;
|
|
265
|
+
const pendingStat = new Promise(resolve => { release = resolve; });
|
|
266
|
+
t.mock.method(fs.promises, 'stat', () => pendingStat);
|
|
267
|
+
const loading = assert.rejects(roster._resolveSecureContext('example.com'), /closing or closed/);
|
|
268
|
+
const closing = roster.close();
|
|
269
|
+
release({});
|
|
270
|
+
await Promise.all([loading, closing]);
|
|
271
|
+
assert.equal(roster._contextLoads.size, 0);
|
|
272
|
+
assert.equal(roster._secureContexts.size, 0);
|
|
273
|
+
});
|
|
@@ -20,7 +20,7 @@ GLE.init = function(fn) {
|
|
|
20
20
|
// See https://git.coolaj86.com/coolaj86/greenlock-express.js/issues/80
|
|
21
21
|
if (fn && false !== fn.cluster && cluster.isWorker) {
|
|
22
22
|
// ignore the init function and launch the worker
|
|
23
|
-
return require("./worker.js").create();
|
|
23
|
+
return require("./worker.js").create(fn);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
var opts;
|
|
@@ -9,12 +9,13 @@ var sni = require("./sni.js");
|
|
|
9
9
|
var cluster = require("cluster");
|
|
10
10
|
var log = require("lemonlog")("greenlock-servers");
|
|
11
11
|
|
|
12
|
-
Servers.create = function(greenlock) {
|
|
12
|
+
Servers.create = function(greenlock, onError) {
|
|
13
13
|
var servers = {};
|
|
14
14
|
var _httpServer;
|
|
15
15
|
var _httpsServer;
|
|
16
16
|
|
|
17
17
|
function startError(e) {
|
|
18
|
+
if (onError) return onError(e);
|
|
18
19
|
explainError(e);
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
@@ -8,7 +8,7 @@ var Servers = require("./servers.js");
|
|
|
8
8
|
Single.create = function(opts) {
|
|
9
9
|
var greenlock = require("./greenlock-shim.js").create(opts);
|
|
10
10
|
|
|
11
|
-
var servers = Servers.create(greenlock);
|
|
11
|
+
var servers = Servers.create(greenlock, opts.onServerError);
|
|
12
12
|
|
|
13
13
|
var single = {
|
|
14
14
|
ready: function(fn) {
|
|
@@ -5,7 +5,7 @@ var Worker = module.exports;
|
|
|
5
5
|
var messageTimeout = 30 * 1000;
|
|
6
6
|
var msgPrefix = "greenlock:";
|
|
7
7
|
|
|
8
|
-
Worker.create = function() {
|
|
8
|
+
Worker.create = function(opts) {
|
|
9
9
|
var greenlock = {};
|
|
10
10
|
["getAcmeHttp01ChallengeResponse", "get", "notify", "_notify"].forEach(function(k) {
|
|
11
11
|
greenlock[k] = function(args) {
|
|
@@ -15,7 +15,7 @@ Worker.create = function() {
|
|
|
15
15
|
|
|
16
16
|
var worker = {
|
|
17
17
|
ready: function(fn) {
|
|
18
|
-
var servers = require("./servers.js").create(greenlock);
|
|
18
|
+
var servers = require("./servers.js").create(greenlock, opts && opts.onServerError);
|
|
19
19
|
fn(servers);
|
|
20
20
|
return worker;
|
|
21
21
|
},
|