roster-server 3.2.1 → 3.4.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 +24 -0
- package/index.js +4 -1
- package/lib/resolve-site-app.js +9 -1
- package/lib/site-worker.js +73 -3
- package/package.json +53 -53
- package/test/isolation.test.js +85 -0
- package/vendor/greenlock/bin/certonly.js +0 -0
- package/vendor/greenlock/bin/greenlock.js +0 -0
- package/vendor/greenlock/logo/beaker-browser-301x112.png +0 -0
- package/vendor/greenlock/logo/greenlock-1063x250.png +0 -0
- package/vendor/greenlock/logo/ibm-301x112.png +0 -0
- package/vendor/greenlock/logo/telebit-301x112.png +0 -0
- package/vendor/greenlock-express/scripts/postinstall +0 -0
package/README.md
CHANGED
|
@@ -184,6 +184,30 @@ Isolation needs a Roster-owned listener, so it applies only when `start()` initi
|
|
|
184
184
|
|
|
185
185
|
Each isolated process loads Node.js and its site, so memory use grows with the number of site directories. Code that runs when a site's module loads runs in that site's process, not in the main process.
|
|
186
186
|
|
|
187
|
+
### Socket sites
|
|
188
|
+
|
|
189
|
+
A site directory whose entry exports an object with a `connection(socket, head)` method receives raw client connections instead of requests. Use it for a site that terminates TLS itself, for example to spread connections across its own worker processes:
|
|
190
|
+
|
|
191
|
+
```javascript
|
|
192
|
+
// www/game.example.com/index.js
|
|
193
|
+
export default {
|
|
194
|
+
async start({ certificate }) {
|
|
195
|
+
// Runs once before the first connection, e.g. to fork workers.
|
|
196
|
+
// certificate(servername) resolves { key, cert } PEM strings; cert includes the chain.
|
|
197
|
+
},
|
|
198
|
+
connection(socket, head) {
|
|
199
|
+
// Terminate TLS here, or pass socket and head to another process.
|
|
200
|
+
},
|
|
201
|
+
async close() {
|
|
202
|
+
// Release the site's resources; runs after Roster stops accepting connections.
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
In production, `socket` is the client's TCP connection and `head` holds the TLS ClientHello that Roster already read; put it back with `socket.unshift(head)` before terminating TLS, and do not `resume()` the socket after wrapping it. In local mode, the site's assigned port accepts plain TCP connections, `socket` arrives paused, and `head` is empty. `connection()` runs synchronously; if it throws, Roster destroys the socket.
|
|
208
|
+
|
|
209
|
+
`certificate(servername)` returns the certificate Roster would serve for that host, with the same wildcard and zone matching. A missing certificate is issued by the main process first, as for any isolated site. Roster also renews it; call `certificate()` again to pick up a renewal, for example on an interval or when a cached copy gets old. It is not available in local mode. Request plugins, `www.` redirects, and `onClose` hooks do not apply: the site owns every connection it receives. A socket site must run isolated; Roster rejects it when it would be served by the main process.
|
|
210
|
+
|
|
187
211
|
## Startup and shutdown
|
|
188
212
|
|
|
189
213
|
Concurrent `init()` calls share initialization, so each factory runs once per registered domain/port. Concurrent `start()` calls share startup. In standalone and local modes, `await roster.start()` waits for listeners to bind. Initialization or bind failures reject and clean up resources already initialized by the instance.
|
package/index.js
CHANGED
|
@@ -8,7 +8,7 @@ const crypto = require('crypto');
|
|
|
8
8
|
const { EventEmitter } = require('events');
|
|
9
9
|
const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
|
|
10
10
|
const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
|
|
11
|
-
const { locateSiteEntry, resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
11
|
+
const { locateSiteEntry, resolveSiteApp, isSocketSite } = require('./lib/resolve-site-app.js');
|
|
12
12
|
const { IsolatedSite } = require('./lib/isolated-site.js');
|
|
13
13
|
const { parseClientHello } = require('./lib/client-hello.js');
|
|
14
14
|
const log = require('lemonlog')('roster');
|
|
@@ -902,6 +902,9 @@ class Roster {
|
|
|
902
902
|
this._sitesByPort = {};
|
|
903
903
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
904
904
|
this._assertOpen();
|
|
905
|
+
if (isSocketSite(siteApp)) {
|
|
906
|
+
throw new Error(`${hostKey} serves raw connections and must run as an isolated site directory`);
|
|
907
|
+
}
|
|
905
908
|
if (hostKey.startsWith('www.') || typeof siteApp !== 'function') continue;
|
|
906
909
|
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
907
910
|
if (!this._sitesByPort[port]) {
|
package/lib/resolve-site-app.js
CHANGED
|
@@ -41,4 +41,12 @@ async function resolveSiteApp(domainPath, options = {}) {
|
|
|
41
41
|
return { siteApp, type: 'js' };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* A socket site exports `{ start?(context), connection(socket, head), close?() }` and serves
|
|
46
|
+
* raw client connections itself, including TLS. Only an isolated site process can hand it sockets.
|
|
47
|
+
*/
|
|
48
|
+
function isSocketSite(siteApp) {
|
|
49
|
+
return typeof siteApp === 'object' && siteApp !== null && typeof siteApp.connection === 'function';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { locateSiteEntry, resolveSiteApp, isSocketSite };
|
package/lib/site-worker.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// Entry point of an isolated site process, forked by IsolatedSite.
|
|
4
4
|
const https = require('https');
|
|
5
|
+
const net = require('net');
|
|
5
6
|
const Roster = require('../index.js');
|
|
6
|
-
const { resolveSiteApp } = require('./resolve-site-app.js');
|
|
7
|
+
const { resolveSiteApp, isSocketSite } = require('./resolve-site-app.js');
|
|
7
8
|
const { serializeError } = require('./isolated-site.js');
|
|
8
9
|
const log = require('lemonlog')('roster');
|
|
9
10
|
|
|
@@ -11,6 +12,8 @@ class SiteWorkerRoster extends Roster {
|
|
|
11
12
|
constructor(site) {
|
|
12
13
|
super({ ...site.options, isolation: false, autoCertificates: false, handleSignals: false });
|
|
13
14
|
this._site = site;
|
|
15
|
+
this._socketSite = null;
|
|
16
|
+
this._socketSiteClose = null;
|
|
14
17
|
this._httpsServers = new Map();
|
|
15
18
|
this._certificateRequests = new Map();
|
|
16
19
|
this._nextCertificateRequest = 0;
|
|
@@ -31,9 +34,69 @@ class SiteWorkerRoster extends Roster {
|
|
|
31
34
|
log.warn(`⚠️ No index file (js/mjs/cjs or index.html) found in ${sitePath}`);
|
|
32
35
|
return;
|
|
33
36
|
}
|
|
37
|
+
if (isSocketSite(resolved.siteApp)) {
|
|
38
|
+
this._socketSite = resolved.siteApp;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
34
41
|
for (const key of keys) this.sites[key] = resolved.siteApp;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
get socketSite() {
|
|
45
|
+
return this._socketSite !== null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The site terminates TLS with certificates Roster resolves: read from the store, or issued by the main process.
|
|
49
|
+
startSocketSite() {
|
|
50
|
+
return this._socketSite.start?.({ certificate: servername => this.ensureCertificate(servername) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// In local mode a socket site gets plain connections on its assigned ports, with no ClientHello.
|
|
54
|
+
async serveLocalSockets() {
|
|
55
|
+
const head = Buffer.alloc(0);
|
|
56
|
+
for (const key of this._site.keys) {
|
|
57
|
+
if (key.startsWith('www.')) continue;
|
|
58
|
+
const { domain, port } = this.parseDomainWithPort(key);
|
|
59
|
+
const server = net.createServer({ pauseOnConnect: true });
|
|
60
|
+
const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], 'localhost');
|
|
61
|
+
server.on('connection', socket => {
|
|
62
|
+
// The site owns the connection from here; it may pass it to another process.
|
|
63
|
+
this._sockets.delete(socket);
|
|
64
|
+
this._handOff(head, socket);
|
|
65
|
+
});
|
|
66
|
+
await listening;
|
|
67
|
+
log.info(`🌐 ${domain} → tcp://localhost:${this._site.localPorts[`${domain}:${port}`]} (socket site)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_handOff(head, socket) {
|
|
72
|
+
if (this._closing || socket.destroyed) {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
this._socketSite.connection(socket, head);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
socket.destroy();
|
|
80
|
+
log.error(`❌ Socket site ${this._site.name} rejected a connection:`, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close() {
|
|
85
|
+
if (!this._socketSite) return super.close();
|
|
86
|
+
// Stop accepting first, then let the site drain the connections it was handed.
|
|
87
|
+
this._socketSiteClose ??= Promise.allSettled([super.close()]).then(async ([listeners]) => {
|
|
88
|
+
const errors = listeners.status === 'rejected' ? [listeners.reason] : [];
|
|
89
|
+
try {
|
|
90
|
+
await this._socketSite.close?.();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push(error);
|
|
93
|
+
}
|
|
94
|
+
const flat = errors.flatMap(error => error instanceof AggregateError ? error.errors : [error]);
|
|
95
|
+
if (flat.length) throw new AggregateError(flat, 'Roster close failed');
|
|
96
|
+
});
|
|
97
|
+
return this._socketSiteClose;
|
|
98
|
+
}
|
|
99
|
+
|
|
37
100
|
generateConfigJson() {}
|
|
38
101
|
|
|
39
102
|
assignPortToDomain(domain, port) {
|
|
@@ -81,6 +144,10 @@ class SiteWorkerRoster extends Roster {
|
|
|
81
144
|
}
|
|
82
145
|
|
|
83
146
|
accept(port, head, socket) {
|
|
147
|
+
if (this._socketSite) {
|
|
148
|
+
this._handOff(head, socket);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
84
151
|
const server = this._httpsServers.get(port);
|
|
85
152
|
if (this._closing || !server) {
|
|
86
153
|
socket.destroy();
|
|
@@ -103,10 +170,13 @@ async function start(site) {
|
|
|
103
170
|
for (const plugin of site.plugins) {
|
|
104
171
|
roster.use(require(plugin.module)[plugin.export](plugin.options));
|
|
105
172
|
}
|
|
106
|
-
|
|
173
|
+
await roster.init();
|
|
174
|
+
if (roster.socketSite) {
|
|
175
|
+
await roster.startSocketSite();
|
|
176
|
+
if (roster.local) await roster.serveLocalSockets();
|
|
177
|
+
} else if (roster.local) {
|
|
107
178
|
await roster.start();
|
|
108
179
|
} else {
|
|
109
|
-
await roster.init();
|
|
110
180
|
roster.serveHttps();
|
|
111
181
|
}
|
|
112
182
|
process.send({ type: 'ready' });
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
2
|
+
"name": "roster-server",
|
|
3
|
+
"version": "3.4.0",
|
|
4
|
+
"description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/clasen/RosterServer.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"greenlock",
|
|
15
|
+
"https",
|
|
16
|
+
"domain",
|
|
17
|
+
"multi-domain",
|
|
18
|
+
"letsencrypt",
|
|
19
|
+
"hosting",
|
|
20
|
+
"ssl",
|
|
21
|
+
"host",
|
|
22
|
+
"vhost",
|
|
23
|
+
"virtual-host",
|
|
24
|
+
"socket.io",
|
|
25
|
+
"websocket",
|
|
26
|
+
"express",
|
|
27
|
+
"greenlock-express",
|
|
28
|
+
"shotx",
|
|
29
|
+
"bun",
|
|
30
|
+
"clasen"
|
|
31
|
+
],
|
|
32
|
+
"author": "Martin Clasen",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/clasen/RosterServer/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/clasen/RosterServer#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@greenlock/manager": "^3.1.0",
|
|
40
|
+
"@root/acme": "^3.1.0",
|
|
41
|
+
"@root/csr": "^0.8.1",
|
|
42
|
+
"@root/encoding": "^1.0.1",
|
|
43
|
+
"@root/keypairs": "^0.10.0",
|
|
44
|
+
"@root/mkdirp": "^1.0.0",
|
|
45
|
+
"@root/request": "^1.6.1",
|
|
46
|
+
"acme-dns-01-cli": "^3.0.7",
|
|
47
|
+
"acme-http-01-standalone": "^3.0.5",
|
|
48
|
+
"cert-info": "^1.5.1",
|
|
49
|
+
"greenlock-store-fs": "^3.2.2",
|
|
50
|
+
"lemonlog": "^1.2.0",
|
|
51
|
+
"redirect-https": "^1.3.1",
|
|
52
|
+
"safe-replace": "^1.1.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/test/isolation.test.js
CHANGED
|
@@ -405,6 +405,91 @@ it('closes isolated sites when the main process dies without closing', { timeout
|
|
|
405
405
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
406
406
|
});
|
|
407
407
|
|
|
408
|
+
function socketSite(server, marker, start = 'async start() {}') {
|
|
409
|
+
return `const fs = require('fs');
|
|
410
|
+
let server = ${server};
|
|
411
|
+
module.exports = {
|
|
412
|
+
${start},
|
|
413
|
+
connection(socket, head) {
|
|
414
|
+
fs.writeFileSync(${JSON.stringify(marker)} + '.head', String(head.length));
|
|
415
|
+
// A ClientHello read by Roster goes back on the stream; a local connection arrives paused.
|
|
416
|
+
if (head.length > 0) socket.unshift(head);
|
|
417
|
+
server.emit('connection', socket);
|
|
418
|
+
if (head.length === 0) socket.resume();
|
|
419
|
+
},
|
|
420
|
+
reply(req, res) {
|
|
421
|
+
res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress }));
|
|
422
|
+
},
|
|
423
|
+
close() {
|
|
424
|
+
fs.writeFileSync(${JSON.stringify(marker)}, 'closed');
|
|
425
|
+
}
|
|
426
|
+
};`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
it('hands raw client sockets with their ClientHello to a socket site that terminates TLS', { timeout: 20000 }, async t => {
|
|
430
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
431
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
432
|
+
// The site gets its certificate from Roster's store through start(), not from files it reads.
|
|
433
|
+
const start = `async start({ certificate }) {
|
|
434
|
+
const pems = await certificate('raw.example.com');
|
|
435
|
+
server = require('https').createServer(pems, (req, res) => module.exports.reply(req, res));
|
|
436
|
+
}`;
|
|
437
|
+
const { roster, port } = await startProduction(t, {
|
|
438
|
+
'raw.example.com': socketSite('null', marker, start),
|
|
439
|
+
'plain.example.com': REPORT_SITE
|
|
440
|
+
}, { certificates: ['plain.example.com', 'raw.example.com'] });
|
|
441
|
+
const raw = JSON.parse((await get(port, 'raw.example.com')).body);
|
|
442
|
+
const plain = JSON.parse((await get(port, 'plain.example.com')).body);
|
|
443
|
+
assert.notEqual(raw.pid, process.pid);
|
|
444
|
+
assert.notEqual(raw.pid, plain.pid);
|
|
445
|
+
assert.match(raw.address, /127\.0\.0\.1$/);
|
|
446
|
+
assert.ok(Number(fs.readFileSync(`${marker}.head`, 'utf8')) > 0);
|
|
447
|
+
await roster.close();
|
|
448
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
449
|
+
assert.equal(isRunning(raw.pid), false);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('issues a missing certificate through the main process when a socket site asks for it', { timeout: 20000 }, async t => {
|
|
453
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
454
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
455
|
+
const store = path.join(path.dirname(marker), 'greenlock.d');
|
|
456
|
+
const issued = [];
|
|
457
|
+
const start = `async start({ certificate }) {
|
|
458
|
+
server = require('https').createServer(await certificate('fresh.example.com'), (req, res) => module.exports.reply(req, res));
|
|
459
|
+
}`;
|
|
460
|
+
const { port } = await startProduction(t, { 'fresh.example.com': socketSite('null', marker, start) }, {
|
|
461
|
+
options: { greenlockStorePath: store },
|
|
462
|
+
issue: async ({ servername }) => {
|
|
463
|
+
issued.push(servername);
|
|
464
|
+
installCertificate(store, servername);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
assert.deepEqual(issued, ['fresh.example.com']);
|
|
468
|
+
assert.notEqual(JSON.parse((await get(port, 'fresh.example.com')).body).pid, process.pid);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('gives a socket site plain connections on its local port', { timeout: 20000 }, async t => {
|
|
472
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
473
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
474
|
+
const { wwwPath } = createWww(t, {
|
|
475
|
+
'raw.example': socketSite(`require('http').createServer((req, res) => module.exports.reply(req, res))`, marker)
|
|
476
|
+
});
|
|
477
|
+
const roster = new Roster({ local: true, wwwPath, minLocalPort: 19370, maxLocalPort: 19379 });
|
|
478
|
+
t.after(() => roster.close().catch(() => {}));
|
|
479
|
+
await roster.start();
|
|
480
|
+
const { port } = new URL(roster.getUrl('raw.example'));
|
|
481
|
+
assert.notEqual(JSON.parse((await localGet(port)).body).pid, process.pid);
|
|
482
|
+
assert.equal(fs.readFileSync(`${marker}.head`, 'utf8'), '0');
|
|
483
|
+
await roster.close();
|
|
484
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('refuses a socket site served by the main process', { timeout: 20000 }, async t => {
|
|
488
|
+
const { wwwPath } = createWww(t, { 'raw.example': `module.exports = { connection() {} };` });
|
|
489
|
+
const roster = new Roster({ local: true, wwwPath, isolation: false, minLocalPort: 19380, maxLocalPort: 19389 });
|
|
490
|
+
await assert.rejects(roster.start(), /must run as an isolated site directory/);
|
|
491
|
+
});
|
|
492
|
+
|
|
408
493
|
it('rejects combining isolation with the legacy cluster launcher', () => {
|
|
409
494
|
assert.throws(() => new Roster({ cluster: true, isolation: true }), /cannot be combined/);
|
|
410
495
|
assert.equal(new Roster({ cluster: true }).isolation, null);
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|