roster-server 2.0.4 → 2.1.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/index.js CHANGED
@@ -33,6 +33,20 @@ function domainToPort(domain, minPort = 3000, maxPort = 65535) {
33
33
  return minPort + (hash % portRange);
34
34
  }
35
35
 
36
+ // Wildcard helpers: *.example.com -> root "example.com"
37
+ function wildcardRoot(pattern) {
38
+ if (!pattern || !pattern.startsWith('*.')) return null;
39
+ return pattern.split('.').slice(1).join('.');
40
+ }
41
+
42
+ // Check if host matches wildcard pattern (e.g. api.example.com matches *.example.com)
43
+ function hostMatchesWildcard(host, pattern) {
44
+ if (!pattern || !pattern.startsWith('*.')) return false;
45
+ const h = (host || '').toLowerCase();
46
+ const suffix = pattern.slice(2).toLowerCase(); // "example.com"
47
+ return h.endsWith('.' + suffix) && h.length > suffix.length;
48
+ }
49
+
36
50
  // Virtual Server that completely isolates applications
37
51
  class VirtualServer extends EventEmitter {
38
52
  constructor(domain) {
@@ -158,6 +172,7 @@ class Roster {
158
172
  this.local = options.local || false;
159
173
  this.domains = [];
160
174
  this.sites = {};
175
+ this.wildcardZones = new Set(); // Root domains that have a wildcard site (e.g. "example.com" for *.example.com)
161
176
  this.domainServers = {}; // Store separate servers for each domain
162
177
  this.portServers = {}; // Store servers by port
163
178
  this.domainPorts = {}; // Store domain → port mapping for local mode
@@ -166,84 +181,46 @@ class Roster {
166
181
  this.filename = options.filename || 'index';
167
182
  this.minLocalPort = options.minLocalPort || 4000;
168
183
  this.maxLocalPort = options.maxLocalPort || 9999;
184
+ this.tlsMinVersion = options.tlsMinVersion ?? 'TLSv1.2';
185
+ this.tlsMaxVersion = options.tlsMaxVersion ?? 'TLSv1.3';
169
186
 
170
187
  const port = options.port === undefined ? 443 : options.port;
171
188
  if (port === 80 && !this.local) {
172
189
  throw new Error('⚠️ Port 80 is reserved for ACME challenge. Please use a different port.');
173
190
  }
174
191
  this.defaultPort = port;
175
-
176
- const validTlsModes = ['auto', 'greenlock', 'static'];
177
- this.tlsMode = options.tlsMode || 'auto';
178
- if (!validTlsModes.includes(this.tlsMode)) {
179
- throw new Error(`Invalid tlsMode "${this.tlsMode}". Must be one of: ${validTlsModes.join(', ')}`);
180
- }
181
- this.tlsDomain = options.tlsDomain || null;
182
- this.tlsOptions = options.tls || {};
183
- }
184
-
185
- detectRuntime() {
186
- return typeof Bun !== 'undefined' ? 'bun' : 'node';
187
- }
188
-
189
- getEffectiveTlsMode() {
190
- if (this.tlsMode !== 'auto') {
191
- return this.tlsMode;
192
- }
193
- return this.detectRuntime() === 'bun' ? 'static' : 'greenlock';
194
- }
195
-
196
- getDefaultTlsOptions() {
197
- return Object.assign({ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' }, this.tlsOptions);
198
- }
199
-
200
- createSNICallback() {
201
- return (domain, callback) => {
202
- try {
203
- const certPath = path.join(this.greenlockStorePath, 'live', domain);
204
- const keyPath = path.join(certPath, 'privkey.pem');
205
- const certFilePath = path.join(certPath, 'cert.pem');
206
- const chainPath = path.join(certPath, 'chain.pem');
207
-
208
- if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
209
- const key = fs.readFileSync(keyPath, 'utf8');
210
- const cert = fs.readFileSync(certFilePath, 'utf8');
211
- const chain = fs.readFileSync(chainPath, 'utf8');
212
-
213
- callback(null, tls.createSecureContext({ key, cert: cert + chain }));
214
- } else {
215
- callback(new Error(`No certificate files available for ${domain} at ${certPath}`));
216
- }
217
- } catch (error) {
218
- callback(error);
192
+ // Use a local wrapper around acme-dns-01-cli so we can provide propagationDelay,
193
+ // zones(), and Promise-style signatures expected by newer ACME validators.
194
+ const defaultDnsChallengeModule = path.join(__dirname, 'vendor', 'acme-dns-01-cli-wrapper.js');
195
+ const shouldUseCliWrapper = (moduleName) =>
196
+ typeof moduleName === 'string' &&
197
+ /(^|[\\/])acme-dns-01-cli([\\/]|$)/.test(moduleName);
198
+
199
+ if (options.dnsChallenge === false) {
200
+ this.dnsChallenge = null;
201
+ } else if (options.dnsChallenge) {
202
+ const provided = { ...options.dnsChallenge };
203
+ if (shouldUseCliWrapper(provided.module) || provided.module === 'acme-dns-01-cli') {
204
+ provided.module = defaultDnsChallengeModule;
219
205
  }
220
- };
221
- }
222
-
223
- createStaticHttpsServer(dispatcher) {
224
- const tlsOpts = Object.assign(this.getDefaultTlsOptions(), {
225
- SNICallback: this.createSNICallback()
226
- });
227
-
228
- if (this.tlsDomain) {
229
- const certPath = path.join(this.greenlockStorePath, 'live', this.tlsDomain);
230
- const keyPath = path.join(certPath, 'privkey.pem');
231
- const certFilePath = path.join(certPath, 'cert.pem');
232
- const chainPath = path.join(certPath, 'chain.pem');
233
-
234
- const missing = [keyPath, certFilePath, chainPath].filter(p => !fs.existsSync(p));
235
- if (missing.length > 0) {
236
- throw new Error(
237
- `Static TLS cert files missing for domain "${this.tlsDomain}":\n` +
238
- missing.map(p => ` - ${p}`).join('\n')
239
- );
206
+ if (provided.propagationDelay === undefined) {
207
+ provided.propagationDelay = 120000;
240
208
  }
241
-
242
- tlsOpts.key = fs.readFileSync(keyPath, 'utf8');
243
- tlsOpts.cert = fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8');
209
+ if (provided.autoContinue === undefined) {
210
+ provided.autoContinue = false;
211
+ }
212
+ if (provided.dryRunDelay === undefined) {
213
+ provided.dryRunDelay = provided.propagationDelay;
214
+ }
215
+ this.dnsChallenge = provided;
216
+ } else {
217
+ this.dnsChallenge = {
218
+ module: defaultDnsChallengeModule,
219
+ propagationDelay: 120000,
220
+ autoContinue: false,
221
+ dryRunDelay: 120000
222
+ };
244
223
  }
245
-
246
- return https.createServer(tlsOpts, dispatcher);
247
224
  }
248
225
 
249
226
  async loadSites() {
@@ -282,13 +259,21 @@ class Roster {
282
259
  }
283
260
 
284
261
  if (siteApp) {
285
- const domainEntries = [domain, `www.${domain}`];
286
- this.domains.push(...domainEntries);
287
- domainEntries.forEach(d => {
288
- this.sites[d] = siteApp;
289
- });
290
-
291
- log.info(`(✔) Loaded site: https://${domain}`);
262
+ if (domain.startsWith('*.')) {
263
+ // Wildcard site: one handler for all subdomains (e.g. *.example.com)
264
+ this.domains.push(domain);
265
+ this.sites[domain] = siteApp;
266
+ const root = wildcardRoot(domain);
267
+ if (root) this.wildcardZones.add(root);
268
+ log.info(`(✔) Loaded wildcard site: https://${domain}`);
269
+ } else {
270
+ const domainEntries = [domain, `www.${domain}`];
271
+ this.domains.push(...domainEntries);
272
+ domainEntries.forEach(d => {
273
+ this.sites[d] = siteApp;
274
+ });
275
+ log.info(`(✔) Loaded site: https://${domain}`);
276
+ }
292
277
  } else {
293
278
  log.warn(`⚠️ No index file (js/mjs/cjs) found in ${domainPath}`);
294
279
  }
@@ -307,8 +292,8 @@ class Roster {
307
292
  const uniqueDomains = new Set();
308
293
 
309
294
  this.domains.forEach(domain => {
310
- const rootDomain = domain.replace(/^www\./, '');
311
- uniqueDomains.add(rootDomain);
295
+ const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
296
+ if (root) uniqueDomains.add(root);
312
297
  });
313
298
 
314
299
  let existingConfig = {};
@@ -322,6 +307,9 @@ class Roster {
322
307
  if ((domain.match(/\./g) || []).length < 2) {
323
308
  altnames.push(`www.${domain}`);
324
309
  }
310
+ if (this.wildcardZones.has(domain)) {
311
+ altnames.push(`*.${domain}`);
312
+ }
325
313
 
326
314
  let existingSite = null;
327
315
  if (existingConfig.sites) {
@@ -334,7 +322,35 @@ class Roster {
334
322
  };
335
323
 
336
324
  if (existingSite && existingSite.renewAt) {
337
- siteConfig.renewAt = existingSite.renewAt;
325
+ const existingAltnames = Array.isArray(existingSite.altnames)
326
+ ? [...existingSite.altnames].sort()
327
+ : [];
328
+ const nextAltnames = [...altnames].sort();
329
+ const sameAltnames =
330
+ existingAltnames.length === nextAltnames.length &&
331
+ existingAltnames.every((name, idx) => name === nextAltnames[idx]);
332
+
333
+ // Keep renewAt only when certificate identifiers are unchanged.
334
+ // If altnames changed (e.g. wildcard added), force immediate re-issue.
335
+ if (sameAltnames) {
336
+ siteConfig.renewAt = existingSite.renewAt;
337
+ }
338
+ }
339
+
340
+ if (this.wildcardZones.has(domain) && this.dnsChallenge) {
341
+ const dns01 = { ...this.dnsChallenge };
342
+ if (dns01.propagationDelay === undefined) {
343
+ dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
344
+ }
345
+ if (dns01.autoContinue === undefined) {
346
+ dns01.autoContinue = false;
347
+ }
348
+ if (dns01.dryRunDelay === undefined) {
349
+ dns01.dryRunDelay = dns01.propagationDelay;
350
+ }
351
+ siteConfig.challenges = {
352
+ 'dns-01': dns01
353
+ };
338
354
  }
339
355
 
340
356
  sitesConfig.push(siteConfig);
@@ -380,6 +396,47 @@ class Roster {
380
396
  log.info(`📁 config.json generated at ${configPath}`);
381
397
  }
382
398
 
399
+ /**
400
+ * Resolve handler for a host (exact match, then wildcard). Used when port is not in the key.
401
+ */
402
+ getHandlerForHost(host) {
403
+ const resolved = this.getHandlerAndKeyForHost(host);
404
+ return resolved ? resolved.handler : null;
405
+ }
406
+
407
+ /**
408
+ * Resolve handler and site key for a host (exact match, then wildcard). Used by getUrl for wildcard lookups.
409
+ */
410
+ getHandlerAndKeyForHost(host) {
411
+ const siteApp = this.sites[host];
412
+ if (siteApp) return { handler: siteApp, siteKey: host };
413
+ for (const key of Object.keys(this.sites)) {
414
+ if (key.startsWith('*.')) {
415
+ const pattern = key.split(':')[0];
416
+ if (hostMatchesWildcard(host, pattern)) return { handler: this.sites[key], siteKey: key };
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Resolve virtualServer and appHandler for a host from portData (exact then wildcard).
424
+ */
425
+ getHandlerForPortData(host, portData) {
426
+ const virtualServer = portData.virtualServers[host];
427
+ const appHandler = portData.appHandlers[host];
428
+ if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
429
+ for (const key of Object.keys(portData.appHandlers)) {
430
+ if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
431
+ return {
432
+ virtualServer: portData.virtualServers[key],
433
+ appHandler: portData.appHandlers[key]
434
+ };
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
383
440
  handleRequest(req, res) {
384
441
  const host = req.headers.host || '';
385
442
 
@@ -390,7 +447,8 @@ class Roster {
390
447
  return;
391
448
  }
392
449
 
393
- const siteApp = this.sites[host];
450
+ const hostWithoutPort = host.split(':')[0];
451
+ const siteApp = this.getHandlerForHost(hostWithoutPort);
394
452
  if (siteApp) {
395
453
  siteApp(req, res);
396
454
  } else {
@@ -409,6 +467,16 @@ class Roster {
409
467
 
410
468
  const { domain, port } = this.parseDomainWithPort(domainString);
411
469
 
470
+ if (domain.startsWith('*.')) {
471
+ const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
472
+ this.domains.push(domain);
473
+ this.sites[domainKey] = requestHandler;
474
+ const root = wildcardRoot(domain);
475
+ if (root) this.wildcardZones.add(root);
476
+ log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
477
+ return this;
478
+ }
479
+
412
480
  const domainEntries = [domain];
413
481
  if ((domain.match(/\./g) || []).length < 2) {
414
482
  domainEntries.push(`www.${domain}`);
@@ -416,7 +484,6 @@ class Roster {
416
484
 
417
485
  this.domains.push(...domainEntries);
418
486
  domainEntries.forEach(d => {
419
- // Store with port information
420
487
  const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
421
488
  this.sites[domainKey] = requestHandler;
422
489
  });
@@ -440,31 +507,25 @@ class Roster {
440
507
 
441
508
  /**
442
509
  * Get the URL for a domain based on the current environment
443
- * @param {string} domain - The domain name
444
- * @returns {string|null} The URL if domain is registered, null otherwise
510
+ * @param {string} domain - The domain name (or subdomain that matches a wildcard site)
511
+ * @returns {string|null} The URL if domain is registered (exact or wildcard), null otherwise
445
512
  */
446
513
  getUrl(domain) {
447
- // Remove www prefix if present
448
514
  const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
449
-
450
- // Check if domain is registered
451
- const isRegistered = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
452
- if (!isRegistered) {
453
- return null;
454
- }
455
-
456
- // Return URL based on environment
515
+
516
+ const exactMatch = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
517
+ const resolved = exactMatch ? { handler: exactMatch, siteKey: cleanDomain } : this.getHandlerAndKeyForHost(cleanDomain);
518
+ if (!resolved) return null;
519
+
457
520
  if (this.local) {
458
- // Local mode: return localhost URL with assigned port
459
- if (this.domainPorts && this.domainPorts[cleanDomain]) {
460
- return `http://localhost:${this.domainPorts[cleanDomain]}`;
521
+ const pattern = resolved.siteKey.split(':')[0];
522
+ if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
523
+ return `http://localhost:${this.domainPorts[pattern]}`;
461
524
  }
462
525
  return null;
463
- } else {
464
- // Production mode: return HTTPS URL
465
- const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
466
- return `https://${cleanDomain}${port}`;
467
526
  }
527
+ const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
528
+ return `https://${cleanDomain}${port}`;
468
529
  }
469
530
 
470
531
  createVirtualServer(domain) {
@@ -509,7 +570,7 @@ class Roster {
509
570
  startLocalMode() {
510
571
  // Store mapping of domain to port for later retrieval
511
572
  this.domainPorts = {};
512
-
573
+
513
574
  // Create a simple HTTP server for each domain with CRC32-based ports
514
575
  for (const [hostKey, siteApp] of Object.entries(this.sites)) {
515
576
  const domain = hostKey.split(':')[0]; // Remove port if present
@@ -521,7 +582,7 @@ class Roster {
521
582
 
522
583
  // Calculate deterministic port based on domain CRC32, with collision detection
523
584
  const port = this.assignPortToDomain(domain);
524
-
585
+
525
586
  // Store domain → port mapping
526
587
  this.domainPorts[domain] = port;
527
588
 
@@ -587,7 +648,18 @@ class Roster {
587
648
  configDir: this.greenlockStorePath,
588
649
  maintainerEmail: this.email,
589
650
  cluster: this.cluster,
590
- staging: this.staging
651
+ staging: this.staging,
652
+ notify: (event, details) => {
653
+ const msg = typeof details === 'string' ? details : (details?.message ?? JSON.stringify(details));
654
+ // Suppress known benign warnings from ACME when using acme-dns-01-cli
655
+ if (event === 'warning' && typeof msg === 'string') {
656
+ if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
657
+ if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
658
+ }
659
+ if (event === 'error') log.error(msg);
660
+ else if (event === 'warning') log.warn(msg);
661
+ else log.info(msg);
662
+ }
591
663
  });
592
664
 
593
665
  return greenlock.ready(glx => {
@@ -605,15 +677,15 @@ class Roster {
605
677
  };
606
678
  }
607
679
 
608
- // Create completely isolated virtual server
609
680
  const virtualServer = this.createVirtualServer(domain);
610
681
  sitesByPort[port].virtualServers[domain] = virtualServer;
611
682
  this.domainServers[domain] = virtualServer;
612
683
 
613
- // Initialize app with virtual server
614
684
  const appHandler = siteApp(virtualServer);
615
685
  sitesByPort[port].appHandlers[domain] = appHandler;
616
- sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
686
+ if (!domain.startsWith('*.')) {
687
+ sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
688
+ }
617
689
  }
618
690
  }
619
691
 
@@ -622,27 +694,27 @@ class Roster {
622
694
  return (req, res) => {
623
695
  const host = req.headers.host || '';
624
696
 
625
- // Remove port from host header if present (e.g., "domain.com:8080" -> "domain.com")
626
- const hostWithoutPort = host.split(':')[0];
697
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
627
698
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
628
699
 
629
- // Handle www redirects
630
700
  if (hostWithoutPort.startsWith('www.')) {
631
701
  res.writeHead(301, { Location: `https://${domain}${req.url}` });
632
702
  res.end();
633
703
  return;
634
704
  }
635
705
 
636
- const virtualServer = portData.virtualServers[domain];
637
- const appHandler = portData.appHandlers[domain];
706
+ const resolved = this.getHandlerForPortData(domain, portData);
707
+ if (!resolved) {
708
+ res.writeHead(404);
709
+ res.end('Site not found');
710
+ return;
711
+ }
712
+ const { virtualServer, appHandler } = resolved;
638
713
 
639
714
  if (virtualServer && virtualServer.requestListeners.length > 0) {
640
- // Set fallback handler on virtual server for non-Socket.IO requests
641
715
  virtualServer.fallbackHandler = appHandler;
642
- // App registered listeners on virtual server - use them
643
716
  virtualServer.processRequest(req, res);
644
717
  } else if (appHandler) {
645
- // App returned a handler function - use it
646
718
  appHandler(req, res);
647
719
  } else {
648
720
  res.writeHead(404);
@@ -651,76 +723,141 @@ class Roster {
651
723
  };
652
724
  };
653
725
 
654
- const runtime = this.detectRuntime();
655
- const effectiveTlsMode = this.getEffectiveTlsMode();
656
- log.info(`Runtime: ${runtime} | TLS mode: ${effectiveTlsMode}`);
657
-
658
726
  httpServer.listen(80, this.hostname, () => {
659
727
  log.info('HTTP server listening on port 80');
660
728
  });
661
729
 
662
- // Create upgrade handler for WebSocket connections
663
730
  const createUpgradeHandler = (portData) => {
664
731
  return (req, socket, head) => {
665
732
  const host = req.headers.host || '';
666
- const hostWithoutPort = host.split(':')[0];
733
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
667
734
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
668
735
 
669
- const virtualServer = portData.virtualServers[domain];
670
-
671
- if (virtualServer) {
672
- virtualServer.processUpgrade(req, socket, head);
736
+ const resolved = this.getHandlerForPortData(domain, portData);
737
+ if (resolved && resolved.virtualServer) {
738
+ resolved.virtualServer.processUpgrade(req, socket, head);
673
739
  } else {
674
740
  socket.destroy();
675
741
  }
676
742
  };
677
743
  };
678
744
 
679
- const attachHttpsListeners = (httpsServer, portNum, upgradeHandler) => {
680
- httpsServer.on('upgrade', upgradeHandler);
681
- httpsServer.on('error', (error) => {
682
- log.error(`HTTPS server error on port ${portNum}:`, error.message);
683
- });
684
- httpsServer.on('tlsClientError', (error) => {
685
- if (!error.message.includes('http request')) {
686
- log.error(`TLS error on port ${portNum}:`, error.message);
687
- }
688
- });
689
- };
690
-
691
745
  // Handle different port types
692
746
  for (const [port, portData] of Object.entries(sitesByPort)) {
693
747
  const portNum = parseInt(port);
694
748
  const dispatcher = createDispatcher(portData);
695
749
  const upgradeHandler = createUpgradeHandler(portData);
696
750
 
697
- let httpsServer;
751
+ if (portNum === this.defaultPort) {
752
+ // Bun has known gaps around SNICallback compatibility.
753
+ // Fallback to static cert loading for the primary domain on default HTTPS port.
754
+ const isBunRuntime = typeof Bun !== 'undefined' || process.release?.name === 'bun';
755
+ const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
756
+ let httpsServer;
757
+
758
+ if (isBunRuntime) {
759
+ const primaryDomain = Object.keys(portData.virtualServers)[0];
760
+ // Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
761
+ const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
762
+ const certPath = path.join(this.greenlockStorePath, 'live', certSubject);
763
+ const keyPath = path.join(certPath, 'privkey.pem');
764
+ const certFilePath = path.join(certPath, 'cert.pem');
765
+ const chainPath = path.join(certPath, 'chain.pem');
766
+
767
+ if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
768
+ const key = fs.readFileSync(keyPath, 'utf8');
769
+ const cert = fs.readFileSync(certFilePath, 'utf8');
770
+ const chain = fs.readFileSync(chainPath, 'utf8');
771
+ httpsServer = https.createServer({
772
+ ...tlsOpts,
773
+ key,
774
+ cert: cert + chain
775
+ }, dispatcher);
776
+ log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
777
+ } else {
778
+ log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
779
+ httpsServer = glx.httpsServer(tlsOpts, dispatcher);
780
+ }
781
+ } else {
782
+ httpsServer = glx.httpsServer(tlsOpts, dispatcher);
783
+ }
698
784
 
699
- if (portNum === this.defaultPort && effectiveTlsMode === 'greenlock') {
700
- log.info(`HTTPS port ${portNum}: using Greenlock SNI (certs managed automatically)`);
701
- httpsServer = glx.httpsServer(null, dispatcher);
785
+ this.portServers[portNum] = httpsServer;
786
+
787
+ // Handle WebSocket upgrade events
788
+ httpsServer.on('upgrade', upgradeHandler);
789
+
790
+ httpsServer.listen(portNum, this.hostname, () => {
791
+ log.info(`HTTPS server listening on port ${portNum}`);
792
+ });
702
793
  } else {
703
- const modeLabel = portNum === this.defaultPort ? effectiveTlsMode : 'static';
704
- log.info(`HTTPS port ${portNum}: using static certs from ${path.join(this.greenlockStorePath, 'live')} [${modeLabel}]`);
705
- const httpsOptions = Object.assign(this.getDefaultTlsOptions(), {
706
- SNICallback: this.createSNICallback()
794
+ // Create HTTPS server for custom ports using Greenlock certificates
795
+ const greenlockStorePath = this.greenlockStorePath;
796
+ const loadCert = (subjectDir) => {
797
+ const certPath = path.join(greenlockStorePath, 'live', subjectDir);
798
+ const keyPath = path.join(certPath, 'privkey.pem');
799
+ const certFilePath = path.join(certPath, 'cert.pem');
800
+ const chainPath = path.join(certPath, 'chain.pem');
801
+ if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
802
+ return {
803
+ key: fs.readFileSync(keyPath, 'utf8'),
804
+ cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
805
+ };
806
+ }
807
+ return null;
808
+ };
809
+ const httpsOptions = {
810
+ minVersion: this.tlsMinVersion,
811
+ maxVersion: this.tlsMaxVersion,
812
+ SNICallback: (servername, callback) => {
813
+ try {
814
+ let pems = loadCert(servername);
815
+ if (!pems && hostMatchesWildcard(servername, '*.' + servername.split('.').slice(1).join('.'))) {
816
+ const zoneSubject = servername.split('.').slice(1).join('.');
817
+ pems = loadCert(zoneSubject);
818
+ }
819
+ if (pems) {
820
+ callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
821
+ } else {
822
+ callback(new Error(`No certificate files available for ${servername}`));
823
+ }
824
+ } catch (error) {
825
+ callback(error);
826
+ }
827
+ }
828
+ };
829
+
830
+ const httpsServer = https.createServer(httpsOptions, dispatcher);
831
+
832
+ // Handle WebSocket upgrade events
833
+ httpsServer.on('upgrade', upgradeHandler);
834
+
835
+ httpsServer.on('error', (error) => {
836
+ log.error(`HTTPS server error on port ${portNum}:`, error.message);
837
+ });
838
+
839
+ httpsServer.on('tlsClientError', (error) => {
840
+ // Suppress HTTP request errors to avoid log spam
841
+ if (!error.message.includes('http request')) {
842
+ log.error(`TLS error on port ${portNum}:`, error.message);
843
+ }
707
844
  });
708
- httpsServer = https.createServer(httpsOptions, dispatcher);
709
- }
710
845
 
711
- attachHttpsListeners(httpsServer, portNum, upgradeHandler);
712
- this.portServers[portNum] = httpsServer;
846
+ this.portServers[portNum] = httpsServer;
713
847
 
714
- httpsServer.listen(portNum, this.hostname, (error) => {
715
- if (error) {
716
- log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
717
- } else {
718
- log.info(`HTTPS server listening on port ${portNum}`);
719
- }
720
- });
848
+ httpsServer.listen(portNum, this.hostname, (error) => {
849
+ if (error) {
850
+ log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
851
+ } else {
852
+ log.info(`HTTPS server listening on port ${portNum}`);
853
+ }
854
+ });
855
+ }
721
856
  }
722
857
  });
723
858
  }
724
859
  }
725
860
 
726
- module.exports = Roster;
861
+ module.exports = Roster;
862
+ module.exports.wildcardRoot = wildcardRoot;
863
+ module.exports.hostMatchesWildcard = hostMatchesWildcard;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node --test 'test/**/*.test.js'"
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
@@ -26,6 +26,7 @@
26
26
  "express",
27
27
  "greenlock-express",
28
28
  "shotx",
29
+ "bun",
29
30
  "clasen"
30
31
  ],
31
32
  "author": "Martin Clasen",
@@ -36,6 +37,7 @@
36
37
  "homepage": "https://github.com/clasen/RosterServer#readme",
37
38
  "dependencies": {
38
39
  "@root/greenlock": "^4.0.5",
40
+ "acme-dns-01-cli": "^3.0.7",
39
41
  "lemonlog": "^1.2.0",
40
42
  "redirect-https": "^1.3.1"
41
43
  }