roster-server 3.0.2 → 3.2.0

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
@@ -272,6 +272,8 @@ const dnsChallenge = {
272
272
 
273
273
  Pass this as the constructor's `dnsChallenge` option. To use another provider, install its Greenlock DNS plugin in the consuming application and supply its module name and provider options. Configure DNS timing on that object with `propagationDelay`, `dryRunDelay`, `dnsPollIntervalMs`, and `dnsPollTimeoutMs`; the wrapper's polling interval defaults to `15000` ms.
274
274
 
275
+ New certificates use an `EC-P256` key by default, which makes each full TLS handshake much cheaper for the server than an RSA key. Clients that cannot negotiate ECDSA, such as Windows XP or Android 2.x, need `serverKeyType: 'RSA-2048'`. The key type applies to certificates issued after it is set: renewal reuses a certificate's existing key, so certificates issued before 3.2.0 keep their RSA key. To move an existing name to the configured key type, remove its directory under `greenlockStorePath/live/` and restart; Roster issues a new certificate on the first connection for that name.
276
+
275
277
  Roster's file-based SNI resolvers cache TLS contexts and detect changed certificate files through metadata, including updates from another process. Concurrent requests for the same certificate name share loads and checks. Standalone startup reuses the initialized certificate runtime and Roster's cancellable renewal loop. Loading an existing certificate does not itself force renewal.
276
278
 
277
279
  ## Configuration reference
@@ -296,6 +298,8 @@ Pass operational settings through the constructor, using the consuming applicati
296
298
  | `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
297
299
  | `isolation` | `true` (`false` with `cluster`) | One process per site directory under `start()`; `false` or `{ restartDelayMs, backlog, helloTimeoutMs }` (defaults `1000`, `511`, `10000`). See [site isolation](#site-isolation). |
298
300
  | `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
301
+ | `keepAliveTimeoutMs` | Node.js default (`5000`) | Idle time before a site server closes a keep-alive connection, in isolated and main-process sites. Set it above the interval at which clients repeat requests so they reuse the connection instead of paying a new TLS handshake. |
302
+ | `serverKeyType` | `EC-P256` | Key for newly issued certificates: `EC-P256`, `EC-P384`, `RSA-2048`, `RSA-3072`, or `RSA-4096`. See [certificates](#certificates-and-dns). |
299
303
  | `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
300
304
  | `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
301
305
  | `disableWildcard`, `combineWildcardCerts` | `false`, `false` | Wildcard registration and certificate behavior. |
package/index.js CHANGED
@@ -13,6 +13,7 @@ const { IsolatedSite } = require('./lib/isolated-site.js');
13
13
  const { parseClientHello } = require('./lib/client-hello.js');
14
14
  const log = require('lemonlog')('roster');
15
15
  const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
16
+ const SERVER_KEY_TYPES = ['RSA-2048', 'RSA-3072', 'RSA-4096', 'EC-P256', 'EC-P384'];
16
17
  // A request plugin carrying { module, export, options } under this key can be recreated in isolated site processes.
17
18
  const PLUGIN_SOURCE = Symbol.for('roster-server.plugin-source');
18
19
 
@@ -355,6 +356,14 @@ class Roster {
355
356
  this.maxLocalPort = options.maxLocalPort || 9999;
356
357
  this.tlsMinVersion = options.tlsMinVersion ?? 'TLSv1.2';
357
358
  this.tlsMaxVersion = options.tlsMaxVersion ?? 'TLSv1.3';
359
+ this.keepAliveTimeoutMs = options.keepAliveTimeoutMs;
360
+ if (this.keepAliveTimeoutMs !== undefined && (!Number.isFinite(this.keepAliveTimeoutMs) || this.keepAliveTimeoutMs <= 0)) {
361
+ throw new TypeError('keepAliveTimeoutMs must be a positive finite number');
362
+ }
363
+ this.serverKeyType = options.serverKeyType ?? 'EC-P256';
364
+ if (!SERVER_KEY_TYPES.includes(this.serverKeyType)) {
365
+ throw new TypeError(`serverKeyType must be one of ${SERVER_KEY_TYPES.join(', ')}`);
366
+ }
358
367
  this.disableWildcard = parseBooleanFlag(options.disableWildcard, false);
359
368
  this.combineWildcardCerts = parseBooleanFlag(options.combineWildcardCerts, false);
360
369
  if (isBunRuntime && this.combineWildcardCerts) {
@@ -607,7 +616,7 @@ class Roster {
607
616
  renewOffset: "-45d",
608
617
  renewStagger: "3d",
609
618
  accountKeyType: "EC-P256",
610
- serverKeyType: "RSA-2048",
619
+ serverKeyType: this.serverKeyType,
611
620
  subscriberEmail: this.email
612
621
  },
613
622
  sites: sitesConfig
@@ -937,6 +946,7 @@ class Roster {
937
946
  greenlockStorePath: this.greenlockStorePath,
938
947
  tlsMinVersion: this.tlsMinVersion,
939
948
  tlsMaxVersion: this.tlsMaxVersion,
949
+ keepAliveTimeoutMs: this.keepAliveTimeoutMs,
940
950
  closeTimeoutMs: this.closeTimeoutMs
941
951
  };
942
952
  const sitesByPath = new Map();
@@ -994,7 +1004,14 @@ class Roster {
994
1004
  socket.on('data', read);
995
1005
  }
996
1006
 
1007
+ // Applies the options shared by every server that serves sites.
1008
+ _configureSiteServer(server) {
1009
+ if (this.keepAliveTimeoutMs !== undefined) server.keepAliveTimeout = this.keepAliveTimeoutMs;
1010
+ return server;
1011
+ }
1012
+
997
1013
  _listenSite(server, port) {
1014
+ this._configureSiteServer(server);
998
1015
  const routes = this._isolatedRoutes[port];
999
1016
  if (!routes) {
1000
1017
  this.portServers[port] = server;
@@ -1545,14 +1562,14 @@ class Roster {
1545
1562
  ? await this.ensureCertificate(normalizedServername)
1546
1563
  : this.loadCertificate(normalizedServername);
1547
1564
 
1548
- const server = https.createServer({
1565
+ const server = this._configureSiteServer(https.createServer({
1549
1566
  minVersion: this.tlsMinVersion,
1550
1567
  maxVersion: this.tlsMaxVersion,
1551
1568
  ...tlsOptions,
1552
1569
  key: pems.key,
1553
1570
  cert: pems.cert,
1554
1571
  SNICallback: this.sniCallback()
1555
- });
1572
+ }));
1556
1573
 
1557
1574
  this.attach(server, { port });
1558
1575
  return server;
@@ -1591,7 +1608,7 @@ class Roster {
1591
1608
  }
1592
1609
  };
1593
1610
 
1594
- const httpServer = http.createServer(dispatcher);
1611
+ const httpServer = this._configureSiteServer(http.createServer(dispatcher));
1595
1612
  this.portServers[port] = httpServer;
1596
1613
 
1597
1614
  httpServer.on('upgrade', (req, socket, head) => {
@@ -67,13 +67,13 @@ class SiteWorkerRoster extends Roster {
67
67
  for (const port of ports) {
68
68
  // Only the default port issues missing certificates, as in the main process.
69
69
  const issue = port === this.defaultPort;
70
- const server = https.createServer({
70
+ const server = this._configureSiteServer(https.createServer({
71
71
  minVersion: this.tlsMinVersion,
72
72
  maxVersion: this.tlsMaxVersion,
73
73
  SNICallback: (servername, callback) => {
74
74
  this._getSecureContext(servername, issue).then(context => callback(null, context), callback);
75
75
  }
76
- });
76
+ }));
77
77
  this.attach(server, { port });
78
78
  this._ownedServers.add(server);
79
79
  this._httpsServers.set(port, server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "3.0.2",
3
+ "version": "3.2.0",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -52,11 +52,13 @@ function createWww(t, sites) {
52
52
  return { root, wwwPath };
53
53
  }
54
54
 
55
- async function startProduction(t, sites, { certificates = [], issue = async () => {}, configure = () => {} } = {}) {
55
+ async function startProduction(t, sites, { certificates = [], issue = async () => {}, configure = () => {}, options = {} } = {}) {
56
56
  const { root, wwwPath } = createWww(t, sites);
57
57
  const store = path.join(root, 'greenlock.d');
58
58
  for (const subject of certificates) installCertificate(store, subject);
59
- const roster = new Roster({ wwwPath, greenlockStorePath: store, isolation: { restartDelayMs: 50 }, closeTimeoutMs: 5000 });
59
+ const roster = new Roster({
60
+ wwwPath, greenlockStorePath: store, isolation: { restartDelayMs: 50 }, closeTimeoutMs: 5000, ...options
61
+ });
60
62
  t.mock.method(shim, 'create', () => ({ get: issue }));
61
63
  t.mock.method(greenlock, 'init', () => ({
62
64
  ready(callback) {
@@ -154,6 +156,43 @@ it('keeps serving in the same worker when a connection arrives without a socket'
154
156
  assert.equal(child.exitCode, null);
155
157
  });
156
158
 
159
+ it('stops tracking connections in the main process once they are handed to a site process', { timeout: 20000 }, async t => {
160
+ const { roster, port } = await startProduction(t, { 'handoff.example.com': REPORT_SITE }, {
161
+ certificates: ['handoff.example.com']
162
+ });
163
+ for (let request = 0; request < 20; request++) {
164
+ assert.equal((await get(port, 'handoff.example.com')).status, 200);
165
+ }
166
+ for (let attempt = 0; attempt < 50 && roster._sockets.size > 0; attempt++) {
167
+ await new Promise(resolve => setTimeout(resolve, 20));
168
+ }
169
+ assert.equal(roster._sockets.size, 0, 'handed-off sockets are still tracked by the main process');
170
+ });
171
+
172
+ // Resolves with how long the server kept the connection open after answering one request.
173
+ function idleKeepAliveMs(port, servername) {
174
+ return new Promise((resolve, reject) => {
175
+ const socket = tls.connect({ host: '127.0.0.1', port, servername, rejectUnauthorized: false }, () => {
176
+ socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: keep-alive\r\n\r\n`);
177
+ });
178
+ let answeredAt = null;
179
+ socket.on('data', () => { answeredAt ??= Date.now(); });
180
+ socket.on('close', () => answeredAt ? resolve(Date.now() - answeredAt) : reject(new Error('closed before answering')));
181
+ socket.on('error', reject);
182
+ });
183
+ }
184
+
185
+ for (const isolation of [true, false]) {
186
+ it(`closes idle keep-alive connections after keepAliveTimeoutMs (isolation ${isolation})`, { timeout: 20000 }, async t => {
187
+ const { port } = await startProduction(t, { 'idle.example.com': REPORT_SITE }, {
188
+ certificates: ['idle.example.com'],
189
+ options: { keepAliveTimeoutMs: 300, ...(isolation ? {} : { isolation: false }) }
190
+ });
191
+ const idleMs = await idleKeepAliveMs(port, 'idle.example.com');
192
+ assert.ok(idleMs >= 250 && idleMs < 2500, `connection stayed open ${idleMs} ms`);
193
+ });
194
+ }
195
+
157
196
  it('keeps serving other sites while one blocks its event loop', { timeout: 20000 }, async t => {
158
197
  const { port } = await startProduction(t, {
159
198
  'busy.example.com': `module.exports = () => (req, res) => {
@@ -813,6 +813,34 @@ describe('Roster generateConfigJson', () => {
813
813
  fs.rmSync(tmpDir, { recursive: true, force: true });
814
814
  }
815
815
  });
816
+ it('issues certificates with the configured server key type', () => {
817
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-config-'));
818
+ try {
819
+ const keyTypeFor = (options) => {
820
+ const roster = new Roster({ local: false, greenlockStorePath: tmpDir, ...options });
821
+ roster.domains = ['example.com', 'www.example.com'];
822
+ roster.generateConfigJson();
823
+ return JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf8')).defaults.serverKeyType;
824
+ };
825
+ assert.strictEqual(keyTypeFor({}), 'EC-P256');
826
+ assert.strictEqual(keyTypeFor({ serverKeyType: 'RSA-2048' }), 'RSA-2048');
827
+ } finally {
828
+ fs.rmSync(tmpDir, { recursive: true, force: true });
829
+ }
830
+ });
831
+ });
832
+
833
+ describe('Roster TLS and keep-alive options', () => {
834
+ it('rejects unsupported server key types', () => {
835
+ for (const serverKeyType of ['EC-P521', 'DSA-1024', 'ec-p256', '']) {
836
+ assert.throws(() => new Roster({ serverKeyType }), TypeError);
837
+ }
838
+ });
839
+ it('rejects keep-alive timeouts that are not positive finite numbers', () => {
840
+ for (const keepAliveTimeoutMs of [0, -1, Infinity, '30000']) {
841
+ assert.throws(() => new Roster({ keepAliveTimeoutMs }), TypeError);
842
+ }
843
+ });
816
844
  });
817
845
 
818
846
  describe('Roster init() (cluster-friendly API)', () => {