roster-server 2.3.16 β 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 +65 -0
- 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/docs/decisions/git-master-acme-init-retry.md +7 -0
- package/index.js +212 -185
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +41 -2
- package/test/roster-server.test.js +273 -0
package/README.md
CHANGED
|
@@ -350,6 +350,71 @@ console.log(customRoster.getUrl('api.example.com'));
|
|
|
350
350
|
// β https://api.example.com:8443
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
+
## π Cluster-Friendly API (init / attach)
|
|
354
|
+
|
|
355
|
+
RosterServer can coexist with external cluster managers (sticky-session libraries, PM2 cluster, custom master/worker architectures) that already own the TCP socket and distribute connections. Instead of letting Roster create and bind servers, you initialize routing separately and wire it into your own server.
|
|
356
|
+
|
|
357
|
+
### How It Works
|
|
358
|
+
|
|
359
|
+
`roster.init()` loads sites, creates VirtualServers, and prepares dispatchers β but creates **no servers** and calls **no `.listen()`**. You then get handler functions to wire into any `http.Server` or `https.Server`.
|
|
360
|
+
|
|
361
|
+
### Quick Example: Sticky-Session Worker
|
|
362
|
+
|
|
363
|
+
```javascript
|
|
364
|
+
import https from 'https';
|
|
365
|
+
import Roster from 'roster-server';
|
|
366
|
+
|
|
367
|
+
const roster = new Roster({
|
|
368
|
+
email: 'admin@example.com',
|
|
369
|
+
wwwPath: '/srv/www',
|
|
370
|
+
greenlockStorePath: '/srv/greenlock.d'
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await roster.init();
|
|
374
|
+
|
|
375
|
+
// Create your own HTTPS server with Roster's SNI + routing
|
|
376
|
+
const server = https.createServer({ SNICallback: roster.sniCallback() });
|
|
377
|
+
roster.attach(server);
|
|
378
|
+
|
|
379
|
+
// Master passes connections via IPC β worker never calls listen()
|
|
380
|
+
process.on('message', (msg, connection) => {
|
|
381
|
+
if (msg === 'sticky-session:connection') {
|
|
382
|
+
server.emit('connection', connection);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### API Reference
|
|
388
|
+
|
|
389
|
+
#### `roster.init()` β `Promise<Roster>`
|
|
390
|
+
|
|
391
|
+
Loads sites, generates SSL config (production), creates VirtualServers and initializes handlers. Idempotent β calling it twice is safe. Returns `this` for chaining.
|
|
392
|
+
|
|
393
|
+
#### `roster.requestHandler(port?)` β `(req, res) => void`
|
|
394
|
+
|
|
395
|
+
Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles wwwβnon-www redirects, wildcard matching, and VirtualServer dispatch.
|
|
396
|
+
|
|
397
|
+
#### `roster.upgradeHandler(port?)` β `(req, socket, head) => void`
|
|
398
|
+
|
|
399
|
+
Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
|
|
400
|
+
|
|
401
|
+
#### `roster.sniCallback()` β `(servername, callback) => void`
|
|
402
|
+
|
|
403
|
+
Returns a TLS SNI callback that resolves certificates from `greenlockStorePath` on disk. Not available in local mode.
|
|
404
|
+
|
|
405
|
+
#### `roster.attach(server, { port }?)` β `Roster`
|
|
406
|
+
|
|
407
|
+
Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
|
|
408
|
+
|
|
409
|
+
### Standalone Mode (unchanged)
|
|
410
|
+
|
|
411
|
+
`roster.start()` still works exactly as before β it calls `init()` internally, then creates and binds servers:
|
|
412
|
+
|
|
413
|
+
```javascript
|
|
414
|
+
const roster = new Roster({ ... });
|
|
415
|
+
await roster.start(); // full standalone mode, no changes needed
|
|
416
|
+
```
|
|
417
|
+
|
|
353
418
|
## π§ A Touch of Magic
|
|
354
419
|
|
|
355
420
|
You might be thinking, "But setting up HTTPS and virtual hosts is supposed to be complicated and time-consuming!" Well, not anymore. With RosterServer, you can get back to writing code that matters, like defending Earth from alien invaders! πΎπΎπΎ
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const Roster = require('../index.js');
|
|
5
|
+
|
|
6
|
+
function hasCertificateFor(storePath, subject) {
|
|
7
|
+
const base = path.join(storePath, 'live', subject);
|
|
8
|
+
return (
|
|
9
|
+
fs.existsSync(path.join(base, 'privkey.pem')) &&
|
|
10
|
+
fs.existsSync(path.join(base, 'cert.pem')) &&
|
|
11
|
+
fs.existsSync(path.join(base, 'chain.pem'))
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
// In this demo, certs are expected to already exist in greenlock.d/live/<subject>.
|
|
17
|
+
// init()+attach() do routing only; no ACME issuance/listen lifecycle is started.
|
|
18
|
+
const greenlockStorePath = process.env.GREENLOCK_STORE_PATH || path.join(__dirname, '..', 'greenlock.d');
|
|
19
|
+
const listenPort = Number(process.env.DEMO_HTTPS_PORT || 19643);
|
|
20
|
+
|
|
21
|
+
const roster = new Roster({
|
|
22
|
+
local: false,
|
|
23
|
+
email: process.env.ADMIN_EMAIL || 'admin@example.com',
|
|
24
|
+
greenlockStorePath
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
roster.register('example.com', () => {
|
|
28
|
+
return (req, res) => {
|
|
29
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
30
|
+
res.end('hello from external HTTPS worker (default port routes)');
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
roster.register('api.example.com:9443', () => {
|
|
35
|
+
return (req, res) => {
|
|
36
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
37
|
+
res.end(JSON.stringify({ ok: true, source: 'api.example.com:9443 route table' }));
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await roster.init();
|
|
42
|
+
|
|
43
|
+
const missing = ['example.com', '*.example.com'].filter((subject) => !hasCertificateFor(greenlockStorePath, subject));
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
console.log('\nβ οΈ Missing certificate files for:');
|
|
46
|
+
missing.forEach((subject) => console.log(` - ${subject}`));
|
|
47
|
+
console.log('\nExpected files:');
|
|
48
|
+
console.log(' greenlock.d/live/<subject>/privkey.pem');
|
|
49
|
+
console.log(' greenlock.d/live/<subject>/cert.pem');
|
|
50
|
+
console.log(' greenlock.d/live/<subject>/chain.pem');
|
|
51
|
+
console.log('\nTip: run regular roster.start() once (or your cert manager) to issue certs first.\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const server = https.createServer({
|
|
55
|
+
SNICallback: roster.sniCallback(),
|
|
56
|
+
minVersion: 'TLSv1.2',
|
|
57
|
+
maxVersion: 'TLSv1.3'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Attach default routing table (defaultPort = 443 routes)
|
|
61
|
+
roster.attach(server);
|
|
62
|
+
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
server.listen(listenPort, '0.0.0.0', resolve);
|
|
65
|
+
server.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log('\nβ
HTTPS cluster-friendly demo running');
|
|
69
|
+
console.log(`Listening on :${listenPort} with external server ownership`);
|
|
70
|
+
console.log('Roster only provides SNI + vhost routing (no internal listen/bootstrap).\n');
|
|
71
|
+
|
|
72
|
+
console.log('Try:');
|
|
73
|
+
console.log(`curl -k --resolve example.com:${listenPort}:127.0.0.1 https://example.com:${listenPort}/`);
|
|
74
|
+
console.log(`curl -k --resolve www.example.com:${listenPort}:127.0.0.1 https://www.example.com:${listenPort}/foo`);
|
|
75
|
+
console.log('\nFor custom route table port=9443, attach another server:');
|
|
76
|
+
console.log(' const srv9443 = https.createServer({ SNICallback: roster.sniCallback() });');
|
|
77
|
+
console.log(' roster.attach(srv9443, { port: 9443 });');
|
|
78
|
+
console.log(' srv9443.listen(19644);');
|
|
79
|
+
console.log(` curl -k --resolve api.example.com:19644:127.0.0.1 https://api.example.com:19644/`);
|
|
80
|
+
|
|
81
|
+
// Sticky-session runtime pattern:
|
|
82
|
+
// process.on('message', (msg, socket) => {
|
|
83
|
+
// if (msg === 'sticky-session:connection') server.emit('connection', socket);
|
|
84
|
+
// });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
main().catch((err) => {
|
|
88
|
+
console.error('Demo failed:', err);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const Roster = require('../index.js');
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const roster = new Roster({
|
|
6
|
+
local: true,
|
|
7
|
+
minLocalPort: 19500,
|
|
8
|
+
maxLocalPort: 19550
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
roster.register('example.com', () => {
|
|
12
|
+
return (req, res) => {
|
|
13
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
14
|
+
res.end('[example.com] hello from external worker wiring');
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
roster.register('api.example.com:9000', () => {
|
|
19
|
+
return (req, res) => {
|
|
20
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
21
|
+
res.end(JSON.stringify({ ok: true, source: 'api.example.com:9000' }));
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// init() prepares virtual-host routing, but does not create/listen sockets.
|
|
26
|
+
await roster.init();
|
|
27
|
+
|
|
28
|
+
// This server represents your external runtime-owned worker server.
|
|
29
|
+
const server443 = http.createServer();
|
|
30
|
+
roster.attach(server443); // defaultPort routes (443)
|
|
31
|
+
|
|
32
|
+
const server9000 = http.createServer();
|
|
33
|
+
roster.attach(server9000, { port: 9000 }); // custom port routes
|
|
34
|
+
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
server443.listen(19501, 'localhost', resolve);
|
|
37
|
+
server443.on('error', reject);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
server9000.listen(19502, 'localhost', resolve);
|
|
42
|
+
server9000.on('error', reject);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log('\nβ
Cluster-friendly worker demo running');
|
|
46
|
+
console.log('Roster did not bind ports itself. External servers own listen().\n');
|
|
47
|
+
console.log('Try requests with Host headers:');
|
|
48
|
+
console.log('curl -H "Host: example.com" http://localhost:19501/');
|
|
49
|
+
console.log('curl -H "Host: api.example.com" http://localhost:19502/\n');
|
|
50
|
+
|
|
51
|
+
// Sticky-session runtimes normally pass accepted sockets into the worker:
|
|
52
|
+
// process.on('message', (msg, socket) => {
|
|
53
|
+
// if (msg === 'sticky-session:connection') server443.emit('connection', socket);
|
|
54
|
+
// });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
console.error('Demo failed:', err);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: n5edmg25e9
|
|
3
|
+
type: architecture
|
|
4
|
+
title: 'Architecture: Cluster-friendly init/attach API for RosterServer'
|
|
5
|
+
created: '2026-03-20 20:38:03'
|
|
6
|
+
---
|
|
7
|
+
# Architecture: Cluster-Friendly init/attach API
|
|
8
|
+
|
|
9
|
+
**What**: Split Roster's monolithic `start()` into `init()` (routing preparation) + optional `start()` (server lifecycle). New public methods: `init()`, `requestHandler(port?)`, `upgradeHandler(port?)`, `sniCallback()`, `attach(server, opts?)`.
|
|
10
|
+
|
|
11
|
+
**Where**: `index.js` β Roster class. Private methods: `_initSiteHandlers()`, `_createDispatcher()`, `_createUpgradeHandler()`, `_normalizeHostInput()`, `_loadCert()`, `_resolvePemsForServername()`, `_initSniResolver()`.
|
|
12
|
+
|
|
13
|
+
**Why**: `start()` assumed full ownership of bootstrap (listen, lifecycle, dispatch), conflicting with external cluster managers (sticky-session, PM2 cluster) that already own the TCP socket and distribute connections.
|
|
14
|
+
|
|
15
|
+
**Design decisions**:
|
|
16
|
+
- `init()` is idempotent (guarded by `_initialized` flag)
|
|
17
|
+
- `start()` calls `init()` internally β zero breaking changes for existing users
|
|
18
|
+
- SNI callback in `init()` path uses disk-based cert resolution only (no Greenlock runtime) β safe for workers
|
|
19
|
+
- Greenlock-backed issuance only happens in `start()` production path β reserved for the process managing certs
|
|
20
|
+
- `_createDispatcher` uses `this.local` to choose http/https protocol for www redirects
|
|
21
|
+
- `startLocalMode()` was refactored to consume pre-initialized `_sitesByPort` from `init()`
|
|
22
|
+
- 14 new tests added covering init/handlers/attach/sni contracts
|
|
23
|
+
|
|
24
|
+
**Key pattern for users**:
|
|
25
|
+
```javascript
|
|
26
|
+
await roster.init();
|
|
27
|
+
const server = https.createServer({ SNICallback: roster.sniCallback() });
|
|
28
|
+
roster.attach(server);
|
|
29
|
+
// Master passes connections β worker never calls listen()
|
|
30
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: 47bowe7gen
|
|
3
|
+
type: decision
|
|
4
|
+
title: Pushed ACME retry commit
|
|
5
|
+
created: '2026-03-17 11:47:46'
|
|
6
|
+
---
|
|
7
|
+
Committed and pushed master commit ecb2723. Changes: added retry loop (3 attempts with incremental backoff) around ACME directory init in vendor/greenlock/greenlock.js and updated package-lock.json version from 2.2.10 to 2.2.12.
|
package/index.js
CHANGED
|
@@ -252,6 +252,9 @@ class Roster {
|
|
|
252
252
|
this.portServers = {}; // Store servers by port
|
|
253
253
|
this.domainPorts = {}; // Store domain β port mapping for local mode
|
|
254
254
|
this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
|
|
255
|
+
this._sitesByPort = {};
|
|
256
|
+
this._initialized = false;
|
|
257
|
+
this._sniCallback = null;
|
|
255
258
|
this.hostname = options.hostname ?? '::';
|
|
256
259
|
this.filename = options.filename || 'index';
|
|
257
260
|
this.minLocalPort = options.minLocalPort || 4000;
|
|
@@ -662,65 +665,219 @@ class Roster {
|
|
|
662
665
|
return null;
|
|
663
666
|
}
|
|
664
667
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
+
}
|
|
673
676
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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)) {
|
|
685
|
+
return {
|
|
686
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
687
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
678
692
|
|
|
679
|
-
|
|
680
|
-
|
|
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;
|
|
700
|
+
}
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
681
703
|
|
|
682
|
-
|
|
683
|
-
|
|
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
|
+
}
|
|
684
715
|
|
|
685
|
-
// Create virtual server for the domain
|
|
686
716
|
const virtualServer = this.createVirtualServer(domain);
|
|
717
|
+
this._sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
687
718
|
this.domainServers[domain] = virtualServer;
|
|
688
719
|
|
|
689
|
-
// Initialize app with virtual server
|
|
690
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
|
+
}
|
|
691
727
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
728
|
+
_createDispatcher(portData) {
|
|
729
|
+
return (req, res) => {
|
|
730
|
+
const host = req.headers.host || '';
|
|
731
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
732
|
+
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
733
|
+
|
|
734
|
+
if (hostWithoutPort.startsWith('www.')) {
|
|
735
|
+
const protocol = this.local ? 'http' : 'https';
|
|
736
|
+
res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
|
|
737
|
+
res.end();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
742
|
+
if (!resolved) {
|
|
743
|
+
res.writeHead(404);
|
|
744
|
+
res.end('Site not found');
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const { virtualServer, appHandler } = resolved;
|
|
748
|
+
|
|
749
|
+
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
695
750
|
virtualServer.fallbackHandler = appHandler;
|
|
751
|
+
virtualServer.processRequest(req, res);
|
|
752
|
+
} else if (appHandler) {
|
|
753
|
+
appHandler(req, res);
|
|
754
|
+
} else {
|
|
755
|
+
res.writeHead(404);
|
|
756
|
+
res.end('Site not found');
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
_createUpgradeHandler(portData) {
|
|
762
|
+
return (req, socket, head) => {
|
|
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);
|
|
768
|
+
if (resolved && resolved.virtualServer) {
|
|
769
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
770
|
+
} else {
|
|
771
|
+
socket.destroy();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
696
775
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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 }));
|
|
701
782
|
} else {
|
|
702
|
-
|
|
703
|
-
res.end('Site not found');
|
|
783
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
704
784
|
}
|
|
785
|
+
} catch (error) {
|
|
786
|
+
callback(error);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async init() {
|
|
792
|
+
if (this._initialized) return this;
|
|
793
|
+
await this.loadSites();
|
|
794
|
+
if (!this.local) {
|
|
795
|
+
this.generateConfigJson();
|
|
796
|
+
}
|
|
797
|
+
this._initSiteHandlers();
|
|
798
|
+
if (!this.local) {
|
|
799
|
+
this._initSniResolver();
|
|
800
|
+
}
|
|
801
|
+
this._initialized = true;
|
|
802
|
+
return this;
|
|
803
|
+
}
|
|
804
|
+
|
|
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');
|
|
705
813
|
};
|
|
814
|
+
}
|
|
815
|
+
return this._createDispatcher(portData);
|
|
816
|
+
}
|
|
706
817
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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(); };
|
|
824
|
+
}
|
|
825
|
+
return this._createUpgradeHandler(portData);
|
|
826
|
+
}
|
|
710
827
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
}
|
|
715
833
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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;
|
|
839
|
+
}
|
|
720
840
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
841
|
+
startLocalMode() {
|
|
842
|
+
this.domainPorts = {};
|
|
843
|
+
|
|
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;
|
|
847
|
+
|
|
848
|
+
const port = this.assignPortToDomain(domain);
|
|
849
|
+
this.domainPorts[domain] = port;
|
|
850
|
+
|
|
851
|
+
const appHandler = portData.appHandlers[domain];
|
|
852
|
+
|
|
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
|
+
};
|
|
864
|
+
|
|
865
|
+
const httpServer = http.createServer(dispatcher);
|
|
866
|
+
this.portServers[port] = httpServer;
|
|
867
|
+
|
|
868
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
869
|
+
virtualServer.processUpgrade(req, socket, head);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
httpServer.listen(port, 'localhost', () => {
|
|
873
|
+
const cleanDomain = normalizeDomainForLocalHost(domain);
|
|
874
|
+
log.info(`π ${domain} β http://${localHostForDomain(cleanDomain)}:${port}`);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
httpServer.on('error', (error) => {
|
|
878
|
+
log.error(`β Error on port ${port} for ${domain}:`, error.message);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
724
881
|
}
|
|
725
882
|
|
|
726
883
|
log.info(`(β) Started ${Object.keys(this.portServers).length} sites in local mode`);
|
|
@@ -728,14 +885,8 @@ class Roster {
|
|
|
728
885
|
}
|
|
729
886
|
|
|
730
887
|
async start() {
|
|
731
|
-
await this.
|
|
888
|
+
await this.init();
|
|
732
889
|
|
|
733
|
-
// Skip Greenlock configuration generation in local mode
|
|
734
|
-
if (!this.local) {
|
|
735
|
-
this.generateConfigJson();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Handle local mode with simple HTTP server
|
|
739
890
|
if (this.local) {
|
|
740
891
|
return this.startLocalMode();
|
|
741
892
|
}
|
|
@@ -794,7 +945,6 @@ class Roster {
|
|
|
794
945
|
if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
|
|
795
946
|
msg = `[${eventDomain}] ${msg}`;
|
|
796
947
|
}
|
|
797
|
-
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
798
948
|
if (event === 'warning' && typeof msg === 'string') {
|
|
799
949
|
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
800
950
|
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
@@ -804,8 +954,6 @@ class Roster {
|
|
|
804
954
|
else log.info(msg);
|
|
805
955
|
}
|
|
806
956
|
};
|
|
807
|
-
// Keep a direct greenlock runtime handle so we can call get() explicitly under Bun
|
|
808
|
-
// before binding :443, avoiding invalid non-TLS responses on startup.
|
|
809
957
|
const greenlockRuntime = GreenlockShim.create(greenlockOptions);
|
|
810
958
|
const greenlock = Greenlock.init({
|
|
811
959
|
...greenlockOptions,
|
|
@@ -814,130 +962,22 @@ class Roster {
|
|
|
814
962
|
|
|
815
963
|
return greenlock.ready(async glx => {
|
|
816
964
|
const httpServer = glx.httpServer();
|
|
817
|
-
|
|
818
|
-
// Group sites by port
|
|
819
|
-
const sitesByPort = {};
|
|
820
|
-
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
821
|
-
if (!hostKey.startsWith('www.')) {
|
|
822
|
-
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
823
|
-
if (!sitesByPort[port]) {
|
|
824
|
-
sitesByPort[port] = {
|
|
825
|
-
virtualServers: {},
|
|
826
|
-
appHandlers: {}
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
const virtualServer = this.createVirtualServer(domain);
|
|
831
|
-
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
832
|
-
this.domainServers[domain] = virtualServer;
|
|
833
|
-
|
|
834
|
-
const appHandler = siteApp(virtualServer);
|
|
835
|
-
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
836
|
-
if (!domain.startsWith('*.')) {
|
|
837
|
-
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
|
|
842
965
|
const bunTlsHotReloadHandlers = [];
|
|
843
966
|
|
|
844
|
-
// Create dispatcher for each port
|
|
845
|
-
const createDispatcher = (portData) => {
|
|
846
|
-
return (req, res) => {
|
|
847
|
-
const host = req.headers.host || '';
|
|
848
|
-
|
|
849
|
-
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
850
|
-
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
851
|
-
|
|
852
|
-
if (hostWithoutPort.startsWith('www.')) {
|
|
853
|
-
res.writeHead(301, { Location: `https://${domain}${req.url}` });
|
|
854
|
-
res.end();
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
const resolved = this.getHandlerForPortData(domain, portData);
|
|
859
|
-
if (!resolved) {
|
|
860
|
-
res.writeHead(404);
|
|
861
|
-
res.end('Site not found');
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
const { virtualServer, appHandler } = resolved;
|
|
865
|
-
|
|
866
|
-
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
867
|
-
virtualServer.fallbackHandler = appHandler;
|
|
868
|
-
virtualServer.processRequest(req, res);
|
|
869
|
-
} else if (appHandler) {
|
|
870
|
-
appHandler(req, res);
|
|
871
|
-
} else {
|
|
872
|
-
res.writeHead(404);
|
|
873
|
-
res.end('Site not found');
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
};
|
|
877
|
-
|
|
878
967
|
httpServer.listen(80, this.hostname, () => {
|
|
879
968
|
log.info('HTTP server listening on port 80');
|
|
880
969
|
});
|
|
881
970
|
|
|
882
|
-
const
|
|
883
|
-
return (req, socket, head) => {
|
|
884
|
-
const host = req.headers.host || '';
|
|
885
|
-
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
886
|
-
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
887
|
-
|
|
888
|
-
const resolved = this.getHandlerForPortData(domain, portData);
|
|
889
|
-
if (resolved && resolved.virtualServer) {
|
|
890
|
-
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
891
|
-
} else {
|
|
892
|
-
socket.destroy();
|
|
893
|
-
}
|
|
894
|
-
};
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
// Handle different port types
|
|
898
|
-
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
971
|
+
for (const [port, portData] of Object.entries(this._sitesByPort)) {
|
|
899
972
|
const portNum = parseInt(port);
|
|
900
|
-
const dispatcher =
|
|
901
|
-
const upgradeHandler =
|
|
902
|
-
|
|
903
|
-
const normalizeHostInput = (value) => {
|
|
904
|
-
if (typeof value === 'string') return value;
|
|
905
|
-
if (!value || typeof value !== 'object') return '';
|
|
906
|
-
if (typeof value.servername === 'string') return value.servername;
|
|
907
|
-
if (typeof value.hostname === 'string') return value.hostname;
|
|
908
|
-
if (typeof value.subject === 'string') return value.subject;
|
|
909
|
-
return '';
|
|
910
|
-
};
|
|
911
|
-
const loadCert = (subjectDir) => {
|
|
912
|
-
const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
913
|
-
if (!normalizedSubject) return null;
|
|
914
|
-
const certPath = path.join(greenlockStorePath, 'live', normalizedSubject);
|
|
915
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
916
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
917
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
918
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
919
|
-
return {
|
|
920
|
-
key: fs.readFileSync(keyPath, 'utf8'),
|
|
921
|
-
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
922
|
-
};
|
|
923
|
-
}
|
|
924
|
-
return null;
|
|
925
|
-
};
|
|
926
|
-
const resolvePemsForServername = (servername) => {
|
|
927
|
-
const host = normalizeHostInput(servername).trim().toLowerCase();
|
|
928
|
-
if (!host) return null;
|
|
929
|
-
const candidates = buildCertLookupCandidates(host);
|
|
930
|
-
for (const candidate of candidates) {
|
|
931
|
-
const pems = loadCert(candidate);
|
|
932
|
-
if (pems) return pems;
|
|
933
|
-
}
|
|
934
|
-
return null;
|
|
935
|
-
};
|
|
973
|
+
const dispatcher = this._createDispatcher(portData);
|
|
974
|
+
const upgradeHandler = this._createUpgradeHandler(portData);
|
|
975
|
+
|
|
936
976
|
const issueAndReloadPemsForServername = async (servername) => {
|
|
937
|
-
const host =
|
|
977
|
+
const host = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
938
978
|
if (!host) return null;
|
|
939
979
|
|
|
940
|
-
let pems =
|
|
980
|
+
let pems = this._resolvePemsForServername(host);
|
|
941
981
|
if (pems) return pems;
|
|
942
982
|
|
|
943
983
|
try {
|
|
@@ -946,11 +986,9 @@ class Roster {
|
|
|
946
986
|
log.warn(`β οΈ Greenlock issuance failed for ${host}: ${error?.message || error}`);
|
|
947
987
|
}
|
|
948
988
|
|
|
949
|
-
pems =
|
|
989
|
+
pems = this._resolvePemsForServername(host);
|
|
950
990
|
if (pems) return pems;
|
|
951
991
|
|
|
952
|
-
// For wildcard zones, try a valid subdomain bootstrap host so Greenlock can
|
|
953
|
-
// resolve the wildcard site without relying on invalid "*.domain" servername input.
|
|
954
992
|
const wildcardSubject = wildcardSubjectForHost(host);
|
|
955
993
|
const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
|
|
956
994
|
if (zone) {
|
|
@@ -960,11 +998,12 @@ class Roster {
|
|
|
960
998
|
} catch (error) {
|
|
961
999
|
log.warn(`β οΈ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
|
|
962
1000
|
}
|
|
963
|
-
pems =
|
|
1001
|
+
pems = this._resolvePemsForServername(host);
|
|
964
1002
|
}
|
|
965
1003
|
|
|
966
1004
|
return pems;
|
|
967
1005
|
};
|
|
1006
|
+
|
|
968
1007
|
const ensureBunDefaultPems = async (primaryDomain) => {
|
|
969
1008
|
let pems = await issueAndReloadPemsForServername(primaryDomain);
|
|
970
1009
|
|
|
@@ -974,7 +1013,7 @@ class Roster {
|
|
|
974
1013
|
|
|
975
1014
|
if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
|
|
976
1015
|
log.warn(`β οΈ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN β clearing stale cert for combined re-issuance`);
|
|
977
|
-
const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
|
|
1016
|
+
const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
|
|
978
1017
|
try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
|
|
979
1018
|
pems = null;
|
|
980
1019
|
}
|
|
@@ -989,7 +1028,7 @@ class Roster {
|
|
|
989
1028
|
log.error(`β Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
|
|
990
1029
|
}
|
|
991
1030
|
|
|
992
|
-
pems =
|
|
1031
|
+
pems = this._resolvePemsForServername(primaryDomain);
|
|
993
1032
|
if (pems) return pems;
|
|
994
1033
|
|
|
995
1034
|
throw new Error(
|
|
@@ -999,15 +1038,11 @@ class Roster {
|
|
|
999
1038
|
};
|
|
1000
1039
|
|
|
1001
1040
|
if (portNum === this.defaultPort) {
|
|
1002
|
-
// Bun has known gaps around SNICallback compatibility.
|
|
1003
|
-
// Fallback to static cert loading for the primary domain on default HTTPS port.
|
|
1004
1041
|
const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
|
|
1005
1042
|
let httpsServer;
|
|
1006
1043
|
|
|
1007
1044
|
if (isBunRuntime) {
|
|
1008
1045
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
1009
|
-
// Under Bun, avoid glx.httpsServer fallback (may serve invalid TLS on :443).
|
|
1010
|
-
// Require concrete PEM files and create native https server directly.
|
|
1011
1046
|
let defaultPems = await ensureBunDefaultPems(primaryDomain);
|
|
1012
1047
|
httpsServer = https.createServer({
|
|
1013
1048
|
...tlsOpts,
|
|
@@ -1043,21 +1078,18 @@ class Roster {
|
|
|
1043
1078
|
}
|
|
1044
1079
|
|
|
1045
1080
|
this.portServers[portNum] = httpsServer;
|
|
1046
|
-
|
|
1047
|
-
// Handle WebSocket upgrade events
|
|
1048
1081
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1049
1082
|
|
|
1050
1083
|
httpsServer.listen(portNum, this.hostname, () => {
|
|
1051
1084
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1052
1085
|
});
|
|
1053
1086
|
} else {
|
|
1054
|
-
// Create HTTPS server for custom ports using Greenlock certificates
|
|
1055
1087
|
const httpsOptions = {
|
|
1056
1088
|
minVersion: this.tlsMinVersion,
|
|
1057
1089
|
maxVersion: this.tlsMaxVersion,
|
|
1058
1090
|
SNICallback: (servername, callback) => {
|
|
1059
1091
|
try {
|
|
1060
|
-
const pems =
|
|
1092
|
+
const pems = this._resolvePemsForServername(servername);
|
|
1061
1093
|
if (pems) {
|
|
1062
1094
|
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
1063
1095
|
} else {
|
|
@@ -1070,8 +1102,6 @@ class Roster {
|
|
|
1070
1102
|
};
|
|
1071
1103
|
|
|
1072
1104
|
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
1073
|
-
|
|
1074
|
-
// Handle WebSocket upgrade events
|
|
1075
1105
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1076
1106
|
|
|
1077
1107
|
httpsServer.on('error', (error) => {
|
|
@@ -1079,7 +1109,6 @@ class Roster {
|
|
|
1079
1109
|
});
|
|
1080
1110
|
|
|
1081
1111
|
httpsServer.on('tlsClientError', (error) => {
|
|
1082
|
-
// Suppress HTTP request errors to avoid log spam
|
|
1083
1112
|
if (!error.message.includes('http request')) {
|
|
1084
1113
|
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
1085
1114
|
}
|
|
@@ -1103,7 +1132,7 @@ class Roster {
|
|
|
1103
1132
|
: 30000;
|
|
1104
1133
|
const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1105
1134
|
? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
|
|
1106
|
-
: 0;
|
|
1135
|
+
: 0;
|
|
1107
1136
|
|
|
1108
1137
|
for (const zone of this.wildcardZones) {
|
|
1109
1138
|
const bootstrapHost = `bun-bootstrap.${zone}`;
|
|
@@ -1112,7 +1141,6 @@ class Roster {
|
|
|
1112
1141
|
log.warn(`β οΈ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
|
|
1113
1142
|
let reloaded = false;
|
|
1114
1143
|
for (const reloadTls of bunTlsHotReloadHandlers) {
|
|
1115
|
-
// Trigger issuance + immediately hot-reload default TLS context when ready.
|
|
1116
1144
|
reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
|
|
1117
1145
|
}
|
|
1118
1146
|
if (!reloaded) {
|
|
@@ -1131,7 +1159,6 @@ class Roster {
|
|
|
1131
1159
|
}
|
|
1132
1160
|
};
|
|
1133
1161
|
|
|
1134
|
-
// Background prewarm + retries so HTTPS startup is not blocked by DNS propagation timing.
|
|
1135
1162
|
attemptPrewarm().catch(() => {});
|
|
1136
1163
|
}
|
|
1137
1164
|
}
|
package/package.json
CHANGED
|
@@ -123,6 +123,30 @@ roster.register('*.example.com:8080', handler);
|
|
|
123
123
|
### Pattern 5: Static Site (no code)
|
|
124
124
|
Place only `index.html` (and assets) in `www/example.com/`. No `index.js` needed. RosterServer serves files with path-traversal protection; `/` β `index.html`, other paths β file or 404. Implemented in `lib/static-site-handler.js` and `lib/resolve-site-app.js`.
|
|
125
125
|
|
|
126
|
+
### Pattern 6: Cluster-Friendly (external server)
|
|
127
|
+
```javascript
|
|
128
|
+
const https = require('https');
|
|
129
|
+
const Roster = require('roster-server');
|
|
130
|
+
|
|
131
|
+
const roster = new Roster({
|
|
132
|
+
email: 'admin@example.com',
|
|
133
|
+
wwwPath: '/srv/www',
|
|
134
|
+
greenlockStorePath: '/srv/greenlock.d'
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await roster.init();
|
|
138
|
+
|
|
139
|
+
const server = https.createServer({ SNICallback: roster.sniCallback() });
|
|
140
|
+
roster.attach(server);
|
|
141
|
+
|
|
142
|
+
// Master passes connections β worker never calls listen()
|
|
143
|
+
process.on('message', (msg, connection) => {
|
|
144
|
+
if (msg === 'sticky-session:connection') {
|
|
145
|
+
server.emit('connection', connection);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
126
150
|
## Key Configuration Options
|
|
127
151
|
|
|
128
152
|
```javascript
|
|
@@ -137,7 +161,7 @@ new Roster({
|
|
|
137
161
|
staging: false, // true = Let's Encrypt staging
|
|
138
162
|
|
|
139
163
|
// Server
|
|
140
|
-
hostname: '
|
|
164
|
+
hostname: '::',
|
|
141
165
|
port: 443, // Default HTTPS port (NOT 80!)
|
|
142
166
|
|
|
143
167
|
// Local mode
|
|
@@ -153,7 +177,22 @@ new Roster({
|
|
|
153
177
|
## Core API
|
|
154
178
|
|
|
155
179
|
### `roster.start()`
|
|
156
|
-
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
|
|
180
|
+
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`. Calls `init()` internally.
|
|
181
|
+
|
|
182
|
+
### `roster.init()`
|
|
183
|
+
Loads sites, creates VirtualServers, prepares dispatchers β but creates **no servers** and calls **no `.listen()`**. Returns `Promise<Roster>`. Idempotent. Use this for cluster-friendly integration where an external manager owns the socket.
|
|
184
|
+
|
|
185
|
+
### `roster.requestHandler(port?)`
|
|
186
|
+
Returns `(req, res) => void` dispatcher for a port (defaults to `defaultPort`). Requires `init()` first. Handles Host-header routing, wwwβnon-www redirects, wildcard matching.
|
|
187
|
+
|
|
188
|
+
### `roster.upgradeHandler(port?)`
|
|
189
|
+
Returns `(req, socket, head) => void` for WebSocket upgrade routing. Requires `init()` first.
|
|
190
|
+
|
|
191
|
+
### `roster.sniCallback()`
|
|
192
|
+
Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. Production mode only. Requires `init()` first.
|
|
193
|
+
|
|
194
|
+
### `roster.attach(server, { port }?)`
|
|
195
|
+
Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
|
|
157
196
|
|
|
158
197
|
### `roster.register(domain, handler)`
|
|
159
198
|
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
@@ -718,3 +718,276 @@ describe('Roster generateConfigJson', () => {
|
|
|
718
718
|
}
|
|
719
719
|
});
|
|
720
720
|
});
|
|
721
|
+
|
|
722
|
+
describe('Roster init() (cluster-friendly API)', () => {
|
|
723
|
+
it('initializes without creating or listening on any server', async () => {
|
|
724
|
+
const roster = new Roster({
|
|
725
|
+
local: true,
|
|
726
|
+
minLocalPort: 19300,
|
|
727
|
+
maxLocalPort: 19309
|
|
728
|
+
});
|
|
729
|
+
roster.register('init-test.example', (server) => {
|
|
730
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
731
|
+
});
|
|
732
|
+
await roster.init();
|
|
733
|
+
assert.strictEqual(roster._initialized, true);
|
|
734
|
+
assert.strictEqual(Object.keys(roster.portServers).length, 0);
|
|
735
|
+
assert.ok(roster._sitesByPort[443]);
|
|
736
|
+
assert.ok(roster.domainServers['init-test.example']);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('is idempotent (calling init twice does not reinitialize)', async () => {
|
|
740
|
+
const roster = new Roster({ local: true });
|
|
741
|
+
roster.register('idem.example', () => () => {});
|
|
742
|
+
await roster.init();
|
|
743
|
+
const firstSitesByPort = roster._sitesByPort;
|
|
744
|
+
await roster.init();
|
|
745
|
+
assert.strictEqual(roster._sitesByPort, firstSitesByPort);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('start() still works after manual init()', async () => {
|
|
749
|
+
const roster = new Roster({
|
|
750
|
+
local: true,
|
|
751
|
+
minLocalPort: 19310,
|
|
752
|
+
maxLocalPort: 19319
|
|
753
|
+
});
|
|
754
|
+
roster.register('after-init.example', (server) => {
|
|
755
|
+
return (req, res) => { res.writeHead(200); res.end('after-init'); };
|
|
756
|
+
});
|
|
757
|
+
await roster.init();
|
|
758
|
+
await roster.start();
|
|
759
|
+
try {
|
|
760
|
+
const port = roster.domainPorts['after-init.example'];
|
|
761
|
+
assert.ok(typeof port === 'number');
|
|
762
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
763
|
+
const result = await httpGet('localhost', port, '/');
|
|
764
|
+
assert.strictEqual(result.statusCode, 200);
|
|
765
|
+
assert.strictEqual(result.body, 'after-init');
|
|
766
|
+
} finally {
|
|
767
|
+
closePortServers(roster);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe('Roster requestHandler() / upgradeHandler()', () => {
|
|
773
|
+
it('throws if called before init()', () => {
|
|
774
|
+
const roster = new Roster({ local: true });
|
|
775
|
+
assert.throws(() => roster.requestHandler(), /Call init\(\) before/);
|
|
776
|
+
assert.throws(() => roster.upgradeHandler(), /Call init\(\) before/);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('returns a working request dispatcher after init()', async () => {
|
|
780
|
+
const roster = new Roster({ local: true });
|
|
781
|
+
roster.register('handler-test.example', (server) => {
|
|
782
|
+
return (req, res) => { res.writeHead(200); res.end('dispatched'); };
|
|
783
|
+
});
|
|
784
|
+
await roster.init();
|
|
785
|
+
|
|
786
|
+
const handler = roster.requestHandler();
|
|
787
|
+
assert.strictEqual(typeof handler, 'function');
|
|
788
|
+
|
|
789
|
+
let statusCode, body;
|
|
790
|
+
const fakeRes = {
|
|
791
|
+
writeHead: (s) => { statusCode = s; },
|
|
792
|
+
end: (b) => { body = b; }
|
|
793
|
+
};
|
|
794
|
+
handler({ headers: { host: 'handler-test.example' }, url: '/' }, fakeRes);
|
|
795
|
+
assert.strictEqual(statusCode, 200);
|
|
796
|
+
assert.strictEqual(body, 'dispatched');
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('returns 404 dispatcher for unregistered port', async () => {
|
|
800
|
+
const roster = new Roster({ local: true });
|
|
801
|
+
roster.register('port-test.example', () => () => {});
|
|
802
|
+
await roster.init();
|
|
803
|
+
|
|
804
|
+
const handler = roster.requestHandler(9999);
|
|
805
|
+
let statusCode;
|
|
806
|
+
const fakeRes = {
|
|
807
|
+
writeHead: (s) => { statusCode = s; },
|
|
808
|
+
end: () => {}
|
|
809
|
+
};
|
|
810
|
+
handler({ headers: { host: 'port-test.example' }, url: '/' }, fakeRes);
|
|
811
|
+
assert.strictEqual(statusCode, 404);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('upgrade handler destroys socket for unknown host', async () => {
|
|
815
|
+
const roster = new Roster({ local: true });
|
|
816
|
+
roster.register('upgrade-test.example', () => () => {});
|
|
817
|
+
await roster.init();
|
|
818
|
+
|
|
819
|
+
const handler = roster.upgradeHandler();
|
|
820
|
+
let destroyed = false;
|
|
821
|
+
const fakeSocket = { destroy: () => { destroyed = true; } };
|
|
822
|
+
handler({ headers: { host: 'unknown.example' }, url: '/' }, fakeSocket, Buffer.alloc(0));
|
|
823
|
+
assert.strictEqual(destroyed, true);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('www redirect uses http:// protocol in local mode', async () => {
|
|
827
|
+
const roster = new Roster({ local: true });
|
|
828
|
+
roster.register('redirect.example', (server) => {
|
|
829
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
830
|
+
});
|
|
831
|
+
await roster.init();
|
|
832
|
+
|
|
833
|
+
const handler = roster.requestHandler();
|
|
834
|
+
let location;
|
|
835
|
+
const fakeRes = {
|
|
836
|
+
writeHead: (s, headers) => { location = headers?.Location; },
|
|
837
|
+
end: () => {}
|
|
838
|
+
};
|
|
839
|
+
handler({ headers: { host: 'www.redirect.example' }, url: '/path' }, fakeRes);
|
|
840
|
+
assert.ok(location);
|
|
841
|
+
assert.ok(location.startsWith('http://'), `Expected http:// redirect, got: ${location}`);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('dispatches correctly for custom registered port via requestHandler(port)', async () => {
|
|
845
|
+
const roster = new Roster({ local: true });
|
|
846
|
+
roster.register('api.ported.example:8443', () => {
|
|
847
|
+
return (req, res) => { res.writeHead(200); res.end('port-8443'); };
|
|
848
|
+
});
|
|
849
|
+
await roster.init();
|
|
850
|
+
|
|
851
|
+
const handler = roster.requestHandler(8443);
|
|
852
|
+
let statusCode;
|
|
853
|
+
let body;
|
|
854
|
+
const fakeRes = {
|
|
855
|
+
writeHead: (s) => { statusCode = s; },
|
|
856
|
+
end: (b) => { body = b; }
|
|
857
|
+
};
|
|
858
|
+
handler({ headers: { host: 'api.ported.example' }, url: '/' }, fakeRes);
|
|
859
|
+
assert.strictEqual(statusCode, 200);
|
|
860
|
+
assert.strictEqual(body, 'port-8443');
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('www redirect uses https:// protocol in production mode', async () => {
|
|
864
|
+
const roster = new Roster({ local: false });
|
|
865
|
+
roster.register('redirect-prod.example', () => {
|
|
866
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
867
|
+
});
|
|
868
|
+
await roster.init();
|
|
869
|
+
|
|
870
|
+
const handler = roster.requestHandler();
|
|
871
|
+
let location;
|
|
872
|
+
const fakeRes = {
|
|
873
|
+
writeHead: (s, headers) => { location = headers?.Location; },
|
|
874
|
+
end: () => {}
|
|
875
|
+
};
|
|
876
|
+
handler({ headers: { host: 'www.redirect-prod.example' }, url: '/secure' }, fakeRes);
|
|
877
|
+
assert.ok(location);
|
|
878
|
+
assert.ok(location.startsWith('https://'), `Expected https:// redirect, got: ${location}`);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('Roster sniCallback()', () => {
|
|
883
|
+
it('throws if called before init()', () => {
|
|
884
|
+
const roster = new Roster({ local: false });
|
|
885
|
+
assert.throws(() => roster.sniCallback(), /Call init\(\) before/);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('throws in local mode (no SNI in HTTP)', async () => {
|
|
889
|
+
const roster = new Roster({ local: true });
|
|
890
|
+
roster.register('sni-local.example', () => () => {});
|
|
891
|
+
await roster.init();
|
|
892
|
+
assert.throws(() => roster.sniCallback(), /not available in local mode/);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('returns a function after init() in production mode', async () => {
|
|
896
|
+
const roster = new Roster({ local: false });
|
|
897
|
+
roster.register('sni-prod.example', () => () => {});
|
|
898
|
+
await roster.init();
|
|
899
|
+
const cb = roster.sniCallback();
|
|
900
|
+
assert.strictEqual(typeof cb, 'function');
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe('Roster attach()', () => {
|
|
905
|
+
it('throws if called before init()', () => {
|
|
906
|
+
const roster = new Roster({ local: true });
|
|
907
|
+
const fakeServer = { on: () => {} };
|
|
908
|
+
assert.throws(() => roster.attach(fakeServer), /Call init\(\) before/);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('wires request and upgrade listeners onto external server', async () => {
|
|
912
|
+
const roster = new Roster({ local: true });
|
|
913
|
+
roster.register('attach-test.example', (server) => {
|
|
914
|
+
return (req, res) => { res.writeHead(200); res.end('attached'); };
|
|
915
|
+
});
|
|
916
|
+
await roster.init();
|
|
917
|
+
|
|
918
|
+
const listeners = {};
|
|
919
|
+
const fakeServer = {
|
|
920
|
+
on: (event, fn) => { listeners[event] = fn; }
|
|
921
|
+
};
|
|
922
|
+
const result = roster.attach(fakeServer);
|
|
923
|
+
assert.strictEqual(result, roster);
|
|
924
|
+
assert.strictEqual(typeof listeners['request'], 'function');
|
|
925
|
+
assert.strictEqual(typeof listeners['upgrade'], 'function');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('uses provided port option when attaching', async () => {
|
|
929
|
+
const roster = new Roster({ local: true });
|
|
930
|
+
roster.register('attach-443.example', () => (req, res) => { res.writeHead(200); res.end('on-443'); });
|
|
931
|
+
roster.register('attach-9443.example:9443', () => (req, res) => { res.writeHead(200); res.end('on-9443'); });
|
|
932
|
+
await roster.init();
|
|
933
|
+
|
|
934
|
+
const listeners = {};
|
|
935
|
+
const fakeServer = {
|
|
936
|
+
on: (event, fn) => { listeners[event] = fn; }
|
|
937
|
+
};
|
|
938
|
+
roster.attach(fakeServer, { port: 9443 });
|
|
939
|
+
|
|
940
|
+
let statusCode;
|
|
941
|
+
let body;
|
|
942
|
+
const fakeRes = {
|
|
943
|
+
writeHead: (s) => { statusCode = s; },
|
|
944
|
+
end: (b) => { body = b; }
|
|
945
|
+
};
|
|
946
|
+
listeners.request({ headers: { host: 'attach-9443.example' }, url: '/' }, fakeRes);
|
|
947
|
+
assert.strictEqual(statusCode, 200);
|
|
948
|
+
assert.strictEqual(body, 'on-9443');
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('attached handler dispatches requests correctly', async () => {
|
|
952
|
+
const roster = new Roster({
|
|
953
|
+
local: true,
|
|
954
|
+
minLocalPort: 19400,
|
|
955
|
+
maxLocalPort: 19409
|
|
956
|
+
});
|
|
957
|
+
roster.register('attach-http.example', (server) => {
|
|
958
|
+
return (req, res) => {
|
|
959
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
960
|
+
res.end('from-attach');
|
|
961
|
+
};
|
|
962
|
+
});
|
|
963
|
+
await roster.init();
|
|
964
|
+
|
|
965
|
+
const server = http.createServer();
|
|
966
|
+
roster.attach(server);
|
|
967
|
+
|
|
968
|
+
const port = 19400;
|
|
969
|
+
await new Promise((resolve, reject) => {
|
|
970
|
+
server.listen(port, 'localhost', resolve);
|
|
971
|
+
server.on('error', reject);
|
|
972
|
+
});
|
|
973
|
+
try {
|
|
974
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
975
|
+
const result = await new Promise((resolve, reject) => {
|
|
976
|
+
const req = http.get(
|
|
977
|
+
{ host: 'localhost', port, path: '/', headers: { host: 'attach-http.example' } },
|
|
978
|
+
(res) => {
|
|
979
|
+
let body = '';
|
|
980
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
981
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
|
982
|
+
}
|
|
983
|
+
);
|
|
984
|
+
req.on('error', reject);
|
|
985
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
986
|
+
});
|
|
987
|
+
assert.strictEqual(result.statusCode, 200);
|
|
988
|
+
assert.strictEqual(result.body, 'from-attach');
|
|
989
|
+
} finally {
|
|
990
|
+
server.close();
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
});
|