roster-server 3.3.0 → 3.4.1
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 +7 -3
- package/index.js +2 -1
- package/lib/resolve-site-app.js +2 -2
- package/lib/site-worker.js +7 -1
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +1 -1
- package/test/isolation.test.js +41 -12
- package/test/lifecycle.test.js +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ await roster.start();
|
|
|
28
28
|
console.log(roster.getUrl('example.com'));
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Local mode binds to `
|
|
31
|
+
Local mode binds to `hostname` (default `::`, IPv4 and IPv6 on all interfaces), skips certificates, and assigns domain ports within `minLocalPort`–`maxLocalPort` (default `4000`–`9999`). Assignments use a domain hash and resolve collisions within the instance; they do not probe for available OS ports. A bind failure rejects startup. Read the assigned URL with `getUrl()` instead of hardcoding a sample port.
|
|
32
32
|
|
|
33
33
|
For production HTTPS, use a real contact email, public DNS pointing to the host, and explicit storage paths:
|
|
34
34
|
|
|
@@ -191,6 +191,10 @@ A site directory whose entry exports an object with a `connection(socket, head)`
|
|
|
191
191
|
```javascript
|
|
192
192
|
// www/game.example.com/index.js
|
|
193
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
|
+
},
|
|
194
198
|
connection(socket, head) {
|
|
195
199
|
// Terminate TLS here, or pass socket and head to another process.
|
|
196
200
|
},
|
|
@@ -202,7 +206,7 @@ export default {
|
|
|
202
206
|
|
|
203
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.
|
|
204
208
|
|
|
205
|
-
Roster
|
|
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.
|
|
206
210
|
|
|
207
211
|
## Startup and shutdown
|
|
208
212
|
|
|
@@ -309,7 +313,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
309
313
|
| `filename` | `index` | Script entry basename, without extension. |
|
|
310
314
|
| `local` | `false` | Local HTTP mode with assigned ports and no certificates. |
|
|
311
315
|
| `port` | `443` | Default port for manual registrations and routing helpers. |
|
|
312
|
-
| `hostname` | `::` |
|
|
316
|
+
| `hostname` | `::` | Bind address in production and local mode. |
|
|
313
317
|
| `minLocalPort`, `maxLocalPort` | `4000`, `9999` | Inclusive local assignment range. |
|
|
314
318
|
| `staging` | `false` | ACME staging environment. |
|
|
315
319
|
| `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
|
package/index.js
CHANGED
|
@@ -950,6 +950,7 @@ class Roster {
|
|
|
950
950
|
const plugins = this.plugins.map(isolatedPluginSource);
|
|
951
951
|
const options = {
|
|
952
952
|
local: this.local,
|
|
953
|
+
hostname: this.hostname,
|
|
953
954
|
port: this.defaultPort,
|
|
954
955
|
filename: this.filename,
|
|
955
956
|
greenlockStorePath: this.greenlockStorePath,
|
|
@@ -1630,7 +1631,7 @@ class Roster {
|
|
|
1630
1631
|
virtualServer.processUpgrade(req, socket, head);
|
|
1631
1632
|
});
|
|
1632
1633
|
|
|
1633
|
-
await this._listen(httpServer, port,
|
|
1634
|
+
await this._listen(httpServer, port, this.hostname);
|
|
1634
1635
|
const cleanDomain = normalizeDomainForLocalHost(domain);
|
|
1635
1636
|
log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
|
|
1636
1637
|
}
|
package/lib/resolve-site-app.js
CHANGED
|
@@ -42,8 +42,8 @@ async function resolveSiteApp(domainPath, options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* A socket site exports `{ connection(socket, head), close?() }` and serves
|
|
46
|
-
* connections itself, including TLS. Only an isolated site process can hand it sockets.
|
|
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
47
|
*/
|
|
48
48
|
function isSocketSite(siteApp) {
|
|
49
49
|
return typeof siteApp === 'object' && siteApp !== null && typeof siteApp.connection === 'function';
|
package/lib/site-worker.js
CHANGED
|
@@ -45,6 +45,11 @@ class SiteWorkerRoster extends Roster {
|
|
|
45
45
|
return this._socketSite !== null;
|
|
46
46
|
}
|
|
47
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
|
+
|
|
48
53
|
// In local mode a socket site gets plain connections on its assigned ports, with no ClientHello.
|
|
49
54
|
async serveLocalSockets() {
|
|
50
55
|
const head = Buffer.alloc(0);
|
|
@@ -52,7 +57,7 @@ class SiteWorkerRoster extends Roster {
|
|
|
52
57
|
if (key.startsWith('www.')) continue;
|
|
53
58
|
const { domain, port } = this.parseDomainWithPort(key);
|
|
54
59
|
const server = net.createServer({ pauseOnConnect: true });
|
|
55
|
-
const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`],
|
|
60
|
+
const listening = this._listen(server, this._site.localPorts[`${domain}:${port}`], this.hostname);
|
|
56
61
|
server.on('connection', socket => {
|
|
57
62
|
// The site owns the connection from here; it may pass it to another process.
|
|
58
63
|
this._sockets.delete(socket);
|
|
@@ -167,6 +172,7 @@ async function start(site) {
|
|
|
167
172
|
}
|
|
168
173
|
await roster.init();
|
|
169
174
|
if (roster.socketSite) {
|
|
175
|
+
await roster.startSocketSite();
|
|
170
176
|
if (roster.local) await roster.serveLocalSockets();
|
|
171
177
|
} else if (roster.local) {
|
|
172
178
|
await roster.start();
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ Use this skill to configure a consuming application. Keep its existing configura
|
|
|
9
9
|
|
|
10
10
|
## Choose the lifecycle owner
|
|
11
11
|
|
|
12
|
-
- **Local development:** `new Roster({ local: true, wwwPath: absolutePath })`, register sites, then `await roster.start()`. Local mode binds to `
|
|
12
|
+
- **Local development:** `new Roster({ local: true, wwwPath: absolutePath })`, register sites, then `await roster.start()`. Local mode binds to `hostname` (default `::`, IPv4 and IPv6) and assigns ports; read URLs with `roster.getUrl(domain)` after startup.
|
|
13
13
|
- **Standalone HTTPS:** set the real `email`, absolute `wwwPath` and `greenlockStorePath`; leave `local` false. `await roster.start()` owns ACME HTTP on port 80 and the configured HTTPS listeners. A different HTTPS port does not eliminate the need for port 80.
|
|
14
14
|
- **External server/worker:** `await roster.init()`, then `roster.attach(server, { port })` or an HTTPS helper. The caller owns listening and closing that server. `port` on `attach()` selects the routing table, not the TCP port.
|
|
15
15
|
- **Serving-only worker:** use `autoCertificates: false` with `init()` and `createServingHttpsServer({ servername })`. A separate manager must provide certificates first. Calling `start()` still enables the standalone ACME lifecycle.
|
package/test/isolation.test.js
CHANGED
|
@@ -91,9 +91,9 @@ function get(port, servername, { pathname = '/', headers = {} } = {}) {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function localGet(port, pathname = '/') {
|
|
94
|
+
function localGet(port, pathname = '/', hostname = 'localhost') {
|
|
95
95
|
return new Promise((resolve, reject) => {
|
|
96
|
-
http.get({ hostname
|
|
96
|
+
http.get({ hostname, port, path: pathname, agent: false }, res => {
|
|
97
97
|
let body = '';
|
|
98
98
|
res.on('data', chunk => { body += chunk; });
|
|
99
99
|
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
@@ -315,6 +315,9 @@ it('isolates directory sites in local mode, including aliases and request plugin
|
|
|
315
315
|
assert.equal(alias.pid, site.pid);
|
|
316
316
|
assert.notEqual(other.pid, site.pid);
|
|
317
317
|
assert.deepEqual(await localGet(port('other.example'), '/wp-login.php'), { status: 404, body: 'Not Found' });
|
|
318
|
+
for (const address of ['127.0.0.1', '::1']) {
|
|
319
|
+
assert.equal(JSON.parse((await localGet(port('site.example'), '/', address)).body).pid, site.pid);
|
|
320
|
+
}
|
|
318
321
|
});
|
|
319
322
|
|
|
320
323
|
it('serves a site module already loaded by the main process from that process', { timeout: 20000 }, async t => {
|
|
@@ -405,11 +408,11 @@ it('closes isolated sites when the main process dies without closing', { timeout
|
|
|
405
408
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
|
406
409
|
});
|
|
407
410
|
|
|
408
|
-
function socketSite(server, marker) {
|
|
411
|
+
function socketSite(server, marker, start = 'async start() {}') {
|
|
409
412
|
return `const fs = require('fs');
|
|
410
|
-
|
|
411
|
-
server.on('request', (req, res) => res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress })));
|
|
413
|
+
let server = ${server};
|
|
412
414
|
module.exports = {
|
|
415
|
+
${start},
|
|
413
416
|
connection(socket, head) {
|
|
414
417
|
fs.writeFileSync(${JSON.stringify(marker)} + '.head', String(head.length));
|
|
415
418
|
// A ClientHello read by Roster goes back on the stream; a local connection arrives paused.
|
|
@@ -417,6 +420,9 @@ module.exports = {
|
|
|
417
420
|
server.emit('connection', socket);
|
|
418
421
|
if (head.length === 0) socket.resume();
|
|
419
422
|
},
|
|
423
|
+
reply(req, res) {
|
|
424
|
+
res.end(JSON.stringify({ pid: process.pid, address: req.socket.remoteAddress }));
|
|
425
|
+
},
|
|
420
426
|
close() {
|
|
421
427
|
fs.writeFileSync(${JSON.stringify(marker)}, 'closed');
|
|
422
428
|
}
|
|
@@ -426,14 +432,15 @@ module.exports = {
|
|
|
426
432
|
it('hands raw client sockets with their ClientHello to a socket site that terminates TLS', { timeout: 20000 }, async t => {
|
|
427
433
|
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
428
434
|
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
435
|
+
// The site gets its certificate from Roster's store through start(), not from files it reads.
|
|
436
|
+
const start = `async start({ certificate }) {
|
|
437
|
+
const pems = await certificate('raw.example.com');
|
|
438
|
+
server = require('https').createServer(pems, (req, res) => module.exports.reply(req, res));
|
|
439
|
+
}`;
|
|
433
440
|
const { roster, port } = await startProduction(t, {
|
|
434
|
-
'raw.example.com': socketSite(
|
|
441
|
+
'raw.example.com': socketSite('null', marker, start),
|
|
435
442
|
'plain.example.com': REPORT_SITE
|
|
436
|
-
}, { certificates: ['plain.example.com'] });
|
|
443
|
+
}, { certificates: ['plain.example.com', 'raw.example.com'] });
|
|
437
444
|
const raw = JSON.parse((await get(port, 'raw.example.com')).body);
|
|
438
445
|
const plain = JSON.parse((await get(port, 'plain.example.com')).body);
|
|
439
446
|
assert.notEqual(raw.pid, process.pid);
|
|
@@ -445,15 +452,37 @@ it('hands raw client sockets with their ClientHello to a socket site that termin
|
|
|
445
452
|
assert.equal(isRunning(raw.pid), false);
|
|
446
453
|
});
|
|
447
454
|
|
|
455
|
+
it('issues a missing certificate through the main process when a socket site asks for it', { timeout: 20000 }, async t => {
|
|
456
|
+
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
457
|
+
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
458
|
+
const store = path.join(path.dirname(marker), 'greenlock.d');
|
|
459
|
+
const issued = [];
|
|
460
|
+
const start = `async start({ certificate }) {
|
|
461
|
+
server = require('https').createServer(await certificate('fresh.example.com'), (req, res) => module.exports.reply(req, res));
|
|
462
|
+
}`;
|
|
463
|
+
const { port } = await startProduction(t, { 'fresh.example.com': socketSite('null', marker, start) }, {
|
|
464
|
+
options: { greenlockStorePath: store },
|
|
465
|
+
issue: async ({ servername }) => {
|
|
466
|
+
issued.push(servername);
|
|
467
|
+
installCertificate(store, servername);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
assert.deepEqual(issued, ['fresh.example.com']);
|
|
471
|
+
assert.notEqual(JSON.parse((await get(port, 'fresh.example.com')).body).pid, process.pid);
|
|
472
|
+
});
|
|
473
|
+
|
|
448
474
|
it('gives a socket site plain connections on its local port', { timeout: 20000 }, async t => {
|
|
449
475
|
const marker = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'roster-socket-site-')), 'closed');
|
|
450
476
|
t.after(() => fs.rmSync(path.dirname(marker), { recursive: true, force: true }));
|
|
451
|
-
const { wwwPath } = createWww(t, {
|
|
477
|
+
const { wwwPath } = createWww(t, {
|
|
478
|
+
'raw.example': socketSite(`require('http').createServer((req, res) => module.exports.reply(req, res))`, marker)
|
|
479
|
+
});
|
|
452
480
|
const roster = new Roster({ local: true, wwwPath, minLocalPort: 19370, maxLocalPort: 19379 });
|
|
453
481
|
t.after(() => roster.close().catch(() => {}));
|
|
454
482
|
await roster.start();
|
|
455
483
|
const { port } = new URL(roster.getUrl('raw.example'));
|
|
456
484
|
assert.notEqual(JSON.parse((await localGet(port)).body).pid, process.pid);
|
|
485
|
+
for (const address of ['127.0.0.1', '::1']) assert.equal((await localGet(port, '/', address)).status, 200);
|
|
457
486
|
assert.equal(fs.readFileSync(`${marker}.head`, 'utf8'), '0');
|
|
458
487
|
await roster.close();
|
|
459
488
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'closed');
|
package/test/lifecycle.test.js
CHANGED
|
@@ -94,7 +94,7 @@ it('waits for listening, shares start, and closes owned servers once', async t =
|
|
|
94
94
|
|
|
95
95
|
it('rejects bind failures and rolls back already opened ports', async t => {
|
|
96
96
|
const blocker = http.createServer();
|
|
97
|
-
await new Promise(resolve => blocker.listen(0, '
|
|
97
|
+
await new Promise(resolve => blocker.listen(0, '::', resolve));
|
|
98
98
|
t.after(() => new Promise(resolve => blocker.close(resolve)));
|
|
99
99
|
const roster = createRoster(t);
|
|
100
100
|
const ports = [0, blocker.address().port];
|