roster-server 2.4.12 → 2.4.14
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 +198 -398
- package/index.js +434 -126
- package/lib/static-site-handler.js +36 -27
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +50 -338
- package/test/https-integration.test.js +149 -0
- package/test/lifecycle.test.js +469 -0
- package/test/roster-server.test.js +22 -22
- package/test/scanner-blocker.test.js +3 -6
- package/test/signals.test.js +183 -0
- package/test/static-and-tls.test.js +273 -0
- package/vendor/greenlock-express/greenlock-express.js +1 -1
- package/vendor/greenlock-express/servers.js +2 -1
- package/vendor/greenlock-express/single.js +1 -1
- package/vendor/greenlock-express/worker.js +2 -2
package/index.js
CHANGED
|
@@ -9,6 +9,30 @@ const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
|
|
|
9
9
|
const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
|
|
10
10
|
const { resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
11
11
|
const log = require('lemonlog')('roster');
|
|
12
|
+
const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
13
|
+
|
|
14
|
+
function requestError(error, res) {
|
|
15
|
+
log.error('Request handler failed:', error?.message || error);
|
|
16
|
+
if (res.destroyed || res.writableEnded) return;
|
|
17
|
+
if (res.headersSent) {
|
|
18
|
+
res.destroy(error);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
22
|
+
res.end('Internal Server Error');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function invokeRequest(handler, req, res) {
|
|
26
|
+
try {
|
|
27
|
+
const result = handler(req, res);
|
|
28
|
+
if (result && typeof result.then === 'function') {
|
|
29
|
+
return Promise.resolve(result).catch(error => requestError(error, res));
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
requestError(error, res);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
12
36
|
|
|
13
37
|
const isBunRuntime = typeof Bun !== 'undefined' || (typeof process !== 'undefined' && process.release?.name === 'bun');
|
|
14
38
|
|
|
@@ -129,6 +153,8 @@ class VirtualServer extends EventEmitter {
|
|
|
129
153
|
this.domain = domain;
|
|
130
154
|
this.requestListeners = [];
|
|
131
155
|
this.upgradeListeners = [];
|
|
156
|
+
this._closeHooks = [];
|
|
157
|
+
this._closed = false;
|
|
132
158
|
|
|
133
159
|
// Simulate http.Server properties
|
|
134
160
|
this.listening = false;
|
|
@@ -156,6 +182,7 @@ class VirtualServer extends EventEmitter {
|
|
|
156
182
|
// Socket.IO compatibility methods
|
|
157
183
|
listeners(event) {
|
|
158
184
|
if (event === 'request') {
|
|
185
|
+
if (this.requestListeners.length === 0 && this.fallbackHandler) return [this.fallbackHandler];
|
|
159
186
|
return this.requestListeners.slice();
|
|
160
187
|
} else if (event === 'upgrade') {
|
|
161
188
|
return this.upgradeListeners.slice();
|
|
@@ -179,9 +206,10 @@ class VirtualServer extends EventEmitter {
|
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
removeAllListeners(event) {
|
|
182
|
-
if (event === 'request') {
|
|
209
|
+
if (event === undefined || event === 'request') {
|
|
183
210
|
this.requestListeners = [];
|
|
184
|
-
}
|
|
211
|
+
}
|
|
212
|
+
if (event === undefined || event === 'upgrade') {
|
|
185
213
|
this.upgradeListeners = [];
|
|
186
214
|
}
|
|
187
215
|
return super.removeAllListeners(event);
|
|
@@ -189,34 +217,34 @@ class VirtualServer extends EventEmitter {
|
|
|
189
217
|
|
|
190
218
|
// Simulate other http.Server methods
|
|
191
219
|
listen() { this.listening = true; return this; }
|
|
192
|
-
close() {
|
|
220
|
+
close(callback) {
|
|
221
|
+
this.listening = false;
|
|
222
|
+
if (!this._closed) {
|
|
223
|
+
this._closed = true;
|
|
224
|
+
this.emit('close');
|
|
225
|
+
}
|
|
226
|
+
if (callback) process.nextTick(callback);
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
onClose(handler) {
|
|
230
|
+
if (typeof handler !== 'function') throw new TypeError('Close hook must be a function');
|
|
231
|
+
if (this._closed) throw new Error('Virtual server is closed');
|
|
232
|
+
this._closeHooks.push(handler);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
193
235
|
setTimeout() { return this; }
|
|
194
236
|
|
|
195
237
|
// Process request with this virtual server's listeners
|
|
196
238
|
processRequest(req, res) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
handled = true;
|
|
203
|
-
return originalEnd.apply(this, args);
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// Try all listeners
|
|
207
|
-
for (const listener of this.requestListeners) {
|
|
208
|
-
if (!handled) {
|
|
209
|
-
listener(req, res);
|
|
239
|
+
const listeners = this.requestListeners.slice();
|
|
240
|
+
if (listeners.length > 0) {
|
|
241
|
+
for (const listener of listeners) {
|
|
242
|
+
if (res.writableEnded || res.destroyed) break;
|
|
243
|
+
invokeRequest(listener.bind(this), req, res);
|
|
210
244
|
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
res.end = originalEnd;
|
|
215
|
-
|
|
216
|
-
// If no listener handled the request, try fallback handler
|
|
217
|
-
if (!handled && this.fallbackHandler) {
|
|
218
|
-
this.fallbackHandler(req, res);
|
|
219
|
-
} else if (!handled) {
|
|
245
|
+
} else if (this.fallbackHandler) {
|
|
246
|
+
return invokeRequest(this.fallbackHandler, req, res);
|
|
247
|
+
} else {
|
|
220
248
|
res.writeHead(404);
|
|
221
249
|
res.end('No handler found');
|
|
222
250
|
}
|
|
@@ -255,6 +283,27 @@ class Roster {
|
|
|
255
283
|
this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
|
|
256
284
|
this._sitesByPort = {};
|
|
257
285
|
this._initialized = false;
|
|
286
|
+
this._closing = false;
|
|
287
|
+
this._initTask = null;
|
|
288
|
+
this._initPromise = null;
|
|
289
|
+
this._startPromise = null;
|
|
290
|
+
this._closePromise = null;
|
|
291
|
+
this.handleSignals = parseBooleanFlag(options.handleSignals, false);
|
|
292
|
+
this._signalHandler = null;
|
|
293
|
+
this._ownedServers = new Set();
|
|
294
|
+
this._sockets = new Set();
|
|
295
|
+
this._upgradedSockets = new Set();
|
|
296
|
+
this._responses = new Set();
|
|
297
|
+
this._attachments = new Map();
|
|
298
|
+
this._retryTimers = new Set();
|
|
299
|
+
this._pendingListens = new Set();
|
|
300
|
+
this._certificateChecks = new Map();
|
|
301
|
+
this._secureContexts = new Map();
|
|
302
|
+
this._contextLoads = new Map();
|
|
303
|
+
this.closeTimeoutMs = options.closeTimeoutMs ?? 30000;
|
|
304
|
+
if (!Number.isFinite(this.closeTimeoutMs) || this.closeTimeoutMs <= 0) {
|
|
305
|
+
throw new TypeError('closeTimeoutMs must be a positive finite number');
|
|
306
|
+
}
|
|
258
307
|
this._sniCallback = null;
|
|
259
308
|
this.hostname = options.hostname ?? '::';
|
|
260
309
|
this.filename = options.filename || 'index';
|
|
@@ -327,6 +376,7 @@ class Roster {
|
|
|
327
376
|
.filter(dirent => dirent.isDirectory());
|
|
328
377
|
|
|
329
378
|
for (const dirent of sites) {
|
|
379
|
+
this._assertOpen();
|
|
330
380
|
const domain = dirent.name;
|
|
331
381
|
const domainPath = path.join(this.wwwPath, domain);
|
|
332
382
|
|
|
@@ -343,6 +393,8 @@ class Roster {
|
|
|
343
393
|
continue;
|
|
344
394
|
}
|
|
345
395
|
|
|
396
|
+
this._assertOpen();
|
|
397
|
+
|
|
346
398
|
const { siteApp, type } = resolved;
|
|
347
399
|
|
|
348
400
|
if (domain.startsWith('*.')) {
|
|
@@ -528,7 +580,7 @@ class Roster {
|
|
|
528
580
|
getHandlerForPortData(host, portData) {
|
|
529
581
|
const virtualServer = portData.virtualServers[host];
|
|
530
582
|
const appHandler = portData.appHandlers[host];
|
|
531
|
-
if (
|
|
583
|
+
if (Object.hasOwn(portData.virtualServers, host)) return { virtualServer, appHandler };
|
|
532
584
|
for (const key of Object.keys(portData.appHandlers)) {
|
|
533
585
|
if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
|
|
534
586
|
return {
|
|
@@ -541,6 +593,7 @@ class Roster {
|
|
|
541
593
|
}
|
|
542
594
|
|
|
543
595
|
handleRequest(req, res) {
|
|
596
|
+
if (!this._beginRequest(res)) return;
|
|
544
597
|
const host = req.headers.host || '';
|
|
545
598
|
const hostWithoutPort = host.split(':')[0];
|
|
546
599
|
const normalizedHost = hostWithoutPort.toLowerCase();
|
|
@@ -557,7 +610,7 @@ class Roster {
|
|
|
557
610
|
|
|
558
611
|
const siteApp = this.getHandlerForHost(hostWithoutPort);
|
|
559
612
|
if (siteApp) {
|
|
560
|
-
siteApp
|
|
613
|
+
return invokeRequest(siteApp, req, res);
|
|
561
614
|
} else {
|
|
562
615
|
res.writeHead(404);
|
|
563
616
|
res.end('Site not found');
|
|
@@ -565,6 +618,7 @@ class Roster {
|
|
|
565
618
|
}
|
|
566
619
|
|
|
567
620
|
register(domainString, requestHandler) {
|
|
621
|
+
this._assertOpen();
|
|
568
622
|
if (!domainString) {
|
|
569
623
|
throw new Error('Domain is required');
|
|
570
624
|
}
|
|
@@ -604,6 +658,7 @@ class Roster {
|
|
|
604
658
|
}
|
|
605
659
|
|
|
606
660
|
use(plugin) {
|
|
661
|
+
this._assertOpen();
|
|
607
662
|
if (typeof plugin !== 'function') {
|
|
608
663
|
throw new Error('plugin must be a function');
|
|
609
664
|
}
|
|
@@ -612,14 +667,20 @@ class Roster {
|
|
|
612
667
|
}
|
|
613
668
|
|
|
614
669
|
_runRequestPlugins(req, res, context) {
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
670
|
+
try {
|
|
671
|
+
for (const plugin of this.plugins) {
|
|
672
|
+
const handled = plugin(req, res, context);
|
|
673
|
+
if (handled && typeof handled.then === 'function') {
|
|
674
|
+
Promise.resolve(handled).catch(() => {});
|
|
675
|
+
throw new Error('Request plugins must be synchronous');
|
|
676
|
+
}
|
|
677
|
+
if (handled === true) return true;
|
|
619
678
|
}
|
|
620
|
-
|
|
679
|
+
return false;
|
|
680
|
+
} catch (error) {
|
|
681
|
+
requestError(error, res);
|
|
682
|
+
return true;
|
|
621
683
|
}
|
|
622
|
-
return false;
|
|
623
684
|
}
|
|
624
685
|
|
|
625
686
|
parseDomainWithPort(domainString) {
|
|
@@ -664,6 +725,9 @@ class Roster {
|
|
|
664
725
|
|
|
665
726
|
// Assign port to domain, detecting collisions with already assigned ports
|
|
666
727
|
assignPortToDomain(domain) {
|
|
728
|
+
if (this.assignedPorts.size >= this.maxLocalPort - this.minLocalPort + 1) {
|
|
729
|
+
throw new Error('Local port range is exhausted');
|
|
730
|
+
}
|
|
667
731
|
let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
|
|
668
732
|
|
|
669
733
|
// If port is already assigned to another domain, increment until we find a free one
|
|
@@ -735,6 +799,7 @@ class Roster {
|
|
|
735
799
|
_initSiteHandlers() {
|
|
736
800
|
this._sitesByPort = {};
|
|
737
801
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
802
|
+
this._assertOpen();
|
|
738
803
|
if (hostKey.startsWith('www.')) continue;
|
|
739
804
|
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
740
805
|
if (!this._sitesByPort[port]) {
|
|
@@ -748,7 +813,13 @@ class Roster {
|
|
|
748
813
|
this._sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
749
814
|
this.domainServers[domain] = virtualServer;
|
|
750
815
|
|
|
751
|
-
|
|
816
|
+
let appHandler;
|
|
817
|
+
virtualServer.fallbackHandler = (req, res) => {
|
|
818
|
+
if (appHandler) return appHandler(req, res);
|
|
819
|
+
res.writeHead(404);
|
|
820
|
+
res.end('Site not found');
|
|
821
|
+
};
|
|
822
|
+
appHandler = siteApp(virtualServer);
|
|
752
823
|
this._sitesByPort[port].appHandlers[domain] = appHandler;
|
|
753
824
|
if (!domain.startsWith('*.')) {
|
|
754
825
|
this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
@@ -758,6 +829,7 @@ class Roster {
|
|
|
758
829
|
|
|
759
830
|
_createDispatcher(portData) {
|
|
760
831
|
return (req, res) => {
|
|
832
|
+
if (!this._beginRequest(res)) return;
|
|
761
833
|
const host = req.headers.host || '';
|
|
762
834
|
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
763
835
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
@@ -766,7 +838,7 @@ class Roster {
|
|
|
766
838
|
|
|
767
839
|
if (hostWithoutPort.startsWith('www.')) {
|
|
768
840
|
const protocol = this.local ? 'http' : 'https';
|
|
769
|
-
res.writeHead(301, { Location: `${protocol}://${
|
|
841
|
+
res.writeHead(301, { Location: `${protocol}://${host.toLowerCase().slice(4)}${req.url}` });
|
|
770
842
|
res.end();
|
|
771
843
|
return;
|
|
772
844
|
}
|
|
@@ -780,10 +852,9 @@ class Roster {
|
|
|
780
852
|
const { virtualServer, appHandler } = resolved;
|
|
781
853
|
|
|
782
854
|
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
783
|
-
virtualServer.fallbackHandler = appHandler;
|
|
784
855
|
virtualServer.processRequest(req, res);
|
|
785
856
|
} else if (appHandler) {
|
|
786
|
-
appHandler
|
|
857
|
+
return invokeRequest(appHandler, req, res);
|
|
787
858
|
} else {
|
|
788
859
|
res.writeHead(404);
|
|
789
860
|
res.end('Site not found');
|
|
@@ -791,8 +862,16 @@ class Roster {
|
|
|
791
862
|
};
|
|
792
863
|
}
|
|
793
864
|
|
|
865
|
+
_trackUpgrade(socket) {
|
|
866
|
+
if (typeof socket.once !== 'function') return;
|
|
867
|
+
this._upgradedSockets.add(socket);
|
|
868
|
+
socket.once('close', () => this._upgradedSockets.delete(socket));
|
|
869
|
+
}
|
|
870
|
+
|
|
794
871
|
_createUpgradeHandler(portData) {
|
|
795
872
|
return (req, socket, head) => {
|
|
873
|
+
if (this._closing) { socket.destroy(); return; }
|
|
874
|
+
this._trackUpgrade(socket);
|
|
796
875
|
const host = req.headers.host || '';
|
|
797
876
|
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
798
877
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
@@ -806,38 +885,69 @@ class Roster {
|
|
|
806
885
|
};
|
|
807
886
|
}
|
|
808
887
|
|
|
809
|
-
|
|
810
|
-
this.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
} catch (error) {
|
|
819
|
-
callback(error);
|
|
820
|
-
return;
|
|
888
|
+
async _resolveSecureContext(servername) {
|
|
889
|
+
this._assertOpen();
|
|
890
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
891
|
+
for (const subject of buildCertLookupCandidates(host)) {
|
|
892
|
+
let pending = this._contextLoads.get(subject);
|
|
893
|
+
if (!pending) {
|
|
894
|
+
pending = this._loadSecureContext(subject).finally(() => this._contextLoads.delete(subject));
|
|
895
|
+
this._contextLoads.set(subject, pending);
|
|
821
896
|
}
|
|
897
|
+
const context = await pending;
|
|
898
|
+
if (context) return context;
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
822
902
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
903
|
+
async _loadSecureContext(subject) {
|
|
904
|
+
const directory = path.join(this.greenlockStorePath, 'live', subject);
|
|
905
|
+
const files = ['privkey.pem', 'cert.pem', 'chain.pem'].map(name => path.join(directory, name));
|
|
906
|
+
let stats;
|
|
907
|
+
try {
|
|
908
|
+
stats = await Promise.all(files.map(file => fs.promises.stat(file, { bigint: true })));
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
911
|
+
this._secureContexts.delete(subject);
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
this._assertOpen();
|
|
915
|
+
const version = stats.map(stat => `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`).join('|');
|
|
916
|
+
const cached = this._secureContexts.get(subject);
|
|
917
|
+
if (cached && cached.version === version) return cached.context;
|
|
918
|
+
const [key, cert, chain] = await Promise.all(files.map(file => fs.promises.readFile(file, 'utf8')));
|
|
919
|
+
this._assertOpen();
|
|
920
|
+
const context = tls.createSecureContext({ key, cert: cert + chain });
|
|
921
|
+
this._secureContexts.set(subject, { version, context });
|
|
922
|
+
return context;
|
|
923
|
+
}
|
|
828
924
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
925
|
+
_checkCertificate(servername) {
|
|
926
|
+
this._assertOpen();
|
|
927
|
+
if (!this._certificateChecks.has(servername)) {
|
|
928
|
+
const pending = Promise.resolve().then(() => {
|
|
929
|
+
this._assertOpen();
|
|
930
|
+
return this._greenlockRuntime.get({ servername });
|
|
931
|
+
}).finally(() => this._certificateChecks.delete(servername));
|
|
932
|
+
this._certificateChecks.set(servername, pending);
|
|
933
|
+
}
|
|
934
|
+
return this._certificateChecks.get(servername);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async _getSecureContext(servername, issue = true) {
|
|
938
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
939
|
+
let context = await this._resolveSecureContext(host);
|
|
940
|
+
if (!context && issue && this._greenlockRuntime && host) {
|
|
941
|
+
await this._checkCertificate(host);
|
|
942
|
+
context = await this._resolveSecureContext(host);
|
|
943
|
+
}
|
|
944
|
+
if (!context) throw new Error(`No certificate files available for ${servername}`);
|
|
945
|
+
return context;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
_initSniResolver() {
|
|
949
|
+
this._sniCallback = (servername, callback) => {
|
|
950
|
+
this._getSecureContext(servername).then(context => callback(null, context), callback);
|
|
841
951
|
};
|
|
842
952
|
}
|
|
843
953
|
|
|
@@ -850,6 +960,7 @@ class Roster {
|
|
|
850
960
|
staging: this.staging,
|
|
851
961
|
skipDryRun: this.skipLocalCheck,
|
|
852
962
|
skipChallengeTest: this.skipLocalCheck,
|
|
963
|
+
renew: false,
|
|
853
964
|
notify: (event, details) => {
|
|
854
965
|
const eventDomain = (() => {
|
|
855
966
|
if (!details || typeof details !== 'object') return null;
|
|
@@ -923,12 +1034,12 @@ class Roster {
|
|
|
923
1034
|
}
|
|
924
1035
|
|
|
925
1036
|
_startCertificateRenewLoop() {
|
|
926
|
-
if (!this._greenlockRuntime || this._certificateRenewTimer) return;
|
|
1037
|
+
if (this._closing || !this._greenlockRuntime || this._certificateRenewTimer) return;
|
|
927
1038
|
const subjects = this._getManagedCertificateSubjects();
|
|
928
1039
|
if (subjects.length === 0) return;
|
|
929
1040
|
this._certificateRenewTimer = setInterval(() => {
|
|
930
1041
|
subjects.forEach((subject) => {
|
|
931
|
-
this.
|
|
1042
|
+
this._checkCertificate(subject).catch((error) => {
|
|
932
1043
|
log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
|
|
933
1044
|
});
|
|
934
1045
|
});
|
|
@@ -939,6 +1050,7 @@ class Roster {
|
|
|
939
1050
|
}
|
|
940
1051
|
|
|
941
1052
|
async ensureCertificate(servername) {
|
|
1053
|
+
this._assertOpen();
|
|
942
1054
|
if (this.local) {
|
|
943
1055
|
throw new Error('ensureCertificate() is not available in local mode');
|
|
944
1056
|
}
|
|
@@ -954,7 +1066,7 @@ class Roster {
|
|
|
954
1066
|
if (!this._greenlockRuntime) {
|
|
955
1067
|
throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
|
|
956
1068
|
}
|
|
957
|
-
await this.
|
|
1069
|
+
await this._checkCertificate(normalizedServername);
|
|
958
1070
|
pems = this._resolvePemsForServername(normalizedServername);
|
|
959
1071
|
if (!pems) {
|
|
960
1072
|
throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
|
|
@@ -963,6 +1075,7 @@ class Roster {
|
|
|
963
1075
|
}
|
|
964
1076
|
|
|
965
1077
|
loadCertificate(servername) {
|
|
1078
|
+
this._assertOpen();
|
|
966
1079
|
if (this.local) {
|
|
967
1080
|
throw new Error('loadCertificate() is not available in local mode');
|
|
968
1081
|
}
|
|
@@ -980,9 +1093,38 @@ class Roster {
|
|
|
980
1093
|
return pems;
|
|
981
1094
|
}
|
|
982
1095
|
|
|
983
|
-
|
|
984
|
-
if (this.
|
|
1096
|
+
_assertOpen() {
|
|
1097
|
+
if (this._closing) throw new Error('Roster is closing or closed');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
init() {
|
|
1101
|
+
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1102
|
+
if (this._initPromise) return this._initPromise;
|
|
1103
|
+
if (this.handleSignals) {
|
|
1104
|
+
this._signalHandler = () => {
|
|
1105
|
+
if (this._closing) return;
|
|
1106
|
+
this.close().catch(error => {
|
|
1107
|
+
log.error('Signal shutdown failed:', error.message);
|
|
1108
|
+
process.exitCode = 1;
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
for (const signal of SHUTDOWN_SIGNALS) process.on(signal, this._signalHandler);
|
|
1112
|
+
}
|
|
1113
|
+
this._initTask = this._initialize();
|
|
1114
|
+
this._initPromise = this._initTask.catch(async error => {
|
|
1115
|
+
if (!this._closing) {
|
|
1116
|
+
try { await this.close(); } catch (cleanupError) {
|
|
1117
|
+
throw new AggregateError([error, cleanupError], 'Initialization and cleanup failed');
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
throw error;
|
|
1121
|
+
});
|
|
1122
|
+
return this._initPromise;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async _initialize() {
|
|
985
1126
|
await this.loadSites();
|
|
1127
|
+
this._assertOpen();
|
|
986
1128
|
if (!this.local) {
|
|
987
1129
|
this.generateConfigJson();
|
|
988
1130
|
if (this.autoCertificates) {
|
|
@@ -990,6 +1132,7 @@ class Roster {
|
|
|
990
1132
|
}
|
|
991
1133
|
}
|
|
992
1134
|
this._initSiteHandlers();
|
|
1135
|
+
this._assertOpen();
|
|
993
1136
|
if (!this.local) {
|
|
994
1137
|
this._initSniResolver();
|
|
995
1138
|
if (this.autoCertificates) {
|
|
@@ -1000,6 +1143,151 @@ class Roster {
|
|
|
1000
1143
|
return this;
|
|
1001
1144
|
}
|
|
1002
1145
|
|
|
1146
|
+
_beginRequest(res) {
|
|
1147
|
+
if (this._closing) {
|
|
1148
|
+
res.writeHead(503, { Connection: 'close' });
|
|
1149
|
+
res.end('Server is closing');
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
if (typeof res.once === 'function') {
|
|
1153
|
+
this._responses.add(res);
|
|
1154
|
+
const done = () => {
|
|
1155
|
+
this._responses.delete(res);
|
|
1156
|
+
res.removeListener('finish', done);
|
|
1157
|
+
res.removeListener('close', done);
|
|
1158
|
+
};
|
|
1159
|
+
res.once('finish', done);
|
|
1160
|
+
res.once('close', done);
|
|
1161
|
+
}
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
_listen(server, port, hostname) {
|
|
1166
|
+
this._assertOpen();
|
|
1167
|
+
this._ownedServers.add(server);
|
|
1168
|
+
server.on('connection', socket => {
|
|
1169
|
+
this._sockets.add(socket);
|
|
1170
|
+
socket.once('close', () => this._sockets.delete(socket));
|
|
1171
|
+
if (this._closing) socket.destroy();
|
|
1172
|
+
});
|
|
1173
|
+
return new Promise((resolve, reject) => {
|
|
1174
|
+
const done = error => {
|
|
1175
|
+
server.removeListener('error', failed);
|
|
1176
|
+
server.removeListener('listening', listening);
|
|
1177
|
+
this._pendingListens.delete(cancel);
|
|
1178
|
+
if (error) reject(error);
|
|
1179
|
+
else resolve();
|
|
1180
|
+
};
|
|
1181
|
+
const failed = error => done(error);
|
|
1182
|
+
const listening = () => done();
|
|
1183
|
+
const cancel = () => {
|
|
1184
|
+
// A pending DNS lookup may complete after close().
|
|
1185
|
+
server.once('listening', () => server.close());
|
|
1186
|
+
done(new Error('Roster is closing or closed'));
|
|
1187
|
+
};
|
|
1188
|
+
this._pendingListens.add(cancel);
|
|
1189
|
+
server.once('error', failed);
|
|
1190
|
+
server.once('listening', listening);
|
|
1191
|
+
server.on('error', error => log.error(`Server error on port ${port}:`, error.message));
|
|
1192
|
+
try { server.listen(port, hostname); } catch (error) { done(error); }
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
close() {
|
|
1197
|
+
if (this._closePromise) return this._closePromise;
|
|
1198
|
+
this._closing = true;
|
|
1199
|
+
this._closePromise = this._close();
|
|
1200
|
+
return this._closePromise;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async _close() {
|
|
1204
|
+
const errors = [];
|
|
1205
|
+
let timer;
|
|
1206
|
+
const timeoutError = new Error('Roster close timed out');
|
|
1207
|
+
const timeout = new Promise((resolve, reject) => {
|
|
1208
|
+
timer = setTimeout(() => {
|
|
1209
|
+
for (const res of this._responses) res.destroy();
|
|
1210
|
+
for (const socket of this._sockets) socket.destroy();
|
|
1211
|
+
for (const server of this._ownedServers) server.closeAllConnections();
|
|
1212
|
+
reject(timeoutError);
|
|
1213
|
+
}, this.closeTimeoutMs);
|
|
1214
|
+
});
|
|
1215
|
+
const wait = async promise => {
|
|
1216
|
+
try { await Promise.race([promise, timeout]); } catch (error) {
|
|
1217
|
+
if (!errors.includes(error)) errors.push(error);
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
try {
|
|
1221
|
+
clearInterval(this._certificateRenewTimer);
|
|
1222
|
+
this._certificateRenewTimer = null;
|
|
1223
|
+
for (const timer of this._retryTimers) clearTimeout(timer);
|
|
1224
|
+
this._retryTimers.clear();
|
|
1225
|
+
for (const cancel of this._pendingListens) cancel();
|
|
1226
|
+
if (this._initTask) await wait(this._initTask.catch(() => {}));
|
|
1227
|
+
|
|
1228
|
+
const draining = [...this._responses].map(res => new Promise(resolve => {
|
|
1229
|
+
const done = () => {
|
|
1230
|
+
res.removeListener('finish', done);
|
|
1231
|
+
res.removeListener('close', done);
|
|
1232
|
+
resolve();
|
|
1233
|
+
};
|
|
1234
|
+
res.once('finish', done);
|
|
1235
|
+
res.once('close', done);
|
|
1236
|
+
}));
|
|
1237
|
+
const closeServer = server => new Promise(resolve => {
|
|
1238
|
+
try {
|
|
1239
|
+
server.close(error => {
|
|
1240
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
|
|
1241
|
+
resolve();
|
|
1242
|
+
});
|
|
1243
|
+
server.closeIdleConnections();
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
errors.push(error);
|
|
1246
|
+
resolve();
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
// Bun's close() releases its native server handle before upgraded
|
|
1250
|
+
// connections finish, preventing a later closeAllConnections().
|
|
1251
|
+
const closing = isBunRuntime ? [] : [...this._ownedServers].map(closeServer);
|
|
1252
|
+
const virtualServers = Object.values(this._sitesByPort).flatMap(portData => Object.values(portData.virtualServers));
|
|
1253
|
+
for (const server of virtualServers) {
|
|
1254
|
+
try { server.close(); } catch (error) { errors.push(error); }
|
|
1255
|
+
}
|
|
1256
|
+
for (const socket of this._upgradedSockets) socket.destroy();
|
|
1257
|
+
await wait(Promise.all([
|
|
1258
|
+
...closing, ...draining,
|
|
1259
|
+
Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
|
|
1260
|
+
]));
|
|
1261
|
+
if (isBunRuntime) {
|
|
1262
|
+
for (const server of this._ownedServers) server.closeAllConnections();
|
|
1263
|
+
await wait(Promise.all([...this._ownedServers].map(closeServer)));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const hooks = virtualServers.flatMap(server => server._closeHooks.splice(0).map(hook =>
|
|
1267
|
+
Promise.resolve().then(() => hook())
|
|
1268
|
+
));
|
|
1269
|
+
await wait(Promise.allSettled(hooks).then(results => {
|
|
1270
|
+
for (const result of results) {
|
|
1271
|
+
if (result.status === 'rejected') errors.push(result.reason);
|
|
1272
|
+
}
|
|
1273
|
+
}));
|
|
1274
|
+
} finally {
|
|
1275
|
+
clearTimeout(timer);
|
|
1276
|
+
if (this._signalHandler) {
|
|
1277
|
+
for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
|
|
1278
|
+
this._signalHandler = null;
|
|
1279
|
+
}
|
|
1280
|
+
for (const [server, handlers] of this._attachments) {
|
|
1281
|
+
server.removeListener('request', handlers.request);
|
|
1282
|
+
server.removeListener('upgrade', handlers.upgrade);
|
|
1283
|
+
}
|
|
1284
|
+
this._attachments.clear();
|
|
1285
|
+
this._secureContexts.clear();
|
|
1286
|
+
this._ownedServers.clear();
|
|
1287
|
+
}
|
|
1288
|
+
if (errors.length) throw new AggregateError(errors, 'Roster close failed');
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1003
1291
|
requestHandler(port) {
|
|
1004
1292
|
if (!this._initialized) throw new Error('Call init() before requestHandler()');
|
|
1005
1293
|
const targetPort = port || this.defaultPort;
|
|
@@ -1030,9 +1318,18 @@ class Roster {
|
|
|
1030
1318
|
}
|
|
1031
1319
|
|
|
1032
1320
|
attach(server, { port } = {}) {
|
|
1321
|
+
this._assertOpen();
|
|
1033
1322
|
if (!this._initialized) throw new Error('Call init() before attach()');
|
|
1034
|
-
|
|
1035
|
-
|
|
1323
|
+
const targetPort = port || this.defaultPort;
|
|
1324
|
+
const existing = this._attachments.get(server);
|
|
1325
|
+
if (existing) {
|
|
1326
|
+
if (existing.port !== targetPort) throw new Error('Server is already attached to another port');
|
|
1327
|
+
return this;
|
|
1328
|
+
}
|
|
1329
|
+
const handlers = { request: this.requestHandler(port), upgrade: this.upgradeHandler(port), port: targetPort };
|
|
1330
|
+
server.on('request', handlers.request);
|
|
1331
|
+
server.on('upgrade', handlers.upgrade);
|
|
1332
|
+
this._attachments.set(server, handlers);
|
|
1036
1333
|
return this;
|
|
1037
1334
|
}
|
|
1038
1335
|
|
|
@@ -1076,7 +1373,7 @@ class Roster {
|
|
|
1076
1373
|
});
|
|
1077
1374
|
}
|
|
1078
1375
|
|
|
1079
|
-
startLocalMode() {
|
|
1376
|
+
async startLocalMode() {
|
|
1080
1377
|
this.domainPorts = {};
|
|
1081
1378
|
|
|
1082
1379
|
for (const portData of Object.values(this._sitesByPort)) {
|
|
@@ -1089,13 +1386,13 @@ class Roster {
|
|
|
1089
1386
|
const appHandler = portData.appHandlers[domain];
|
|
1090
1387
|
|
|
1091
1388
|
const dispatcher = (req, res) => {
|
|
1389
|
+
if (!this._beginRequest(res)) return;
|
|
1092
1390
|
const host = (req.headers.host || '').split(':')[0].toLowerCase();
|
|
1093
1391
|
if (this._runRequestPlugins(req, res, { host, domain })) return;
|
|
1094
|
-
virtualServer.fallbackHandler = appHandler;
|
|
1095
1392
|
if (virtualServer.requestListeners.length > 0) {
|
|
1096
1393
|
virtualServer.processRequest(req, res);
|
|
1097
1394
|
} else if (appHandler) {
|
|
1098
|
-
appHandler
|
|
1395
|
+
return invokeRequest(appHandler, req, res);
|
|
1099
1396
|
} else {
|
|
1100
1397
|
res.writeHead(404);
|
|
1101
1398
|
res.end('Site not found');
|
|
@@ -1106,17 +1403,14 @@ class Roster {
|
|
|
1106
1403
|
this.portServers[port] = httpServer;
|
|
1107
1404
|
|
|
1108
1405
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
1406
|
+
if (this._closing) { socket.destroy(); return; }
|
|
1407
|
+
this._trackUpgrade(socket);
|
|
1109
1408
|
virtualServer.processUpgrade(req, socket, head);
|
|
1110
1409
|
});
|
|
1111
1410
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
httpServer.on('error', (error) => {
|
|
1118
|
-
log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
|
|
1119
|
-
});
|
|
1411
|
+
await this._listen(httpServer, port, 'localhost');
|
|
1412
|
+
const cleanDomain = normalizeDomainForLocalHost(domain);
|
|
1413
|
+
log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
|
|
1120
1414
|
}
|
|
1121
1415
|
}
|
|
1122
1416
|
|
|
@@ -1124,27 +1418,50 @@ class Roster {
|
|
|
1124
1418
|
return Promise.resolve();
|
|
1125
1419
|
}
|
|
1126
1420
|
|
|
1127
|
-
|
|
1421
|
+
start() {
|
|
1422
|
+
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1423
|
+
if (!this._startPromise) {
|
|
1424
|
+
this._startPromise = this._start().catch(async error => {
|
|
1425
|
+
if (!this._closing) {
|
|
1426
|
+
try { await this.close(); } catch (cleanupError) {
|
|
1427
|
+
throw new AggregateError([error, cleanupError], 'Startup and cleanup failed');
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
throw error;
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
return this._startPromise;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async _start() {
|
|
1128
1437
|
await this.init();
|
|
1438
|
+
this._assertOpen();
|
|
1129
1439
|
|
|
1130
1440
|
if (this.local) {
|
|
1131
1441
|
return this.startLocalMode();
|
|
1132
1442
|
}
|
|
1133
1443
|
|
|
1134
1444
|
const greenlockOptions = this._buildGreenlockOptions();
|
|
1135
|
-
const greenlockRuntime = GreenlockShim.create(greenlockOptions);
|
|
1445
|
+
const greenlockRuntime = this._greenlockRuntime || GreenlockShim.create(greenlockOptions);
|
|
1446
|
+
this._greenlockRuntime = greenlockRuntime;
|
|
1447
|
+
this._startCertificateRenewLoop();
|
|
1136
1448
|
const greenlock = Greenlock.init({
|
|
1137
1449
|
...greenlockOptions,
|
|
1138
|
-
greenlock: greenlockRuntime
|
|
1450
|
+
greenlock: greenlockRuntime,
|
|
1451
|
+
onServerError: error => log.error('Server error:', error.message)
|
|
1139
1452
|
});
|
|
1140
1453
|
|
|
1141
|
-
|
|
1454
|
+
if (this.cluster && require('cluster').isPrimary) {
|
|
1455
|
+
return greenlock.ready();
|
|
1456
|
+
}
|
|
1457
|
+
const glx = await new Promise(resolve => greenlock.ready(resolve));
|
|
1458
|
+
this._assertOpen();
|
|
1459
|
+
{
|
|
1142
1460
|
const httpServer = glx.httpServer();
|
|
1143
1461
|
const bunTlsHotReloadHandlers = [];
|
|
1144
1462
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
});
|
|
1463
|
+
await this._listen(httpServer, 80, this.hostname);
|
|
1464
|
+
log.info('HTTP server listening on port 80');
|
|
1148
1465
|
|
|
1149
1466
|
for (const [port, portData] of Object.entries(this._sitesByPort)) {
|
|
1150
1467
|
const portNum = parseInt(port);
|
|
@@ -1159,7 +1476,7 @@ class Roster {
|
|
|
1159
1476
|
if (pems) return pems;
|
|
1160
1477
|
|
|
1161
1478
|
try {
|
|
1162
|
-
await
|
|
1479
|
+
await this._checkCertificate(host);
|
|
1163
1480
|
} catch (error) {
|
|
1164
1481
|
log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
|
|
1165
1482
|
}
|
|
@@ -1172,7 +1489,7 @@ class Roster {
|
|
|
1172
1489
|
if (zone) {
|
|
1173
1490
|
const bootstrapHost = `bun-bootstrap.${zone}`;
|
|
1174
1491
|
try {
|
|
1175
|
-
await
|
|
1492
|
+
await this._checkCertificate(bootstrapHost);
|
|
1176
1493
|
} catch (error) {
|
|
1177
1494
|
log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
|
|
1178
1495
|
}
|
|
@@ -1201,7 +1518,7 @@ class Roster {
|
|
|
1201
1518
|
const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
|
|
1202
1519
|
log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
|
|
1203
1520
|
try {
|
|
1204
|
-
await
|
|
1521
|
+
await this._checkCertificate(certSubject);
|
|
1205
1522
|
} catch (error) {
|
|
1206
1523
|
log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
|
|
1207
1524
|
}
|
|
@@ -1222,23 +1539,28 @@ class Roster {
|
|
|
1222
1539
|
if (isBunRuntime) {
|
|
1223
1540
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
1224
1541
|
let defaultPems = await ensureBunDefaultPems(primaryDomain);
|
|
1542
|
+
let defaultContext = tls.createSecureContext(defaultPems);
|
|
1225
1543
|
httpsServer = https.createServer({
|
|
1226
1544
|
...tlsOpts,
|
|
1227
1545
|
key: defaultPems.key,
|
|
1228
1546
|
cert: defaultPems.cert,
|
|
1229
1547
|
SNICallback: (servername, callback) => {
|
|
1230
|
-
|
|
1231
|
-
.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
.
|
|
1548
|
+
const resolveContext = async () => {
|
|
1549
|
+
const cached = await this._resolveSecureContext(servername);
|
|
1550
|
+
if (cached) return cached;
|
|
1551
|
+
const pems = await issueAndReloadPemsForServername(servername);
|
|
1552
|
+
this._assertOpen();
|
|
1553
|
+
return pems ? this._getSecureContext(servername, false) : defaultContext;
|
|
1554
|
+
};
|
|
1555
|
+
resolveContext().then(context => callback(null, context), callback);
|
|
1236
1556
|
}
|
|
1237
1557
|
}, dispatcher);
|
|
1238
1558
|
const reloadBunDefaultTls = async (servername, reason) => {
|
|
1239
1559
|
const nextPems = await issueAndReloadPemsForServername(servername);
|
|
1240
1560
|
if (!nextPems) return false;
|
|
1561
|
+
this._assertOpen();
|
|
1241
1562
|
defaultPems = nextPems;
|
|
1563
|
+
defaultContext = tls.createSecureContext(defaultPems);
|
|
1242
1564
|
if (typeof httpsServer.setSecureContext === 'function') {
|
|
1243
1565
|
try {
|
|
1244
1566
|
httpsServer.setSecureContext({ key: defaultPems.key, cert: defaultPems.cert });
|
|
@@ -1258,34 +1580,20 @@ class Roster {
|
|
|
1258
1580
|
this.portServers[portNum] = httpsServer;
|
|
1259
1581
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1260
1582
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
});
|
|
1583
|
+
await this._listen(httpsServer, portNum, this.hostname);
|
|
1584
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1264
1585
|
} else {
|
|
1265
1586
|
const httpsOptions = {
|
|
1266
1587
|
minVersion: this.tlsMinVersion,
|
|
1267
1588
|
maxVersion: this.tlsMaxVersion,
|
|
1268
1589
|
SNICallback: (servername, callback) => {
|
|
1269
|
-
|
|
1270
|
-
const pems = this._resolvePemsForServername(servername);
|
|
1271
|
-
if (pems) {
|
|
1272
|
-
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
1273
|
-
} else {
|
|
1274
|
-
callback(new Error(`No certificate files available for ${servername}`));
|
|
1275
|
-
}
|
|
1276
|
-
} catch (error) {
|
|
1277
|
-
callback(error);
|
|
1278
|
-
}
|
|
1590
|
+
this._getSecureContext(servername, false).then(context => callback(null, context), callback);
|
|
1279
1591
|
}
|
|
1280
1592
|
};
|
|
1281
1593
|
|
|
1282
1594
|
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
1283
1595
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1284
1596
|
|
|
1285
|
-
httpsServer.on('error', (error) => {
|
|
1286
|
-
log.error(`HTTPS server error on port ${portNum}:`, error.message);
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
1597
|
httpsServer.on('tlsClientError', (error) => {
|
|
1290
1598
|
if (!error.message.includes('http request')) {
|
|
1291
1599
|
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
@@ -1294,13 +1602,8 @@ class Roster {
|
|
|
1294
1602
|
|
|
1295
1603
|
this.portServers[portNum] = httpsServer;
|
|
1296
1604
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
|
|
1300
|
-
} else {
|
|
1301
|
-
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1302
|
-
}
|
|
1303
|
-
});
|
|
1605
|
+
await this._listen(httpsServer, portNum, this.hostname);
|
|
1606
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1304
1607
|
}
|
|
1305
1608
|
}
|
|
1306
1609
|
|
|
@@ -1315,6 +1618,7 @@ class Roster {
|
|
|
1315
1618
|
for (const zone of this.wildcardZones) {
|
|
1316
1619
|
const bootstrapHost = `bun-bootstrap.${zone}`;
|
|
1317
1620
|
const attemptPrewarm = async (attempt = 1) => {
|
|
1621
|
+
if (this._closing) return;
|
|
1318
1622
|
try {
|
|
1319
1623
|
log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
|
|
1320
1624
|
let reloaded = false;
|
|
@@ -1331,16 +1635,20 @@ class Roster {
|
|
|
1331
1635
|
log.warn(`⚠️ Bun wildcard prewarm stopped for ${zone} after ${attempt} attempts`);
|
|
1332
1636
|
return;
|
|
1333
1637
|
}
|
|
1334
|
-
|
|
1638
|
+
if (this._closing) return;
|
|
1639
|
+
const timer = setTimeout(() => {
|
|
1640
|
+
this._retryTimers.delete(timer);
|
|
1335
1641
|
attemptPrewarm(attempt + 1).catch(() => {});
|
|
1336
1642
|
}, retryDelayMs);
|
|
1643
|
+
this._retryTimers.add(timer);
|
|
1337
1644
|
}
|
|
1338
1645
|
};
|
|
1339
1646
|
|
|
1340
1647
|
attemptPrewarm().catch(() => {});
|
|
1341
1648
|
}
|
|
1342
1649
|
}
|
|
1343
|
-
}
|
|
1650
|
+
}
|
|
1651
|
+
return this;
|
|
1344
1652
|
}
|
|
1345
1653
|
}
|
|
1346
1654
|
|