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 +1 -1
- package/index.js +71 -58
- package/package.json +1 -1
- package/test/roster-server.test.js +37 -0
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
|
|
307
|
-
|
|
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 =
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
+
});
|