roster-server 2.0.4 → 2.1.0
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 +57 -122
- package/demo/wildcard-example.js +31 -0
- package/index.js +292 -155
- package/package.json +4 -2
- package/skills/roster-server/SKILL.md +12 -4
- package/test/roster-server.test.js +446 -0
- package/vendor/acme-dns-01-cli-wrapper.js +161 -0
package/index.js
CHANGED
|
@@ -33,6 +33,20 @@ function domainToPort(domain, minPort = 3000, maxPort = 65535) {
|
|
|
33
33
|
return minPort + (hash % portRange);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Wildcard helpers: *.example.com -> root "example.com"
|
|
37
|
+
function wildcardRoot(pattern) {
|
|
38
|
+
if (!pattern || !pattern.startsWith('*.')) return null;
|
|
39
|
+
return pattern.split('.').slice(1).join('.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if host matches wildcard pattern (e.g. api.example.com matches *.example.com)
|
|
43
|
+
function hostMatchesWildcard(host, pattern) {
|
|
44
|
+
if (!pattern || !pattern.startsWith('*.')) return false;
|
|
45
|
+
const h = (host || '').toLowerCase();
|
|
46
|
+
const suffix = pattern.slice(2).toLowerCase(); // "example.com"
|
|
47
|
+
return h.endsWith('.' + suffix) && h.length > suffix.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
// Virtual Server that completely isolates applications
|
|
37
51
|
class VirtualServer extends EventEmitter {
|
|
38
52
|
constructor(domain) {
|
|
@@ -158,6 +172,7 @@ class Roster {
|
|
|
158
172
|
this.local = options.local || false;
|
|
159
173
|
this.domains = [];
|
|
160
174
|
this.sites = {};
|
|
175
|
+
this.wildcardZones = new Set(); // Root domains that have a wildcard site (e.g. "example.com" for *.example.com)
|
|
161
176
|
this.domainServers = {}; // Store separate servers for each domain
|
|
162
177
|
this.portServers = {}; // Store servers by port
|
|
163
178
|
this.domainPorts = {}; // Store domain → port mapping for local mode
|
|
@@ -166,84 +181,46 @@ class Roster {
|
|
|
166
181
|
this.filename = options.filename || 'index';
|
|
167
182
|
this.minLocalPort = options.minLocalPort || 4000;
|
|
168
183
|
this.maxLocalPort = options.maxLocalPort || 9999;
|
|
184
|
+
this.tlsMinVersion = options.tlsMinVersion ?? 'TLSv1.2';
|
|
185
|
+
this.tlsMaxVersion = options.tlsMaxVersion ?? 'TLSv1.3';
|
|
169
186
|
|
|
170
187
|
const port = options.port === undefined ? 443 : options.port;
|
|
171
188
|
if (port === 80 && !this.local) {
|
|
172
189
|
throw new Error('⚠️ Port 80 is reserved for ACME challenge. Please use a different port.');
|
|
173
190
|
}
|
|
174
191
|
this.defaultPort = port;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
getEffectiveTlsMode() {
|
|
190
|
-
if (this.tlsMode !== 'auto') {
|
|
191
|
-
return this.tlsMode;
|
|
192
|
-
}
|
|
193
|
-
return this.detectRuntime() === 'bun' ? 'static' : 'greenlock';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
getDefaultTlsOptions() {
|
|
197
|
-
return Object.assign({ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' }, this.tlsOptions);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
createSNICallback() {
|
|
201
|
-
return (domain, callback) => {
|
|
202
|
-
try {
|
|
203
|
-
const certPath = path.join(this.greenlockStorePath, 'live', domain);
|
|
204
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
205
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
206
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
207
|
-
|
|
208
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
209
|
-
const key = fs.readFileSync(keyPath, 'utf8');
|
|
210
|
-
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
211
|
-
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
212
|
-
|
|
213
|
-
callback(null, tls.createSecureContext({ key, cert: cert + chain }));
|
|
214
|
-
} else {
|
|
215
|
-
callback(new Error(`No certificate files available for ${domain} at ${certPath}`));
|
|
216
|
-
}
|
|
217
|
-
} catch (error) {
|
|
218
|
-
callback(error);
|
|
192
|
+
// Use a local wrapper around acme-dns-01-cli so we can provide propagationDelay,
|
|
193
|
+
// zones(), and Promise-style signatures expected by newer ACME validators.
|
|
194
|
+
const defaultDnsChallengeModule = path.join(__dirname, 'vendor', 'acme-dns-01-cli-wrapper.js');
|
|
195
|
+
const shouldUseCliWrapper = (moduleName) =>
|
|
196
|
+
typeof moduleName === 'string' &&
|
|
197
|
+
/(^|[\\/])acme-dns-01-cli([\\/]|$)/.test(moduleName);
|
|
198
|
+
|
|
199
|
+
if (options.dnsChallenge === false) {
|
|
200
|
+
this.dnsChallenge = null;
|
|
201
|
+
} else if (options.dnsChallenge) {
|
|
202
|
+
const provided = { ...options.dnsChallenge };
|
|
203
|
+
if (shouldUseCliWrapper(provided.module) || provided.module === 'acme-dns-01-cli') {
|
|
204
|
+
provided.module = defaultDnsChallengeModule;
|
|
219
205
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
createStaticHttpsServer(dispatcher) {
|
|
224
|
-
const tlsOpts = Object.assign(this.getDefaultTlsOptions(), {
|
|
225
|
-
SNICallback: this.createSNICallback()
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
if (this.tlsDomain) {
|
|
229
|
-
const certPath = path.join(this.greenlockStorePath, 'live', this.tlsDomain);
|
|
230
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
231
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
232
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
233
|
-
|
|
234
|
-
const missing = [keyPath, certFilePath, chainPath].filter(p => !fs.existsSync(p));
|
|
235
|
-
if (missing.length > 0) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`Static TLS cert files missing for domain "${this.tlsDomain}":\n` +
|
|
238
|
-
missing.map(p => ` - ${p}`).join('\n')
|
|
239
|
-
);
|
|
206
|
+
if (provided.propagationDelay === undefined) {
|
|
207
|
+
provided.propagationDelay = 120000;
|
|
240
208
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
209
|
+
if (provided.autoContinue === undefined) {
|
|
210
|
+
provided.autoContinue = false;
|
|
211
|
+
}
|
|
212
|
+
if (provided.dryRunDelay === undefined) {
|
|
213
|
+
provided.dryRunDelay = provided.propagationDelay;
|
|
214
|
+
}
|
|
215
|
+
this.dnsChallenge = provided;
|
|
216
|
+
} else {
|
|
217
|
+
this.dnsChallenge = {
|
|
218
|
+
module: defaultDnsChallengeModule,
|
|
219
|
+
propagationDelay: 120000,
|
|
220
|
+
autoContinue: false,
|
|
221
|
+
dryRunDelay: 120000
|
|
222
|
+
};
|
|
244
223
|
}
|
|
245
|
-
|
|
246
|
-
return https.createServer(tlsOpts, dispatcher);
|
|
247
224
|
}
|
|
248
225
|
|
|
249
226
|
async loadSites() {
|
|
@@ -282,13 +259,21 @@ class Roster {
|
|
|
282
259
|
}
|
|
283
260
|
|
|
284
261
|
if (siteApp) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
this.sites[
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
262
|
+
if (domain.startsWith('*.')) {
|
|
263
|
+
// Wildcard site: one handler for all subdomains (e.g. *.example.com)
|
|
264
|
+
this.domains.push(domain);
|
|
265
|
+
this.sites[domain] = siteApp;
|
|
266
|
+
const root = wildcardRoot(domain);
|
|
267
|
+
if (root) this.wildcardZones.add(root);
|
|
268
|
+
log.info(`(✔) Loaded wildcard site: https://${domain}`);
|
|
269
|
+
} else {
|
|
270
|
+
const domainEntries = [domain, `www.${domain}`];
|
|
271
|
+
this.domains.push(...domainEntries);
|
|
272
|
+
domainEntries.forEach(d => {
|
|
273
|
+
this.sites[d] = siteApp;
|
|
274
|
+
});
|
|
275
|
+
log.info(`(✔) Loaded site: https://${domain}`);
|
|
276
|
+
}
|
|
292
277
|
} else {
|
|
293
278
|
log.warn(`⚠️ No index file (js/mjs/cjs) found in ${domainPath}`);
|
|
294
279
|
}
|
|
@@ -307,8 +292,8 @@ class Roster {
|
|
|
307
292
|
const uniqueDomains = new Set();
|
|
308
293
|
|
|
309
294
|
this.domains.forEach(domain => {
|
|
310
|
-
const
|
|
311
|
-
uniqueDomains.add(
|
|
295
|
+
const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
|
|
296
|
+
if (root) uniqueDomains.add(root);
|
|
312
297
|
});
|
|
313
298
|
|
|
314
299
|
let existingConfig = {};
|
|
@@ -322,6 +307,9 @@ class Roster {
|
|
|
322
307
|
if ((domain.match(/\./g) || []).length < 2) {
|
|
323
308
|
altnames.push(`www.${domain}`);
|
|
324
309
|
}
|
|
310
|
+
if (this.wildcardZones.has(domain)) {
|
|
311
|
+
altnames.push(`*.${domain}`);
|
|
312
|
+
}
|
|
325
313
|
|
|
326
314
|
let existingSite = null;
|
|
327
315
|
if (existingConfig.sites) {
|
|
@@ -334,7 +322,35 @@ class Roster {
|
|
|
334
322
|
};
|
|
335
323
|
|
|
336
324
|
if (existingSite && existingSite.renewAt) {
|
|
337
|
-
|
|
325
|
+
const existingAltnames = Array.isArray(existingSite.altnames)
|
|
326
|
+
? [...existingSite.altnames].sort()
|
|
327
|
+
: [];
|
|
328
|
+
const nextAltnames = [...altnames].sort();
|
|
329
|
+
const sameAltnames =
|
|
330
|
+
existingAltnames.length === nextAltnames.length &&
|
|
331
|
+
existingAltnames.every((name, idx) => name === nextAltnames[idx]);
|
|
332
|
+
|
|
333
|
+
// Keep renewAt only when certificate identifiers are unchanged.
|
|
334
|
+
// If altnames changed (e.g. wildcard added), force immediate re-issue.
|
|
335
|
+
if (sameAltnames) {
|
|
336
|
+
siteConfig.renewAt = existingSite.renewAt;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.wildcardZones.has(domain) && this.dnsChallenge) {
|
|
341
|
+
const dns01 = { ...this.dnsChallenge };
|
|
342
|
+
if (dns01.propagationDelay === undefined) {
|
|
343
|
+
dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
|
|
344
|
+
}
|
|
345
|
+
if (dns01.autoContinue === undefined) {
|
|
346
|
+
dns01.autoContinue = false;
|
|
347
|
+
}
|
|
348
|
+
if (dns01.dryRunDelay === undefined) {
|
|
349
|
+
dns01.dryRunDelay = dns01.propagationDelay;
|
|
350
|
+
}
|
|
351
|
+
siteConfig.challenges = {
|
|
352
|
+
'dns-01': dns01
|
|
353
|
+
};
|
|
338
354
|
}
|
|
339
355
|
|
|
340
356
|
sitesConfig.push(siteConfig);
|
|
@@ -380,6 +396,47 @@ class Roster {
|
|
|
380
396
|
log.info(`📁 config.json generated at ${configPath}`);
|
|
381
397
|
}
|
|
382
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Resolve handler for a host (exact match, then wildcard). Used when port is not in the key.
|
|
401
|
+
*/
|
|
402
|
+
getHandlerForHost(host) {
|
|
403
|
+
const resolved = this.getHandlerAndKeyForHost(host);
|
|
404
|
+
return resolved ? resolved.handler : null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve handler and site key for a host (exact match, then wildcard). Used by getUrl for wildcard lookups.
|
|
409
|
+
*/
|
|
410
|
+
getHandlerAndKeyForHost(host) {
|
|
411
|
+
const siteApp = this.sites[host];
|
|
412
|
+
if (siteApp) return { handler: siteApp, siteKey: host };
|
|
413
|
+
for (const key of Object.keys(this.sites)) {
|
|
414
|
+
if (key.startsWith('*.')) {
|
|
415
|
+
const pattern = key.split(':')[0];
|
|
416
|
+
if (hostMatchesWildcard(host, pattern)) return { handler: this.sites[key], siteKey: key };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resolve virtualServer and appHandler for a host from portData (exact then wildcard).
|
|
424
|
+
*/
|
|
425
|
+
getHandlerForPortData(host, portData) {
|
|
426
|
+
const virtualServer = portData.virtualServers[host];
|
|
427
|
+
const appHandler = portData.appHandlers[host];
|
|
428
|
+
if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
|
|
429
|
+
for (const key of Object.keys(portData.appHandlers)) {
|
|
430
|
+
if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
|
|
431
|
+
return {
|
|
432
|
+
virtualServer: portData.virtualServers[key],
|
|
433
|
+
appHandler: portData.appHandlers[key]
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
383
440
|
handleRequest(req, res) {
|
|
384
441
|
const host = req.headers.host || '';
|
|
385
442
|
|
|
@@ -390,7 +447,8 @@ class Roster {
|
|
|
390
447
|
return;
|
|
391
448
|
}
|
|
392
449
|
|
|
393
|
-
const
|
|
450
|
+
const hostWithoutPort = host.split(':')[0];
|
|
451
|
+
const siteApp = this.getHandlerForHost(hostWithoutPort);
|
|
394
452
|
if (siteApp) {
|
|
395
453
|
siteApp(req, res);
|
|
396
454
|
} else {
|
|
@@ -409,6 +467,16 @@ class Roster {
|
|
|
409
467
|
|
|
410
468
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
411
469
|
|
|
470
|
+
if (domain.startsWith('*.')) {
|
|
471
|
+
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
472
|
+
this.domains.push(domain);
|
|
473
|
+
this.sites[domainKey] = requestHandler;
|
|
474
|
+
const root = wildcardRoot(domain);
|
|
475
|
+
if (root) this.wildcardZones.add(root);
|
|
476
|
+
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
|
|
412
480
|
const domainEntries = [domain];
|
|
413
481
|
if ((domain.match(/\./g) || []).length < 2) {
|
|
414
482
|
domainEntries.push(`www.${domain}`);
|
|
@@ -416,7 +484,6 @@ class Roster {
|
|
|
416
484
|
|
|
417
485
|
this.domains.push(...domainEntries);
|
|
418
486
|
domainEntries.forEach(d => {
|
|
419
|
-
// Store with port information
|
|
420
487
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
421
488
|
this.sites[domainKey] = requestHandler;
|
|
422
489
|
});
|
|
@@ -440,31 +507,25 @@ class Roster {
|
|
|
440
507
|
|
|
441
508
|
/**
|
|
442
509
|
* Get the URL for a domain based on the current environment
|
|
443
|
-
* @param {string} domain - The domain name
|
|
444
|
-
* @returns {string|null} The URL if domain is registered, null otherwise
|
|
510
|
+
* @param {string} domain - The domain name (or subdomain that matches a wildcard site)
|
|
511
|
+
* @returns {string|null} The URL if domain is registered (exact or wildcard), null otherwise
|
|
445
512
|
*/
|
|
446
513
|
getUrl(domain) {
|
|
447
|
-
// Remove www prefix if present
|
|
448
514
|
const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
if (!
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Return URL based on environment
|
|
515
|
+
|
|
516
|
+
const exactMatch = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
|
|
517
|
+
const resolved = exactMatch ? { handler: exactMatch, siteKey: cleanDomain } : this.getHandlerAndKeyForHost(cleanDomain);
|
|
518
|
+
if (!resolved) return null;
|
|
519
|
+
|
|
457
520
|
if (this.local) {
|
|
458
|
-
|
|
459
|
-
if (this.domainPorts && this.domainPorts[
|
|
460
|
-
return `http://localhost:${this.domainPorts[
|
|
521
|
+
const pattern = resolved.siteKey.split(':')[0];
|
|
522
|
+
if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
|
|
523
|
+
return `http://localhost:${this.domainPorts[pattern]}`;
|
|
461
524
|
}
|
|
462
525
|
return null;
|
|
463
|
-
} else {
|
|
464
|
-
// Production mode: return HTTPS URL
|
|
465
|
-
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
466
|
-
return `https://${cleanDomain}${port}`;
|
|
467
526
|
}
|
|
527
|
+
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
528
|
+
return `https://${cleanDomain}${port}`;
|
|
468
529
|
}
|
|
469
530
|
|
|
470
531
|
createVirtualServer(domain) {
|
|
@@ -509,7 +570,7 @@ class Roster {
|
|
|
509
570
|
startLocalMode() {
|
|
510
571
|
// Store mapping of domain to port for later retrieval
|
|
511
572
|
this.domainPorts = {};
|
|
512
|
-
|
|
573
|
+
|
|
513
574
|
// Create a simple HTTP server for each domain with CRC32-based ports
|
|
514
575
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
515
576
|
const domain = hostKey.split(':')[0]; // Remove port if present
|
|
@@ -521,7 +582,7 @@ class Roster {
|
|
|
521
582
|
|
|
522
583
|
// Calculate deterministic port based on domain CRC32, with collision detection
|
|
523
584
|
const port = this.assignPortToDomain(domain);
|
|
524
|
-
|
|
585
|
+
|
|
525
586
|
// Store domain → port mapping
|
|
526
587
|
this.domainPorts[domain] = port;
|
|
527
588
|
|
|
@@ -587,7 +648,18 @@ class Roster {
|
|
|
587
648
|
configDir: this.greenlockStorePath,
|
|
588
649
|
maintainerEmail: this.email,
|
|
589
650
|
cluster: this.cluster,
|
|
590
|
-
staging: this.staging
|
|
651
|
+
staging: this.staging,
|
|
652
|
+
notify: (event, details) => {
|
|
653
|
+
const msg = typeof details === 'string' ? details : (details?.message ?? JSON.stringify(details));
|
|
654
|
+
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
655
|
+
if (event === 'warning' && typeof msg === 'string') {
|
|
656
|
+
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
657
|
+
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
658
|
+
}
|
|
659
|
+
if (event === 'error') log.error(msg);
|
|
660
|
+
else if (event === 'warning') log.warn(msg);
|
|
661
|
+
else log.info(msg);
|
|
662
|
+
}
|
|
591
663
|
});
|
|
592
664
|
|
|
593
665
|
return greenlock.ready(glx => {
|
|
@@ -605,15 +677,15 @@ class Roster {
|
|
|
605
677
|
};
|
|
606
678
|
}
|
|
607
679
|
|
|
608
|
-
// Create completely isolated virtual server
|
|
609
680
|
const virtualServer = this.createVirtualServer(domain);
|
|
610
681
|
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
611
682
|
this.domainServers[domain] = virtualServer;
|
|
612
683
|
|
|
613
|
-
// Initialize app with virtual server
|
|
614
684
|
const appHandler = siteApp(virtualServer);
|
|
615
685
|
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
616
|
-
|
|
686
|
+
if (!domain.startsWith('*.')) {
|
|
687
|
+
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
688
|
+
}
|
|
617
689
|
}
|
|
618
690
|
}
|
|
619
691
|
|
|
@@ -622,27 +694,27 @@ class Roster {
|
|
|
622
694
|
return (req, res) => {
|
|
623
695
|
const host = req.headers.host || '';
|
|
624
696
|
|
|
625
|
-
|
|
626
|
-
const hostWithoutPort = host.split(':')[0];
|
|
697
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
627
698
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
628
699
|
|
|
629
|
-
// Handle www redirects
|
|
630
700
|
if (hostWithoutPort.startsWith('www.')) {
|
|
631
701
|
res.writeHead(301, { Location: `https://${domain}${req.url}` });
|
|
632
702
|
res.end();
|
|
633
703
|
return;
|
|
634
704
|
}
|
|
635
705
|
|
|
636
|
-
const
|
|
637
|
-
|
|
706
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
707
|
+
if (!resolved) {
|
|
708
|
+
res.writeHead(404);
|
|
709
|
+
res.end('Site not found');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const { virtualServer, appHandler } = resolved;
|
|
638
713
|
|
|
639
714
|
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
640
|
-
// Set fallback handler on virtual server for non-Socket.IO requests
|
|
641
715
|
virtualServer.fallbackHandler = appHandler;
|
|
642
|
-
// App registered listeners on virtual server - use them
|
|
643
716
|
virtualServer.processRequest(req, res);
|
|
644
717
|
} else if (appHandler) {
|
|
645
|
-
// App returned a handler function - use it
|
|
646
718
|
appHandler(req, res);
|
|
647
719
|
} else {
|
|
648
720
|
res.writeHead(404);
|
|
@@ -651,76 +723,141 @@ class Roster {
|
|
|
651
723
|
};
|
|
652
724
|
};
|
|
653
725
|
|
|
654
|
-
const runtime = this.detectRuntime();
|
|
655
|
-
const effectiveTlsMode = this.getEffectiveTlsMode();
|
|
656
|
-
log.info(`Runtime: ${runtime} | TLS mode: ${effectiveTlsMode}`);
|
|
657
|
-
|
|
658
726
|
httpServer.listen(80, this.hostname, () => {
|
|
659
727
|
log.info('HTTP server listening on port 80');
|
|
660
728
|
});
|
|
661
729
|
|
|
662
|
-
// Create upgrade handler for WebSocket connections
|
|
663
730
|
const createUpgradeHandler = (portData) => {
|
|
664
731
|
return (req, socket, head) => {
|
|
665
732
|
const host = req.headers.host || '';
|
|
666
|
-
const hostWithoutPort = host.split(':')[0];
|
|
733
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
667
734
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
668
735
|
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
virtualServer.processUpgrade(req, socket, head);
|
|
736
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
737
|
+
if (resolved && resolved.virtualServer) {
|
|
738
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
673
739
|
} else {
|
|
674
740
|
socket.destroy();
|
|
675
741
|
}
|
|
676
742
|
};
|
|
677
743
|
};
|
|
678
744
|
|
|
679
|
-
const attachHttpsListeners = (httpsServer, portNum, upgradeHandler) => {
|
|
680
|
-
httpsServer.on('upgrade', upgradeHandler);
|
|
681
|
-
httpsServer.on('error', (error) => {
|
|
682
|
-
log.error(`HTTPS server error on port ${portNum}:`, error.message);
|
|
683
|
-
});
|
|
684
|
-
httpsServer.on('tlsClientError', (error) => {
|
|
685
|
-
if (!error.message.includes('http request')) {
|
|
686
|
-
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
};
|
|
690
|
-
|
|
691
745
|
// Handle different port types
|
|
692
746
|
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
693
747
|
const portNum = parseInt(port);
|
|
694
748
|
const dispatcher = createDispatcher(portData);
|
|
695
749
|
const upgradeHandler = createUpgradeHandler(portData);
|
|
696
750
|
|
|
697
|
-
|
|
751
|
+
if (portNum === this.defaultPort) {
|
|
752
|
+
// Bun has known gaps around SNICallback compatibility.
|
|
753
|
+
// Fallback to static cert loading for the primary domain on default HTTPS port.
|
|
754
|
+
const isBunRuntime = typeof Bun !== 'undefined' || process.release?.name === 'bun';
|
|
755
|
+
const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
|
|
756
|
+
let httpsServer;
|
|
757
|
+
|
|
758
|
+
if (isBunRuntime) {
|
|
759
|
+
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
760
|
+
// Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
|
|
761
|
+
const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
|
|
762
|
+
const certPath = path.join(this.greenlockStorePath, 'live', certSubject);
|
|
763
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
764
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
765
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
766
|
+
|
|
767
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
768
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
769
|
+
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
770
|
+
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
771
|
+
httpsServer = https.createServer({
|
|
772
|
+
...tlsOpts,
|
|
773
|
+
key,
|
|
774
|
+
cert: cert + chain
|
|
775
|
+
}, dispatcher);
|
|
776
|
+
log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
|
|
777
|
+
} else {
|
|
778
|
+
log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
|
|
779
|
+
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
780
|
+
}
|
|
781
|
+
} else {
|
|
782
|
+
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
783
|
+
}
|
|
698
784
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
785
|
+
this.portServers[portNum] = httpsServer;
|
|
786
|
+
|
|
787
|
+
// Handle WebSocket upgrade events
|
|
788
|
+
httpsServer.on('upgrade', upgradeHandler);
|
|
789
|
+
|
|
790
|
+
httpsServer.listen(portNum, this.hostname, () => {
|
|
791
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
792
|
+
});
|
|
702
793
|
} else {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
794
|
+
// Create HTTPS server for custom ports using Greenlock certificates
|
|
795
|
+
const greenlockStorePath = this.greenlockStorePath;
|
|
796
|
+
const loadCert = (subjectDir) => {
|
|
797
|
+
const certPath = path.join(greenlockStorePath, 'live', subjectDir);
|
|
798
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
799
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
800
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
801
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
802
|
+
return {
|
|
803
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
804
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return null;
|
|
808
|
+
};
|
|
809
|
+
const httpsOptions = {
|
|
810
|
+
minVersion: this.tlsMinVersion,
|
|
811
|
+
maxVersion: this.tlsMaxVersion,
|
|
812
|
+
SNICallback: (servername, callback) => {
|
|
813
|
+
try {
|
|
814
|
+
let pems = loadCert(servername);
|
|
815
|
+
if (!pems && hostMatchesWildcard(servername, '*.' + servername.split('.').slice(1).join('.'))) {
|
|
816
|
+
const zoneSubject = servername.split('.').slice(1).join('.');
|
|
817
|
+
pems = loadCert(zoneSubject);
|
|
818
|
+
}
|
|
819
|
+
if (pems) {
|
|
820
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
821
|
+
} else {
|
|
822
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
823
|
+
}
|
|
824
|
+
} catch (error) {
|
|
825
|
+
callback(error);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
831
|
+
|
|
832
|
+
// Handle WebSocket upgrade events
|
|
833
|
+
httpsServer.on('upgrade', upgradeHandler);
|
|
834
|
+
|
|
835
|
+
httpsServer.on('error', (error) => {
|
|
836
|
+
log.error(`HTTPS server error on port ${portNum}:`, error.message);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
httpsServer.on('tlsClientError', (error) => {
|
|
840
|
+
// Suppress HTTP request errors to avoid log spam
|
|
841
|
+
if (!error.message.includes('http request')) {
|
|
842
|
+
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
843
|
+
}
|
|
707
844
|
});
|
|
708
|
-
httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
709
|
-
}
|
|
710
845
|
|
|
711
|
-
|
|
712
|
-
this.portServers[portNum] = httpsServer;
|
|
846
|
+
this.portServers[portNum] = httpsServer;
|
|
713
847
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
848
|
+
httpsServer.listen(portNum, this.hostname, (error) => {
|
|
849
|
+
if (error) {
|
|
850
|
+
log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
|
|
851
|
+
} else {
|
|
852
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
721
856
|
}
|
|
722
857
|
});
|
|
723
858
|
}
|
|
724
859
|
}
|
|
725
860
|
|
|
726
|
-
module.exports = Roster;
|
|
861
|
+
module.exports = Roster;
|
|
862
|
+
module.exports.wildcardRoot = wildcardRoot;
|
|
863
|
+
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roster-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
8
8
|
},
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"express",
|
|
27
27
|
"greenlock-express",
|
|
28
28
|
"shotx",
|
|
29
|
+
"bun",
|
|
29
30
|
"clasen"
|
|
30
31
|
],
|
|
31
32
|
"author": "Martin Clasen",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"homepage": "https://github.com/clasen/RosterServer#readme",
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@root/greenlock": "^4.0.5",
|
|
40
|
+
"acme-dns-01-cli": "^3.0.7",
|
|
39
41
|
"lemonlog": "^1.2.0",
|
|
40
42
|
"redirect-https": "^1.3.1"
|
|
41
43
|
}
|