roster-server 2.4.0 → 2.4.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 +48 -39
- package/demo/cluster-sticky-https-sni.js +90 -0
- package/demo/cluster-sticky-worker.js +60 -0
- package/docs/architecture/decision-roster-cluster-friendly-api.md +30 -0
- package/index.js +204 -269
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +39 -59
- package/test/roster-server.test.js +273 -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;
|
|
@@ -562,21 +552,13 @@ class Roster {
|
|
|
562
552
|
}
|
|
563
553
|
}
|
|
564
554
|
|
|
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 = {}) {
|
|
555
|
+
register(domainString, requestHandler) {
|
|
573
556
|
if (!domainString) {
|
|
574
557
|
throw new Error('Domain is required');
|
|
575
558
|
}
|
|
576
559
|
if (typeof requestHandler !== 'function') {
|
|
577
560
|
throw new Error('requestHandler must be a function');
|
|
578
561
|
}
|
|
579
|
-
const { silent = false, skipDomainBookkeeping = false } = options || {};
|
|
580
562
|
|
|
581
563
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
582
564
|
|
|
@@ -586,13 +568,11 @@ class Roster {
|
|
|
586
568
|
return this;
|
|
587
569
|
}
|
|
588
570
|
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
589
|
-
|
|
571
|
+
this.domains.push(domain);
|
|
590
572
|
this.sites[domainKey] = requestHandler;
|
|
591
573
|
const root = wildcardRoot(domain);
|
|
592
574
|
if (root) this.wildcardZones.add(root);
|
|
593
|
-
|
|
594
|
-
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
595
|
-
}
|
|
575
|
+
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
596
576
|
return this;
|
|
597
577
|
}
|
|
598
578
|
|
|
@@ -601,17 +581,13 @@ class Roster {
|
|
|
601
581
|
domainEntries.push(`www.${domain}`);
|
|
602
582
|
}
|
|
603
583
|
|
|
604
|
-
|
|
605
|
-
this.domains.push(...domainEntries);
|
|
606
|
-
}
|
|
584
|
+
this.domains.push(...domainEntries);
|
|
607
585
|
domainEntries.forEach(d => {
|
|
608
586
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
609
587
|
this.sites[domainKey] = requestHandler;
|
|
610
588
|
});
|
|
611
589
|
|
|
612
|
-
|
|
613
|
-
log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
614
|
-
}
|
|
590
|
+
log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
615
591
|
return this;
|
|
616
592
|
}
|
|
617
593
|
|
|
@@ -655,49 +631,114 @@ class Roster {
|
|
|
655
631
|
return new VirtualServer(domain);
|
|
656
632
|
}
|
|
657
633
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
634
|
+
// Assign port to domain, detecting collisions with already assigned ports
|
|
635
|
+
assignPortToDomain(domain) {
|
|
636
|
+
let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
|
|
637
|
+
|
|
638
|
+
// If port is already assigned to another domain, increment until we find a free one
|
|
639
|
+
while (this.assignedPorts.has(port)) {
|
|
640
|
+
port++;
|
|
641
|
+
if (port > this.maxLocalPort) {
|
|
642
|
+
port = this.minLocalPort; // Wrap around if we exceed max port
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
this.assignedPorts.add(port);
|
|
647
|
+
return port;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Get SSL context from Greenlock for custom ports
|
|
651
|
+
async getSSLContext(domain, greenlock) {
|
|
652
|
+
try {
|
|
653
|
+
// Try to get existing certificate for the domain
|
|
654
|
+
const site = await greenlock.get({ servername: domain });
|
|
655
|
+
if (site && site.pems) {
|
|
656
|
+
return {
|
|
657
|
+
key: site.pems.privkey,
|
|
658
|
+
cert: site.pems.cert + site.pems.chain
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Return undefined to let HTTPS server handle SNI callback
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
_normalizeHostInput(value) {
|
|
669
|
+
if (typeof value === 'string') return value;
|
|
670
|
+
if (!value || typeof value !== 'object') return '';
|
|
671
|
+
if (typeof value.servername === 'string') return value.servername;
|
|
672
|
+
if (typeof value.hostname === 'string') return value.hostname;
|
|
673
|
+
if (typeof value.subject === 'string') return value.subject;
|
|
674
|
+
return '';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_loadCert(subjectDir) {
|
|
678
|
+
const normalizedSubject = this._normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
679
|
+
if (!normalizedSubject) return null;
|
|
680
|
+
const certPath = path.join(this.greenlockStorePath, 'live', normalizedSubject);
|
|
681
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
682
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
683
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
684
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
661
685
|
return {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
isWww: false
|
|
686
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
687
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
665
688
|
};
|
|
666
689
|
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
667
692
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
if (
|
|
693
|
+
_resolvePemsForServername(servername) {
|
|
694
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
695
|
+
if (!host) return null;
|
|
696
|
+
const candidates = buildCertLookupCandidates(host);
|
|
697
|
+
for (const candidate of candidates) {
|
|
698
|
+
const pems = this._loadCert(candidate);
|
|
699
|
+
if (pems) return pems;
|
|
675
700
|
}
|
|
676
|
-
|
|
677
|
-
const isWww = aliasedHost.startsWith('www.');
|
|
678
|
-
return {
|
|
679
|
-
normalizedHost: aliasedHost,
|
|
680
|
-
routedHost: isWww ? aliasedHost.slice(4) : aliasedHost,
|
|
681
|
-
isWww
|
|
682
|
-
};
|
|
701
|
+
return null;
|
|
683
702
|
}
|
|
684
703
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
704
|
+
_initSiteHandlers() {
|
|
705
|
+
this._sitesByPort = {};
|
|
706
|
+
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
707
|
+
if (hostKey.startsWith('www.')) continue;
|
|
708
|
+
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
709
|
+
if (!this._sitesByPort[port]) {
|
|
710
|
+
this._sitesByPort[port] = {
|
|
711
|
+
virtualServers: {},
|
|
712
|
+
appHandlers: {}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const virtualServer = this.createVirtualServer(domain);
|
|
717
|
+
this._sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
718
|
+
this.domainServers[domain] = virtualServer;
|
|
690
719
|
|
|
720
|
+
const appHandler = siteApp(virtualServer);
|
|
721
|
+
this._sitesByPort[port].appHandlers[domain] = appHandler;
|
|
722
|
+
if (!domain.startsWith('*.')) {
|
|
723
|
+
this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
_createDispatcher(portData) {
|
|
691
729
|
return (req, res) => {
|
|
692
|
-
const
|
|
730
|
+
const host = req.headers.host || '';
|
|
731
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
732
|
+
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
693
733
|
|
|
694
|
-
if (
|
|
695
|
-
|
|
734
|
+
if (hostWithoutPort.startsWith('www.')) {
|
|
735
|
+
const protocol = this.local ? 'http' : 'https';
|
|
736
|
+
res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
|
|
696
737
|
res.end();
|
|
697
738
|
return;
|
|
698
739
|
}
|
|
699
740
|
|
|
700
|
-
const resolved = this.getHandlerForPortData(
|
|
741
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
701
742
|
if (!resolved) {
|
|
702
743
|
res.writeHead(404);
|
|
703
744
|
res.end('Site not found');
|
|
@@ -717,11 +758,13 @@ class Roster {
|
|
|
717
758
|
};
|
|
718
759
|
}
|
|
719
760
|
|
|
720
|
-
|
|
721
|
-
const { hostAliases } = options;
|
|
761
|
+
_createUpgradeHandler(portData) {
|
|
722
762
|
return (req, socket, head) => {
|
|
723
|
-
const
|
|
724
|
-
const
|
|
763
|
+
const host = req.headers.host || '';
|
|
764
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
765
|
+
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
766
|
+
|
|
767
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
725
768
|
if (resolved && resolved.virtualServer) {
|
|
726
769
|
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
727
770
|
} else {
|
|
@@ -730,169 +773,111 @@ class Roster {
|
|
|
730
773
|
};
|
|
731
774
|
}
|
|
732
775
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
};
|
|
776
|
+
_initSniResolver() {
|
|
777
|
+
this._sniCallback = (servername, callback) => {
|
|
778
|
+
try {
|
|
779
|
+
const pems = this._resolvePemsForServername(servername);
|
|
780
|
+
if (pems) {
|
|
781
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
782
|
+
} else {
|
|
783
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
784
|
+
}
|
|
785
|
+
} catch (error) {
|
|
786
|
+
callback(error);
|
|
753
787
|
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
754
790
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
761
|
-
if (!domain.startsWith('*.')) {
|
|
762
|
-
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
763
|
-
}
|
|
791
|
+
async init() {
|
|
792
|
+
if (this._initialized) return this;
|
|
793
|
+
await this.loadSites();
|
|
794
|
+
if (!this.local) {
|
|
795
|
+
this.generateConfigJson();
|
|
764
796
|
}
|
|
765
|
-
|
|
766
|
-
|
|
797
|
+
this._initSiteHandlers();
|
|
798
|
+
if (!this.local) {
|
|
799
|
+
this._initSniResolver();
|
|
800
|
+
}
|
|
801
|
+
this._initialized = true;
|
|
802
|
+
return this;
|
|
767
803
|
}
|
|
768
804
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
};
|
|
805
|
+
requestHandler(port) {
|
|
806
|
+
if (!this._initialized) throw new Error('Call init() before requestHandler()');
|
|
807
|
+
const targetPort = port || this.defaultPort;
|
|
808
|
+
const portData = this._sitesByPort[targetPort];
|
|
809
|
+
if (!portData) {
|
|
810
|
+
return (req, res) => {
|
|
811
|
+
res.writeHead(404);
|
|
812
|
+
res.end('Site not found');
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
return this._createDispatcher(portData);
|
|
801
816
|
}
|
|
802
817
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
port++;
|
|
810
|
-
if (port > this.maxLocalPort) {
|
|
811
|
-
port = this.minLocalPort; // Wrap around if we exceed max port
|
|
812
|
-
}
|
|
818
|
+
upgradeHandler(port) {
|
|
819
|
+
if (!this._initialized) throw new Error('Call init() before upgradeHandler()');
|
|
820
|
+
const targetPort = port || this.defaultPort;
|
|
821
|
+
const portData = this._sitesByPort[targetPort];
|
|
822
|
+
if (!portData) {
|
|
823
|
+
return (req, socket, head) => { socket.destroy(); };
|
|
813
824
|
}
|
|
814
|
-
|
|
815
|
-
this.assignedPorts.add(port);
|
|
816
|
-
return port;
|
|
825
|
+
return this._createUpgradeHandler(portData);
|
|
817
826
|
}
|
|
818
827
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (site && site.pems) {
|
|
825
|
-
return {
|
|
826
|
-
key: site.pems.privkey,
|
|
827
|
-
cert: site.pems.cert + site.pems.chain
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
} catch (error) {
|
|
831
|
-
}
|
|
828
|
+
sniCallback() {
|
|
829
|
+
if (!this._initialized) throw new Error('Call init() before sniCallback()');
|
|
830
|
+
if (!this._sniCallback) throw new Error('SNI callback not available in local mode');
|
|
831
|
+
return this._sniCallback;
|
|
832
|
+
}
|
|
832
833
|
|
|
833
|
-
|
|
834
|
-
|
|
834
|
+
attach(server, { port } = {}) {
|
|
835
|
+
if (!this._initialized) throw new Error('Call init() before attach()');
|
|
836
|
+
server.on('request', this.requestHandler(port));
|
|
837
|
+
server.on('upgrade', this.upgradeHandler(port));
|
|
838
|
+
return this;
|
|
835
839
|
}
|
|
836
840
|
|
|
837
|
-
// Start server in local mode with HTTP - simplified version
|
|
838
841
|
startLocalMode() {
|
|
839
|
-
// Store mapping of domain to port for later retrieval
|
|
840
842
|
this.domainPorts = {};
|
|
841
843
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// Skip www domains in local mode
|
|
847
|
-
if (domain.startsWith('www.')) {
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Calculate deterministic port based on domain CRC32, with collision detection
|
|
852
|
-
const port = this.assignPortToDomain(domain);
|
|
853
|
-
|
|
854
|
-
// Store domain → port mapping
|
|
855
|
-
this.domainPorts[domain] = port;
|
|
856
|
-
|
|
857
|
-
// Create virtual server for the domain
|
|
858
|
-
const virtualServer = this.createVirtualServer(domain);
|
|
859
|
-
this.domainServers[domain] = virtualServer;
|
|
844
|
+
for (const portData of Object.values(this._sitesByPort)) {
|
|
845
|
+
for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
|
|
846
|
+
if (domain.startsWith('www.')) continue;
|
|
860
847
|
|
|
861
|
-
|
|
862
|
-
|
|
848
|
+
const port = this.assignPortToDomain(domain);
|
|
849
|
+
this.domainPorts[domain] = port;
|
|
863
850
|
|
|
864
|
-
|
|
865
|
-
const dispatcher = (req, res) => {
|
|
866
|
-
// Set fallback handler on virtual server for non-Socket.IO requests
|
|
867
|
-
virtualServer.fallbackHandler = appHandler;
|
|
851
|
+
const appHandler = portData.appHandlers[domain];
|
|
868
852
|
|
|
869
|
-
|
|
870
|
-
virtualServer.
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
853
|
+
const dispatcher = (req, res) => {
|
|
854
|
+
virtualServer.fallbackHandler = appHandler;
|
|
855
|
+
if (virtualServer.requestListeners.length > 0) {
|
|
856
|
+
virtualServer.processRequest(req, res);
|
|
857
|
+
} else if (appHandler) {
|
|
858
|
+
appHandler(req, res);
|
|
859
|
+
} else {
|
|
860
|
+
res.writeHead(404);
|
|
861
|
+
res.end('Site not found');
|
|
862
|
+
}
|
|
863
|
+
};
|
|
878
864
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
this.portServers[port] = httpServer;
|
|
865
|
+
const httpServer = http.createServer(dispatcher);
|
|
866
|
+
this.portServers[port] = httpServer;
|
|
882
867
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
});
|
|
868
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
869
|
+
virtualServer.processUpgrade(req, socket, head);
|
|
870
|
+
});
|
|
887
871
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
872
|
+
httpServer.listen(port, 'localhost', () => {
|
|
873
|
+
const cleanDomain = normalizeDomainForLocalHost(domain);
|
|
874
|
+
log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
|
|
875
|
+
});
|
|
892
876
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
877
|
+
httpServer.on('error', (error) => {
|
|
878
|
+
log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
896
881
|
}
|
|
897
882
|
|
|
898
883
|
log.info(`(✔) Started ${Object.keys(this.portServers).length} sites in local mode`);
|
|
@@ -900,14 +885,8 @@ class Roster {
|
|
|
900
885
|
}
|
|
901
886
|
|
|
902
887
|
async start() {
|
|
903
|
-
await this.
|
|
888
|
+
await this.init();
|
|
904
889
|
|
|
905
|
-
// Skip Greenlock configuration generation in local mode
|
|
906
|
-
if (!this.local) {
|
|
907
|
-
this.generateConfigJson();
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// Handle local mode with simple HTTP server
|
|
911
890
|
if (this.local) {
|
|
912
891
|
return this.startLocalMode();
|
|
913
892
|
}
|
|
@@ -966,7 +945,6 @@ class Roster {
|
|
|
966
945
|
if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
|
|
967
946
|
msg = `[${eventDomain}] ${msg}`;
|
|
968
947
|
}
|
|
969
|
-
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
970
948
|
if (event === 'warning' && typeof msg === 'string') {
|
|
971
949
|
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
972
950
|
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
@@ -976,8 +954,6 @@ class Roster {
|
|
|
976
954
|
else log.info(msg);
|
|
977
955
|
}
|
|
978
956
|
};
|
|
979
|
-
// Keep a direct greenlock runtime handle so we can call get() explicitly under Bun
|
|
980
|
-
// before binding :443, avoiding invalid non-TLS responses on startup.
|
|
981
957
|
const greenlockRuntime = GreenlockShim.create(greenlockOptions);
|
|
982
958
|
const greenlock = Greenlock.init({
|
|
983
959
|
...greenlockOptions,
|
|
@@ -986,50 +962,22 @@ class Roster {
|
|
|
986
962
|
|
|
987
963
|
return greenlock.ready(async glx => {
|
|
988
964
|
const httpServer = glx.httpServer();
|
|
989
|
-
const { sitesByPort } = this.prepareSites();
|
|
990
|
-
|
|
991
965
|
const bunTlsHotReloadHandlers = [];
|
|
992
966
|
|
|
993
967
|
httpServer.listen(80, this.hostname, () => {
|
|
994
968
|
log.info('HTTP server listening on port 80');
|
|
995
969
|
});
|
|
996
970
|
|
|
997
|
-
|
|
998
|
-
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
971
|
+
for (const [port, portData] of Object.entries(this._sitesByPort)) {
|
|
999
972
|
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
|
-
};
|
|
973
|
+
const dispatcher = this._createDispatcher(portData);
|
|
974
|
+
const upgradeHandler = this._createUpgradeHandler(portData);
|
|
975
|
+
|
|
1028
976
|
const issueAndReloadPemsForServername = async (servername) => {
|
|
1029
|
-
const host =
|
|
977
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
1030
978
|
if (!host) return null;
|
|
1031
979
|
|
|
1032
|
-
let pems =
|
|
980
|
+
let pems = this._resolvePemsForServername(host);
|
|
1033
981
|
if (pems) return pems;
|
|
1034
982
|
|
|
1035
983
|
try {
|
|
@@ -1038,11 +986,9 @@ class Roster {
|
|
|
1038
986
|
log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
|
|
1039
987
|
}
|
|
1040
988
|
|
|
1041
|
-
pems =
|
|
989
|
+
pems = this._resolvePemsForServername(host);
|
|
1042
990
|
if (pems) return pems;
|
|
1043
991
|
|
|
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
992
|
const wildcardSubject = wildcardSubjectForHost(host);
|
|
1047
993
|
const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
|
|
1048
994
|
if (zone) {
|
|
@@ -1052,11 +998,12 @@ class Roster {
|
|
|
1052
998
|
} catch (error) {
|
|
1053
999
|
log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
|
|
1054
1000
|
}
|
|
1055
|
-
pems =
|
|
1001
|
+
pems = this._resolvePemsForServername(host);
|
|
1056
1002
|
}
|
|
1057
1003
|
|
|
1058
1004
|
return pems;
|
|
1059
1005
|
};
|
|
1006
|
+
|
|
1060
1007
|
const ensureBunDefaultPems = async (primaryDomain) => {
|
|
1061
1008
|
let pems = await issueAndReloadPemsForServername(primaryDomain);
|
|
1062
1009
|
|
|
@@ -1066,7 +1013,7 @@ class Roster {
|
|
|
1066
1013
|
|
|
1067
1014
|
if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
|
|
1068
1015
|
log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN — clearing stale cert for combined re-issuance`);
|
|
1069
|
-
const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
|
|
1016
|
+
const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
|
|
1070
1017
|
try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
|
|
1071
1018
|
pems = null;
|
|
1072
1019
|
}
|
|
@@ -1081,7 +1028,7 @@ class Roster {
|
|
|
1081
1028
|
log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
|
|
1082
1029
|
}
|
|
1083
1030
|
|
|
1084
|
-
pems =
|
|
1031
|
+
pems = this._resolvePemsForServername(primaryDomain);
|
|
1085
1032
|
if (pems) return pems;
|
|
1086
1033
|
|
|
1087
1034
|
throw new Error(
|
|
@@ -1091,15 +1038,11 @@ class Roster {
|
|
|
1091
1038
|
};
|
|
1092
1039
|
|
|
1093
1040
|
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
1041
|
const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
|
|
1097
1042
|
let httpsServer;
|
|
1098
1043
|
|
|
1099
1044
|
if (isBunRuntime) {
|
|
1100
1045
|
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
1046
|
let defaultPems = await ensureBunDefaultPems(primaryDomain);
|
|
1104
1047
|
httpsServer = https.createServer({
|
|
1105
1048
|
...tlsOpts,
|
|
@@ -1135,21 +1078,18 @@ class Roster {
|
|
|
1135
1078
|
}
|
|
1136
1079
|
|
|
1137
1080
|
this.portServers[portNum] = httpsServer;
|
|
1138
|
-
|
|
1139
|
-
// Handle WebSocket upgrade events
|
|
1140
1081
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1141
1082
|
|
|
1142
1083
|
httpsServer.listen(portNum, this.hostname, () => {
|
|
1143
1084
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1144
1085
|
});
|
|
1145
1086
|
} else {
|
|
1146
|
-
// Create HTTPS server for custom ports using Greenlock certificates
|
|
1147
1087
|
const httpsOptions = {
|
|
1148
1088
|
minVersion: this.tlsMinVersion,
|
|
1149
1089
|
maxVersion: this.tlsMaxVersion,
|
|
1150
1090
|
SNICallback: (servername, callback) => {
|
|
1151
1091
|
try {
|
|
1152
|
-
const pems =
|
|
1092
|
+
const pems = this._resolvePemsForServername(servername);
|
|
1153
1093
|
if (pems) {
|
|
1154
1094
|
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
1155
1095
|
} else {
|
|
@@ -1162,8 +1102,6 @@ class Roster {
|
|
|
1162
1102
|
};
|
|
1163
1103
|
|
|
1164
1104
|
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
1165
|
-
|
|
1166
|
-
// Handle WebSocket upgrade events
|
|
1167
1105
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1168
1106
|
|
|
1169
1107
|
httpsServer.on('error', (error) => {
|
|
@@ -1171,7 +1109,6 @@ class Roster {
|
|
|
1171
1109
|
});
|
|
1172
1110
|
|
|
1173
1111
|
httpsServer.on('tlsClientError', (error) => {
|
|
1174
|
-
// Suppress HTTP request errors to avoid log spam
|
|
1175
1112
|
if (!error.message.includes('http request')) {
|
|
1176
1113
|
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
1177
1114
|
}
|
|
@@ -1195,7 +1132,7 @@ class Roster {
|
|
|
1195
1132
|
: 30000;
|
|
1196
1133
|
const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1197
1134
|
? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1198
|
-
: 0;
|
|
1135
|
+
: 0;
|
|
1199
1136
|
|
|
1200
1137
|
for (const zone of this.wildcardZones) {
|
|
1201
1138
|
const bootstrapHost = `bun-bootstrap.${zone}`;
|
|
@@ -1204,7 +1141,6 @@ class Roster {
|
|
|
1204
1141
|
log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
|
|
1205
1142
|
let reloaded = false;
|
|
1206
1143
|
for (const reloadTls of bunTlsHotReloadHandlers) {
|
|
1207
|
-
// Trigger issuance + immediately hot-reload default TLS context when ready.
|
|
1208
1144
|
reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
|
|
1209
1145
|
}
|
|
1210
1146
|
if (!reloaded) {
|
|
@@ -1223,7 +1159,6 @@ class Roster {
|
|
|
1223
1159
|
}
|
|
1224
1160
|
};
|
|
1225
1161
|
|
|
1226
|
-
// Background prewarm + retries so HTTPS startup is not blocked by DNS propagation timing.
|
|
1227
1162
|
attemptPrewarm().catch(() => {});
|
|
1228
1163
|
}
|
|
1229
1164
|
}
|