roster-server 3.3.0 → 3.4.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ await roster.start();
28
28
  console.log(roster.getUrl('example.com'));
29
29
  ```
30
30
 
31
- Local mode binds to `localhost`, skips certificates, and assigns domain ports within `minLocalPort`–`maxLocalPort` (default `4000`–`9999`). Assignments use a domain hash and resolve collisions within the instance; they do not probe for available OS ports. A bind failure rejects startup. Read the assigned URL with `getUrl()` instead of hardcoding a sample port.
31
+ Local mode binds to `hostname` (default `::`, IPv4 and IPv6 on all interfaces), skips certificates, and assigns domain ports within `minLocalPort`–`maxLocalPort` (default `4000`–`9999`). Assignments use a domain hash and resolve collisions within the instance; they do not probe for available OS ports. A bind failure rejects startup. Read the assigned URL with `getUrl()` instead of hardcoding a sample port.
32
32
 
33
33
  For production HTTPS, use a real contact email, public DNS pointing to the host, and explicit storage paths:
34
34
 
@@ -191,6 +191,10 @@ A site directory whose entry exports an object with a `connection(socket, head)`
191
191
  ```javascript
192
192
  // www/game.example.com/index.js
193
193
  export default {
194
+ async start({ certificate }) {
195
+ // Runs once before the first connection, e.g. to fork workers.
196
+ // certificate(servername) resolves { key, cert } PEM strings; cert includes the chain.
197
+ },
194
198
  connection(socket, head) {
195
199
  // Terminate TLS here, or pass socket and head to another process.
196
200
  },
@@ -202,7 +206,7 @@ export default {
202
206
 
203
207
  In production, `socket` is the client's TCP connection and `head` holds the TLS ClientHello that Roster already read; put it back with `socket.unshift(head)` before terminating TLS, and do not `resume()` the socket after wrapping it. In local mode, the site's assigned port accepts plain TCP connections, `socket` arrives paused, and `head` is empty. `connection()` runs synchronously; if it throws, Roster destroys the socket.
204
208
 
205
- Roster keeps issuing and renewing the site's certificates under `greenlockStorePath/live/<domain>`, but the site loads them and must pick up renewed files itself. Request plugins, `www.` redirects, and `onClose` hooks do not apply: the site owns every connection it receives. A socket site must run isolated; Roster rejects it when it would be served by the main process.
209
+ `certificate(servername)` returns the certificate Roster would serve for that host, with the same wildcard and zone matching. A missing certificate is issued by the main process first, as for any isolated site. Roster also renews it; call `certificate()` again to pick up a renewal, for example on an interval or when a cached copy gets old. It is not available in local mode. Request plugins, `www.` redirects, and `onClose` hooks do not apply: the site owns every connection it receives. A socket site must run isolated; Roster rejects it when it would be served by the main process.
206
210
 
207
211
  ## Startup and shutdown
208
212
 
@@ -309,7 +313,7 @@ Pass operational settings through the constructor, using the consuming applicati
309
313
  | `filename` | `index` | Script entry basename, without extension. |
310
314
  | `local` | `false` | Local HTTP mode with assigned ports and no certificates. |
311
315
  | `port` | `443` | Default port for manual registrations and routing helpers. |
312
- | `hostname` | `::` | Production bind address; local mode uses `localhost`. |
316
+ | `hostname` | `::` | Bind address in production and local mode. |
313
317
  | `minLocalPort`, `maxLocalPort` | `4000`, `9999` | Inclusive local assignment range. |
314
318
  | `staging` | `false` | ACME staging environment. |
315
319
  | `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
package/index.js CHANGED
@@ -950,6 +950,7 @@ class Roster {
950
950
  const plugins = this.plugins.map(isolatedPluginSource);
951
951
  const options = {
952
952
  local: this.local,
953
+ hostname: this.hostname,
953
954
  port: this.defaultPort,
954
955
  filename: this.filename,
955
956
  greenlockStorePath: this.greenlockStorePath,
@@ -1630,7 +1631,7 @@ class Roster {
1630
1631
  virtualServer.processUpgrade(req, socket, head);
1631
1632
  });
1632
1633
 
1633
- await this._listen(httpServer, port, 'localhost');
1634
+ await this._listen(httpServer, port, this.hostname);
1634
1635
  const cleanDomain = normalizeDomainForLocalHost(domain);
1635
1636
  log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1636
1637
  }
@@ -42,8 +42,8 @@ async function resolveSiteApp(domainPath, options = {}) {
42
42
  }
43
43
 
44
44
  /**
45
- * A socket site exports `{ connection(socket, head), close?() }` and serves raw client
46
- * connections itself, including TLS. Only an isolated site process can hand it sockets.
45
+ * A socket site exports `{ start?(context), connection(socket, head), close?() }` and serves
46
+ * raw client connections itself, including TLS. Only an isolated site process can hand it sockets.
47
47
  */
48
48
  function isSocketSite(siteApp) {
49
49
  return typeof siteApp === 'object' && siteApp !== null && typeof siteApp.connection === 'function';
@@ -45,6 +45,11 @@ class SiteWorkerRoster extends Roster {
45
45
  return this._socketSite !== null;
46
46
  }
47
47
 
48
+ // The site terminates TLS with certificates Roster resolves: read from the store, or issued by the main process.
49
+ startSocketSite() {
50
+ return this._socketSite.start?.({ certificate: servername => this.ensureCertificate(servername) });
51
+ }
52
+
48
53
  // In local mode a socket site gets plain connections on its assigned ports, with no ClientHello.
49
54
  async serveLocalSockets() {
50
55
  const head = Buffer.alloc(0);
@@ -52,7 +57,7 @@ class SiteWorkerRoster extends Roster {
52
57
  if (key.startsWith('www.')) continue;
53
58
  const { domain, port } = this.parseDomainWithPort(key);
54
59
  const server = net.createServer({ pauseOnConnect: true });
55
- const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], 'localhost');
60
+ const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], this.hostname);
56
61
  server.on('connection', socket => {
57
62
  // The site owns the connection from here; it may pass it to another process.
58
63
  this._sockets.delete(socket);
@@ -167,6 +172,7 @@ async function start(site) {
167
172
  }
168
173
  await roster.init();
169
174
  if (roster.socketSite) {
175
+ await roster.startSocketSite();
170
176
  if (roster.local) await roster.serveLocalSockets();
171
177
  } else if (roster.local) {
172
178
  await roster.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,7 +9,7 @@ Use this skill to configure a consuming application. Keep its existing configura
9
9
 
10
10
  ## Choose the lifecycle owner
11
11
 
12
- - **Local development:** `new Roster({ local: true, wwwPath: absolutePath })`, register sites, then `await roster.start()`. Local mode binds to `localhost` and assigns ports; read URLs with `roster.getUrl(domain)` after startup.
12
+ - **Local development:** `new Roster({ local: true, wwwPath: absolutePath })`, register sites, then `await roster.start()`. Local mode binds to `hostname` (default `::`, IPv4 and IPv6) and assigns ports; read URLs with `roster.getUrl(domain)` after startup.
13
13
  - **Standalone HTTPS:** set the real `email`, absolute `wwwPath` and `greenlockStorePath`; leave `local` false. `await roster.start()` owns ACME HTTP on port 80 and the configured HTTPS listeners. A different HTTPS port does not eliminate the need for port 80.
14
14
  - **External server/worker:** `await roster.init()`, then `roster.attach(server, { port })` or an HTTPS helper. The caller owns listening and closing that server. `port` on `attach()` selects the routing table, not the TCP port.
15
15
  - **Serving-only worker:** use `autoCertificates: false` with `init()` and `createServingHttpsServer({ servername })`. A separate manager must provide certificates first. Calling `start()` still enables the standalone ACME lifecycle.
@@ -91,9 +91,9 @@ function get(port, servername, { pathname = '/', headers = {} } = {}) {
91
91
  });
92
92
  }
93
93
 
94
- function localGet(port, pathname = '/') {
94
+ function localGet(port, pathname = '/', hostname = 'localhost') {
95
95
  return new Promise((resolve, reject) => {
96
- http.get({ hostname: 'localhost', port, path: pathname, agent: false }, res => {
96
+ http.get({ hostname, port, path: pathname, agent: false }, res => {
97
97
  let body = '';
98
98
  res.on('data', chunk => { body += chunk; });
99
99
  res.on('end', () => resolve({ status: res.statusCode, body }));
@@ -315,6 +315,9 @@ it('isolates directory sites in local mode, including aliases and request plugin
315
315
  assert.equal(alias.pid, site.pid);
316
316
  assert.notEqual(other.pid, site.pid);
317
317
  assert.deepEqual(await localGet(port('other.example'), '/wp-login.php'), { status: 404, body: 'Not Found' });
318
+ for (const address of ['127.0.0.1', '::1']) {
319
+ assert.equal(JSON.parse((await localGet(port('site.example'), '/', address)).body).pid, site.pid);
320
+ }
318
321
  });
319
322
 
320
323
  it('serves a site module already loaded by the main process from that process', { timeout: 20000 }, async t => {
@@ -405,11 +408,11 @@ it('closes isolated sites when the main process dies without closing', { timeout
405
408
  assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
406
409
  });
407
410
 
408
- function socketSite(server, marker) {
411
+ function socketSite(server, marker, start = 'async start() {}') {
409
412
  return `const fs = require('fs');
410
- const server = ${server};
411
- server.on('request', (req, res) => res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress })));
413
+ let server = ${server};
412
414
  module.exports = {
415
+ ${start},
413
416
  connection(socket, head) {
414
417
  fs.writeFileSync(${JSON.stringify(marker)} + '.head', String(head.length));
415
418
  // A ClientHello read by Roster goes back on the stream; a local connection arrives paused.
@@ -417,6 +420,9 @@ module.exports = {
417
420
  server.emit('connection', socket);
418
421
  if (head.length === 0) socket.resume();
419
422
  },
423
+ reply(req, res) {
424
+ res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress }));
425
+ },
420
426
  close() {
421
427
  fs.writeFileSync(${JSON.stringify(marker)}, 'closed');
422
428
  }
