roster-server 3.2.1 → 3.3.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 +20 -0
- package/index.js +4 -1
- package/lib/resolve-site-app.js +9 -1
- package/lib/site-worker.js +67 -3
- package/package.json +53 -53
- package/test/isolation.test.js +60 -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,26 @@ 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
|
+
connection(socket, head) {
|
|
195
|
+
// Terminate TLS here, or pass socket and head to another process.
|
|
196
|
+
},
|
|
197
|
+
async close() {
|
|
198
|
+
// Release the site's resources; runs after Roster stops accepting connections.
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
Roster keeps issuing and renewing the site's certificates under `greenlockStorePath/live/<domain>`, but the site loads them and must pick up renewed files itself. 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.
|
|
206
|
+
|
|
187
207
|
## Startup and shutdown
|
|
188
208
|
|
|
189
209
|
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 `{ connection(socket, head), close?() }` and serves raw client
|
|
46
|
+
* 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,64 @@ 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
|
+
// In local mode a socket site gets plain connections on its assigned ports, with no ClientHello.
|
|
49
|
+
async serveLocalSockets() {
|
|
50
|
+
const head = Buffer.alloc(0);
|
|
51
|
+
for (const key of this._site.keys) {
|
|
52
|
+
if (key.startsWith('www.')) continue;
|
|
53
|
+
const { domain, port } = this.parseDomainWithPort(key);
|
|
54
|
+
const server = net.createServer({ pauseOnConnect: true });
|
|
55
|
+
const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], 'localhost');
|
|
56
|
+
server.on('connection', socket => {
|
|
57
|
+
// The site owns the connection from here; it may pass it to another process.
|
|
58
|
+
this._sockets.delete(socket);
|
|
59
|
+
this._handOff(head, socket);
|
|
60
|
+
});
|
|
61
|
+
await listening;
|
|
62
|
+
log.info(`🌐 ${domain} → tcp://localhost:${this._site.localPorts[`${domain}:${port}`]} (socket site)`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_handOff(head, socket) {
|
|
67
|
+
if (this._closing || socket.destroyed) {
|
|
68
|
+
socket.destroy();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
this._socketSite.connection(socket, head);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
log.error(`❌ Socket site ${this._site.name} rejected a connection:`, error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close() {
|
|
80
|
+
if (!this._socketSite) return super.close();
|
|
81
|
+
// Stop accepting first, then let the site drain the connections it was handed.
|
|
82
|
+
this._socketSiteClose ??= Promise.allSettled([super.close()]).then(async ([listeners]) => {
|
|
83
|
+
const errors = listeners.status === 'rejected' ? [listeners.reason] : [];
|
|
84
|
+
try {
|
|
85
|
+
await this._socketSite.close?.();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
errors.push(error);
|
|
88
|
+
}
|
|
89
|
+
const flat = errors.flatMap(error => error instanceof AggregateError ? error.errors : [error]);
|
|
90
|
+
if (flat.length) throw new AggregateError(flat, 'Roster close failed');
|
|
91
|
+
});
|
|
92
|
+
return this._socketSiteClose;
|
|
93
|
+
}
|
|
94
|
+
|
|
37
95
|
generateConfigJson() {}
|
|
38
96
|
|
|
39
97
|
assignPortToDomain(domain, port) {
|
|
@@ -81,6 +139,10 @@ class SiteWorkerRoster extends Roster {
|
|
|
81
139
|
}
|
|
82
140
|
|
|
83
141
|
accept(port, head, socket) {
|
|
142
|
+
if (this._socketSite) {
|
|
143
|
+
this._handOff(head, socket);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
84
146
|
const server = this._httpsServers.get(port);
|
|
85
147
|
if (this._closing || !server) {
|
|
86
148
|
socket.destroy();
|
|
@@ -103,10 +165,12 @@ async function start(site) {
|
|
|
103
165
|
for (const plugin of site.plugins) {
|
|
104
166
|
roster.use(require(plugin.module)[plugin.export](plugin.options));
|
|
105
167
|
}
|
|
106
|
-
|
|
168
|
+
await roster.init();
|
|
169
|
+
if (roster.socketSite) {
|
|
170
|
+
if (roster.local) await roster.serveLocalSockets();
|
|
171
|
+
} else if (roster.local) {
|
|
107
172
|
await roster.start();
|
|
108
173
|
} else {
|
|
109
|
-
await roster.init();
|
|
110
174
|
roster.serveHttps();
|
|
111
175
|
}
|
|
112
176
|
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.3.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,66 @@ 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) {
|
|
409
|
+
return `const fs = require('fs');
|
|
410
|
+
const server = ${server};
|
|
411
|
+
server.on('request', (req, res) => res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress })));
|
|
412
|
+
module.exports = {
|
|
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
|
+
close() {
|
|
421
|
+
fs.writeFileSync(${JSON.stringify(marker)}, 'closed');
|
|
422
|
+
}
|
|
423
|
+
};`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
it('hands raw client sockets with their ClientHello to a socket site that terminates TLS', { timeout: 20000 }, async t => {
|
|
427
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
428
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
429
|
+
const tlsServer = `require('https').createServer({
|
|
430
|
+
key: fs.readFileSync(${JSON.stringify(path.join(pki, 'key.pem'))}),
|
|
431
|
+
cert: fs.readFileSync(${JSON.stringify(path.join(pki, 'cert.pem'))})
|
|
432
|
+
})`;
|
|
433
|
+
const { roster, port } = await startProduction(t, {
|
|
434
|
+
'raw.example.com': socketSite(tlsServer, marker),
|
|
435
|
+
'plain.example.com': REPORT_SITE
|
|
436
|
+
}, { certificates: ['plain.example.com'] });
|
|
437
|
+
const raw = JSON.parse((await get(port, 'raw.example.com')).body);
|
|
438
|
+
const plain = JSON.parse((await get(port, 'plain.example.com')).body);
|
|
439
|
+
assert.notEqual(raw.pid, process.pid);
|
|
440
|
+
assert.notEqual(raw.pid, plain.pid);
|
|
441
|
+
assert.match(raw.address, /127\.0\.0\.1$/);
|
|
442
|
+
assert.ok(Number(fs.readFileSync(`${marker}.head`, 'utf8')) > 0);
|
|
443
|
+
await roster.close();
|
|
444
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
445
|
+
assert.equal(isRunning(raw.pid), false);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('gives a socket site plain connections on its local port', { timeout: 20000 }, async t => {
|
|
449
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
450
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
451
|
+
const { wwwPath } = createWww(t, { 'raw.example': socketSite(`require('http').createServer()`, marker) });
|
|
452
|
+
const roster = new Roster({ local: true, wwwPath, minLocalPort: 19370, maxLocalPort: 19379 });
|
|
453
|
+
t.after(() => roster.close().catch(() => {}));
|
|
454
|
+
await roster.start();
|
|
455
|
+
const { port } = new URL(roster.getUrl('raw.example'));
|
|
456
|
+
assert.notEqual(JSON.parse((await localGet(port)).body).pid, process.pid);
|
|
457
|
+
assert.equal(fs.readFileSync(`${marker}.head`, 'utf8'), '0');
|
|
458
|
+
await roster.close();
|
|
459
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('refuses a socket site served by the main process', { timeout: 20000 }, async t => {
|
|
463
|
+
const { wwwPath } = createWww(t, { 'raw.example': `module.exports = { connection() {} };` });
|
|
464
|
+
const roster = new Roster({ local: true, wwwPath, isolation: false, minLocalPort: 19380, maxLocalPort: 19389 });
|
|
465
|
+
await assert.rejects(roster.start(), /must run as an isolated site directory/);
|
|
466
|
+
});
|
|
467
|
+
|
|
408
468
|
it('rejects combining isolation with the legacy cluster launcher', () => {
|
|
409
469
|
assert.throws(() => new Roster({ cluster: true, isolation: true }), /cannot be combined/);
|
|
410
470
|
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
|