roster-server 2.0.6 → 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/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`).
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 = {};
@@ -252,6 +307,9 @@ class Roster {
252
307
  if ((domain.match(/\./g) || []).length < 2) {
253
308
  altnames.push(`www.${domain}`);
254
309
  }
310
+ if (this.wildcardZones.has(domain)) {
311
+ altnames.push(`*.${domain}`);
312
+ }
255
313
 
256
314
  let existingSite = null;
257
315
  if (existingConfig.sites) {
@@ -264,7 +322,35 @@ class Roster {
264
322
  };
265
323
 
266
324
  if (existingSite && existingSite.renewAt) {
267
- 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
+ };
268
354
  }
269
355
 
270
356
  sitesConfig.push(siteConfig);
@@ -310,6 +396,47 @@ class Roster {
310
396
  log.info(`📁 config.json generated at ${configPath}`);
311
397
  }
312
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
+
313
440
  handleRequest(req, res) {
314
441
  const host = req.headers.host || '';
315
442
 
@@ -320,7 +447,8 @@ class Roster {
320
447
  return;
321
448
  }
322
449
 
323
- const siteApp = this.sites[host];
450
+ const hostWithoutPort = host.split(':')[0];
451
+ const siteApp = this.getHandlerForHost(hostWithoutPort);
324
452
  if (siteApp) {
325
453
  siteApp(req, res);
326
454
  } else {
@@ -339,6 +467,16 @@ class Roster {
339
467
 
340
468
  const { domain, port } = this.parseDomainWithPort(domainString);
341
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
+
342
480
  const domainEntries = [domain];
343
481
  if ((domain.match(/\./g) || []).length < 2) {
344
482
  domainEntries.push(`www.${domain}`);
@@ -346,7 +484,6 @@ class Roster {
346
484
 
347
485
  this.domains.push(...domainEntries);
348
486
  domainEntries.forEach(d => {
349
- // Store with port information
350
487
  const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
351
488
  this.sites[domainKey] = requestHandler;
352
489
  });
@@ -370,31 +507,25 @@ class Roster {
370
507
 
371
508
  /**
372
509
  * 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
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
375
512
  */
376
513
  getUrl(domain) {
377
- // Remove www prefix if present
378
514
  const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
379
515
 
380
- // Check if domain is registered
381
- const isRegistered = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
382
- if (!isRegistered) {
383
- return null;
384
- }
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;
385
519
 
386
- // Return URL based on environment
387
520
  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]}`;
521
+ const pattern = resolved.siteKey.split(':')[0];
522
+ if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
523
+ return `http://localhost:${this.domainPorts[pattern]}`;
391
524
  }
392
525
  return null;
393
- } else {
394
- // Production mode: return HTTPS URL
395
- const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
396
- return `https://${cleanDomain}${port}`;
397
526
  }
527
+ const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
528
+ return `https://${cleanDomain}${port}`;
398
529
  }
399
530
 
400
531
  createVirtualServer(domain) {
@@ -517,7 +648,18 @@ class Roster {
517
648
  configDir: this.greenlockStorePath,
518
649
  maintainerEmail: this.email,
519
650
  cluster: this.cluster,
520
- 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
+ }
521
663
  });
522
664
 
523
665
  return greenlock.ready(glx => {
@@ -535,15 +677,15 @@ class Roster {
535
677
  };
536
678
  }
537
679
 
538
- // Create completely isolated virtual server
539
680
  const virtualServer = this.createVirtualServer(domain);
540
681
  sitesByPort[port].virtualServers[domain] = virtualServer;
541
682
  this.domainServers[domain] = virtualServer;
542
683
 
543
- // Initialize app with virtual server
544
684
  const appHandler = siteApp(virtualServer);
545
685
  sitesByPort[port].appHandlers[domain] = appHandler;
546
- sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
686
+ if (!domain.startsWith('*.')) {
687
+ sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
688
+ }
547
689
  }
548
690
  }
549
691
 
