roster-server 2.0.6 → 2.1.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
@@ -46,17 +46,42 @@ Your project should look something like this:
46
46
  └── www/
47
47
  ├── example.com/
48
48
  │ └── index.js
49
- └── subdomain.example.com/
49
+ ├── subdomain.example.com/
50
50
  │ └── index.js
51
- └── other-domain.com/
52
- └── index.js
51
+ ├── other-domain.com/
52
+ └── index.js
53
+ └── *.example.com/ # Wildcard: one handler for all subdomains (api.example.com, app.example.com, etc.)
54
+ └── index.js
53
55
  ```
54
56
 
57
+ ### Wildcard DNS (*.example.com)
58
+
59
+ You can serve all subdomains of a domain with a single handler in three ways:
60
+
61
+ 1. **Folder**: Create a directory named literally `*.example.com` under `www` (e.g. `www/*.example.com/index.js`). Any request to `api.example.com`, `app.example.com`, etc. will use that handler.
62
+ 2. **Register (default port)**: `roster.register('*.example.com', handler)` for the default HTTPS port.
63
+ 3. **Register (custom port)**: `roster.register('*.example.com:8080', handler)` for a specific port.
64
+
65
+ Wildcard SSL certificates require **DNS-01** validation (Let's Encrypt does not support HTTP-01 for wildcards). By default Roster uses `acme-dns-01-cli` through an internal wrapper (adds `propagationDelay` and modern plugin signatures). Override with a custom plugin:
66
+
67
+ ```javascript
68
+ import Roster from 'roster-server';
69
+
70
+ const roster = new Roster({
71
+ email: 'admin@example.com',
72
+ wwwPath: '/srv/www',
73
+ greenlockStorePath: '/srv/greenlock.d',
74
+ dnsChallenge: { module: 'acme-dns-01-route53', /* provider options */ } // optional override
75
+ });
76
+ ```
77
+
78
+ Set `dnsChallenge: false` to disable. For other DNS providers install the plugin in your app and pass it. See [Greenlock DNS plugins](https://git.rootprojects.org/root/greenlock-express.js#dns-01-challenge-plugins).
79
+
55
80
  ### Setting Up Your Server
56
81
 
57
82
  ```javascript
58
83
  // /srv/roster/server.js
59
- const Roster = require('roster-server');
84
+ import Roster from 'roster-server';
60
85
 