@@ -426,14 +432,15 @@ module.exports = {
426
432
  it('hands raw client sockets with their ClientHello to a socket site that terminates TLS', { timeout: 20000 }, async t => {
427
433
  const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
428
434
  t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
429
- const tlsServer = `require('https').createServer({
430
- key: fs.readFileSync(${JSON.stringify(path.join(pki, 'key.pem'))}),
431
- cert: fs.readFileSync(${JSON.stringify(path.join(pki, 'cert.pem'))})
432
- })`;
435
+ // The site gets its certificate from Roster's store through start(), not from files it reads.
436
+ const start = `async start({ certificate }) {
437
+ const pems = await certificate('raw.example.com');
438
+ server = require('https').createServer(pems, (req, res) => module.exports.reply(req, res));
439
+ }`;
433
440
  const { roster, port } = await startProduction(t, {
434
- 'raw.example.com': socketSite(tlsServer, marker),
441
+ 'raw.example.com': socketSite('null', marker, start),
435
442
  'plain.example.com': REPORT_SITE
436
- }, { certificates: ['plain.example.com'] });
443
+ }, { certificates: ['plain.example.com', 'raw.example.com'] });
437
444
  const raw = JSON.parse((await get(port, 'raw.example.com')).body);
438
445
  const plain = JSON.parse((await get(port, 'plain.example.com')).body);
439
446
  assert.notEqual(raw.pid, process.pid);
@@ -445,15 +452,37 @@ it('hands raw client sockets with their ClientHello to a socket site that termin
445
452
  assert.equal(isRunning(raw.pid), false);
446
453
  });
