roster-server 2.4.12 → 2.4.13

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.
@@ -16,14 +16,8 @@ const {
16
16
  buildCertLookupCandidates
17
17
  } = require('../index.js');
18
18
 
19
- function closePortServers(roster) {
20
- if (roster.portServers && typeof roster.portServers === 'object') {
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 dispatch = (port) => {
530
+ const { Writable } = require('stream');
531
+ const dispatch = async (port) => {
537
532
  let statusCode;
538
533
  let body = '';
539
- const res = {
540
- writeHead: (status) => { statusCode = status; },
541
- end: (value) => { body = value ? value.toString() : ''; }
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
- assert.throws(() => {
195
- asyncRoster.requestHandler()(
196
- { headers: { host: 'example.com' }, url: '/', socket: {} },
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,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
  },