roster-server 2.4.14 → 3.0.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 +56 -5
- package/index.js +229 -17
- package/lib/client-hello.js +68 -0
- package/lib/isolated-site.js +146 -0
- package/lib/resolve-site-app.js +27 -25
- package/lib/site-worker.js +145 -0
- package/package.json +53 -53
- package/plugins/scanner-blocker.js +9 -1
- package/skills/roster-server/SKILL.md +1 -1
- package/test/isolation.test.js +372 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 👾 RosterServer
|
|
2
2
|
|
|
3
|
-
Host multiple domains
|
|
3
|
+
Host multiple domains behind one Node.js entry point, with HTTPS certificates managed through Greenlock, Express and Socket.IO integration, static file serving, and local HTTP development. Each site receives a virtual server for request and upgrade listeners. By default every site directory runs in its own process, so a busy, blocked, or crashed site does not take the others down; see [site isolation](#site-isolation).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -85,7 +85,7 @@ module.exports = () => {
|
|
|
85
85
|
};
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
The virtual server supports request/upgrade listener integration; it is not a separately listening TCP server
|
|
88
|
+
The virtual server supports request/upgrade listener integration; it is not a separately listening TCP server. Process isolation comes from [site isolation](#site-isolation), not from the virtual server. Request listeners own their requests, including asynchronous responses. Roster does not invoke the returned handler just because a listener has not finished. Wrappers can capture the site's HTTP fallback through `server.listeners('request')`.
|
|
89
89
|
|
|
90
90
|
Thrown or rejected request-handler errors produce a `500`, or destroy the response if headers were already sent.
|
|
91
91
|
|
|
@@ -120,6 +120,12 @@ roster.register('api.example.com:8443', site);
|
|
|
120
120
|
roster.register('*.example.com:8443', site);
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
Instead of a factory, `register()` also accepts a site directory, loaded like a discovered site. A directory registration can run in an isolated process, and every domain registered with the same directory, including the discovered one, shares that process:
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
roster.register('en.example.com', path.join(wwwPath, 'example.com'));
|
|
127
|
+
```
|
|
128
|
+
|
|
123
129
|
A registration without an explicit port uses the instance's `port`. Exact hosts take precedence over wildcard matches. `*.example.com` does not match the apex `example.com`; register the apex separately if needed. Wildcard certificates require [DNS-01 configuration](#certificates-and-dns).
|
|
124
130
|
|
|
125
131
|
Requests to `www` hosts redirect to the host without `www`, preserving the explicit port in the Host header. `getUrl(domain)` normalizes `www` and supports wildcard lookups. In local mode it returns `localhost` or a subdomain such as `api.localhost`, with the assigned port. In production it uses the instance's default port; it is not a per-port URL lookup for registrations made only on non-default ports, which can return `null`.
|
|
@@ -135,6 +141,49 @@ A directory with `index.html` and no entry script needs no JavaScript factory:
|
|
|
135
141
|
|
|
136
142
|
Filesystem operations are asynchronous, and disconnected downloads close their file handles.
|
|
137
143
|
|
|
144
|
+
## Site isolation
|
|
145
|
+
|
|
146
|
+
`start()` runs each site directory in its own Node.js process. This applies to discovered `www/` sites and to directories passed to `register()`. One process serves every domain and port registered with that directory, so a site with SQLite, files written by lowdb, cron jobs, or internal servers never runs twice. Disable isolation with `isolation: false`.
|
|
147
|
+
|
|
148
|
+
The main process keeps the listeners, certificate issuance and renewal (ACME, SNI, wildcard), port 80, and shutdown. For each HTTPS connection it reads only the TLS ClientHello and passes the raw socket to the process serving that SNI host. That process terminates TLS and handles requests, WebSocket upgrades, request plugins, and `onClose` hooks. The socket is the client's own connection, so `req.socket.remoteAddress` and all headers arrive unchanged. A TLS handshake does not use the main process's CPU or event loop. In local mode, each isolated process listens on its own assigned port.
|
|
149
|
+
|
|
150
|
+
These sites stay in the main process:
|
|
151
|
+
|
|
152
|
+
- A factory function passed to `register()`, because a function cannot move to another process. Register the site directory instead to isolate it.
|
|
153
|
+
- A site whose entry module the main process already loaded with `require()`. Isolating it would create a second instance. This check cannot see ESM modules imported by the application; do not import an isolated site's module from the server script.
|
|
154
|
+
- Connections without SNI, or for a host no isolated site serves.
|
|
155
|
+
|
|
156
|
+
Roster logs the sites that are not isolated at startup.
|
|
157
|
+
|
|
158
|
+
A crashed isolated process is restarted after `isolation.restartDelayMs`, and the other sites keep serving. While it is down, its connections are refused. If its startup fails, `start()` rejects, as it does for an in-process factory error. Once more than `isolation.backlog` connections are waiting for a site process, new connections to that site are dropped. A client that sends no ClientHello within `isolation.helloTimeoutMs` is disconnected.
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
const roster = new Roster({
|
|
162
|
+
isolation: { restartDelayMs: 1000, backlog: 511, helloTimeoutMs: 10000 }
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
When at least one site is isolated, request plugins added with `use()` before `start()` are recreated in every isolated process. Plugin state, such as scanner-blocker bans, is therefore per site. A plugin can be recreated only if it carries a `Roster.pluginSource` descriptor whose options are JSON-serializable; otherwise `start()` rejects. `createScannerBlocker()` provides one unless its options include functions such as `onBlock`. For a custom plugin:
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
// my-plugin.cjs
|
|
170
|
+
const Roster = require('roster-server');
|
|
171
|
+
|
|
172
|
+
function createMyPlugin(options) {
|
|
173
|
+
const plugin = (req, res, context) => false;
|
|
174
|
+
plugin[Roster.pluginSource] = { module: __filename, export: 'createMyPlugin', options };
|
|
175
|
+
return plugin;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { createMyPlugin };
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
On `close()`, or a handled signal, each isolated process drains its requests, runs its `onClose` hooks, and exits. Hook errors from those processes appear in the same `AggregateError`. When `closeTimeoutMs` expires, Roster kills the remaining isolated processes. Isolated processes ignore `SIGINT` and `SIGTERM` and wait for the main process, so a signal sent to the whole process group by a terminal or process manager does not cut their drain short. If the main process exits without closing, each isolated process closes itself.
|
|
182
|
+
|
|
183
|
+
Isolation needs a Roster-owned listener, so it applies only when `start()` initializes the instance. Calling `init()` yourself keeps every site in the current process for `attach()` and the other external-server helpers. Isolation is not available on Bun, whose HTTPS server cannot adopt a passed socket; there, Roster logs a warning and serves every site in the main process. Isolation cannot be combined with the legacy `cluster: true` launcher, which disables it by default.
|
|
184
|
+
|
|
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
|
+
|
|
138
187
|
## Startup and shutdown
|
|
139
188
|
|
|
140
189
|
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.
|
|
@@ -203,7 +252,7 @@ During shutdown, `roster.close()` drains its routed requests, closes its routed
|
|
|
203
252
|
|
|
204
253
|
For multiple workers, keep certificate issuance in one manager process and use `autoCertificates: false` with `init()` in serving workers. The manager can use `start()` to provide ACME HTTP and `ensureCertificate()` before starting workers. Keep domain/certificate configuration consistent: `init()` still writes configuration even in serving-only mode. `start()` retains the standalone ACME lifecycle even if `autoCertificates` is false.
|
|
205
254
|
|
|
206
|
-
The legacy `cluster: true` launcher delegates process management to Greenlock. `close()` applies to the current Roster instance; it does not stop worker processes. External cluster managers should coordinate shutdown in each worker.
|
|
255
|
+
The legacy `cluster: true` launcher delegates process management to Greenlock and turns off [site isolation](#site-isolation). `close()` applies to the current Roster instance; it does not stop worker processes. External cluster managers should coordinate shutdown in each worker.
|
|
207
256
|
|
|
208
257
|
## Certificates and DNS
|
|
209
258
|
|
|
@@ -245,6 +294,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
245
294
|
| `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
|
|
246
295
|
| `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
|
|
247
296
|
| `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
|
|
297
|
+
| `isolation` | `true` (`false` with `cluster`) | One process per site directory under `start()`; `false` or `{ restartDelayMs, backlog, helloTimeoutMs }` (defaults `1000`, `511`, `10000`). See [site isolation](#site-isolation). |
|
|
248
298
|
| `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
|
|
249
299
|
| `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
|
|
250
300
|
| `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
|
|
@@ -255,7 +305,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
255
305
|
|
|
256
306
|
| Method | Contract |
|
|
257
307
|
| --- | --- |
|
|
258
|
-
| `register(domain, factory)` | Register before initialization; accepts `domain:port` and wildcard domains. Returns `Roster`. |
|
|
308
|
+
| `register(domain, factory \| siteDirectory)` | Register before initialization; accepts `domain:port` and wildcard domains. A site directory can be isolated; a factory runs in the main process. Returns `Roster`. |
|
|
259
309
|
| `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
|
|
260
310
|
| `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
|
|
261
311
|
| `start()` | Start standalone/local listeners; await readiness. |
|
|
@@ -270,6 +320,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
270
320
|
| `createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` | `Promise<https.Server>` with certificates and dispatchers; does not listen. Certificate assurance defaults to `true`. Requires production-mode `init()`. |
|
|
271
321
|
| `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
|
|
272
322
|
| `virtualServer.onClose(fn)` | Register a cleanup hook; returns the virtual server. See [shutdown](#startup-and-shutdown). |
|
|
323
|
+
| `Roster.pluginSource` | Symbol for a request plugin's `{ module, export, options }` descriptor. See [site isolation](#site-isolation). |
|
|
273
324
|
|
|
274
325
|
## Request plugins
|
|
275
326
|
|
|
@@ -283,7 +334,7 @@ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
|
|
|
283
334
|
roster.use(createScannerBlocker());
|
|
284
335
|
```
|
|
285
336
|
|
|
286
|
-
The blocker rejects common PHP, WordPress, repository, and sensitive-file probes with `404`. Defaults: a `60000` ms strike window, `3` strikes, a `900000` ms ban, `10000` tracked clients, and `trustProxy: false`. Configure `windowMs`, `strikeThreshold`, `banDurationMs`, and `maxTrackedClients` on the plugin. State is bounded, in memory, and per process.
|
|
337
|
+
The blocker rejects common PHP, WordPress, repository, and sensitive-file probes with `404`. Defaults: a `60000` ms strike window, `3` strikes, a `900000` ms ban, `10000` tracked clients, and `trustProxy: false`. Configure `windowMs`, `strikeThreshold`, `banDurationMs`, and `maxTrackedClients` on the plugin. State is bounded, in memory, and per process, so each isolated site keeps its own bans.
|
|
287
338
|
|
|
288
339
|
Enable `trustProxy` only behind a trusted proxy that overwrites `X-Forwarded-For`. The optional synchronous `onBlock(event)` callback can integrate the application's logging or enforcement; it does not itself provide shared or persistent bans.
|
|
289
340
|
|
package/index.js
CHANGED
|
@@ -2,14 +2,37 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const https = require('https');
|
|
5
|
+
const net = require('net');
|
|
5
6
|
const tls = require('tls');
|
|
6
7
|
const crypto = require('crypto');
|
|
7
8
|
const { EventEmitter } = require('events');
|
|
8
9
|
const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
|
|
9
10
|
const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
|
|
10
|
-
const { resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
11
|
+
const { locateSiteEntry, resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
12
|
+
const { IsolatedSite } = require('./lib/isolated-site.js');
|
|
13
|
+
const { parseClientHello } = require('./lib/client-hello.js');
|
|
11
14
|
const log = require('lemonlog')('roster');
|
|
12
15
|
const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
16
|
+
// A request plugin carrying { module, export, options } under this key can be recreated in isolated site processes.
|
|
17
|
+
const PLUGIN_SOURCE = Symbol.for('roster-server.plugin-source');
|
|
18
|
+
|
|
19
|
+
function isJsonValue(value) {
|
|
20
|
+
if (value === null || ['string', 'number', 'boolean', 'undefined'].includes(typeof value)) return true;
|
|
21
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
22
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
23
|
+
return Object.values(value).every(isJsonValue);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isolatedPluginSource(plugin) {
|
|
27
|
+
const source = plugin[PLUGIN_SOURCE];
|
|
28
|
+
if (!source || typeof source.module !== 'string' || typeof source.export !== 'string' || !isJsonValue(source.options)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Request plugin ${plugin.name || '(anonymous)'} cannot be recreated in isolated site processes. ` +
|
|
31
|
+
'Attach a Roster.pluginSource descriptor with JSON-serializable options, or set isolation: false'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return { module: source.module, export: source.export, options: source.options };
|
|
35
|
+
}
|
|
13
36
|
|
|
14
37
|
function requestError(error, res) {
|
|
15
38
|
log.error('Request handler failed:', error?.message || error);
|
|
@@ -272,6 +295,27 @@ class Roster {
|
|
|
272
295
|
this.greenlockStorePath = options.greenlockStorePath || path.join(basePath, 'greenlock.d');
|
|
273
296
|
this.staging = options.staging || false;
|
|
274
297
|
this.cluster = options.cluster || false;
|
|
298
|
+
const isolation = options.isolation ?? !this.cluster;
|
|
299
|
+
if (typeof isolation !== 'boolean' && (typeof isolation !== 'object' || isolation === null)) {
|
|
300
|
+
throw new TypeError('isolation must be a boolean or an options object');
|
|
301
|
+
}
|
|
302
|
+
if (isolation && this.cluster) {
|
|
303
|
+
throw new Error('isolation cannot be combined with cluster: true');
|
|
304
|
+
}
|
|
305
|
+
this.isolation = isolation ? {
|
|
306
|
+
restartDelayMs: 1000,
|
|
307
|
+
backlog: 511,
|
|
308
|
+
helloTimeoutMs: 10000,
|
|
309
|
+
...(isolation === true ? {} : isolation)
|
|
310
|
+
} : null;
|
|
311
|
+
for (const [name, value] of Object.entries(this.isolation || {})) {
|
|
312
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
313
|
+
throw new TypeError(`isolation.${name} must be a positive finite number`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
this._isolate = false;
|
|
317
|
+
this._isolatedSites = [];
|
|
318
|
+
this._isolatedRoutes = {};
|
|
275
319
|
this.local = options.local || false;
|
|
276
320
|
this.domains = [];
|
|
277
321
|
this.sites = {};
|
|
@@ -382,7 +426,9 @@ class Roster {
|
|
|
382
426
|
|
|
383
427
|
let resolved;
|
|
384
428
|
try {
|
|
385
|
-
resolved =
|
|
429
|
+
resolved = this._isolate
|
|
430
|
+
? this._locateSite(domainPath)
|
|
431
|
+
: await resolveSiteApp(domainPath, { filename: this.filename });
|
|
386
432
|
} catch (err) {
|
|
387
433
|
log.warn(`⚠️ Error loading site in ${domainPath}:`, err);
|
|
388
434
|
continue;
|
|
@@ -418,6 +464,42 @@ class Roster {
|
|
|
418
464
|
}
|
|
419
465
|
}
|
|
420
466
|
|
|
467
|
+
// An isolated site is recorded by its directory and loaded only by its own process.
|
|
468
|
+
_locateSite(domainPath) {
|
|
469
|
+
const entry = locateSiteEntry(domainPath, { filename: this.filename });
|
|
470
|
+
return entry ? { siteApp: domainPath, type: entry.type } : null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_isLoadedInProcess(sitePath) {
|
|
474
|
+
const entry = locateSiteEntry(sitePath, { filename: this.filename });
|
|
475
|
+
if (!entry || entry.type !== 'js') return false;
|
|
476
|
+
return Boolean(require.cache[entry.path] || require.cache[fs.realpathSync(entry.path)]);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Load site directories that stay in this process; the rest keep their path for an isolated process.
|
|
480
|
+
async _resolveSiteSources() {
|
|
481
|
+
const sitePaths = new Set(Object.values(this.sites).filter(source => typeof source === 'string'));
|
|
482
|
+
for (const sitePath of sitePaths) {
|
|
483
|
+
this._assertOpen();
|
|
484
|
+
if (this._isolate) {
|
|
485
|
+
if (!this._isLoadedInProcess(sitePath)) continue;
|
|
486
|
+
log.warn(`⚠️ ${sitePath} is already loaded by the main process; serving it there so it never runs twice`);
|
|
487
|
+
}
|
|
488
|
+
let resolved = null;
|
|
489
|
+
try {
|
|
490
|
+
resolved = await resolveSiteApp(sitePath, { filename: this.filename });
|
|
491
|
+
} catch (err) {
|
|
492
|
+
log.warn(`⚠️ Error loading site in ${sitePath}:`, err);
|
|
493
|
+
}
|
|
494
|
+
this._assertOpen();
|
|
495
|
+
for (const [key, source] of Object.entries(this.sites)) {
|
|
496
|
+
if (source !== sitePath) continue;
|
|
497
|
+
if (resolved) this.sites[key] = resolved.siteApp;
|
|
498
|
+
else delete this.sites[key];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
421
503
|
generateConfigJson() {
|
|
422
504
|
const configDir = this.greenlockStorePath;
|
|
423
505
|
const configPath = path.join(configDir, 'config.json');
|
|
@@ -622,8 +704,13 @@ class Roster {
|
|
|
622
704
|
if (!domainString) {
|
|
623
705
|
throw new Error('Domain is required');
|
|
624
706
|
}
|
|
625
|
-
if (typeof requestHandler
|
|
626
|
-
|
|
707
|
+
if (typeof requestHandler === 'string') {
|
|
708
|
+
requestHandler = path.resolve(requestHandler);
|
|
709
|
+
if (!locateSiteEntry(requestHandler, { filename: this.filename })) {
|
|
710
|
+
throw new Error(`No index file (js/mjs/cjs or index.html) found in ${requestHandler}`);
|
|
711
|
+
}
|
|
712
|
+
} else if (typeof requestHandler !== 'function') {
|
|
713
|
+
throw new Error('requestHandler must be a function or a site directory path');
|
|
627
714
|
}
|
|
628
715
|
|
|
629
716
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
@@ -800,7 +887,7 @@ class Roster {
|
|
|
800
887
|
this._sitesByPort = {};
|
|
801
888
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
802
889
|
this._assertOpen();
|
|
803
|
-
if (hostKey.startsWith('www.')) continue;
|
|
890
|
+
if (hostKey.startsWith('www.') || typeof siteApp !== 'function') continue;
|
|
804
891
|
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
805
892
|
if (!this._sitesByPort[port]) {
|
|
806
893
|
this._sitesByPort[port] = {
|
|
@@ -827,6 +914,104 @@ class Roster {
|
|
|
827
914
|
}
|
|
828
915
|
}
|
|
829
916
|
|
|
917
|
+
// Sites left with a directory source after _resolveSiteSources get one process per directory.
|
|
918
|
+
_planIsolatedSites() {
|
|
919
|
+
if (!this._isolate) return;
|
|
920
|
+
const keysBySitePath = new Map();
|
|
921
|
+
for (const [key, source] of Object.entries(this.sites)) {
|
|
922
|
+
if (typeof source !== 'string') continue;
|
|
923
|
+
if (!keysBySitePath.has(source)) keysBySitePath.set(source, []);
|
|
924
|
+
keysBySitePath.get(source).push(key);
|
|
925
|
+
}
|
|
926
|
+
const shared = Object.keys(this.sites).filter(key => !key.startsWith('www.') && typeof this.sites[key] === 'function');
|
|
927
|
+
if (shared.length > 0) {
|
|
928
|
+
log.warn(`⚠️ Not isolated, served by the main process: ${shared.join(', ')}`);
|
|
929
|
+
}
|
|
930
|
+
if (keysBySitePath.size === 0) return;
|
|
931
|
+
|
|
932
|
+
const plugins = this.plugins.map(isolatedPluginSource);
|
|
933
|
+
const options = {
|
|
934
|
+
local: this.local,
|
|
935
|
+
port: this.defaultPort,
|
|
936
|
+
filename: this.filename,
|
|
937
|
+
greenlockStorePath: this.greenlockStorePath,
|
|
938
|
+
tlsMinVersion: this.tlsMinVersion,
|
|
939
|
+
tlsMaxVersion: this.tlsMaxVersion,
|
|
940
|
+
closeTimeoutMs: this.closeTimeoutMs
|
|
941
|
+
};
|
|
942
|
+
const sitesByPath = new Map();
|
|
943
|
+
for (const [sitePath, keys] of keysBySitePath) {
|
|
944
|
+
const site = new IsolatedSite({
|
|
945
|
+
name: path.basename(sitePath),
|
|
946
|
+
config: { path: sitePath, keys, options, plugins },
|
|
947
|
+
isolation: this.isolation,
|
|
948
|
+
issueCertificate: servername => this._checkCertificate(servername)
|
|
949
|
+
});
|
|
950
|
+
sitesByPath.set(sitePath, site);
|
|
951
|
+
this._isolatedSites.push(site);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const isolatedPorts = new Set([...keysBySitePath.values()].flat().map(key => this.parseDomainWithPort(key).port));
|
|
955
|
+
for (const [key, source] of Object.entries(this.sites)) {
|
|
956
|
+
if (key.startsWith('www.')) continue;
|
|
957
|
+
const { domain, port } = this.parseDomainWithPort(key);
|
|
958
|
+
if (!isolatedPorts.has(port)) continue;
|
|
959
|
+
const routes = this._isolatedRoutes[port] ??= { exact: new Map(), wildcards: [] };
|
|
960
|
+
const site = sitesByPath.get(source) || null;
|
|
961
|
+
if (domain.startsWith('*.')) routes.wildcards.push([domain, site]);
|
|
962
|
+
else routes.exact.set(domain, site);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Same precedence as getHandlerForPortData: exact host, then wildcards in registration order.
|
|
967
|
+
_isolatedSiteFor(routes, servername) {
|
|
968
|
+
const domain = servername.startsWith('www.') ? servername.slice(4) : servername;
|
|
969
|
+
if (routes.exact.has(domain)) return routes.exact.get(domain);
|
|
970
|
+
const wildcard = routes.wildcards.find(([pattern]) => hostMatchesWildcard(domain, pattern));
|
|
971
|
+
return wildcard ? wildcard[1] : null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Read the TLS ClientHello and hand the raw socket to the process serving its SNI host.
|
|
975
|
+
_route(socket, port, routes, server) {
|
|
976
|
+
let head = Buffer.alloc(0);
|
|
977
|
+
socket.setTimeout(this.isolation.helloTimeoutMs, () => socket.destroy());
|
|
978
|
+
socket.on('error', () => socket.destroy());
|
|
979
|
+
const read = chunk => {
|
|
980
|
+
head = Buffer.concat([head, chunk]);
|
|
981
|
+
const servername = parseClientHello(head);
|
|
982
|
+
if (servername === undefined) return;
|
|
983
|
+
socket.removeListener('data', read);
|
|
984
|
+
socket.pause();
|
|
985
|
+
socket.setTimeout(0);
|
|
986
|
+
const site = servername && this._isolatedSiteFor(routes, servername);
|
|
987
|
+
if (site) {
|
|
988
|
+
site.handoff(port, head, socket);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
socket.unshift(head);
|
|
992
|
+
server.emit('connection', socket);
|
|
993
|
+
};
|
|
994
|
+
socket.on('data', read);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
_listenSite(server, port) {
|
|
998
|
+
const routes = this._isolatedRoutes[port];
|
|
999
|
+
if (!routes) {
|
|
1000
|
+
this.portServers[port] = server;
|
|
1001
|
+
return this._listen(server, port, this.hostname);
|
|
1002
|
+
}
|
|
1003
|
+
this._ownedServers.add(server);
|
|
1004
|
+
const router = net.createServer(socket => this._route(socket, port, routes, server));
|
|
1005
|
+
this.portServers[port] = router;
|
|
1006
|
+
return this._listen(router, port, this.hostname);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async _startIsolatedSites() {
|
|
1010
|
+
this._assertOpen();
|
|
1011
|
+
await Promise.all(this._isolatedSites.map(site => site.start()));
|
|
1012
|
+
this._assertOpen();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
830
1015
|
_createDispatcher(portData) {
|
|
831
1016
|
return (req, res) => {
|
|
832
1017
|
if (!this._beginRequest(res)) return;
|
|
@@ -1125,6 +1310,7 @@ class Roster {
|
|
|
1125
1310
|
async _initialize() {
|
|
1126
1311
|
await this.loadSites();
|
|
1127
1312
|
this._assertOpen();
|
|
1313
|
+
await this._resolveSiteSources();
|
|
1128
1314
|
if (!this.local) {
|
|
1129
1315
|
this.generateConfigJson();
|
|
1130
1316
|
if (this.autoCertificates) {
|
|
@@ -1132,6 +1318,7 @@ class Roster {
|
|
|
1132
1318
|
}
|
|
1133
1319
|
}
|
|
1134
1320
|
this._initSiteHandlers();
|
|
1321
|
+
this._planIsolatedSites();
|
|
1135
1322
|
this._assertOpen();
|
|
1136
1323
|
if (!this.local) {
|
|
1137
1324
|
this._initSniResolver();
|
|
@@ -1208,7 +1395,9 @@ class Roster {
|
|
|
1208
1395
|
timer = setTimeout(() => {
|
|
1209
1396
|
for (const res of this._responses) res.destroy();
|
|
1210
1397
|
for (const socket of this._sockets) socket.destroy();
|
|
1211
|
-
|
|
1398
|
+
// Isolation routers are plain TCP servers; their sockets are in _sockets.
|
|
1399
|
+
for (const server of this._ownedServers) server.closeAllConnections?.();
|
|
1400
|
+
for (const site of this._isolatedSites) site.kill();
|
|
1212
1401
|
reject(timeoutError);
|
|
1213
1402
|
}, this.closeTimeoutMs);
|
|
1214
1403
|
});
|
|
@@ -1240,7 +1429,7 @@ class Roster {
|
|
|
1240
1429
|
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
|
|
1241
1430
|
resolve();
|
|
1242
1431
|
});
|
|
1243
|
-
server.closeIdleConnections();
|
|
1432
|
+
server.closeIdleConnections?.();
|
|
1244
1433
|
} catch (error) {
|
|
1245
1434
|
errors.push(error);
|
|
1246
1435
|
resolve();
|
|
@@ -1254,8 +1443,11 @@ class Roster {
|
|
|
1254
1443
|
try { server.close(); } catch (error) { errors.push(error); }
|
|
1255
1444
|
}
|
|
1256
1445
|
for (const socket of this._upgradedSockets) socket.destroy();
|
|
1446
|
+
const isolated = this._isolatedSites.map(site => site.close().then(siteErrors => {
|
|
1447
|
+
errors.push(...siteErrors);
|
|
1448
|
+
}));
|
|
1257
1449
|
await wait(Promise.all([
|
|
1258
|
-
...closing, ...draining,
|
|
1450
|
+
...closing, ...draining, ...isolated,
|
|
1259
1451
|
Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
|
|
1260
1452
|
]));
|
|
1261
1453
|
if (isBunRuntime) {
|
|
@@ -1376,11 +1568,11 @@ class Roster {
|
|
|
1376
1568
|
async startLocalMode() {
|
|
1377
1569
|
this.domainPorts = {};
|
|
1378
1570
|
|
|
1379
|
-
for (const portData of Object.
|
|
1571
|
+
for (const [sitePort, portData] of Object.entries(this._sitesByPort)) {
|
|
1380
1572
|
for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
|
|
1381
1573
|
if (domain.startsWith('www.')) continue;
|
|
1382
1574
|
|
|
1383
|
-
const port = this.assignPortToDomain(domain);
|
|
1575
|
+
const port = this.assignPortToDomain(domain, Number(sitePort));
|
|
1384
1576
|
this.domainPorts[domain] = port;
|
|
1385
1577
|
|
|
1386
1578
|
const appHandler = portData.appHandlers[domain];
|
|
@@ -1414,13 +1606,32 @@ class Roster {
|
|
|
1414
1606
|
}
|
|
1415
1607
|
}
|
|
1416
1608
|
|
|
1417
|
-
|
|
1609
|
+
for (const site of this._isolatedSites) {
|
|
1610
|
+
for (const key of site.config.keys) {
|
|
1611
|
+
if (key.startsWith('www.')) continue;
|
|
1612
|
+
const { domain, port: sitePort } = this.parseDomainWithPort(key);
|
|
1613
|
+
const port = this.assignPortToDomain(domain, sitePort);
|
|
1614
|
+
site.localPorts[`${domain}:${sitePort}`] = port;
|
|
1615
|
+
this.domainPorts[domain] = port;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
await this._startIsolatedSites();
|
|
1619
|
+
|
|
1620
|
+
log.info(`(✔) Started ${Object.keys(this.domainPorts).length} sites in local mode`);
|
|
1418
1621
|
return Promise.resolve();
|
|
1419
1622
|
}
|
|
1420
1623
|
|
|
1421
1624
|
start() {
|
|
1422
1625
|
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1423
1626
|
if (!this._startPromise) {
|
|
1627
|
+
// Isolation requires Roster to own the listeners, so only start() initializes for it.
|
|
1628
|
+
if (this.isolation && !this._initPromise) {
|
|
1629
|
+
if (isBunRuntime) {
|
|
1630
|
+
log.warn('⚠️ Bun runtime detected: site isolation is not supported; all sites run in the main process');
|
|
1631
|
+
} else {
|
|
1632
|
+
this._isolate = true;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1424
1635
|
this._startPromise = this._start().catch(async error => {
|
|
1425
1636
|
if (!this._closing) {
|
|
1426
1637
|
try { await this.close(); } catch (cleanupError) {
|
|
@@ -1462,9 +1673,12 @@ class Roster {
|
|
|
1462
1673
|
|
|
1463
1674
|
await this._listen(httpServer, 80, this.hostname);
|
|
1464
1675
|
log.info('HTTP server listening on port 80');
|
|
1676
|
+
await this._startIsolatedSites();
|
|
1465
1677
|
|
|
1466
|
-
|
|
1678
|
+
const ports = new Set([...Object.keys(this._sitesByPort), ...Object.keys(this._isolatedRoutes)]);
|
|
1679
|
+
for (const port of ports) {
|
|
1467
1680
|
const portNum = parseInt(port);
|
|
1681
|
+
const portData = this._sitesByPort[port] || { virtualServers: {}, appHandlers: {} };
|
|
1468
1682
|
const dispatcher = this._createDispatcher(portData);
|
|
1469
1683
|
const upgradeHandler = this._createUpgradeHandler(portData);
|
|
1470
1684
|
|
|
@@ -1577,10 +1791,9 @@ class Roster {
|
|
|
1577
1791
|
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
1578
1792
|
}
|
|
1579
1793
|
|
|
1580
|
-
this.portServers[portNum] = httpsServer;
|
|
1581
1794
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1582
1795
|
|
|
1583
|
-
await this.
|
|
1796
|
+
await this._listenSite(httpsServer, portNum);
|
|
1584
1797
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1585
1798
|
} else {
|
|
1586
1799
|
const httpsOptions = {
|
|
@@ -1600,9 +1813,7 @@ class Roster {
|
|
|
1600
1813
|
}
|
|
1601
1814
|
});
|
|
1602
1815
|
|
|
1603
|
-
this.
|
|
1604
|
-
|
|
1605
|
-
await this._listen(httpsServer, portNum, this.hostname);
|
|
1816
|
+
await this._listenSite(httpsServer, portNum);
|
|
1606
1817
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1607
1818
|
}
|
|
1608
1819
|
}
|
|
@@ -1654,6 +1865,7 @@ class Roster {
|
|
|
1654
1865
|
|
|
1655
1866
|
module.exports = Roster;
|
|
1656
1867
|
module.exports.isBunRuntime = isBunRuntime;
|
|
1868
|
+
module.exports.pluginSource = PLUGIN_SOURCE;
|
|
1657
1869
|
module.exports.wildcardRoot = wildcardRoot;
|
|
1658
1870
|
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|
|
1659
1871
|
module.exports.wildcardSubjectForHost = wildcardSubjectForHost;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const RECORD_HEADER_BYTES = 5;
|
|
4
|
+
const HANDSHAKE_RECORD = 0x16;
|
|
5
|
+
const CLIENT_HELLO = 0x01;
|
|
6
|
+
const SERVER_NAME_EXTENSION = 0x0000;
|
|
7
|
+
const HOST_NAME = 0x00;
|
|
8
|
+
// TLS limits a record to 16 KiB; a ClientHello spanning more than four records is not a browser handshake.
|
|
9
|
+
const MAX_CLIENT_HELLO_BYTES = 4 * (RECORD_HEADER_BYTES + 16384);
|
|
10
|
+
|
|
11
|
+
function readServerName(hello) {
|
|
12
|
+
let offset = 4 + 2 + 32;
|
|
13
|
+
offset += 1 + hello[offset];
|
|
14
|
+
offset += 2 + hello.readUInt16BE(offset);
|
|
15
|
+
offset += 1 + hello[offset];
|
|
16
|
+
const extensionsEnd = offset + 2 + hello.readUInt16BE(offset);
|
|
17
|
+
offset += 2;
|
|
18
|
+
while (offset + 4 <= extensionsEnd) {
|
|
19
|
+
const type = hello.readUInt16BE(offset);
|
|
20
|
+
const length = hello.readUInt16BE(offset + 2);
|
|
21
|
+
offset += 4;
|
|
22
|
+
if (type === SERVER_NAME_EXTENSION) {
|
|
23
|
+
const listEnd = offset + 2 + hello.readUInt16BE(offset);
|
|
24
|
+
let entry = offset + 2;
|
|
25
|
+
while (entry + 3 <= listEnd) {
|
|
26
|
+
const nameLength = hello.readUInt16BE(entry + 1);
|
|
27
|
+
if (hello[entry] === HOST_NAME) {
|
|
28
|
+
return hello.toString('ascii', entry + 3, entry + 3 + nameLength).toLowerCase().replace(/\.$/, '') || null;
|
|
29
|
+
}
|
|
30
|
+
entry += 3 + nameLength;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
offset += length;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract the SNI host name from the first bytes a TLS client sends.
|
|
41
|
+
* @param {Buffer} buffer - Bytes received so far on the connection.
|
|
42
|
+
* @returns {string|null|undefined} The lowercase server name, `null` when the bytes are not a
|
|
43
|
+
* ClientHello carrying one, or `undefined` when more bytes are needed to decide.
|
|
44
|
+
*/
|
|
45
|
+
function parseClientHello(buffer) {
|
|
46
|
+
const fragments = [];
|
|
47
|
+
let offset = 0;
|
|
48
|
+
while (true) {
|
|
49
|
+
if (buffer.length >= MAX_CLIENT_HELLO_BYTES) return null;
|
|
50
|
+
if (buffer.length < offset + RECORD_HEADER_BYTES) return undefined;
|
|
51
|
+
if (buffer[offset] !== HANDSHAKE_RECORD) return null;
|
|
52
|
+
const recordEnd = offset + RECORD_HEADER_BYTES + buffer.readUInt16BE(offset + 3);
|
|
53
|
+
if (buffer.length < recordEnd) return undefined;
|
|
54
|
+
fragments.push(buffer.subarray(offset + RECORD_HEADER_BYTES, recordEnd));
|
|
55
|
+
offset = recordEnd;
|
|
56
|
+
const hello = Buffer.concat(fragments);
|
|
57
|
+
if (hello.length < 4) continue;
|
|
58
|
+
if (hello[0] !== CLIENT_HELLO) return null;
|
|
59
|
+
if (hello.length < 4 + hello.readUIntBE(1, 3)) continue;
|
|
60
|
+
try {
|
|
61
|
+
return readServerName(hello);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { parseClientHello };
|