@@ -552,27 +694,27 @@ class Roster {
552
694
  return (req, res) => {
553
695
  const host = req.headers.host || '';
554
696
 
555
- // Remove port from host header if present (e.g., "domain.com:8080" -> "domain.com")
556
- const hostWithoutPort = host.split(':')[0];
697
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
557
698
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
558
699
 
559
- // Handle www redirects
560
700
  if (hostWithoutPort.startsWith('www.')) {
561
701
  res.writeHead(301, { Location: `https://${domain}${req.url}` });
562
702
  res.end();
563
703
  return;
564
704
  }
565
705
 
566
- const virtualServer = portData.virtualServers[domain];
567
- 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;
568
713
 
569
714
  if (virtualServer && virtualServer.requestListeners.length > 0) {
570
- // Set fallback handler on virtual server for non-Socket.IO requests
571
715
  virtualServer.fallbackHandler = appHandler;
572
- // App registered listeners on virtual server - use them
573
716
  virtualServer.processRequest(req, res);
574
717
  } else if (appHandler) {
575
- // App returned a handler function - use it
576
718
  appHandler(req, res);
577
719
  } else {
578
720
  res.writeHead(404);
@@ -585,19 +727,16 @@ class Roster {
585
727
  log.info('HTTP server listening on port 80');
586
728
  });
587
729
 
588
- // Create upgrade handler for WebSocket connections
589
730
  const createUpgradeHandler = (portData) => {
590
731
  return (req, socket, head) => {
591
732
  const host = req.headers.host || '';
592
- const hostWithoutPort = host.split(':')[0];
733
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
593
734
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
594
735
 
595
- const virtualServer = portData.virtualServers[domain];
596
-
597
- if (virtualServer) {
598
- virtualServer.processUpgrade(req, socket, head);
736
+ const resolved = this.getHandlerForPortData(domain, portData);
737
+ if (resolved && resolved.virtualServer) {
738
+ resolved.virtualServer.processUpgrade(req, socket, head);
599
739
  } else {
600
- // No virtual server found, destroy the socket
601
740
  socket.destroy();
602
741
  }
603
742
  };
@@ -618,7 +757,9 @@ class Roster {
618
757
 
619
758
  if (isBunRuntime) {
620
759
  const primaryDomain = Object.keys(portData.virtualServers)[0];
621
- const certPath = path.join(this.greenlockStorePath, 'live', primaryDomain);
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);
622
763
  const keyPath = path.join(certPath, 'privkey.pem');
623
764
  const certFilePath = path.join(certPath, 'cert.pem');
624
765
  const chainPath = path.join(certPath, 'chain.pem');
@@ -634,7 +775,7 @@ class Roster {
634
775
  }, dispatcher);
635
776
  log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
636
777
  } else {
637
- log.warn(`⚠️ Bun runtime detected but cert files missing for ${primaryDomain}; falling back to Greenlock HTTPS server`);
778
+ log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
638
779
  httpsServer = glx.httpsServer(tlsOpts, dispatcher);
639
780
  }
640
781
  } else {
@@ -651,28 +792,34 @@ class Roster {
651
792
  });
652
793
  } else {
653
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
+ };
654
809
  const httpsOptions = {
655
810
  minVersion: this.tlsMinVersion,
656
811
  maxVersion: this.tlsMaxVersion,
657
- // SNI callback to get certificates dynamically
658
- SNICallback: (domain, callback) => {
812
+ SNICallback: (servername, callback) => {
659
813
  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
- }));
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 }));
674
821
  } else {
675
- callback(new Error(`No certificate files available for ${domain}`));
822
+ callback(new Error(`No certificate files available for ${servername}`));
676
823
  }
677
824
  } catch (error) {
678
825
  callback(error);
@@ -711,4 +858,6 @@ class Roster {
711
858
  }
712
859
  }
713
860
 
714
- 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.6",
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",
@@ -37,6 +37,7 @@
37
37
  "homepage": "https://github.com/clasen/RosterServer#readme",
38
38
  "dependencies": {
39
39
  "@root/greenlock": "^4.0.5",
40
+ "acme-dns-01-cli": "^3.0.7",
40
41
  "lemonlog": "^1.2.0",
41
42
  "redirect-https": "^1.3.1"
42
43
  }
@@ -40,8 +40,10 @@ project/
40
40
  ├── www/
41
41
  │ ├── example.com/
42
42
  │ │ └── index.js # Handler for example.com
43
- └── api.example.com/
44
- └── index.js # Handler for subdomain
43
+ ├── api.example.com/
44
+ └── index.js # Handler for subdomain
45
+ │ └── *.example.com/
46
+ │ └── index.js # Wildcard: one handler for all subdomains
45
47
  └── server.js # Your setup
46
48
  ```
47
49
 
@@ -104,6 +106,10 @@ roster.register('example.com', (httpsServer) => {
104
106
 
105
107
  // With custom port
106
108
  roster.register('api.example.com:8443', handler);
109
+
110
+ // Wildcard: one handler for all subdomains (default port or custom)
111
+ roster.register('*.example.com', handler);
112
+ roster.register('*.example.com:8080', handler);
107
113
  ```
