roster-server 2.4.0 → 2.4.4
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/.greenlockrc +1 -0
- package/README.md +50 -39
- package/demo/cluster-sticky-worker.js +60 -0
- package/demo/https-cluster-configurable.js +84 -0
- package/docs/architecture/decision-roster-cluster-friendly-api.md +30 -0
- package/docs/decisions/roster-autocert-defaults.md +20 -0
- package/index.js +320 -294
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +41 -58
- package/test/roster-server.test.js +305 -150
- package/docs/decisions/external-runtime-router-api.md +0 -12
- package/docs/patterns/roster-dispatch-reuse.md +0 -12
package/index.js
CHANGED
|
@@ -109,19 +109,6 @@ function parseBooleanFlag(value, fallback = false) {
|
|
|
109
109
|
return fallback;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function normalizeHostInput(value) {
|
|
113
|
-
if (typeof value === 'string') return value;
|
|
114
|
-
if (!value || typeof value !== 'object') return '';
|
|
115
|
-
if (typeof value.servername === 'string') return value.servername;
|
|
116
|
-
if (typeof value.hostname === 'string') return value.hostname;
|
|
117
|
-
if (typeof value.subject === 'string') return value.subject;
|
|
118
|
-
return '';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function hostWithoutPort(value) {
|
|
122
|
-
return normalizeHostInput(value).split(':')[0].trim().toLowerCase();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
112
|
function normalizeDomainForLocalHost(domain) {
|
|
126
113
|
return (domain || '').trim().toLowerCase().replace(/^www\./, '');
|
|
127
114
|
}
|
|
@@ -265,6 +252,9 @@ class Roster {
|
|
|
265
252
|
this.portServers = {}; // Store servers by port
|
|
266
253
|
this.domainPorts = {}; // Store domain → port mapping for local mode
|
|
267
254
|
this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
|
|
255
|
+
this._sitesByPort = {};
|
|
256
|
+
this._initialized = false;
|
|
257
|
+
this._sniCallback = null;
|
|
268
258
|
this.hostname = options.hostname ?? '::';
|
|
269
259
|
this.filename = options.filename || 'index';
|
|
270
260
|
this.minLocalPort = options.minLocalPort || 4000;
|
|
@@ -278,6 +268,12 @@ class Roster {
|
|
|
278
268
|
}
|
|
279
269
|
|
|
280
270
|
this.skipLocalCheck = parseBooleanFlag(options.skipLocalCheck, true);
|
|
271
|
+
this.autoCertificates = parseBooleanFlag(options.autoCertificates, true);
|
|
272
|
+
this.certificateRenewIntervalMs = Number.isFinite(Number(options.certificateRenewIntervalMs))
|
|
273
|
+
? Math.max(60000, Number(options.certificateRenewIntervalMs))
|
|
274
|
+
: 12 * 60 * 60 * 1000;
|
|
275
|
+
this._greenlockRuntime = null;
|
|
276
|
+
this._certificateRenewTimer = null;
|
|
281
277
|
|
|
282
278
|
const port = options.port === undefined ? 443 : options.port;
|
|
283
279
|
if (port === 80 && !this.local) {
|
|
@@ -562,21 +558,13 @@ class Roster {
|
|
|
562
558
|
}
|
|
563
559
|
}
|
|
564
560
|
|
|
565
|
-
|
|
566
|
-
* Register a domain handler.
|
|
567
|
-
* @param {string} domainString Domain, optionally with :port.
|
|
568
|
-
* @param {(virtualServer: VirtualServer) => Function} requestHandler Site app factory.
|
|
569
|
-
* @param {{ silent?: boolean, skipDomainBookkeeping?: boolean }} [options]
|
|
570
|
-
* @returns {Roster}
|
|
571
|
-
*/
|
|
572
|
-
register(domainString, requestHandler, options = {}) {
|
|
561
|
+
register(domainString, requestHandler) {
|
|
573
562
|
if (!domainString) {
|
|
574
563
|
throw new Error('Domain is required');
|
|
575
564
|
}
|
|
576
565
|
if (typeof requestHandler !== 'function') {
|
|
577
566
|
throw new Error('requestHandler must be a function');
|
|
578
567
|
}
|
|
579
|
-
const { silent = false, skipDomainBookkeeping = false } = options || {};
|
|
580
568
|
|
|
581
569
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
582
570
|
|
|
@@ -586,13 +574,11 @@ class Roster {
|
|
|
586
574
|
return this;
|
|
587
575
|
}
|
|
588
576
|
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
589
|
-
|
|
577
|
+
this.domains.push(domain);
|
|
590
578
|
this.sites[domainKey] = requestHandler;
|
|
591
579
|
const root = wildcardRoot(domain);
|
|
592
580
|
if (root) this.wildcardZones.add(root);
|
|
593
|
-
|
|
594
|
-
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
595
|
-
}
|
|
581
|
+
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
596
582
|
return this;
|
|
597
583
|
}
|
|
598
584
|
|
|
@@ -601,17 +587,13 @@ class Roster {
|
|
|
601
587
|
domainEntries.push(`www.${domain}`);
|
|
602
588
|
}
|
|
603
589
|
|
|
604
|
-
|
|
605
|
-
this.domains.push(...domainEntries);
|
|
606
|
-
}
|
|
590
|
+
this.domains.push(...domainEntries);
|
|
607
591
|
domainEntries.forEach(d => {
|
|
608
592
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
609
593
|
this.sites[domainKey] = requestHandler;
|
|
610
594
|
});
|
|
611
595
|
|
|
612
|
-
|
|
613
|
-
log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
614
|
-
}
|
|
596
|
+
log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
615
597
|
return this;
|
|
616
598
|
}
|
|
617
599
|
|
|
@@ -655,151 +637,6 @@ class Roster {
|
|
|
655
637
|
return new VirtualServer(domain);
|
|
656
638
|
}
|
|
657
639
|
|
|
658
|
-
resolveRoutedHost(rawHost, hostAliases) {
|
|
659
|
-
const normalizedHost = hostWithoutPort(rawHost);
|
|
660
|
-
if (!normalizedHost) {
|
|
661
|
-
return {
|
|
662
|
-
normalizedHost: '',
|
|
663
|
-
routedHost: '',
|
|
664
|
-
isWww: false
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
let aliasedHost = normalizedHost;
|
|
669
|
-
if (typeof hostAliases === 'function') {
|
|
670
|
-
const mapped = hostAliases(normalizedHost);
|
|
671
|
-
if (mapped) aliasedHost = hostWithoutPort(mapped);
|
|
672
|
-
} else if (hostAliases && typeof hostAliases === 'object') {
|
|
673
|
-
const mapped = hostAliases[normalizedHost];
|
|
674
|
-
if (mapped) aliasedHost = hostWithoutPort(mapped);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const isWww = aliasedHost.startsWith('www.');
|
|
678
|
-
return {
|
|
679
|
-
normalizedHost: aliasedHost,
|
|
680
|
-
routedHost: isWww ? aliasedHost.slice(4) : aliasedHost,
|
|
681
|
-
isWww
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
createPortRequestDispatcher(portData, options = {}) {
|
|
686
|
-
const {
|
|
687
|
-
hostAliases,
|
|
688
|
-
allowWwwRedirect = true
|
|
689
|
-
} = options;
|
|
690
|
-
|
|
691
|
-
return (req, res) => {
|
|
692
|
-
const { normalizedHost, routedHost, isWww } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
|
|
693
|
-
|
|
694
|
-
if (allowWwwRedirect && isWww) {
|
|
695
|
-
res.writeHead(301, { Location: `https://${routedHost}${req.url}` });
|
|
696
|
-
res.end();
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const resolved = this.getHandlerForPortData(routedHost, portData);
|
|
701
|
-
if (!resolved) {
|
|
702
|
-
res.writeHead(404);
|
|
703
|
-
res.end('Site not found');
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
const { virtualServer, appHandler } = resolved;
|
|
707
|
-
|
|
708
|
-
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
709
|
-
virtualServer.fallbackHandler = appHandler;
|
|
710
|
-
virtualServer.processRequest(req, res);
|
|
711
|
-
} else if (appHandler) {
|
|
712
|
-
appHandler(req, res);
|
|
713
|
-
} else {
|
|
714
|
-
res.writeHead(404);
|
|
715
|
-
res.end('Site not found');
|
|
716
|
-
}
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
createPortUpgradeDispatcher(portData, options = {}) {
|
|
721
|
-
const { hostAliases } = options;
|
|
722
|
-
return (req, socket, head) => {
|
|
723
|
-
const { routedHost } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
|
|
724
|
-
const resolved = this.getHandlerForPortData(routedHost, portData);
|
|
725
|
-
if (resolved && resolved.virtualServer) {
|
|
726
|
-
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
727
|
-
} else {
|
|
728
|
-
socket.destroy();
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Prepare port-based virtual server/app handler data without listening.
|
|
735
|
-
* @param {{ targetPort?: number }} [options]
|
|
736
|
-
* @returns {{ sitesByPort: Record<number, { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }> }}
|
|
737
|
-
*/
|
|
738
|
-
prepareSites(options = {}) {
|
|
739
|
-
const { targetPort } = options;
|
|
740
|
-
const sitesByPort = {};
|
|
741
|
-
|
|
742
|
-
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
743
|
-
if (hostKey.startsWith('www.')) continue;
|
|
744
|
-
|
|
745
|
-
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
746
|
-
if (targetPort !== undefined && port !== targetPort) continue;
|
|
747
|
-
|
|
748
|
-
if (!sitesByPort[port]) {
|
|
749
|
-
sitesByPort[port] = {
|
|
750
|
-
virtualServers: {},
|
|
751
|
-
appHandlers: {}
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const virtualServer = this.createVirtualServer(domain);
|
|
756
|
-
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
757
|
-
this.domainServers[domain] = virtualServer;
|
|
758
|
-
|
|
759
|
-
const appHandler = siteApp(virtualServer);
|
|
760
|
-
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
761
|
-
if (!domain.startsWith('*.')) {
|
|
762
|
-
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
return { sitesByPort };
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Build a router that can be attached to externally managed servers (e.g. sticky-session workers).
|
|
771
|
-
* This does not call listen() and does not boot Greenlock.
|
|
772
|
-
* @param {{ targetPort?: number, hostAliases?: Record<string, string>|((host: string) => string|undefined), allowWwwRedirect?: boolean }} [options]
|
|
773
|
-
* @returns {{ attach: (server: import('http').Server) => import('http').Server, dispatchRequest: Function, dispatchUpgrade: Function, portData: { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }, diagnostics: { targetPort: number, hosts: string[], virtualServers: string[] } }}
|
|
774
|
-
*/
|
|
775
|
-
buildRuntimeRouter(options = {}) {
|
|
776
|
-
const {
|
|
777
|
-
targetPort = this.defaultPort,
|
|
778
|
-
hostAliases,
|
|
779
|
-
allowWwwRedirect = true
|
|
780
|
-
} = options;
|
|
781
|
-
const { sitesByPort } = this.prepareSites({ targetPort });
|
|
782
|
-
const portData = sitesByPort[targetPort] || { virtualServers: {}, appHandlers: {} };
|
|
783
|
-
const dispatchRequest = this.createPortRequestDispatcher(portData, { hostAliases, allowWwwRedirect });
|
|
784
|
-
const dispatchUpgrade = this.createPortUpgradeDispatcher(portData, { hostAliases });
|
|
785
|
-
|
|
786
|
-
return {
|
|
787
|
-
attach: (server) => {
|
|
788
|
-
server.on('request', dispatchRequest);
|
|
789
|
-
server.on('upgrade', dispatchUpgrade);
|
|
790
|
-
return server;
|
|
791
|
-
},
|
|
792
|
-
dispatchRequest,
|
|
793
|
-
dispatchUpgrade,
|
|
794
|
-
portData,
|
|
795
|
-
diagnostics: {
|
|
796
|
-
targetPort,
|
|
797
|
-
hosts: Object.keys(portData.appHandlers),
|
|
798
|
-
virtualServers: Object.keys(portData.virtualServers)
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
|
|
803
640
|
// Assign port to domain, detecting collisions with already assigned ports
|
|
804
641
|
assignPortToDomain(domain) {
|
|
805
642
|
let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
|
|
@@ -834,85 +671,151 @@ class Roster {
|
|
|
834
671
|
return null;
|
|
835
672
|
}
|
|
836
673
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
674
|
+
_normalizeHostInput(value) {
|
|
675
|
+
if (typeof value === 'string') return value;
|
|
676
|
+
if (!value || typeof value !== 'object') return '';
|
|
677
|
+
if (typeof value.servername === 'string') return value.servername;
|
|
678
|
+
if (typeof value.hostname === 'string') return value.hostname;
|
|
679
|
+
if (typeof value.subject === 'string') return value.subject;
|
|
680
|
+
return '';
|
|
681
|
+
}
|
|
845
682
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
683
|
+
_loadCert(subjectDir) {
|
|
684
|
+
const normalizedSubject = this._normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
685
|
+
if (!normalizedSubject) return null;
|
|
686
|
+
const certPath = path.join(this.greenlockStorePath, 'live', normalizedSubject);
|
|
687
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
688
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
689
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
690
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
691
|
+
return {
|
|
692
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
693
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
850
698
|
|
|
851
|
-
|
|
852
|
-
|
|
699
|
+
_resolvePemsForServername(servername) {
|
|
700
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
701
|
+
if (!host) return null;
|
|
702
|
+
const candidates = buildCertLookupCandidates(host);
|
|
703
|
+
for (const candidate of candidates) {
|
|
704
|
+
const pems = this._loadCert(candidate);
|
|
705
|
+
if (pems) return pems;
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
853
709
|
|
|
854
|
-
|
|
855
|
-
|
|
710
|
+
_initSiteHandlers() {
|
|
711
|
+
this._sitesByPort = {};
|
|
712
|
+
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
713
|
+
if (hostKey.startsWith('www.')) continue;
|
|
714
|
+
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
715
|
+
if (!this._sitesByPort[port]) {
|
|
716
|
+
this._sitesByPort[port] = {
|
|
717
|
+
virtualServers: {},
|
|
718
|
+
appHandlers: {}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
856
721
|
|
|
857
|
-
// Create virtual server for the domain
|
|
858
722
|
const virtualServer = this.createVirtualServer(domain);
|
|
723
|
+
this._sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
859
724
|
this.domainServers[domain] = virtualServer;
|
|
860
725
|
|
|
861
|
-
// Initialize app with virtual server
|
|
862
726
|
const appHandler = siteApp(virtualServer);
|
|
727
|
+
this._sitesByPort[port].appHandlers[domain] = appHandler;
|
|
728
|
+
if (!domain.startsWith('*.')) {
|
|
729
|
+
this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
863
733
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if (virtualServer.requestListeners.length > 0) {
|
|
870
|
-
virtualServer.processRequest(req, res);
|
|
871
|
-
} else if (appHandler) {
|
|
872
|
-
appHandler(req, res);
|
|
873
|
-
} else {
|
|
874
|
-
res.writeHead(404);
|
|
875
|
-
res.end('Site not found');
|
|
876
|
-
}
|
|
877
|
-
};
|
|
734
|
+
_createDispatcher(portData) {
|
|
735
|
+
return (req, res) => {
|
|
736
|
+
const host = req.headers.host || '';
|
|
737
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
738
|
+
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
878
739
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
740
|
+
if (hostWithoutPort.startsWith('www.')) {
|
|
741
|
+
const protocol = this.local ? 'http' : 'https';
|
|
742
|
+
res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
|
|
743
|
+
res.end();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
882
746
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
747
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
748
|
+
if (!resolved) {
|
|
749
|
+
res.writeHead(404);
|
|
750
|
+
res.end('Site not found');
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const { virtualServer, appHandler } = resolved;
|
|
887
754
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
})
|
|
755
|
+
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
756
|
+
virtualServer.fallbackHandler = appHandler;
|
|
757
|
+
virtualServer.processRequest(req, res);
|
|
758
|
+
} else if (appHandler) {
|
|
759
|
+
appHandler(req, res);
|
|
760
|
+
} else {
|
|
761
|
+
res.writeHead(404);
|
|
762
|
+
res.end('Site not found');
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
}
|
|
892
766
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
767
|
+
_createUpgradeHandler(portData) {
|
|
768
|
+
return (req, socket, head) => {
|
|
769
|
+
const host = req.headers.host || '';
|
|
770
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
771
|
+
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
897
772
|
|
|
898
|
-
|
|
899
|
-
|
|
773
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
774
|
+
if (resolved && resolved.virtualServer) {
|
|
775
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
776
|
+
} else {
|
|
777
|
+
socket.destroy();
|
|
778
|
+
}
|
|
779
|
+
};
|
|
900
780
|
}
|
|
901
781
|
|
|
902
|
-
|
|
903
|
-
|
|
782
|
+
_initSniResolver() {
|
|
783
|
+
this._sniCallback = (servername, callback) => {
|
|
784
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
785
|
+
try {
|
|
786
|
+
const pems = this._resolvePemsForServername(normalizedServername);
|
|
787
|
+
if (pems) {
|
|
788
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
} catch (error) {
|
|
792
|
+
callback(error);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
904
795
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
796
|
+
// Cluster-friendly automatic issuance path (no internal listen lifecycle).
|
|
797
|
+
if (!this._greenlockRuntime || !normalizedServername) {
|
|
798
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
909
801
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
802
|
+
this._greenlockRuntime.get({ servername: normalizedServername })
|
|
803
|
+
.then(() => {
|
|
804
|
+
const issued = this._resolvePemsForServername(normalizedServername);
|
|
805
|
+
if (issued) {
|
|
806
|
+
callback(null, tls.createSecureContext({ key: issued.key, cert: issued.cert }));
|
|
807
|
+
} else {
|
|
808
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
809
|
+
}
|
|
810
|
+
})
|
|
811
|
+
.catch((error) => {
|
|
812
|
+
callback(error);
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
}
|
|
914
816
|
|
|
915
|
-
|
|
817
|
+
_buildGreenlockOptions() {
|
|
818
|
+
return {
|
|
916
819
|
packageRoot: __dirname,
|
|
917
820
|
configDir: this.greenlockStorePath,
|
|
918
821
|
maintainerEmail: this.email,
|
|
@@ -966,7 +869,6 @@ class Roster {
|
|
|
966
869
|
if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
|
|
967
870
|
msg = `[${eventDomain}] ${msg}`;
|
|
968
871
|
}
|
|
969
|
-
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
970
872
|
if (event === 'warning' && typeof msg === 'string') {
|
|
971
873
|
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
972
874
|
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
@@ -976,8 +878,173 @@ class Roster {
|
|
|
976
878
|
else log.info(msg);
|
|
977
879
|
}
|
|
978
880
|
};
|
|
979
|
-
|
|
980
|
-
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
_getManagedCertificateSubjects() {
|
|
884
|
+
const uniqueDomains = new Set();
|
|
885
|
+
this.domains.forEach((domain) => {
|
|
886
|
+
const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
|
|
887
|
+
if (root) uniqueDomains.add(root);
|
|
888
|
+
});
|
|
889
|
+
const subjects = [];
|
|
890
|
+
uniqueDomains.forEach((domain) => {
|
|
891
|
+
subjects.push(domain);
|
|
892
|
+
const includeWildcard = this.wildcardZones.has(domain) && this.dnsChallenge && !this.combineWildcardCerts;
|
|
893
|
+
if (includeWildcard) subjects.push(`*.${domain}`);
|
|
894
|
+
});
|
|
895
|
+
return [...new Set(subjects)];
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
_startCertificateRenewLoop() {
|
|
899
|
+
if (!this._greenlockRuntime || this._certificateRenewTimer) return;
|
|
900
|
+
const subjects = this._getManagedCertificateSubjects();
|
|
901
|
+
if (subjects.length === 0) return;
|
|
902
|
+
this._certificateRenewTimer = setInterval(() => {
|
|
903
|
+
subjects.forEach((subject) => {
|
|
904
|
+
this._greenlockRuntime.get({ servername: subject }).catch((error) => {
|
|
905
|
+
log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}, this.certificateRenewIntervalMs);
|
|
909
|
+
if (typeof this._certificateRenewTimer.unref === 'function') {
|
|
910
|
+
this._certificateRenewTimer.unref();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async ensureCertificate(servername) {
|
|
915
|
+
if (this.local) {
|
|
916
|
+
throw new Error('ensureCertificate() is not available in local mode');
|
|
917
|
+
}
|
|
918
|
+
if (!this._initialized) {
|
|
919
|
+
throw new Error('Call init() before ensureCertificate()');
|
|
920
|
+
}
|
|
921
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
922
|
+
if (!normalizedServername) {
|
|
923
|
+
throw new Error('servername is required');
|
|
924
|
+
}
|
|
925
|
+
let pems = this._resolvePemsForServername(normalizedServername);
|
|
926
|
+
if (pems) return pems;
|
|
927
|
+
if (!this._greenlockRuntime) {
|
|
928
|
+
throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
|
|
929
|
+
}
|
|
930
|
+
await this._greenlockRuntime.get({ servername: normalizedServername });
|
|
931
|
+
pems = this._resolvePemsForServername(normalizedServername);
|
|
932
|
+
if (!pems) {
|
|
933
|
+
throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
|
|
934
|
+
}
|
|
935
|
+
return pems;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async init() {
|
|
939
|
+
if (this._initialized) return this;
|
|
940
|
+
await this.loadSites();
|
|
941
|
+
if (!this.local) {
|
|
942
|
+
this.generateConfigJson();
|
|
943
|
+
if (this.autoCertificates) {
|
|
944
|
+
this._greenlockRuntime = GreenlockShim.create(this._buildGreenlockOptions());
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
this._initSiteHandlers();
|
|
948
|
+
if (!this.local) {
|
|
949
|
+
this._initSniResolver();
|
|
950
|
+
if (this.autoCertificates) {
|
|
951
|
+
this._startCertificateRenewLoop();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
this._initialized = true;
|
|
955
|
+
return this;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
requestHandler(port) {
|
|
959
|
+
if (!this._initialized) throw new Error('Call init() before requestHandler()');
|
|
960
|
+
const targetPort = port || this.defaultPort;
|
|
961
|
+
const portData = this._sitesByPort[targetPort];
|
|
962
|
+
if (!portData) {
|
|
963
|
+
return (req, res) => {
|
|
964
|
+
res.writeHead(404);
|
|
965
|
+
res.end('Site not found');
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return this._createDispatcher(portData);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
upgradeHandler(port) {
|
|
972
|
+
if (!this._initialized) throw new Error('Call init() before upgradeHandler()');
|
|
973
|
+
const targetPort = port || this.defaultPort;
|
|
974
|
+
const portData = this._sitesByPort[targetPort];
|
|
975
|
+
if (!portData) {
|
|
976
|
+
return (req, socket, head) => { socket.destroy(); };
|
|
977
|
+
}
|
|
978
|
+
return this._createUpgradeHandler(portData);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
sniCallback() {
|
|
982
|
+
if (!this._initialized) throw new Error('Call init() before sniCallback()');
|
|
983
|
+
if (!this._sniCallback) throw new Error('SNI callback not available in local mode');
|
|
984
|
+
return this._sniCallback;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
attach(server, { port } = {}) {
|
|
988
|
+
if (!this._initialized) throw new Error('Call init() before attach()');
|
|
989
|
+
server.on('request', this.requestHandler(port));
|
|
990
|
+
server.on('upgrade', this.upgradeHandler(port));
|
|
991
|
+
return this;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
startLocalMode() {
|
|
995
|
+
this.domainPorts = {};
|
|
996
|
+
|
|
997
|
+
for (const portData of Object.values(this._sitesByPort)) {
|
|
998
|
+
for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
|
|
999
|
+
if (domain.startsWith('www.')) continue;
|
|
1000
|
+
|
|
1001
|
+
const port = this.assignPortToDomain(domain);
|
|
1002
|
+
this.domainPorts[domain] = port;
|
|
1003
|
+
|
|
1004
|
+
const appHandler = portData.appHandlers[domain];
|
|
1005
|
+
|
|
1006
|
+
const dispatcher = (req, res) => {
|
|
1007
|
+
virtualServer.fallbackHandler = appHandler;
|
|
1008
|
+
if (virtualServer.requestListeners.length > 0) {
|
|
1009
|
+
virtualServer.processRequest(req, res);
|
|
1010
|
+
} else if (appHandler) {
|
|
1011
|
+
appHandler(req, res);
|
|
1012
|
+
} else {
|
|
1013
|
+
res.writeHead(404);
|
|
1014
|
+
res.end('Site not found');
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
const httpServer = http.createServer(dispatcher);
|
|
1019
|
+
this.portServers[port] = httpServer;
|
|
1020
|
+
|
|
1021
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
1022
|
+
virtualServer.processUpgrade(req, socket, head);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
httpServer.listen(port, 'localhost', () => {
|
|
1026
|
+
const cleanDomain = normalizeDomainForLocalHost(domain);
|
|
1027
|
+
log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
httpServer.on('error', (error) => {
|
|
1031
|
+
log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
log.info(`(✔) Started ${Object.keys(this.portServers).length} sites in local mode`);
|
|
1037
|
+
return Promise.resolve();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async start() {
|
|
1041
|
+
await this.init();
|
|
1042
|
+
|
|
1043
|
+
if (this.local) {
|
|
1044
|
+
return this.startLocalMode();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const greenlockOptions = this._buildGreenlockOptions();
|
|
981
1048
|
const greenlockRuntime = GreenlockShim.create(greenlockOptions);
|
|
982
1049
|
const greenlock = Greenlock.init({
|
|
983
1050
|
...greenlockOptions,
|
|
@@ -986,50 +1053,22 @@ class Roster {
|
|
|
986
1053
|
|
|
987
1054
|
return greenlock.ready(async glx => {
|
|
988
1055
|
const httpServer = glx.httpServer();
|
|
989
|
-
const { sitesByPort } = this.prepareSites();
|
|
990
|
-
|
|
991
1056
|
const bunTlsHotReloadHandlers = [];
|
|
992
1057
|
|
|
993
1058
|
httpServer.listen(80, this.hostname, () => {
|
|
994
1059
|
log.info('HTTP server listening on port 80');
|
|
995
1060
|
});
|
|
996
1061
|
|
|
997
|
-
|
|
998
|
-
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
1062
|
+
for (const [port, portData] of Object.entries(this._sitesByPort)) {
|
|
999
1063
|
const portNum = parseInt(port);
|
|
1000
|
-
const dispatcher = this.
|
|
1001
|
-
const upgradeHandler = this.
|
|
1002
|
-
|
|
1003
|
-
const loadCert = (subjectDir) => {
|
|
1004
|
-
const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
1005
|
-
if (!normalizedSubject) return null;
|
|
1006
|
-
const certPath = path.join(greenlockStorePath, 'live', normalizedSubject);
|
|
1007
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
1008
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
1009
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
1010
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
1011
|
-
return {
|
|
1012
|
-
key: fs.readFileSync(keyPath, 'utf8'),
|
|
1013
|
-
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
return null;
|
|
1017
|
-
};
|
|
1018
|
-
const resolvePemsForServername = (servername) => {
|
|
1019
|
-
const host = normalizeHostInput(servername).trim().toLowerCase();
|
|
1020
|
-
if (!host) return null;
|
|
1021
|
-
const candidates = buildCertLookupCandidates(host);
|
|
1022
|
-
for (const candidate of candidates) {
|
|
1023
|
-
const pems = loadCert(candidate);
|
|
1024
|
-
if (pems) return pems;
|
|
1025
|
-
}
|
|
1026
|
-
return null;
|
|
1027
|
-
};
|
|
1064
|
+
const dispatcher = this._createDispatcher(portData);
|
|
1065
|
+
const upgradeHandler = this._createUpgradeHandler(portData);
|
|
1066
|
+
|
|
1028
1067
|
const issueAndReloadPemsForServername = async (servername) => {
|
|
1029
|
-
const host =
|
|
1068
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
1030
1069
|
if (!host) return null;
|
|
1031
1070
|
|
|
1032
|
-
let pems =
|
|
1071
|
+
let pems = this._resolvePemsForServername(host);
|
|
1033
1072
|
if (pems) return pems;
|
|
1034
1073
|
|
|
1035
1074
|
try {
|
|
@@ -1038,11 +1077,9 @@ class Roster {
|
|
|
1038
1077
|
log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
|
|
1039
1078
|
}
|
|
1040
1079
|
|
|
1041
|
-
pems =
|
|
1080
|
+
pems = this._resolvePemsForServername(host);
|
|
1042
1081
|
if (pems) return pems;
|
|
1043
1082
|
|
|
1044
|
-
// For wildcard zones, try a valid subdomain bootstrap host so Greenlock can
|
|
1045
|
-
// resolve the wildcard site without relying on invalid "*.domain" servername input.
|
|
1046
1083
|
const wildcardSubject = wildcardSubjectForHost(host);
|
|
1047
1084
|
const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
|
|
1048
1085
|
if (zone) {
|
|
@@ -1052,11 +1089,12 @@ class Roster {
|
|
|
1052
1089
|
} catch (error) {
|
|
1053
1090
|
log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
|
|
1054
1091
|
}
|
|
1055
|
-
pems =
|
|
1092
|
+
pems = this._resolvePemsForServername(host);
|
|
1056
1093
|
}
|
|
1057
1094
|
|
|
1058
1095
|
return pems;
|
|
1059
1096
|
};
|
|
1097
|
+
|
|
1060
1098
|
const ensureBunDefaultPems = async (primaryDomain) => {
|
|
1061
1099
|
let pems = await issueAndReloadPemsForServername(primaryDomain);
|
|
1062
1100
|
|
|
@@ -1066,7 +1104,7 @@ class Roster {
|
|
|
1066
1104
|
|
|
1067
1105
|
if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
|
|
1068
1106
|
log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN — clearing stale cert for combined re-issuance`);
|
|
1069
|
-
const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
|
|
1107
|
+
const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
|
|
1070
1108
|
try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
|
|
1071
1109
|
pems = null;
|
|
1072
1110
|
}
|
|
@@ -1081,7 +1119,7 @@ class Roster {
|
|
|
1081
1119
|
log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
|
|
1082
1120
|
}
|
|
1083
1121
|
|
|
1084
|
-
pems =
|
|
1122
|
+
pems = this._resolvePemsForServername(primaryDomain);
|
|
1085
1123
|
if (pems) return pems;
|
|
1086
1124
|
|
|
1087
1125
|
throw new Error(
|
|
@@ -1091,15 +1129,11 @@ class Roster {
|
|
|
1091
1129
|
};
|
|
1092
1130
|
|
|
1093
1131
|
if (portNum === this.defaultPort) {
|
|
1094
|
-
// Bun has known gaps around SNICallback compatibility.
|
|
1095
|
-
// Fallback to static cert loading for the primary domain on default HTTPS port.
|
|
1096
1132
|
const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
|
|
1097
1133
|
let httpsServer;
|
|
1098
1134
|
|
|
1099
1135
|
if (isBunRuntime) {
|
|
1100
1136
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
1101
|
-
// Under Bun, avoid glx.httpsServer fallback (may serve invalid TLS on :443).
|
|
1102
|
-
// Require concrete PEM files and create native https server directly.
|
|
1103
1137
|
let defaultPems = await ensureBunDefaultPems(primaryDomain);
|
|
1104
1138
|
httpsServer = https.createServer({
|
|
1105
1139
|
...tlsOpts,
|
|
@@ -1135,21 +1169,18 @@ class Roster {
|
|
|
1135
1169
|
}
|
|
1136
1170
|
|
|
1137
1171
|
this.portServers[portNum] = httpsServer;
|
|
1138
|
-
|
|
1139
|
-
// Handle WebSocket upgrade events
|
|
1140
1172
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1141
1173
|
|
|
1142
1174
|
httpsServer.listen(portNum, this.hostname, () => {
|
|
1143
1175
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1144
1176
|
});
|
|
1145
1177
|
} else {
|
|
1146
|
-
// Create HTTPS server for custom ports using Greenlock certificates
|
|
1147
1178
|
const httpsOptions = {
|
|
1148
1179
|
minVersion: this.tlsMinVersion,
|
|
1149
1180
|
maxVersion: this.tlsMaxVersion,
|
|
1150
1181
|
SNICallback: (servername, callback) => {
|
|
1151
1182
|
try {
|
|
1152
|
-
const pems =
|
|
1183
|
+
const pems = this._resolvePemsForServername(servername);
|
|
1153
1184
|
if (pems) {
|
|
1154
1185
|
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
1155
1186
|
} else {
|
|
@@ -1162,8 +1193,6 @@ class Roster {
|
|
|
1162
1193
|
};
|
|
1163
1194
|
|
|
1164
1195
|
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
1165
|
-
|
|
1166
|
-
// Handle WebSocket upgrade events
|
|
1167
1196
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1168
1197
|
|
|
1169
1198
|
httpsServer.on('error', (error) => {
|
|
@@ -1171,7 +1200,6 @@ class Roster {
|
|
|
1171
1200
|
});
|
|
1172
1201
|
|
|
1173
1202
|
httpsServer.on('tlsClientError', (error) => {
|
|
1174
|
-
// Suppress HTTP request errors to avoid log spam
|
|
1175
1203
|
if (!error.message.includes('http request')) {
|
|
1176
1204
|
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
1177
1205
|
}
|
|
@@ -1195,7 +1223,7 @@ class Roster {
|
|
|
1195
1223
|
: 30000;
|
|
1196
1224
|
const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1197
1225
|
? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1198
|
-
: 0;
|
|
1226
|
+
: 0;
|
|
1199
1227
|
|
|
1200
1228
|
for (const zone of this.wildcardZones) {
|
|
1201
1229
|
const bootstrapHost = `bun-bootstrap.${zone}`;
|
|
@@ -1204,7 +1232,6 @@ class Roster {
|
|
|
1204
1232
|
log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
|
|
1205
1233
|
let reloaded = false;
|
|
1206
1234
|
for (const reloadTls of bunTlsHotReloadHandlers) {
|
|
1207
|
-
// Trigger issuance + immediately hot-reload default TLS context when ready.
|
|
1208
1235
|
reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
|
|
1209
1236
|
}
|
|
1210
1237
|
if (!reloaded) {
|
|
@@ -1223,7 +1250,6 @@ class Roster {
|
|
|
1223
1250
|
}
|
|
1224
1251
|
};
|
|
1225
1252
|
|
|
1226
|
-
// Background prewarm + retries so HTTPS startup is not blocked by DNS propagation timing.
|
|
1227
1253
|
attemptPrewarm().catch(() => {});
|
|
1228
1254
|
}
|
|
1229
1255
|
}
|