roster-server 2.1.0 → 2.1.2
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 +83 -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,41 @@ 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 normalizeHostInput = (value) => {
|
|
758
|
+
if (typeof value === 'string') return value;
|
|
759
|
+
if (!value || typeof value !== 'object') return '';
|
|
760
|
+
if (typeof value.servername === 'string') return value.servername;
|
|
761
|
+
if (typeof value.hostname === 'string') return value.hostname;
|
|
762
|
+
if (typeof value.subject === 'string') return value.subject;
|
|
763
|
+
return '';
|
|
764
|
+
};
|
|
765
|
+
const loadCert = (subjectDir) => {
|
|
766
|
+
const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
767
|
+
if (!normalizedSubject) return null;
|
|
768
|
+
const certPath = path.join(greenlockStorePath, 'live', normalizedSubject);
|
|
769
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
770
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
771
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
772
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
773
|
+
return {
|
|
774
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
775
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
};
|
|
780
|
+
const zoneSubjectForHost = (servername) => {
|
|
781
|
+
const host = normalizeHostInput(servername).trim().toLowerCase();
|
|
782
|
+
const labels = host.split('.').filter(Boolean);
|
|
783
|
+
if (labels.length < 3) return null;
|
|
784
|
+
return labels.slice(1).join('.');
|
|
785
|
+
};
|
|
786
|
+
const resolvePemsForServername = (servername) => {
|
|
787
|
+
const host = normalizeHostInput(servername).trim().toLowerCase();
|
|
788
|
+
if (!host) return null;
|
|
789
|
+
return loadCert(host) || loadCert(zoneSubjectForHost(host));
|
|
790
|
+
};
|
|
750
791
|
|
|
751
792
|
if (portNum === this.defaultPort) {
|
|
752
793
|
// Bun has known gaps around SNICallback compatibility.
|
|
@@ -759,21 +800,23 @@ class Roster {
|
|
|
759
800
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
760
801
|
// Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
|
|
761
802
|
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');
|
|
803
|
+
const defaultPems = resolvePemsForServername(certSubject);
|
|
804
|
+
|
|
805
|
+
if (defaultPems) {
|
|
771
806
|
httpsServer = https.createServer({
|
|
772
807
|
...tlsOpts,
|
|
773
|
-
key,
|
|
774
|
-
cert: cert
|
|
808
|
+
key: defaultPems.key,
|
|
809
|
+
cert: defaultPems.cert,
|
|
810
|
+
SNICallback: (servername, callback) => {
|
|
811
|
+
try {
|
|
812
|
+
const pems = resolvePemsForServername(servername) || defaultPems;
|
|
813
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
814
|
+
} catch (error) {
|
|
815
|
+
callback(error);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
775
818
|
}, dispatcher);
|
|
776
|
-
log.warn(`⚠️ Bun runtime detected: using
|
|
819
|
+
log.warn(`⚠️ Bun runtime detected: using file-based TLS with SNI for ${primaryDomain} on port ${portNum}`);
|
|
777
820
|
} else {
|
|
778
821
|
log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
|
|
779
822
|
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
@@ -792,30 +835,12 @@ class Roster {
|
|
|
792
835
|
});
|
|
793
836
|
} else {
|
|
794
837
|
// 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
838
|
const httpsOptions = {
|
|
810
839
|
minVersion: this.tlsMinVersion,
|
|
811
840
|
maxVersion: this.tlsMaxVersion,
|
|
812
841
|
SNICallback: (servername, callback) => {
|
|
813
842
|
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
|
-
}
|
|
843
|
+
const pems = resolvePemsForServername(servername);
|
|
819
844
|
if (pems) {
|
|
820
845
|
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
821
846
|
} 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
|
+
});
|