108
114
 
109
115
  ## Key Configuration Options
@@ -113,6 +119,7 @@ new Roster({
113
119
  email: 'admin@example.com', // Required for SSL
114
120
  wwwPath: '/srv/www', // Site handlers directory
115
121
  greenlockStorePath: '/srv/greenlock.d', // SSL storage
122
+ dnsChallenge: { ... }, // Optional override. Default is local/manual DNS-01 (acme-dns-01-cli)
116
123
 
117
124
  // Environment
118
125
  local: false, // true = HTTP, false = HTTPS
@@ -138,13 +145,13 @@ new Roster({
138
145
  Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
139
146
 
140
147
  ### `roster.register(domain, handler)`
141
- Manually register a domain handler. Domain can include port: `'api.com:8443'`.
148
+ Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
142
149
 
143
150
  ### `roster.getUrl(domain)`
144
151
  Get environment-aware URL:
145
152
  - Local mode: `http://localhost:{port}`
146
153
  - Production: `https://{domain}` or `https://{domain}:{port}`
147
- - Returns `null` if domain not registered
154
+ - Returns `null` if domain not registered. Supports wildcard-matched hosts (e.g. `getUrl('api.example.com')` when `*.example.com` is registered).
148
155
 
149
156
  ## How It Works
150
157
 
@@ -170,6 +177,7 @@ Each domain gets isolated server instance that simulates `http.Server`:
170
177
  - Auto-renewal 45 days before expiration
171
178
  - SNI support for multiple domains
172
179
  - Custom ports reuse certificates via SNI callback
180
+ - **Wildcard** (`*.example.com`): use folder `www/*.example.com/` or `roster.register('*.example.com', handler)`. Default DNS-01 plugin is local/manual `acme-dns-01-cli`; set `dnsChallenge` only when overriding provider integration.
173
181
 
174
182
  ## Common Issues & Solutions
175
183
 
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const http = require('http');
8
+ const os = require('os');
9
+ const Roster = require('../index.js');
10
+ const { wildcardRoot, hostMatchesWildcard } = require('../index.js');
11
+
12
+ function closePortServers(roster) {
13
+ if (roster.portServers && typeof roster.portServers === 'object') {
14
+ for (const server of Object.values(roster.portServers)) {
15
+ try {
16
+ server.close();
17
+ } catch (_) {}
18
+ }
19
+ }
20
+ }
21
+
22
+ function httpGet(host, port, pathname = '/') {
23
+ return new Promise((resolve, reject) => {
24
+ const req = http.get(
25
+ { host, port, path: pathname, headers: { host: host + (port === 80 ? '' : ':' + port) } },
26
+ (res) => {
27
+ let body = '';
28
+ res.on('data', (chunk) => { body += chunk; });
29
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
30
+ }
31
+ );
32
+ req.on('error', reject);
33
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
34
+ });
35
+ }
36
+
37
+ describe('wildcardRoot', () => {
38
+ it('returns root domain for *.example.com', () => {
39
+ assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
40
+ });
41
+ it('returns root for *.sub.example.com', () => {
42
+ assert.strictEqual(wildcardRoot('*.sub.example.com'), 'sub.example.com');
43
+ });
44
+ it('returns null for non-wildcard', () => {
45
+ assert.strictEqual(wildcardRoot('example.com'), null);
46
+ assert.strictEqual(wildcardRoot('api.example.com'), null);
47
+ });
48
+ it('returns null for empty or null', () => {
49
+ assert.strictEqual(wildcardRoot(''), null);
50
+ assert.strictEqual(wildcardRoot(null), null);
51
+ });
52
+ });
53
+
54
+ describe('hostMatchesWildcard', () => {
55
+ it('matches subdomain to pattern', () => {
56
+ assert.strictEqual(hostMatchesWildcard('api.example.com', '*.example.com'), true);
57
+ assert.strictEqual(hostMatchesWildcard('app.example.com', '*.example.com'), true);
58
+ assert.strictEqual(hostMatchesWildcard('a.example.com', '*.example.com'), true);
59
+ });
60
+ it('does not match apex domain', () => {
61
+ assert.strictEqual(hostMatchesWildcard('example.com', '*.example.com'), false);
62
+ });
63
+ it('does not match other zones', () => {
64
+ assert.strictEqual(hostMatchesWildcard('api.other.com', '*.example.com'), false);
65
+ assert.strictEqual(hostMatchesWildcard('example.com.evil.com', '*.example.com'), false);
66
+ });
67
+ it('returns false for invalid pattern', () => {
68
+ assert.strictEqual(hostMatchesWildcard('api.example.com', 'example.com'), false);
69
+ assert.strictEqual(hostMatchesWildcard('api.example.com', ''), false);
70
+ assert.strictEqual(hostMatchesWildcard('api.example.com', null), false);
71
+ });
72
+ it('matches case-insensitively (Host header may be any case)', () => {
73
+ assert.strictEqual(hostMatchesWildcard('Admin.Tagnu.com', '*.tagnu.com'), true);
74
+ assert.strictEqual(hostMatchesWildcard('API.EXAMPLE.COM', '*.example.com'), true);
75
+ });
76
+ });
77
+
78
+ describe('Roster', () => {
79
+ describe('parseDomainWithPort', () => {
80
+ it('parses *.example.com with default port', () => {
81
+ const roster = new Roster({ local: true });
82
+ assert.deepStrictEqual(roster.parseDomainWithPort('*.example.com'), {
83
+ domain: '*.example.com',
84
+ port: 443
85
+ });
86
+ });
87
+ it('parses *.example.com:8080', () => {
88
+ const roster = new Roster({ local: true });
89
+ assert.deepStrictEqual(roster.parseDomainWithPort('*.example.com:8080'), {
90
+ domain: '*.example.com',
91
+ port: 8080
92
+ });
93
+ });
94
+ it('parses normal domain with port', () => {
95
+ const roster = new Roster({ local: true });
96
+ assert.deepStrictEqual(roster.parseDomainWithPort('example.com:8443'), {
97
+ domain: 'example.com',
98
+ port: 8443
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('register (wildcard)', () => {
104
+ it('registers *.example.com and resolves handler for subdomain', () => {
105
+ const roster = new Roster({ local: true });
106
+ const handler = () => {};
107
+ roster.register('*.example.com', handler);
108
+ assert.strictEqual(roster.getHandlerForHost('api.example.com'), handler);
109
+ assert.strictEqual(roster.getHandlerForHost('app.example.com'), handler);
110
+ assert.strictEqual(roster.getHandlerForHost('example.com'), null);
111
+ assert.ok(roster.wildcardZones.has('example.com'));
112
+ });
113
+ it('registers *.example.com:8080 with custom port', () => {
114
+ const roster = new Roster({ local: true });
115
+ const handler = () => {};
116
+ roster.register('*.example.com:8080', handler);
117
+ assert.strictEqual(roster.sites['*.example.com:8080'], handler);
118
+ assert.ok(roster.wildcardZones.has('example.com'));
119
+ });
120
+ it('getHandlerAndKeyForHost returns handler and siteKey for wildcard match', () => {
121
+ const roster = new Roster({ local: true });
122
+ const handler = () => {};
123
+ roster.register('*.example.com', handler);
124
+ const resolved = roster.getHandlerAndKeyForHost('api.example.com');
125
+ assert.ok(resolved);
126
+ assert.strictEqual(resolved.handler, handler);
127
+ assert.strictEqual(resolved.siteKey, '*.example.com');
128
+ });
129
+ it('exact match takes precedence over wildcard', () => {
130
+ const roster = new Roster({ local: true });
131
+ const exactHandler = () => {};
132
+ const wildcardHandler = () => {};
133
+ roster.register('api.example.com', exactHandler);
134
+ roster.register('*.example.com', wildcardHandler);
135
+ assert.strictEqual(roster.getHandlerForHost('api.example.com'), exactHandler);
136
+ });
137
+ });
138
+
139
+ describe('getHandlerForPortData', () => {
140
+ it('returns exact match when present', () => {
141
+ const roster = new Roster({ local: true });
142
+ const vs = roster.createVirtualServer('example.com');
143
+ const handler = () => {};
144
+ const portData = {
145
+ virtualServers: { 'example.com': vs },
146
+ appHandlers: { 'example.com': handler }
147
+ };
148
+ const resolved = roster.getHandlerForPortData('example.com', portData);
149
+ assert.ok(resolved);
150
+ assert.strictEqual(resolved.virtualServer, vs);
151
+ assert.strictEqual(resolved.appHandler, handler);
152
+ });
153
+ it('returns wildcard match for subdomain', () => {
154
+ const roster = new Roster({ local: true });
155
+ const vs = roster.createVirtualServer('*.example.com');
156
+ const handler = () => {};
157
+ const portData = {
158
+ virtualServers: { '*.example.com': vs },
159
+ appHandlers: { '*.example.com': handler }
160
+ };
161
+ const resolved = roster.getHandlerForPortData('api.example.com', portData);
162
+ assert.ok(resolved);
163
+ assert.strictEqual(resolved.virtualServer, vs);
164
+ assert.strictEqual(resolved.appHandler, handler);
165
+ });
166
+ it('returns null when no match', () => {
167
+ const roster = new Roster({ local: true });
168
+ const portData = { virtualServers: {}, appHandlers: {} };
169
+ assert.strictEqual(roster.getHandlerForPortData('unknown.com', portData), null);
170
+ });
171
+ });
172
+
173
+ describe('getUrl (wildcard)', () => {
174
+ it('returns URL for wildcard-matched host in local mode', () => {
175
+ const roster = new Roster({ local: true });
176
+ roster.register('*.example.com', () => {});
177
+ roster.domainPorts = { '*.example.com': 9999 };
178
+ roster.local = true;
179
+ assert.strictEqual(roster.getUrl('api.example.com'), 'http://localhost:9999');
180
+ });
181
+ it('returns https URL for wildcard-matched host in production', () => {
182
+ const roster = new Roster({ local: false });
183
+ roster.register('*.example.com', () => {});
184
+ roster.local = false;
185
+ assert.strictEqual(roster.getUrl('api.example.com'), 'https://api.example.com');
186
+ });
187
+ it('returns null for host that matches no site', () => {
188
+ const roster = new Roster({ local: true });
189
+ assert.strictEqual(roster.getUrl('unknown.com'), null);
190
+ });
191
+ });
192
+
193
+ describe('register validation', () => {
194
+ it('throws when domain is missing', () => {
195
+ const roster = new Roster({ local: true });
196
+ assert.throws(() => roster.register('', () => {}), /Domain is required/);
197
+ assert.throws(() => roster.register(null, () => {}), /Domain is required/);
198
+ });
199
+ it('throws when handler is not a function', () => {
200
+ const roster = new Roster({ local: true });
201
+ assert.throws(() => roster.register('*.example.com', {}), /requestHandler must be a function/);
202
+ });
203
+ });
204
+
205
+ describe('constructor', () => {
206
+ it('throws when port is 80 and not local', () => {
207
+ assert.throws(() => new Roster({ port: 80, local: false }), /Port 80 is reserved/);
208
+ });
209
+ it('allows port 80 when local is true', () => {
210
+ const roster = new Roster({ port: 80, local: true });
211
+ assert.strictEqual(roster.defaultPort, 80);
212
+ });
213
+ it('sets defaultPort 443 when port not given', () => {
214
+ const roster = new Roster({ local: true });
215
+ assert.strictEqual(roster.defaultPort, 443);
216
+ });
217
+ it('uses acme-dns-01-cli by default (resolved to absolute path for Greenlock)', () => {
218
+ const roster = new Roster({ local: false });
219
+ assert.ok(roster.dnsChallenge);
220
+ assert.strictEqual(typeof roster.dnsChallenge.module, 'string');
221
+ assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
222
+ assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
223
+ assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
224
+ assert.strictEqual(roster.dnsChallenge.autoContinue, false);
225
+ assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
226
+ });
227
+ it('normalizes explicit acme-dns-01-cli module to wrapper and sets default propagationDelay', () => {
228
+ const roster = new Roster({ local: false, dnsChallenge: { module: 'acme-dns-01-cli' } });
229
+ assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
230
+ assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
231
+ assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
232
+ assert.strictEqual(roster.dnsChallenge.autoContinue, false);
233
+ assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
234
+ });
235
+ it('keeps explicit non-cli dnsChallenge module as-is', () => {
236
+ const roster = new Roster({ local: false, dnsChallenge: { module: 'acme-dns-01-route53', token: 'x' } });
237
+ assert.strictEqual(roster.dnsChallenge.module, 'acme-dns-01-route53');
238
+ assert.strictEqual(roster.dnsChallenge.token, 'x');
239
+ assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
240
+ assert.strictEqual(roster.dnsChallenge.autoContinue, false);
241
+ assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
242
+ });
243
+ it('normalizes explicit acme-dns-01-cli absolute path to wrapper', () => {
244
+ const path = require('path');
245
+ const roster = new Roster({
246
+ local: false,
247
+ dnsChallenge: { module: path.join('/srv/roster/node_modules/acme-dns-01-cli', 'index.js') }
248
+ });
249
+ assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
250
+ assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
251
+ assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
252
+ assert.strictEqual(roster.dnsChallenge.autoContinue, false);
253
+ assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
254
+ });
255
+ it('allows disabling DNS challenge with dnsChallenge: false', () => {
256
+ const roster = new Roster({ local: false, dnsChallenge: false });
257
+ assert.strictEqual(roster.dnsChallenge, null);
258
+ });
259
+ });
260
+
261
+ describe('register (normal domain)', () => {
262
+ it('adds domain and www when domain has fewer than 2 dots', () => {
263
+ const roster = new Roster({ local: true });
264
+ const handler = () => {};
265
+ roster.register('example.com', handler);
266
+ assert.strictEqual(roster.sites['example.com'], handler);
267
+ assert.strictEqual(roster.sites['www.example.com'], handler);
268
+ });
269
+ it('does not add www for multi-label domain', () => {
270
+ const roster = new Roster({ local: true });
271
+ const handler = () => {};
272
+ roster.register('api.example.com', handler);
273
+ assert.strictEqual(roster.sites['api.example.com'], handler);
274
+ assert.strictEqual(roster.sites['www.api.example.com'], undefined);
275
+ });
276
+ });
277
+
278
+ describe('getUrl (exact domain)', () => {
279
+ it('returns http://localhost:PORT in local mode for registered domain', () => {
280
+ const roster = new Roster({ local: true });
281
+ roster.register('exact.local', () => {});
282
+ roster.domainPorts = { 'exact.local': 4567 };
283
+ roster.local = true;
284
+ assert.strictEqual(roster.getUrl('exact.local'), 'http://localhost:4567');
285
+ });
286
+ it('returns https URL in production for registered domain', () => {
287
+ const roster = new Roster({ local: false });
288
+ roster.register('example.com', () => {});
289
+ roster.local = false;
290
+ assert.strictEqual(roster.getUrl('example.com'), 'https://example.com');
291
+ });
292
+ it('strips www and returns canonical URL (same as non-www)', () => {
293
+ const roster = new Roster({ local: false });
294
+ roster.register('example.com', () => {});
295
+ assert.strictEqual(roster.getUrl('www.example.com'), 'https://example.com');
296
+ assert.strictEqual(roster.getUrl('example.com'), 'https://example.com');
297
+ });
298
+ });
299
+
300
+ describe('handleRequest', () => {
301
+ it('redirects www to non-www with 301', () => {
302
+ const roster = new Roster({ local: true });
303
+ const res = {
304
+ writeHead: (status, headers) => {
305
+ assert.strictEqual(status, 301);
306
+ assert.strictEqual(headers.Location, 'https://example.com/');
307
+ },
308
+ end: () => {}
309
+ };
310
+ roster.handleRequest(
311
+ { headers: { host: 'www.example.com' }, url: '/' },
312
+ res
313
+ );
314
+ });
315
+ it('returns 404 when host has no handler', () => {
316
+ const roster = new Roster({ local: true });
317
+ let status;
318
+ const res = {
319
+ writeHead: (s) => { status = s; },
320
+ end: () => {}
321
+ };
322
+ roster.handleRequest(
323
+ { headers: { host: 'unknown.example.com' }, url: '/' },
324
+ res
325
+ );
326
+ assert.strictEqual(status, 404);
327
+ });
328
+ it('invokes handler for registered host', () => {
329
+ const roster = new Roster({ local: true });
330
+ let called = false;
331
+ roster.register('example.com', (req, res) => {
332
+ called = true;
333
+ res.writeHead(200);
334
+ res.end('ok');
335
+ });
336
+ const res = {
337
+ writeHead: () => {},
338
+ end: () => {}
339
+ };
340
+ roster.handleRequest(
341
+ { headers: { host: 'example.com' }, url: '/' },
342
+ res
343
+ );
344
+ assert.strictEqual(called, true);
345
+ });
346
+ });
347
+ });
348
+
349
+ describe('Roster local mode (local: true)', () => {
350
+ it('starts HTTP server and responds for registered domain', async () => {
351
+ const roster = new Roster({
352
+ local: true,
353
+ minLocalPort: 19090,
354
+ maxLocalPort: 19099,
355
+ hostname: 'localhost'
356
+ });
357
+ const body = 'local-mode-ok';
358
+ roster.register('testlocal.example', (server) => {
359
+ return (req, res) => {
360
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
361
+ res.end(body);
362
+ };
363
+ });
364
+ await roster.start();
365
+ try {
366
+ const port = roster.domainPorts['testlocal.example'];
367
+ assert.ok(typeof port === 'number' && port >= 19090 && port <= 19099);
368
+ await new Promise((r) => setTimeout(r, 50));
369
+ const result = await httpGet('localhost', port, '/');
370
+ assert.strictEqual(result.statusCode, 200);
371
+ assert.strictEqual(result.body, body);
372
+ } finally {
373
+ closePortServers(roster);
374
+ }
375
+ });
376
+
377
+ it('getUrl returns localhost URL after start', async () => {
378
+ const roster = new Roster({
379
+ local: true,
380
+ minLocalPort: 19100,
381
+ maxLocalPort: 19109
382
+ });
383
+ roster.register('geturltest.example', () => () => {});
384
+ await roster.start();
385
+ try {
386
+ const url = roster.getUrl('geturltest.example');
387
+ assert.ok(url && url.startsWith('http://localhost:'));
388
+ assert.ok(roster.domainPorts['geturltest.example'] !== undefined);
389
+ } finally {
390
+ closePortServers(roster);
391
+ }
392
+ });
393
+ });
394
+
395
+ describe('Roster loadSites', () => {
396
+ it('loads site from www directory and registers domain + www', async () => {
397
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
398
+ const wwwPath = path.join(tmpDir, 'www');
399
+ const siteDir = path.join(wwwPath, 'loaded.example');
400
+ fs.mkdirSync(siteDir, { recursive: true });
401
+ fs.writeFileSync(
402
+ path.join(siteDir, 'index.js'),
403
+ 'module.exports = () => (req, res) => { res.writeHead(200); res.end("loaded"); };',
404
+ 'utf8'
405
+ );
406
+ try {
407
+ const roster = new Roster({ wwwPath, local: true });
408
+ await roster.loadSites();
409
+ assert.ok(roster.sites['loaded.example']);
410
+ assert.ok(roster.sites['www.loaded.example']);
411
+ const handler = roster.sites['loaded.example'];
412
+ assert.strictEqual(typeof handler, 'function');
413
+ } finally {
414
+ fs.rmSync(tmpDir, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ it('loads wildcard site from www/*.example.com directory', async () => {
419
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
420
+ const wwwPath = path.join(tmpDir, 'www');
421
+ const siteDir = path.join(wwwPath, '*.wildcard.example');
422
+ fs.mkdirSync(siteDir, { recursive: true });
423
+ fs.writeFileSync(
424
+ path.join(siteDir, 'index.js'),
425
+ 'module.exports = () => (req, res) => { res.writeHead(200); res.end("wildcard"); };',
426
+ 'utf8'
427
+ );
428
+ try {
429
+ const roster = new Roster({ wwwPath, local: true });
430
+ await roster.loadSites();
431
+ assert.ok(roster.sites['*.wildcard.example']);
432
+ assert.ok(roster.wildcardZones.has('wildcard.example'));
433
+ assert.strictEqual(roster.getHandlerForHost('api.wildcard.example'), roster.sites['*.wildcard.example']);
434
+ } finally {
435
+ fs.rmSync(tmpDir, { recursive: true, force: true });
436
+ }
437
+ });
438
+
439
+ it('does not throw when www path does not exist', async () => {
440
+ const roster = new Roster({
441
+ wwwPath: path.join(os.tmpdir(), 'roster-nonexistent-' + Date.now()),
442
+ local: true
443
+ });
444
+ await assert.doesNotReject(roster.loadSites());
445
+ });
446
+ });
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ const legacyCli = require('acme-dns-01-cli');
4
+
5
+ function toPromise(fn, context) {
6
+ if (typeof fn !== 'function') {
7
+ return async function () {
8
+ return null;
9
+ };
10
+ }
11
+
12
+ return async function (opts) {
13
+ return new Promise((resolve, reject) => {
14
+ let done = false;
15
+ const finish = (err, result) => {
16
+ if (done) return;
17
+ done = true;
18
+ if (err) reject(err);
19
+ else resolve(result);
20
+ };
21
+
22
+ try {
23
+ // Legacy callback style
24
+ if (fn.length >= 2) {
25
+ fn.call(context, opts, finish);
26
+ return;
27
+ }
28
+
29
+ // Promise or sync style
30
+ Promise.resolve(fn.call(context, opts)).then(
31
+ (result) => finish(null, result),
32
+ finish
33
+ );
34
+ } catch (err) {
35
+ finish(err);
36
+ }
37
+ });
38
+ };
39
+ }
40
+
41
+ module.exports.create = function create(config = {}) {
42
+ const challenger = legacyCli.create(config);
43
+ const propagationDelay = Number.isFinite(config.propagationDelay)
44
+ ? config.propagationDelay
45
+ : 120000;
46
+ const envAutoContinue = process.env.ROSTER_DNS_AUTO_CONTINUE;
47
+ const parseAutoContinue = (value, fallback) => {
48
+ if (value === undefined || value === null || value === '') return fallback;
49
+ const normalized = String(value).trim().toLowerCase();
50
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
51
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
52
+ return fallback;
53
+ };
54
+ const autoContinue = config.autoContinue !== undefined
55
+ ? parseAutoContinue(config.autoContinue, false)
56
+ : parseAutoContinue(envAutoContinue, false);
57
+ const dryRunDelay = Number.isFinite(config.dryRunDelay)
58
+ ? config.dryRunDelay
59
+ : Number.isFinite(Number(process.env.ROSTER_DNS_DRYRUN_DELAY_MS))
60
+ ? Number(process.env.ROSTER_DNS_DRYRUN_DELAY_MS)
61
+ : propagationDelay;
62
+
63
+ function sleep(ms) {
64
+ return new Promise((resolve) => setTimeout(resolve, ms));
65
+ }
66
+
67
+ const presentedByHost = new Map();
68
+
69
+ async function setChallenge(opts) {
70
+ const isInteractive = Boolean(process.stdin && process.stdin.isTTY);
71
+ if (isInteractive && !autoContinue) {
72
+ return toPromise(challenger.set, challenger)(opts);
73
+ }
74
+
75
+ const ch = opts?.challenge || {};
76
+ console.info('');
77
+ console.info("[ACME dns-01 '" + (ch.altname || opts?.altname || 'unknown') + "' CHALLENGE]");
78
+ console.info("You're about to receive the following DNS query:");
79
+ console.info('');
80
+ const dnsHost = String(ch.dnsHost || '');
81
+ const dnsAuth = ch.dnsAuthorization || opts?.dnsAuthorization || null;
82
+ if (dnsHost && dnsAuth) {
83
+ presentedByHost.set(dnsHost, dnsAuth);
84
+ }
85
+ const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
86
+ const effectiveDelay = isDryRunChallenge
87
+ ? Math.max(0, dryRunDelay)
88
+ : propagationDelay;
89
+
90
+ console.info(
91
+ '\tTXT\t' +
92
+ (ch.dnsHost || '_acme-challenge.<domain>') +
93
+ '\t' +
94
+ (ch.dnsAuthorization || '<dns-authorization-token>') +
95
+ '\tTTL 60'
96
+ );
97
+ console.info('');
98
+ console.info(
99
+ 'Non-interactive mode (or autoContinue) detected. ' +
100
+ 'Set the TXT record now. Continuing automatically in ' +
101
+ effectiveDelay +
102
+ 'ms...'
103
+ );
104
+ await sleep(effectiveDelay);
105
+ return null;
106
+ }
107
+
108
+ async function getChallenge(opts) {
109
+ const ch = opts?.challenge || {};
110
+ const dnsHost = String(ch.dnsHost || opts?.dnsHost || '');
111
+ const dnsAuthorization =
112
+ ch.dnsAuthorization ||
113
+ opts?.dnsAuthorization ||
114
+ (dnsHost ? presentedByHost.get(dnsHost) : null);
115
+
116
+ if (!dnsAuthorization) {
117
+ return null;
118
+ }
119
+
120
+ return {
121
+ ...(typeof ch === 'object' ? ch : {}),
122
+ dnsAuthorization
123
+ };
124
+ }
125
+
126
+ const wrapped = {
127
+ propagationDelay,
128
+ set: setChallenge,
129
+ remove: toPromise(challenger.remove, challenger),
130
+ get: getChallenge,
131
+ zones: async (opts) => {
132
+ const dnsHost =
133
+ opts?.dnsHost ||
134
+ opts?.challenge?.dnsHost ||
135
+ opts?.challenge?.altname ||
136
+ opts?.altname;
137
+
138
+ if (!dnsHost || typeof dnsHost !== 'string') {
139
+ return [];
140
+ }
141
+
142
+ // Best-effort root zone extraction for legacy/manual flow.
143
+ const zone = dnsHost
144
+ .replace(/^_acme-challenge\./, '')
145
+ .replace(/^_greenlock-[^.]+\./, '')
146
+ .replace(/\.$/, '');
147
+
148
+ return zone ? [zone] : [];
149
+ }
150
+ };
151
+
152
+ if (typeof challenger.init === 'function') {
153
+ wrapped.init = toPromise(challenger.init, challenger);
154
+ }
155
+
156
+ if (challenger.options && typeof challenger.options === 'object') {
157
+ wrapped.options = { ...challenger.options };
158
+ }
159
+
160
+ return wrapped;
161
+ };