roster-server 2.4.4 → 2.4.6
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 +46 -0
- package/demo/https-cluster-configurable.js +30 -19
- package/index.js +58 -0
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +34 -0
- package/test/roster-server.test.js +54 -0
- package/demo/cluster-sticky-worker.js +0 -60
package/README.md
CHANGED
|
@@ -386,6 +386,36 @@ process.on('message', (msg, connection) => {
|
|
|
386
386
|
});
|
|
387
387
|
```
|
|
388
388
|
|
|
389
|
+
### Production Pattern: Single Certificate Manager + Workers
|
|
390
|
+
|
|
391
|
+
For robust ACME behavior with cluster runtimes, run a single certificate manager process (primary) and keep workers in serving-only mode. This avoids challenge race conditions while keeping certificate lifecycle automatic.
|
|
392
|
+
|
|
393
|
+
```javascript
|
|
394
|
+
// primary
|
|
395
|
+
const certManager = new Roster({
|
|
396
|
+
email: 'admin@example.com',
|
|
397
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
398
|
+
wwwPath: '/srv/www'
|
|
399
|
+
});
|
|
400
|
+
certManager.register('example.com', () => (req, res) => res.end('manager'));
|
|
401
|
+
await certManager.start(); // enables ACME challenge lifecycle
|
|
402
|
+
await certManager.ensureCertificate('example.com');
|
|
403
|
+
|
|
404
|
+
// worker
|
|
405
|
+
const workerRoster = new Roster({
|
|
406
|
+
email: 'admin@example.com',
|
|
407
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
408
|
+
wwwPath: '/srv/www',
|
|
409
|
+
autoCertificates: false
|
|
410
|
+
});
|
|
411
|
+
workerRoster.register('example.com', () => (req, res) => res.end('worker'));
|
|
412
|
+
await workerRoster.init();
|
|
413
|
+
const server = await workerRoster.createServingHttpsServer({ servername: 'example.com' });
|
|
414
|
+
server.listen(4336);
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Reference implementation: `demo/https-cluster-configurable.js`.
|
|
418
|
+
|
|
389
419
|
### API Reference
|
|
390
420
|
|
|
391
421
|
#### `roster.init()` → `Promise<Roster>`
|
|
@@ -404,6 +434,22 @@ Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to th
|
|
|
404
434
|
|
|
405
435
|
Returns a TLS SNI callback. It resolves certificates from `greenlockStorePath` and, when `autoCertificates` is enabled (default), can issue missing certificates automatically. Not available in local mode.
|
|
406
436
|
|
|
437
|
+
#### `roster.ensureCertificate(servername)` → `Promise<{ key, cert }>`
|
|
438
|
+
|
|
439
|
+
Ensures a certificate exists for `servername`. With `autoCertificates` enabled (default), it issues missing certificates automatically and returns PEMs.
|
|
440
|
+
|
|
441
|
+
#### `roster.loadCertificate(servername)` → `{ key, cert }`
|
|
442
|
+
|
|
443
|
+
Loads an existing certificate from `greenlockStorePath` without issuing new certificates. Useful for serving-only workers.
|
|
444
|
+
|
|
445
|
+
#### `roster.createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` → `Promise<https.Server>`
|
|
446
|
+
|
|
447
|
+
Creates an HTTPS server prewired with default cert, SNI callback, and request/upgrade routing. By default it ensures certificate issuance before returning.
|
|
448
|
+
|
|
449
|
+
#### `roster.createServingHttpsServer({ servername, port?, tlsOptions? })` → `Promise<https.Server>`
|
|
450
|
+
|
|
451
|
+
Convenience alias for serving-only workers. Equivalent to `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
452
|
+
|
|
407
453
|
#### `roster.attach(server, { port }?)` → `Roster`
|
|
408
454
|
|
|
409
455
|
Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
|
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
const cluster = require('cluster');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const https = require('https');
|
|
5
4
|
const Roster = require('../index.js');
|
|
6
5
|
|
|
7
6
|
// Change these values to your target domain and HTTPS port.
|
|
8
7
|
const CONFIG = {
|
|
9
|
-
domain: '
|
|
8
|
+
domain: 'example.com',
|
|
10
9
|
httpsPort: 4336,
|
|
11
10
|
workers: Math.max(1, Math.min(2, os.cpus().length)),
|
|
11
|
+
certificateManagerHttpsPort: 0, // 0 = ephemeral, manager does not serve public traffic
|
|
12
12
|
wwwPath: path.join(__dirname, 'www'),
|
|
13
13
|
greenlockStorePath: path.join(__dirname, '..', 'greenlock.d'),
|
|
14
|
-
email: 'mclasen@
|
|
15
|
-
staging: false
|
|
16
|
-
autoCertificates: true
|
|
14
|
+
email: 'mclasen@example.com',
|
|
15
|
+
staging: false
|
|
17
16
|
};
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
function createRoster({ isCertificateManager }) {
|
|
20
19
|
const roster = new Roster({
|
|
21
20
|
local: false,
|
|
22
21
|
email: CONFIG.email,
|
|
23
22
|
staging: CONFIG.staging,
|
|
24
23
|
wwwPath: CONFIG.wwwPath,
|
|
25
24
|
greenlockStorePath: CONFIG.greenlockStorePath,
|
|
26
|
-
autoCertificates:
|
|
25
|
+
autoCertificates: isCertificateManager,
|
|
26
|
+
// Certificate manager can bind an ephemeral HTTPS port; workers serve real traffic.
|
|
27
|
+
port: isCertificateManager ? CONFIG.certificateManagerHttpsPort : 443
|
|
27
28
|
});
|
|
29
|
+
return roster;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startWorker() {
|
|
33
|
+
const roster = createRoster({ isCertificateManager: false });
|
|
28
34
|
|
|
29
35
|
// Domain is configured from CONFIG (single source of truth).
|
|
30
36
|
roster.register(CONFIG.domain, () => {
|
|
@@ -35,19 +41,10 @@ async function startWorker() {
|
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
await roster.init();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const server = https.createServer({
|
|
42
|
-
key: defaultPems.key,
|
|
43
|
-
cert: defaultPems.cert,
|
|
44
|
-
SNICallback: roster.sniCallback(),
|
|
45
|
-
minVersion: 'TLSv1.2',
|
|
46
|
-
maxVersion: 'TLSv1.3'
|
|
44
|
+
const server = await roster.createServingHttpsServer({
|
|
45
|
+
servername: CONFIG.domain
|
|
47
46
|
});
|
|
48
47
|
|
|
49
|
-
roster.attach(server);
|
|
50
|
-
|
|
51
48
|
server.listen(CONFIG.httpsPort, () => {
|
|
52
49
|
console.log(`[worker ${process.pid}] listening on https://0.0.0.0:${CONFIG.httpsPort} for domain ${CONFIG.domain}`);
|
|
53
50
|
});
|
|
@@ -58,7 +55,21 @@ async function startPrimary() {
|
|
|
58
55
|
console.log(`domain=${CONFIG.domain} port=${CONFIG.httpsPort}`);
|
|
59
56
|
console.log(`wwwPath=${CONFIG.wwwPath}`);
|
|
60
57
|
console.log(`greenlockStorePath=${CONFIG.greenlockStorePath}\n`);
|
|
61
|
-
console.log(
|
|
58
|
+
console.log('[primary] cert lifecycle managed by roster-server\n');
|
|
59
|
+
|
|
60
|
+
// Primary is the single certificate manager to avoid ACME race conditions.
|
|
61
|
+
// It starts Roster standalone lifecycle so ACME http-01 challenge server (:80) is active.
|
|
62
|
+
const certificateManager = createRoster({ isCertificateManager: true });
|
|
63
|
+
certificateManager.register(CONFIG.domain, () => {
|
|
64
|
+
return (req, res) => {
|
|
65
|
+
res.writeHead(200);
|
|
66
|
+
res.end('certificate-manager');
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
await certificateManager.start();
|
|
70
|
+
await certificateManager.ensureCertificate(CONFIG.domain);
|
|
71
|
+
const subject = CONFIG.domain;
|
|
72
|
+
console.log(`[primary] certificate ready for ${CONFIG.domain} (subject=${subject})\n`);
|
|
62
73
|
|
|
63
74
|
for (let i = 0; i < CONFIG.workers; i++) {
|
|
64
75
|
cluster.fork();
|
package/index.js
CHANGED
|
@@ -935,6 +935,24 @@ class Roster {
|
|
|
935
935
|
return pems;
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
+
loadCertificate(servername) {
|
|
939
|
+
if (this.local) {
|
|
940
|
+
throw new Error('loadCertificate() is not available in local mode');
|
|
941
|
+
}
|
|
942
|
+
if (!this._initialized) {
|
|
943
|
+
throw new Error('Call init() before loadCertificate()');
|
|
944
|
+
}
|
|
945
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
946
|
+
if (!normalizedServername) {
|
|
947
|
+
throw new Error('servername is required');
|
|
948
|
+
}
|
|
949
|
+
const pems = this._resolvePemsForServername(normalizedServername);
|
|
950
|
+
if (!pems) {
|
|
951
|
+
throw new Error(`No certificate files available for ${normalizedServername}`);
|
|
952
|
+
}
|
|
953
|
+
return pems;
|
|
954
|
+
}
|
|
955
|
+
|
|
938
956
|
async init() {
|
|
939
957
|
if (this._initialized) return this;
|
|
940
958
|
await this.loadSites();
|
|
@@ -991,6 +1009,46 @@ class Roster {
|
|
|
991
1009
|
return this;
|
|
992
1010
|
}
|
|
993
1011
|
|
|
1012
|
+
async createManagedHttpsServer(options = {}) {
|
|
1013
|
+
if (this.local) throw new Error('createManagedHttpsServer() is not available in local mode');
|
|
1014
|
+
if (!this._initialized) throw new Error('Call init() before createManagedHttpsServer()');
|
|
1015
|
+
|
|
1016
|
+
const {
|
|
1017
|
+
servername,
|
|
1018
|
+
port,
|
|
1019
|
+
ensureCertificate = true,
|
|
1020
|
+
tlsOptions = {}
|
|
1021
|
+
} = options;
|
|
1022
|
+
|
|
1023
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
1024
|
+
if (!normalizedServername) {
|
|
1025
|
+
throw new Error('servername is required');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const pems = ensureCertificate
|
|
1029
|
+
? await this.ensureCertificate(normalizedServername)
|
|
1030
|
+
: this.loadCertificate(normalizedServername);
|
|
1031
|
+
|
|
1032
|
+
const server = https.createServer({
|
|
1033
|
+
minVersion: this.tlsMinVersion,
|
|
1034
|
+
maxVersion: this.tlsMaxVersion,
|
|
1035
|
+
...tlsOptions,
|
|
1036
|
+
key: pems.key,
|
|
1037
|
+
cert: pems.cert,
|
|
1038
|
+
SNICallback: this.sniCallback()
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
this.attach(server, { port });
|
|
1042
|
+
return server;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async createServingHttpsServer(options = {}) {
|
|
1046
|
+
return this.createManagedHttpsServer({
|
|
1047
|
+
...options,
|
|
1048
|
+
ensureCertificate: false
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
994
1052
|
startLocalMode() {
|
|
995
1053
|
this.domainPorts = {};
|
|
996
1054
|
|
package/package.json
CHANGED
|
@@ -147,6 +147,31 @@ process.on('message', (msg, connection) => {
|
|
|
147
147
|
});
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Pattern 7: Cluster Production (single cert manager + workers)
|
|
151
|
+
```javascript
|
|
152
|
+
// PRIMARY: certificate manager (single process)
|
|
153
|
+
const manager = new Roster({
|
|
154
|
+
email: 'admin@example.com',
|
|
155
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
156
|
+
wwwPath: '/srv/www'
|
|
157
|
+
});
|
|
158
|
+
manager.register('example.com', () => (req, res) => res.end('manager'));
|
|
159
|
+
await manager.start();
|
|
160
|
+
await manager.ensureCertificate('example.com');
|
|
161
|
+
|
|
162
|
+
// WORKER: serving-only process
|
|
163
|
+
const worker = new Roster({
|
|
164
|
+
email: 'admin@example.com',
|
|
165
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
166
|
+
wwwPath: '/srv/www',
|
|
167
|
+
autoCertificates: false
|
|
168
|
+
});
|
|
169
|
+
worker.register('example.com', () => (req, res) => res.end('worker'));
|
|
170
|
+
await worker.init();
|
|
171
|
+
const httpsServer = await worker.createServingHttpsServer({ servername: 'example.com' });
|
|
172
|
+
httpsServer.listen(4336);
|
|
173
|
+
```
|
|
174
|
+
|
|
150
175
|
## Key Configuration Options
|
|
151
176
|
|
|
152
177
|
```javascript
|
|
@@ -194,6 +219,15 @@ Returns `(servername, callback) => void` TLS SNI callback that resolves certs fr
|
|
|
194
219
|
### `roster.ensureCertificate(servername)`
|
|
195
220
|
Forces certificate availability for a domain and returns `{ key, cert }`. With `autoCertificates` enabled (default), it issues certs automatically when missing.
|
|
196
221
|
|
|
222
|
+
### `roster.loadCertificate(servername)`
|
|
223
|
+
Loads existing `{ key, cert }` from `greenlockStorePath` without issuing new certificates.
|
|
224
|
+
|
|
225
|
+
### `roster.createManagedHttpsServer(options)`
|
|
226
|
+
Creates a pre-wired `https.Server` with default cert, SNI callback, and attached request/upgrade handlers.
|
|
227
|
+
|
|
228
|
+
### `roster.createServingHttpsServer(options)`
|
|
229
|
+
Serving-only helper for worker processes. Same as `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
230
|
+
|
|
197
231
|
### `roster.attach(server, { port }?)`
|
|
198
232
|
Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
|
|
199
233
|
|
|
@@ -933,6 +933,60 @@ describe('Roster ensureCertificate()', () => {
|
|
|
933
933
|
});
|
|
934
934
|
});
|
|
935
935
|
|
|
936
|
+
describe('Roster loadCertificate()', () => {
|
|
937
|
+
it('throws if called before init()', () => {
|
|
938
|
+
const roster = new Roster({ local: false });
|
|
939
|
+
assert.throws(() => roster.loadCertificate('example.com'), /Call init\(\) before loadCertificate/);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('throws in local mode', async () => {
|
|
943
|
+
const roster = new Roster({ local: true });
|
|
944
|
+
roster.register('local-load.example', () => () => {});
|
|
945
|
+
await roster.init();
|
|
946
|
+
assert.throws(() => roster.loadCertificate('local-load.example'), /not available in local mode/);
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe('Roster createManagedHttpsServer()', () => {
|
|
951
|
+
it('throws if called before init()', async () => {
|
|
952
|
+
const roster = new Roster({ local: false });
|
|
953
|
+
await assert.rejects(
|
|
954
|
+
() => roster.createManagedHttpsServer({ servername: 'example.com' }),
|
|
955
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
956
|
+
);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('throws in local mode', async () => {
|
|
960
|
+
const roster = new Roster({ local: true });
|
|
961
|
+
roster.register('local-managed.example', () => () => {});
|
|
962
|
+
await roster.init();
|
|
963
|
+
await assert.rejects(
|
|
964
|
+
() => roster.createManagedHttpsServer({ servername: 'local-managed.example' }),
|
|
965
|
+
/not available in local mode/
|
|
966
|
+
);
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
describe('Roster createServingHttpsServer()', () => {
|
|
971
|
+
it('throws if called before init()', async () => {
|
|
972
|
+
const roster = new Roster({ local: false });
|
|
973
|
+
await assert.rejects(
|
|
974
|
+
() => roster.createServingHttpsServer({ servername: 'example.com' }),
|
|
975
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
976
|
+
);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('throws in local mode', async () => {
|
|
980
|
+
const roster = new Roster({ local: true });
|
|
981
|
+
roster.register('local-serving.example', () => () => {});
|
|
982
|
+
await roster.init();
|
|
983
|
+
await assert.rejects(
|
|
984
|
+
() => roster.createServingHttpsServer({ servername: 'local-serving.example' }),
|
|
985
|
+
/not available in local mode/
|
|
986
|
+
);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
936
990
|
describe('Roster attach()', () => {
|
|
937
991
|
it('throws if called before init()', () => {
|
|
938
992
|
const roster = new Roster({ local: true });
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
});
|