447
454
 
455
+ it('issues a missing certificate through the main process when a socket site asks for it', { timeout: 20000 }, async t => {
456
+ const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
457
+ t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
458
+ const store = path.join(path.dirname(marker), 'greenlock.d');
459
+ const issued = [];
460
+ const start = `async start({ certificate }) {
461
+ server = require('https').createServer(await certificate('fresh.example.com'), (req, res) => module.exports.reply(req, res));
462
+ }`;
463
+ const { port } = await startProduction(t, { 'fresh.example.com': socketSite('null', marker, start) }, {
464
+ options: { greenlockStorePath: store },
465
+ issue: async ({ servername }) => {
466
+ issued.push(servername);
467
+ installCertificate(store, servername);
468
+ }
469
+ });
470
+ assert.deepEqual(issued, ['fresh.example.com']);
471
+ assert.notEqual(JSON.parse((await get(port, 'fresh.example.com')).body).pid, process.pid);
472
+ });
473
+
448
474
  it('gives a socket site plain connections on its local port', { timeout: 20000 }, async t => {
449
475
  const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
450
476
  t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
451
- const { wwwPath } = createWww(t, { 'raw.example': socketSite(`require('http').createServer()`, marker) });
477
+ const { wwwPath } = createWww(t, {
478
+ 'raw.example': socketSite(`require('http').createServer((req, res) => module.exports.reply(req, res))`, marker)
479
+ });
452
480
  const roster = new Roster({ local: true, wwwPath, minLocalPort: 19370, maxLocalPort: 19379 });
453
481
  t.after(() => roster.close().catch(() => {}));
454
482
  await roster.start();
455
483
  const { port } = new URL(roster.getUrl('raw.example'));
456
484
  assert.notEqual(JSON.parse((await localGet(port)).body).pid, process.pid);
485
+ for (const address of ['127.0.0.1', '::1']) assert.equal((await localGet(port, '/', address)).status, 200);
457
486
  assert.equal(fs.readFileSync(`${marker}.head`, 'utf8'), '0');
458
487
  await roster.close();
459
488
  assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
@@ -94,7 +94,7 @@ it('waits for listening, shares start, and closes owned servers once', async t =
94
94
 
95
95
  it('rejects bind failures and rolls back already opened ports', async t => {
96
96
  const blocker = http.createServer();
97
- await new Promise(resolve => blocker.listen(0, 'localhost', resolve));
97
+ await new Promise(resolve => blocker.listen(0, '::', resolve));
98
98
  t.after(() => new Promise(resolve => blocker.close(resolve)));
99
99
  const roster = createRoster(t);
100
100
  const ports = [0, blocker.address().port];