roster-server 2.1.0 → 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
@@ -210,7 +210,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
210
210
  - `email` (string): Your email for Let's Encrypt notifications.
211
211
  - `wwwPath` (string): Path to your `www` directory containing your sites.
212
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`).
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.
214
214
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
215
215
  - `local` (boolean): Set to `true` to run in local development mode.
216
216
  - `minLocalPort` (number): Minimum port for local mode (default: 4000).
package/index.js CHANGED
@@ -303,41 +303,40 @@ class Roster {
303
303
  }
304
304
 
305
305
  uniqueDomains.forEach(domain => {
306
- const altnames = [domain];
307
- if ((domain.match(/\./g) || []).length < 2) {
308
- altnames.push(`www.${domain}`);
309
- }
310
- if (this.wildcardZones.has(domain)) {
311
- altnames.push(`*.${domain}`);
312
- }
313
-
314
- let existingSite = null;
315
- if (existingConfig.sites) {
316
- existingSite = existingConfig.sites.find(site => site.subject === domain);
317
- }
318
-
319
- const siteConfig = {
320
- subject: domain,
321
- altnames: altnames
322
- };
323
-
324
- if (existingSite && existingSite.renewAt) {
306
+ const applyRenewAtIfUnchanged = (siteConfig, existingSite) => {
307
+ if (!existingSite || !existingSite.renewAt) return;
325
308
  const existingAltnames = Array.isArray(existingSite.altnames)
326
309
  ? [...existingSite.altnames].sort()
327
310
  : [];
328
- const nextAltnames = [...altnames].sort();
311
+ const nextAltnames = Array.isArray(siteConfig.altnames)
312
+ ? [...siteConfig.altnames].sort()
313
+ : [];
329
314
  const sameAltnames =
330
315
  existingAltnames.length === nextAltnames.length &&
331
316
  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
317
  if (sameAltnames) {
336
318
  siteConfig.renewAt = existingSite.renewAt;
337
319
  }
320
+ };
321
+
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}`);
338
326
  }
327
+ const primarySite = {
328
+ subject: domain,
329
+ altnames: primaryAltnames
330
+ };
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);
339
336
 
337
+ // Wildcard cert is issued separately and uses dns-01 only.
340
338
  if (this.wildcardZones.has(domain) && this.dnsChallenge) {
339
+ const wildcardSubject = `*.${domain}`;
341
340
  const dns01 = { ...this.dnsChallenge };
342
341
  if (dns01.propagationDelay === undefined) {
343
342
  dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
@@ -348,12 +347,19 @@ class Roster {
348
347
  if (dns01.dryRunDelay === undefined) {
349
348
  dns01.dryRunDelay = dns01.propagationDelay;
350
349
  }
351
- siteConfig.challenges = {
352
- 'dns-01': dns01
350
+ const wildcardSite = {
351
+ subject: wildcardSubject,
352
+ altnames: [wildcardSubject],
353
+ challenges: {
354
+ 'dns-01': dns01
355
+ }
353
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);
354
362
  }
355
-
356
- sitesConfig.push(siteConfig);
357
363
  });
358
364
 
359
365
  const newConfig = {
@@ -747,6 +753,29 @@ class Roster {
747
753
  const portNum = parseInt(port);
748
754
  const dispatcher = createDispatcher(portData);
749
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
+ };
750
779
 
751
780
  if (portNum === this.defaultPort) {
752
781
  // Bun has known gaps around SNICallback compatibility.
@@ -759,21 +788,23 @@ class Roster {
759
788
  const primaryDomain = Object.keys(portData.virtualServers)[0];
760
789
  // Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
761
790
  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');
791
+ const defaultPems = resolvePemsForServername(certSubject);
792
+
793
+ if (defaultPems) {
771
794
  httpsServer = https.createServer({
772
795
  ...tlsOpts,
773
- key,
774
- 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
+ }
775
806
  }, dispatcher);
776
- 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}`);
777
808
  } else {
778
809
  log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
779
810
  httpsServer = glx.httpsServer(tlsOpts, dispatcher);
@@ -792,30 +823,12 @@ class Roster {
792
823
  });
793
824
  } else {
794
825
  // 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
826
  const httpsOptions = {
810
827
  minVersion: this.tlsMinVersion,
811
828
  maxVersion: this.tlsMaxVersion,
812
829
  SNICallback: (servername, callback) => {
813
830
  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
- }
831
+ const pems = resolvePemsForServername(servername);
819
832
  if (pems) {
820
833
  callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
821
834
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -444,3 +444,40 @@ describe('Roster loadSites', () => {
444
444
  await assert.doesNotReject(roster.loadSites());
445
445
  });
446
446
  });
447
+
448
+ describe('Roster generateConfigJson', () => {
449
+ it('uses http-01 for apex/www and dns-01 only for wildcard cert', () => {
450
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-config-'));
451
+ try {
452
+ const roster = new Roster({
453
+ local: false,
454
+ greenlockStorePath: tmpDir,
455
+ dnsChallenge: {
456
+ module: 'acme-dns-01-cli',
457
+ propagationDelay: 120000,
458
+ autoContinue: false,
459
+ dryRunDelay: 120000
460
+ }
461
+ });
462
+
463
+ roster.domains = ['tagnu.com', 'www.tagnu.com', '*.tagnu.com'];
464
+ roster.wildcardZones.add('tagnu.com');
465
+ roster.generateConfigJson();
466
+
467
+ const configPath = path.join(tmpDir, 'config.json');
468
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
469
+ const apexSite = config.sites.find((site) => site.subject === 'tagnu.com');
470
+ const wildcardSite = config.sites.find((site) => site.subject === '*.tagnu.com');
471
+
472
+ assert.ok(apexSite);
473
+ assert.deepStrictEqual(apexSite.altnames.sort(), ['tagnu.com', 'www.tagnu.com'].sort());
474
+ assert.strictEqual(apexSite.challenges, undefined);
475
+
476
+ assert.ok(wildcardSite);
477
+ assert.deepStrictEqual(wildcardSite.altnames, ['*.tagnu.com']);
478
+ assert.ok(wildcardSite.challenges && wildcardSite.challenges['dns-01']);
479
+ } finally {
480
+ fs.rmSync(tmpDir, { recursive: true, force: true });
481
+ }
482
+ });
483
+ });