roster-server 2.4.11 → 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,12 +501,63 @@ 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
  });
514
508
 
515
509
  describe('Roster loadSites', () => {
510
+ it('keeps discovered sites on 443 when the same domain is registered on other ports', async () => {
511
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
512
+ const wwwPath = path.join(tmpDir, 'www');
513
+ const siteDir = path.join(wwwPath, 'persist.example.com');
514
+ fs.mkdirSync(siteDir, { recursive: true });
515
+ fs.writeFileSync(path.join(siteDir, 'index.html'), '<html>static-443</html>', 'utf8');
516
+
517
+ const roster = new Roster({ wwwPath, local: true, port: 8880 });
518
+ roster.register('persist.example.com', () => (req, res) => {
519
+ res.writeHead(200);
520
+ res.end('app-8880');
521
+ });
522
+ roster.register('persist.example.com:8882', () => (req, res) => {
523
+ res.writeHead(200);
524
+ res.end('socket-8882');
525
+ });
526
+
527
+ try {
528
+ await roster.init();
529
+
530
+ const { Writable } = require('stream');
531
+ const dispatch = async (port) => {
532
+ let statusCode;
533
+ let body = '';
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
+ });
542
+ roster.requestHandler(port)(
543
+ { headers: { host: 'persist.example.com' }, method: 'GET', url: '/' },
544
+ res
545
+ );
546
+ await finished;
547
+ return { statusCode, body };
548
+ };
549
+
550
+ assert.deepStrictEqual(await dispatch(443), {
551
+ statusCode: 200,
552
+ body: '<html>static-443</html>'
553
+ });
554
+ assert.deepStrictEqual(await dispatch(8880), { statusCode: 200, body: 'app-8880' });
555
+ assert.deepStrictEqual(await dispatch(8882), { statusCode: 200, body: 'socket-8882' });
556
+ } finally {
557
+ fs.rmSync(tmpDir, { recursive: true, force: true });
558
+ }
559
+ });
560
+
516
561
  it('loads site from www directory and registers domain + www', async () => {
517
562
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
518
563
  const wwwPath = path.join(tmpDir, 'www');
@@ -646,7 +691,7 @@ describe('Roster loadSites', () => {
646
691
  assert.strictEqual(result.statusCode, 200);
647
692
  assert.ok(result.body.includes('static ok'));
648
693
  } finally {
649
- closePortServers(roster);
694
+ await closePortServers(roster);
650
695
  fs.rmSync(tmpDir, { recursive: true, force: true });
651
696
  }
652
697
  });
@@ -666,7 +711,7 @@ describe('Roster loadSites', () => {
666
711
  const result = await httpGet('localhost', port, '/nonexistent.html');
667
712
  assert.strictEqual(result.statusCode, 404);
668
713
  } finally {
669
- closePortServers(roster);
714
+ await closePortServers(roster);
670
715
  fs.rmSync(tmpDir, { recursive: true, force: true });
671
716
  }
672
717
  });
@@ -693,7 +738,7 @@ describe('Roster loadSites', () => {
693
738
  assert.strictEqual(resultNoSlash.statusCode, 200);
694
739
  assert.ok(resultNoSlash.body.includes('en page'));
695
740
  } finally {
696
- closePortServers(roster);
741
+ await closePortServers(roster);
697
742
  fs.rmSync(tmpDir, { recursive: true, force: true });
698
743
  }
699
744
  });
@@ -815,7 +860,7 @@ describe('Roster init() (cluster-friendly API)', () => {
815
860
  assert.strictEqual(result.statusCode, 200);
816
861
  assert.strictEqual(result.body, 'after-init');
817
862
  } finally {
818
- closePortServers(roster);
863
+ await closePortServers(roster);
819
864
  }
820
865
  });
821
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
  },