roster-server 2.4.13 → 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 +69 -6
- package/index.js +246 -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 +2 -2
- package/test/isolation.test.js +372 -0
- package/test/signals.test.js +183 -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.
|
|
@@ -149,7 +198,18 @@ Register `virtualServer.onClose(fn)` in a factory for timers, databases, queues,
|
|
|
149
198
|
|
|
150
199
|
`closeTimeoutMs` (default `30000`) bounds the whole operation. At the deadline Roster destroys remaining owned connections and active routed responses, attempts cleanup hooks, and rejects with a timeout. It cannot forcibly interrupt an application Promise or an ACME operation already in progress.
|
|
151
200
|
|
|
152
|
-
Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
|
|
201
|
+
Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
|
|
202
|
+
|
|
203
|
+
For automatic shutdown on `SIGINT` or `SIGTERM`, explicitly enable `handleSignals`:
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
const roster = new Roster({ handleSignals: true });
|
|
207
|
+
await roster.start();
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The option defaults to `false`. Listeners are installed once when `init()` or `start()` begins and removed when closure completes, including failed startup or cleanup. Signals call `roster.close()`; repeated signals during closure do not repeat cleanup. Existing application signal listeners remain installed. Shutdown failures are logged and set `process.exitCode = 1`.
|
|
211
|
+
|
|
212
|
+
Roster does not force process termination or close caller-owned servers. The process exits naturally when its remaining work finishes; other resources or cluster workers still require application coordination. If the application already coordinates shutdown, leave `handleSignals` disabled and connect Roster to that flow:
|
|
153
213
|
|
|
154
214
|
```javascript
|
|
155
215
|
async function shutdown() {
|
|
@@ -192,7 +252,7 @@ During shutdown, `roster.close()` drains its routed requests, closes its routed
|
|
|
192
252
|
|
|
193
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.
|
|
194
254
|
|
|
195
|
-
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.
|
|
196
256
|
|
|
197
257
|
## Certificates and DNS
|
|
198
258
|
|
|
@@ -233,6 +293,8 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
233
293
|
| `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
|
|
234
294
|
| `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
|
|
235
295
|
| `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
|
|
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). |
|
|
236
298
|
| `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
|
|
237
299
|
| `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
|
|
238
300
|
| `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
|
|
@@ -243,7 +305,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
243
305
|
|
|
244
306
|
| Method | Contract |
|
|
245
307
|
| --- | --- |
|
|
246
|
-
| `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`. |
|
|
247
309
|
| `use(plugin)` | Add a synchronous request plugin. Returns `Roster`. |
|
|
248
310
|
| `init()` | `Promise<Roster>`; prepare routing and factories without listening. |
|
|
249
311
|
| `start()` | Start standalone/local listeners; await readiness. |
|
|
@@ -258,6 +320,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
258
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()`. |
|
|
259
321
|
| `createServingHttpsServer({ servername, port?, tlsOptions? })` | Same helper with `ensureCertificate: false`; set `autoCertificates: false` for a serving-only instance. |
|
|
260
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). |
|
|
261
324
|
|
|
262
325
|
## Request plugins
|
|
263
326
|
|
|
@@ -271,7 +334,7 @@ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
|
|
|
271
334
|
roster.use(createScannerBlocker());
|
|
272
335
|
```
|
|
273
336
|
|
|
274
|
-
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.
|
|
275
338
|
|
|
276
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.
|
|
277
340
|
|
package/index.js
CHANGED
|
@@ -2,13 +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');
|
|
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
|
+
}
|
|
12
36
|
|
|
13
37
|
function requestError(error, res) {
|
|
14
38
|
log.error('Request handler failed:', error?.message || error);
|
|
@@ -271,6 +295,27 @@ class Roster {
|
|
|
271
295
|
this.greenlockStorePath = options.greenlockStorePath || path.join(basePath, 'greenlock.d');
|
|
272
296
|
this.staging = options.staging || false;
|
|
273
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 = {};
|
|
274
319
|
this.local = options.local || false;
|
|
275
320
|
this.domains = [];
|
|
276
321
|
this.sites = {};
|
|
@@ -287,6 +332,8 @@ class Roster {
|
|
|
287
332
|
this._initPromise = null;
|
|
288
333
|
this._startPromise = null;
|
|
289
334
|
this._closePromise = null;
|
|
335
|
+
this.handleSignals = parseBooleanFlag(options.handleSignals, false);
|
|
336
|
+
this._signalHandler = null;
|
|
290
337
|
this._ownedServers = new Set();
|
|
291
338
|
this._sockets = new Set();
|
|
292
339
|
this._upgradedSockets = new Set();
|
|
@@ -379,7 +426,9 @@ class Roster {
|
|
|
379
426
|
|
|
380
427
|
let resolved;
|
|
381
428
|
try {
|
|
382
|
-
resolved =
|
|
429
|
+
resolved = this._isolate
|
|
430
|
+
? this._locateSite(domainPath)
|
|
431
|
+
: await resolveSiteApp(domainPath, { filename: this.filename });
|
|
383
432
|
} catch (err) {
|
|
384
433
|
log.warn(`⚠️ Error loading site in ${domainPath}:`, err);
|
|
385
434
|
continue;
|
|
@@ -415,6 +464,42 @@ class Roster {
|
|
|
415
464
|
}
|
|
416
465
|
}
|
|
417
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
|
+
|
|
418
503
|
generateConfigJson() {
|
|
419
504
|
const configDir = this.greenlockStorePath;
|
|
420
505
|
const configPath = path.join(configDir, 'config.json');
|
|
@@ -619,8 +704,13 @@ class Roster {
|
|
|
619
704
|
if (!domainString) {
|
|
620
705
|
throw new Error('Domain is required');
|
|
621
706
|
}
|
|
622
|
-
if (typeof requestHandler
|
|
623
|
-
|
|
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');
|
|
624
714
|
}
|
|
625
715
|
|
|
626
716
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
@@ -797,7 +887,7 @@ class Roster {
|
|
|
797
887
|
this._sitesByPort = {};
|
|
798
888
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
799
889
|
this._assertOpen();
|
|
800
|
-
if (hostKey.startsWith('www.')) continue;
|
|
890
|
+
if (hostKey.startsWith('www.') || typeof siteApp !== 'function') continue;
|
|
801
891
|
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
802
892
|
if (!this._sitesByPort[port]) {
|
|
803
893
|
this._sitesByPort[port] = {
|
|
@@ -824,6 +914,104 @@ class Roster {
|
|
|
824
914
|
}
|
|
825
915
|
}
|
|
826
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
|
+
|
|
827
1015
|
_createDispatcher(portData) {
|
|
828
1016
|
return (req, res) => {
|
|
829
1017
|
if (!this._beginRequest(res)) return;
|
|
@@ -1097,6 +1285,16 @@ class Roster {
|
|
|
1097
1285
|
init() {
|
|
1098
1286
|
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1099
1287
|
if (this._initPromise) return this._initPromise;
|
|
1288
|
+
if (this.handleSignals) {
|
|
1289
|
+
this._signalHandler = () => {
|
|
1290
|
+
if (this._closing) return;
|
|
1291
|
+
this.close().catch(error => {
|
|
1292
|
+
log.error('Signal shutdown failed:', error.message);
|
|
1293
|
+
process.exitCode = 1;
|
|
1294
|
+
});
|
|
1295
|
+
};
|
|
1296
|
+
for (const signal of SHUTDOWN_SIGNALS) process.on(signal, this._signalHandler);
|
|
1297
|
+
}
|
|
1100
1298
|
this._initTask = this._initialize();
|
|
1101
1299
|
this._initPromise = this._initTask.catch(async error => {
|
|
1102
1300
|
if (!this._closing) {
|
|
@@ -1112,6 +1310,7 @@ class Roster {
|
|
|
1112
1310
|
async _initialize() {
|
|
1113
1311
|
await this.loadSites();
|
|
1114
1312
|
this._assertOpen();
|
|
1313
|
+
await this._resolveSiteSources();
|
|
1115
1314
|
if (!this.local) {
|
|
1116
1315
|
this.generateConfigJson();
|
|
1117
1316
|
if (this.autoCertificates) {
|
|
@@ -1119,6 +1318,7 @@ class Roster {
|
|
|
1119
1318
|
}
|
|
1120
1319
|
}
|
|
1121
1320
|
this._initSiteHandlers();
|
|
1321
|
+
this._planIsolatedSites();
|
|
1122
1322
|
this._assertOpen();
|
|
1123
1323
|
if (!this.local) {
|
|
1124
1324
|
this._initSniResolver();
|
|
@@ -1195,7 +1395,9 @@ class Roster {
|
|
|
1195
1395
|
timer = setTimeout(() => {
|
|
1196
1396
|
for (const res of this._responses) res.destroy();
|
|
1197
1397
|
for (const socket of this._sockets) socket.destroy();
|
|
1198
|
-
|
|
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();
|
|
1199
1401
|
reject(timeoutError);
|
|
1200
1402
|
}, this.closeTimeoutMs);
|
|
1201
1403
|
});
|
|
@@ -1227,7 +1429,7 @@ class Roster {
|
|
|
1227
1429
|
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') errors.push(error);
|
|
1228
1430
|
resolve();
|
|
1229
1431
|
});
|
|
1230
|
-
server.closeIdleConnections();
|
|
1432
|
+
server.closeIdleConnections?.();
|
|
1231
1433
|
} catch (error) {
|
|
1232
1434
|
errors.push(error);
|
|
1233
1435
|
resolve();
|
|
@@ -1241,8 +1443,11 @@ class Roster {
|
|
|
1241
1443
|
try { server.close(); } catch (error) { errors.push(error); }
|
|
1242
1444
|
}
|
|
1243
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
|
+
}));
|
|
1244
1449
|
await wait(Promise.all([
|
|
1245
|
-
...closing, ...draining,
|
|
1450
|
+
...closing, ...draining, ...isolated,
|
|
1246
1451
|
Promise.allSettled([...this._certificateChecks.values(), ...this._contextLoads.values()])
|
|
1247
1452
|
]));
|
|
1248
1453
|
if (isBunRuntime) {
|
|
@@ -1260,6 +1465,10 @@ class Roster {
|
|
|
1260
1465
|
}));
|
|
1261
1466
|
} finally {
|
|
1262
1467
|
clearTimeout(timer);
|
|
1468
|
+
if (this._signalHandler) {
|
|
1469
|
+
for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
|
|
1470
|
+
this._signalHandler = null;
|
|
1471
|
+
}
|
|
1263
1472
|
for (const [server, handlers] of this._attachments) {
|
|
1264
1473
|
server.removeListener('request', handlers.request);
|
|
1265
1474
|
server.removeListener('upgrade', handlers.upgrade);
|
|
@@ -1359,11 +1568,11 @@ class Roster {
|
|
|
1359
1568
|
async startLocalMode() {
|
|
1360
1569
|
this.domainPorts = {};
|
|
1361
1570
|
|
|
1362
|
-
for (const portData of Object.
|
|
1571
|
+
for (const [sitePort, portData] of Object.entries(this._sitesByPort)) {
|
|
1363
1572
|
for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
|
|
1364
1573
|
if (domain.startsWith('www.')) continue;
|
|
1365
1574
|
|
|
1366
|
-
const port = this.assignPortToDomain(domain);
|
|
1575
|
+
const port = this.assignPortToDomain(domain, Number(sitePort));
|
|
1367
1576
|
this.domainPorts[domain] = port;
|
|
1368
1577
|
|
|
1369
1578
|
const appHandler = portData.appHandlers[domain];
|
|
@@ -1397,13 +1606,32 @@ class Roster {
|
|
|
1397
1606
|
}
|
|
1398
1607
|
}
|
|
1399
1608
|
|
|
1400
|
-
|
|
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`);
|
|
1401
1621
|
return Promise.resolve();
|
|
1402
1622
|
}
|
|
1403
1623
|
|
|
1404
1624
|
start() {
|
|
1405
1625
|
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1406
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
|
+
}
|
|
1407
1635
|
this._startPromise = this._start().catch(async error => {
|
|
1408
1636
|
if (!this._closing) {
|
|
1409
1637
|
try { await this.close(); } catch (cleanupError) {
|
|
@@ -1445,9 +1673,12 @@ class Roster {
|
|
|
1445
1673
|
|
|
1446
1674
|
await this._listen(httpServer, 80, this.hostname);
|
|
1447
1675
|
log.info('HTTP server listening on port 80');
|
|
1676
|
+
await this._startIsolatedSites();
|
|
1448
1677
|
|
|
1449
|
-
|
|
1678
|
+
const ports = new Set([...Object.keys(this._sitesByPort), ...Object.keys(this._isolatedRoutes)]);
|
|
1679
|
+
for (const port of ports) {
|
|
1450
1680
|
const portNum = parseInt(port);
|
|
1681
|
+
const portData = this._sitesByPort[port] || { virtualServers: {}, appHandlers: {} };
|
|
1451
1682
|
const dispatcher = this._createDispatcher(portData);
|
|
1452
1683
|
const upgradeHandler = this._createUpgradeHandler(portData);
|
|
1453
1684
|
|
|
@@ -1560,10 +1791,9 @@ class Roster {
|
|
|
1560
1791
|
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
1561
1792
|
}
|
|
1562
1793
|
|
|
1563
|
-
this.portServers[portNum] = httpsServer;
|
|
1564
1794
|
httpsServer.on('upgrade', upgradeHandler);
|
|
1565
1795
|
|
|
1566
|
-
await this.
|
|
1796
|
+
await this._listenSite(httpsServer, portNum);
|
|
1567
1797
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1568
1798
|
} else {
|
|
1569
1799
|
const httpsOptions = {
|
|
@@ -1583,9 +1813,7 @@ class Roster {
|
|
|
1583
1813
|
}
|
|
1584
1814
|
});
|
|
1585
1815
|
|
|
1586
|
-
this.
|
|
1587
|
-
|
|
1588
|
-
await this._listen(httpsServer, portNum, this.hostname);
|
|
1816
|
+
await this._listenSite(httpsServer, portNum);
|
|
1589
1817
|
log.info(`HTTPS server listening on port ${portNum}`);
|
|
1590
1818
|
}
|
|
1591
1819
|
}
|
|
@@ -1637,6 +1865,7 @@ class Roster {
|
|
|
1637
1865
|
|
|
1638
1866
|
module.exports = Roster;
|
|
1639
1867
|
module.exports.isBunRuntime = isBunRuntime;
|
|
1868
|
+
module.exports.pluginSource = PLUGIN_SOURCE;
|
|
1640
1869
|
module.exports.wildcardRoot = wildcardRoot;
|
|
1641
1870
|
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|
|
1642
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 };
|