61
86
  const options = {
62
87
  email: 'admin@example.com',
@@ -78,7 +103,7 @@ I'll help analyze the example files shown. You have 3 different implementations
78
103
 
79
104
  1. **Basic HTTP Handler**:
80
105
  ```javascript:demo/www/example.com/index.js
81
- module.exports = (httpsServer) => {
106
+ export default (httpsServer) => {
82
107
  return (req, res) => {
83
108
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
84
109
  res.end('"Loco de pensar, queriendo entrar en razón, y el corazón tiene razones que la propia razón nunca entenderá."');
@@ -88,9 +113,9 @@ module.exports = (httpsServer) => {
88
113
 
89
114
  2. **Express App**:
90
115
  ```javascript:demo/www/express.example.com/index.js
91
- const express = require('express');
116
+ import express from 'express';
92
117
 
93
- module.exports = (httpsServer) => {
118
+ export default (httpsServer) => {
94
119
  const app = express();
95
120
  app.get('/', (req, res) => {
96
121
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
@@ -103,9 +128,9 @@ module.exports = (httpsServer) => {
103
128
 
104
129
  3. **Socket.IO Server**:
105
130
  ```javascript:demo/www/sio.example.com/index.js
106
- const { Server } = require('socket.io');
131
+ import { Server } from 'socket.io';
107
132
 
108
- module.exports = (httpsServer) => {
133
+ export default (httpsServer) => {
109
134
  const io = new Server(httpsServer);
110
135
 
111
136
  io.on('connection', (socket) => {
@@ -185,6 +210,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
185
210
  - `email` (string): Your email for Let's Encrypt notifications.
186
211
  - `wwwPath` (string): Path to your `www` directory containing your sites.
187
212
  - `greenlockStorePath` (string): Directory for Greenlock configuration.
213
+ - `dnsChallenge` (object|false): Optional override for wildcard DNS-01 challenge config. Default is local/manual `acme-dns-01-cli` wrapper with `propagationDelay: 120000`, `autoContinue: false`, and `dryRunDelay: 120000`. This is safer for manual DNS providers (Linode/Cloudflare UI) because Roster waits longer and does not auto-advance in interactive terminals. Set `false` to disable. You can pass `{ module: '...', propagationDelay: 180000 }` to tune DNS wait time (ms). Set `autoContinue: true` (or env `ROSTER_DNS_AUTO_CONTINUE=1`) to continue automatically after delay. For Greenlock dry-runs (`_greenlock-dryrun-*`), delay defaults to `dryRunDelay` (same as `propagationDelay` unless overridden with `dnsChallenge.dryRunDelay` or env `ROSTER_DNS_DRYRUN_DELAY_MS`). When wildcard sites are present, Roster creates a separate wildcard certificate (`*.example.com`) that uses `dns-01`, while apex/www stay on the regular certificate flow (typically `http-01`), reducing manual TXT records.
188
214
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
189
215
  - `local` (boolean): Set to `true` to run in local development mode.
190
216
  - `minLocalPort` (number): Minimum port for local mode (default: 4000).
@@ -199,6 +225,8 @@ When `{ local: true }` is enabled, RosterServer **Skips SSL/HTTPS**: Runs pure H
199
225
  ### Setting Up Local Mode
200
226
 
201
227
  ```javascript
228
+ import Roster from 'roster-server';
229
+
202
230
  const server = new Roster({
203
231
  wwwPath: '/srv/www',
204
232
  local: true, // Enable local development mode
@@ -219,6 +247,8 @@ In local mode, domains are automatically assigned ports based on a CRC32 hash of
219
247
  You can customize the port range:
220
248
 
221
249
  ```javascript
250
+ import Roster from 'roster-server';
251
+
222
252
  const roster = new Roster({
223
253
  local: true,
224
254
  minLocalPort: 5000, // Start from port 5000
@@ -233,6 +263,8 @@ RosterServer provides a method to get the URL for a domain that adapts automatic
233
263
  **Instance Method: `roster.getUrl(domain)`**
234
264
 
235
265
  ```javascript
266
+ import Roster from 'roster-server';
267
+
236
268
  const roster = new Roster({ local: true });
237
269
  roster.register('example.com', handler);
238
270
 
@@ -255,6 +287,8 @@ This method:
255
287
  **Example Usage:**
256
288
 
257
289
  ```javascript
290
+ import Roster from 'roster-server';
291
+
258
292
  // Local development
259
293
  const localRoster = new Roster({ local: true });
260
294
  localRoster.register('example.com', handler);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Wildcard DNS demo: one handler for all subdomains (*.example.com)
3
+ *
4
+ * Run from repo root: node demo/wildcard-example.js
5
+ * Then open the printed URL (or use curl with Host header) to see the wildcard response.
6
+ * Any subdomain (api.example.com, app.example.com, foo.example.com) uses the same handler.
7
+ */
8
+ const Roster = require('../index.js');
9
+ const path = require('path');
10
+ const { wildcardRoot } = require('../index.js');
11
+
12
+ const roster = new Roster({
13
+ local: true,
14
+ wwwPath: path.join(__dirname, 'www'),
15
+ });
16
+
17
+ roster.start().then(() => {
18
+ const wildcardPattern = roster.domains.find((d) => d.startsWith('*.'));
19
+ const subdomain = wildcardPattern ? 'api.' + wildcardRoot(wildcardPattern) : 'api.example.com';
20
+ const wildcardUrl = roster.getUrl(subdomain);
21
+
22
+ console.log('\n🌐 Wildcard demo');
23
+ console.log(' Loaded:', wildcardPattern ? `https://${wildcardPattern}` : '(none)');
24
+ console.log(' Any subdomain uses the same handler.\n');
25
+ console.log(' Try:', wildcardUrl || '(no wildcard site in www path)');
26
+ if (wildcardUrl) {
27
+ const host = wildcardPattern ? 'api.' + wildcardRoot(wildcardPattern) : 'api.example.com';
28
+ console.log(' Or: curl -H "Host: ' + host + '"', wildcardUrl);
29
+ }
30
+ console.log('');
31
+ });
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
@@ -174,6 +189,38 @@ class Roster {
174
189
  throw new Error('⚠️ Port 80 is reserved for ACME challenge. Please use a different port.');
175
190
  }
176
191
  this.defaultPort = port;
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;
205
+ }
206
+ if (provided.propagationDelay === undefined) {
207
+ provided.propagationDelay = 120000;
208
+ }
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
+ };
223
+ }
177
224
  }
178
225
 
179
226
  async loadSites() {
@@ -212,13 +259,21 @@ class Roster {
212
259
  }
213
260
 
214
261
  if (siteApp) {
215
- const domainEntries = [domain, `www.${domain}`];
216
- this.domains.push(...domainEntries);
217
- domainEntries.forEach(d => {
218
- this.sites[d] = siteApp;
219
- });
220
-
221
- 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
+ }
222
277
  } else {
223
278
  log.warn(`⚠️ No index file (js/mjs/cjs) found in ${domainPath}`);
224
279
  }
@@ -237,8 +292,8 @@ class Roster {
237
292
  const uniqueDomains = new Set();
238
293
 
239
294
  this.domains.forEach(domain => {
240
- const rootDomain = domain.replace(/^www\./, '');
241
- uniqueDomains.add(rootDomain);
295
+ const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
296
+ if (root) uniqueDomains.add(root);
242
297
  });
243
298
 
244
299
  let existingConfig = {};
@@ -248,26 +303,63 @@ class Roster {
248
303
  }
249
304
 
250
305
  uniqueDomains.forEach(domain => {
251
- const altnames = [domain];
252
- if ((domain.match(/\./g) || []).length < 2) {
253
- altnames.push(`www.${domain}`);
254
- }
306
+ const applyRenewAtIfUnchanged = (siteConfig, existingSite) => {
307
+ if (!existingSite || !existingSite.renewAt) return;
308
+ const existingAltnames = Array.isArray(existingSite.altnames)
309
+ ? [...existingSite.altnames].sort()
310
+ : [];
311
+ const nextAltnames = Array.isArray(siteConfig.altnames)
312
+ ? [...siteConfig.altnames].sort()
313
+ : [];
314
+ const sameAltnames =
315
+ existingAltnames.length === nextAltnames.length &&
316
+ existingAltnames.every((name, idx) => name === nextAltnames[idx]);
317
+ if (sameAltnames) {
318
+ siteConfig.renewAt = existingSite.renewAt;
319
+ }
320
+ };
255
321
 
256
- let existingSite = null;
257
- if (existingConfig.sites) {
258
- existingSite = existingConfig.sites.find(site => site.subject === domain);
322
+ // Primary cert for apex/www uses default challenge flow (typically http-01).
323
+ const primaryAltnames = [domain];
324
+ if ((domain.match(/\./g) || []).length < 2) {
325
+ primaryAltnames.push(`www.${domain}`);
259
326
  }
260
-
261
- const siteConfig = {
327
+ const primarySite = {
262
328
  subject: domain,
263
- altnames: altnames
329
+ altnames: primaryAltnames
264
330
  };
265
-
266
- if (existingSite && existingSite.renewAt) {
267
- siteConfig.renewAt = existingSite.renewAt;
331
+ const existingPrimarySite = Array.isArray(existingConfig.sites)
332
+ ? existingConfig.sites.find(site => site.subject === domain)
333
+ : null;
334
+ applyRenewAtIfUnchanged(primarySite, existingPrimarySite);
335
+ sitesConfig.push(primarySite);
336
+
337
+ // Wildcard cert is issued separately and uses dns-01 only.
338
+ if (this.wildcardZones.has(domain) && this.dnsChallenge) {
339
+ const wildcardSubject = `*.${domain}`;
340
+ const dns01 = { ...this.dnsChallenge };
341
+ if (dns01.propagationDelay === undefined) {
342
+ dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
343
+ }
344
+ if (dns01.autoContinue === undefined) {
345
+ dns01.autoContinue = false;
346
+ }
347
+ if (dns01.dryRunDelay === undefined) {
348
+ dns01.dryRunDelay = dns01.propagationDelay;
349
+ }
350
+ const wildcardSite = {
351
+ subject: wildcardSubject,
352
+ altnames: [wildcardSubject],
353
+ challenges: {
354
+ 'dns-01': dns01
355
+ }
356
+ };
357
+ const existingWildcardSite = Array.isArray(existingConfig.sites)
358
+ ? existingConfig.sites.find(site => site.subject === wildcardSubject)
359
+ : null;
360
+ applyRenewAtIfUnchanged(wildcardSite, existingWildcardSite);
361
+ sitesConfig.push(wildcardSite);
268
362
  }
269
-
270
- sitesConfig.push(siteConfig);
271
363
  });
272
364
 
273
365
  const newConfig = {
@@ -310,6 +402,47 @@ class Roster {
310
402
  log.info(`📁 config.json generated at ${configPath}`);
311
403
  }
312
404
 
405
+ /**
406
+ * Resolve handler for a host (exact match, then wildcard). Used when port is not in the key.
407
+ */
408
+ getHandlerForHost(host) {
409
+ const resolved = this.getHandlerAndKeyForHost(host);
410
+ return resolved ? resolved.handler : null;
411
+ }
412
+
413
+ /**
414
+ * Resolve handler and site key for a host (exact match, then wildcard). Used by getUrl for wildcard lookups.
415
+ */
416
+ getHandlerAndKeyForHost(host) {
417
+ const siteApp = this.sites[host];
418
+ if (siteApp) return { handler: siteApp, siteKey: host };
419
+ for (const key of Object.keys(this.sites)) {
420
+ if (key.startsWith('*.')) {
421
+ const pattern = key.split(':')[0];
422
+ if (hostMatchesWildcard(host, pattern)) return { handler: this.sites[key], siteKey: key };
423
+ }
424
+ }
425
+ return null;
426
+ }
427
+
428
+ /**
429
+ * Resolve virtualServer and appHandler for a host from portData (exact then wildcard).
430
+ */
431
+ getHandlerForPortData(host, portData) {
432
+ const virtualServer = portData.virtualServers[host];
433
+ const appHandler = portData.appHandlers[host];
434
+ if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
435
+ for (const key of Object.keys(portData.appHandlers)) {
436
+ if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
437
+ return {
438
+ virtualServer: portData.virtualServers[key],
439
+ appHandler: portData.appHandlers[key]
440
+ };
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
313
446
  handleRequest(req, res) {
314
447
  const host = req.headers.host || '';
315
448
 
@@ -320,7 +453,8 @@ class Roster {
320
453
  return;
321
454
  }
322
455
 
323
- const siteApp = this.sites[host];
456
+ const hostWithoutPort = host.split(':')[0];
457
+ const siteApp = this.getHandlerForHost(hostWithoutPort);
324
458
  if (siteApp) {
325
459
  siteApp(req, res);
326
460
  } else {
@@ -339,6 +473,16 @@ class Roster {
339
473
 
340
474
  const { domain, port } = this.parseDomainWithPort(domainString);
341
475
 
476
+ if (domain.startsWith('*.')) {
477
+ const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
478
+ this.domains.push(domain);
479
+ this.sites[domainKey] = requestHandler;
480
+ const root = wildcardRoot(domain);
481
+ if (root) this.wildcardZones.add(root);
482
+ log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
483
+ return this;
484
+ }
485
+
342
486
  const domainEntries = [domain];
343
487
  if ((domain.match(/\./g) || []).length < 2) {
344
488
  domainEntries.push(`www.${domain}`);
@@ -346,7 +490,6 @@ class Roster {
346
490
 
347
491
  this.domains.push(...domainEntries);
348
492
  domainEntries.forEach(d => {
349
- // Store with port information
350
493
  const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
351
494
  this.sites[domainKey] = requestHandler;
352
495
  });
@@ -370,31 +513,25 @@ class Roster {
370
513
 
371
514
  /**
372
515
  * Get the URL for a domain based on the current environment
373
- * @param {string} domain - The domain name
374
- * @returns {string|null} The URL if domain is registered, null otherwise
516
+ * @param {string} domain - The domain name (or subdomain that matches a wildcard site)
517
+ * @returns {string|null} The URL if domain is registered (exact or wildcard), null otherwise
375
518
  */
376
519
  getUrl(domain) {
377
- // Remove www prefix if present
378
520
  const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
379
521
 
380
- // Check if domain is registered
381
- const isRegistered = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
382
- if (!isRegistered) {
383
- return null;
384
- }
522
+ const exactMatch = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
523
+ const resolved = exactMatch ? { handler: exactMatch, siteKey: cleanDomain } : this.getHandlerAndKeyForHost(cleanDomain);
524
+ if (!resolved) return null;
385
525
 
386
- // Return URL based on environment
387
526
  if (this.local) {
388
- // Local mode: return localhost URL with assigned port
389
- if (this.domainPorts && this.domainPorts[cleanDomain]) {
390
- return `http://localhost:${this.domainPorts[cleanDomain]}`;
527
+ const pattern = resolved.siteKey.split(':')[0];
528
+ if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
529
+ return `http://localhost:${this.domainPorts[pattern]}`;
391
530
  }
392
531
  return null;
393
- } else {
394
- // Production mode: return HTTPS URL
395
- const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
396
- return `https://${cleanDomain}${port}`;
397
532
  }
533
+ const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
534
+ return `https://${cleanDomain}${port}`;
398
535
  }
399
536
 
400
537
  createVirtualServer(domain) {
@@ -517,7 +654,18 @@ class Roster {
517
654
  configDir: this.greenlockStorePath,
518
655
  maintainerEmail: this.email,
519
656
  cluster: this.cluster,
520
- staging: this.staging
657
+ staging: this.staging,
658
+ notify: (event, details) => {
659
+ const msg = typeof details === 'string' ? details : (details?.message ?? JSON.stringify(details));
660
+ // Suppress known benign warnings from ACME when using acme-dns-01-cli
661
+ if (event === 'warning' && typeof msg === 'string') {
662
+ if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
663
+ if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
664
+ }
665
+ if (event === 'error') log.error(msg);
666
+ else if (event === 'warning') log.warn(msg);
667
+ else log.info(msg);
668
+ }
521
669
  });
522
670
 
523
671
  return greenlock.ready(glx => {
@@ -535,15 +683,15 @@ class Roster {
535
683
  };
536
684
  }
537
685
 
538
- // Create completely isolated virtual server
539
686
  const virtualServer = this.createVirtualServer(domain);
540
687
  sitesByPort[port].virtualServers[domain] = virtualServer;
541
688
  this.domainServers[domain] = virtualServer;
542
689
 
543
- // Initialize app with virtual server
544
690
  const appHandler = siteApp(virtualServer);
545
691
  sitesByPort[port].appHandlers[domain] = appHandler;
546
- sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
692
+ if (!domain.startsWith('*.')) {
693
+ sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
694
+ }
547
695
  }
548
696
  }
549
697
 
@@ -552,27 +700,27 @@ class Roster {
552
700
  return (req, res) => {
553
701
  const host = req.headers.host || '';
554
702
 
555
- // Remove port from host header if present (e.g., "domain.com:8080" -> "domain.com")
556
- const hostWithoutPort = host.split(':')[0];
703
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
557
704
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
558
705
 
559
- // Handle www redirects
560
706
  if (hostWithoutPort.startsWith('www.')) {
561
707
  res.writeHead(301, { Location: `https://${domain}${req.url}` });
562
708
  res.end();
563
709
  return;
564
710
  }
565
711
 
566
- const virtualServer = portData.virtualServers[domain];
567
- const appHandler = portData.appHandlers[domain];
712
+ const resolved = this.getHandlerForPortData(domain, portData);
713
+ if (!resolved) {
714
+ res.writeHead(404);
715
+ res.end('Site not found');
716
+ return;
717
+ }
718
+ const { virtualServer, appHandler } = resolved;
568
719
 
569
720
  if (virtualServer && virtualServer.requestListeners.length > 0) {
570
- // Set fallback handler on virtual server for non-Socket.IO requests
571
721
  virtualServer.fallbackHandler = appHandler;
572
- // App registered listeners on virtual server - use them
573
722
  virtualServer.processRequest(req, res);
574
723
  } else if (appHandler) {
575
- // App returned a handler function - use it
576
724
  appHandler(req, res);
577
725
  } else {
578
726
  res.writeHead(404);
@@ -585,19 +733,16 @@ class Roster {
585
733
  log.info('HTTP server listening on port 80');
586
734
  });
587
735
 
588
- // Create upgrade handler for WebSocket connections
589
736
  const createUpgradeHandler = (portData) => {
590
737
  return (req, socket, head) => {
591
738
  const host = req.headers.host || '';
592
- const hostWithoutPort = host.split(':')[0];
739
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
593
740
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
594
741
 
595
- const virtualServer = portData.virtualServers[domain];
596
-
597
- if (virtualServer) {
598
- virtualServer.processUpgrade(req, socket, head);
742
+ const resolved = this.getHandlerForPortData(domain, portData);
743
+ if (resolved && resolved.virtualServer) {
744
+ resolved.virtualServer.processUpgrade(req, socket, head);
599
745
  } else {
600
- // No virtual server found, destroy the socket
601
746
  socket.destroy();
602
747
  }
603
748
  };
@@ -608,6 +753,29 @@ class Roster {
608
753
  const portNum = parseInt(port);
609
754
  const dispatcher = createDispatcher(portData);
610
755
  const upgradeHandler = createUpgradeHandler(portData);
756
+ const greenlockStorePath = this.greenlockStorePath;
757
+ const loadCert = (subjectDir) => {
758
+ const certPath = path.join(greenlockStorePath, 'live', subjectDir);
759
+ const keyPath = path.join(certPath, 'privkey.pem');
760
+ const certFilePath = path.join(certPath, 'cert.pem');
761
+ const chainPath = path.join(certPath, 'chain.pem');
762
+ if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
763
+ return {
764
+ key: fs.readFileSync(keyPath, 'utf8'),
765
+ cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
766
+ };
767
+ }
768
+ return null;
769
+ };
770
+ const zoneSubjectForHost = (servername) => {
771
+ const labels = String(servername || '').split('.').filter(Boolean);
772
+ if (labels.length < 3) return null;
773
+ return labels.slice(1).join('.');
774
+ };
775
+ const resolvePemsForServername = (servername) => {
776
+ if (!servername) return null;
777
+ return loadCert(servername) || loadCert(zoneSubjectForHost(servername));
778
+ };
611
779
 
612
780
  if (portNum === this.defaultPort) {
613
781
  // Bun has known gaps around SNICallback compatibility.
@@ -618,23 +786,27 @@ class Roster {
618
786
 
619
787
  if (isBunRuntime) {
620
788
  const primaryDomain = Object.keys(portData.virtualServers)[0];
621
- const certPath = path.join(this.greenlockStorePath, 'live', primaryDomain);
622
- const keyPath = path.join(certPath, 'privkey.pem');
623
- const certFilePath = path.join(certPath, 'cert.pem');
624
- const chainPath = path.join(certPath, 'chain.pem');
625
-
626
- if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
627
- const key = fs.readFileSync(keyPath, 'utf8');
628
- const cert = fs.readFileSync(certFilePath, 'utf8');
629
- const chain = fs.readFileSync(chainPath, 'utf8');
789
+ // Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
790
+ const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
791
+ const defaultPems = resolvePemsForServername(certSubject);
792
+
793
+ if (defaultPems) {
630
794
  httpsServer = https.createServer({
631
795
  ...tlsOpts,
632
- key,
633
- cert: cert + chain
796
+ key: defaultPems.key,
797
+ cert: defaultPems.cert,
798
+ SNICallback: (servername, callback) => {
799
+ try {
800
+ const pems = resolvePemsForServername(servername) || defaultPems;
801
+ callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
802
+ } catch (error) {
803
+ callback(error);
804
+ }
805
+ }
634
806
  }, dispatcher);
635
- log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
807
+ log.warn(`⚠️ Bun runtime detected: using file-based TLS with SNI for ${primaryDomain} on port ${portNum}`);
636
808
  } else {
637
- log.warn(`⚠️ Bun runtime detected but cert files missing for ${primaryDomain}; falling back to Greenlock HTTPS server`);
809
+ log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
638
810
  httpsServer = glx.httpsServer(tlsOpts, dispatcher);
639
811
  }
640
812
  } else {
@@ -654,25 +826,13 @@ class Roster {
654
826
  const httpsOptions = {
655
827
  minVersion: this.tlsMinVersion,
656
828
  maxVersion: this.tlsMaxVersion,
657
- // SNI callback to get certificates dynamically
658
- SNICallback: (domain, callback) => {
829
+ SNICallback: (servername, callback) => {
659
830
  try {
660
- const certPath = path.join(this.greenlockStorePath, 'live', domain);
661
- const keyPath = path.join(certPath, 'privkey.pem');
662
- const certFilePath = path.join(certPath, 'cert.pem');
663
- const chainPath = path.join(certPath, 'chain.pem');
664
-
665
- if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
666
- const key = fs.readFileSync(keyPath, 'utf8');
667
- const cert = fs.readFileSync(certFilePath, 'utf8');
668
- const chain = fs.readFileSync(chainPath, 'utf8');
669
-
670
- callback(null, tls.createSecureContext({
671
- key: key,
672
- cert: cert + chain
673
- }));
831
+ const pems = resolvePemsForServername(servername);
832
+ if (pems) {
833
+ callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
674
834
  } else {
675
- callback(new Error(`No certificate files available for ${domain}`));
835
+ callback(new Error(`No certificate files available for ${servername}`));
676
836
  }
677
837
  } catch (error) {
678
838
  callback(error);
@@ -711,4 +871,6 @@ class Roster {
711
871
  }
712
872
  }
713
873
 
714
- module.exports = Roster;
874
+ module.exports = Roster;
875
+ module.exports.wildcardRoot = wildcardRoot;
876
+ module.exports.hostMatchesWildcard = hostMatchesWildcard;