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.
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { fork } = require('child_process');
5
+ const log = require('lemonlog')('roster');
6
+
7
+ const WORKER_PATH = path.join(__dirname, 'site-worker.js');
8
+
9
+ function serializeError(error) {
10
+ if (!(error instanceof Error)) return { message: String(error) };
11
+ return { name: error.name, message: error.message, stack: error.stack, code: error.code };
12
+ }
13
+
14
+ function deserializeError(data) {
15
+ const error = new Error(data.message);
16
+ if (data.name) error.name = data.name;
17
+ if (data.stack) error.stack = data.stack;
18
+ if (data.code !== undefined) error.code = data.code;
19
+ return error;
20
+ }
21
+
22
+ /**
23
+ * A site that runs in its own process. The main process hands it raw client
24
+ * sockets; the site process terminates TLS and serves them.
25
+ */
26
+ class IsolatedSite {
27
+ /**
28
+ * @param {object} site
29
+ * @param {string} site.name - Label for logs and the process title.
30
+ * @param {object} site.config - Serializable start payload for the site process.
31
+ * @param {{ restartDelayMs: number, backlog: number }} site.isolation
32
+ * @param {(servername: string) => Promise<unknown>} site.issueCertificate
33
+ */
34
+ constructor({ name, config, isolation, issueCertificate }) {
35
+ this.name = name;
36
+ this.config = config;
37
+ this.isolation = isolation;
38
+ this.issueCertificate = issueCertificate;
39
+ this.localPorts = {};
40
+ this.child = null;
41
+ this.ready = false;
42
+ this.pending = 0;
43
+ this._closing = null;
44
+ this._resolveClose = null;
45
+ this._restartTimer = null;
46
+ }
47
+
48
+ start() {
49
+ return new Promise((resolve, reject) => this._spawn({ resolve, reject }));
50
+ }
51
+
52
+ _spawn(startup) {
53
+ if (this._closing) {
54
+ startup?.reject(new Error('Roster is closing or closed'));
55
+ return;
56
+ }
57
+ const child = fork(WORKER_PATH, [], { serialization: 'json' });
58
+ this.child = child;
59
+ let failure = null;
60
+ let closedErrors = null;
61
+ child.on('message', message => {
62
+ if (message.type === 'ready') {
63
+ this.ready = true;
64
+ if (startup) {
65
+ log.info(`(✔) Isolated site ${this.name} (pid ${child.pid})`);
66
+ startup.resolve();
67
+ startup = null;
68
+ } else {
69
+ log.info(`(✔) Isolated site ${this.name} restarted (pid ${child.pid})`);
70
+ }
71
+ } else if (message.type === 'failed') {
72
+ failure = deserializeError(message.error);
73
+ } else if (message.type === 'certificate') {
74
+ this._issueCertificate(child, message);
75
+ } else if (message.type === 'closed') {
76
+ closedErrors = message.errors.map(deserializeError);
77
+ }
78
+ });
79
+ child.on('error', error => { failure = failure || error; });
80
+ child.once('exit', (code, signal) => {
81
+ this.child = null;
82
+ this.ready = false;
83
+ this.pending = 0;
84
+ const reason = failure || new Error(`Isolated site ${this.name} exited with ${signal || `code ${code}`}`);
85
+ if (startup) startup.reject(this._closing ? new Error('Roster is closing or closed') : reason);
86
+ if (this._resolveClose) {
87
+ this._resolveClose(closedErrors || [reason]);
88
+ return;
89
+ }
90
+ if (startup) return;
91
+ log.error(`❌ Isolated site ${this.name} stopped: ${reason.message}. Restarting in ${this.isolation.restartDelayMs} ms`);
92
+ this._restartTimer = setTimeout(() => {
93
+ this._restartTimer = null;
94
+ this._spawn(null);
95
+ }, this.isolation.restartDelayMs);
96
+ });
97
+ child.send({ type: 'start', ...this.config, name: this.name, localPorts: this.localPorts });
98
+ }
99
+
100
+ _issueCertificate(child, { id, servername }) {
101
+ Promise.resolve()
102
+ .then(() => this.issueCertificate(servername))
103
+ .then(() => ({}), error => ({ error: serializeError(error) }))
104
+ .then(result => {
105
+ if (child.connected) child.send({ type: 'certificate', id, ...result });
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Pass an accepted client socket, with the bytes already read from it, to the site process.
111
+ */
112
+ handoff(port, head, socket) {
113
+ const child = this.child;
114
+ if (!this.ready || this.pending >= this.isolation.backlog) {
115
+ socket.destroy();
116
+ return;
117
+ }
118
+ this.pending++;
119
+ child.send({ type: 'connection', port, head: head.toString('base64') }, socket, error => {
120
+ if (this.child === child) this.pending--;
121
+ if (error) socket.destroy();
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Ask the site process to drain and run its cleanup hooks, then wait for it to exit.
127
+ * @returns {Promise<Error[]>} Errors reported by the site's shutdown.
128
+ */
129
+ close() {
130
+ if (this._closing) return this._closing;
131
+ clearTimeout(this._restartTimer);
132
+ if (!this.child) {
133
+ this._closing = Promise.resolve([]);
134
+ return this._closing;
135
+ }
136
+ this._closing = new Promise(resolve => { this._resolveClose = resolve; });
137
+ if (this.child.connected) this.child.send({ type: 'close' });
138
+ return this._closing;
139
+ }
140
+
141
+ kill() {
142
+ if (this.child) this.child.kill('SIGKILL');
143
+ }
144
+ }
145
+
146
+ module.exports = { IsolatedSite, serializeError };
@@ -5,38 +5,40 @@ const path = require('path');
5
5
  const { createStaticHandler } = require('./static-site-handler.js');
6
6
 
7
7
  /**
8
- * Resolve a site app for a domain directory.
8
+ * Locate a site's entry without loading it.
9
9
  * Tries index.js / index.mjs / index.cjs first; falls back to static (index.html) if present.
10
10
  * @param {string} domainPath - Absolute path to the domain folder (e.g. www/example.com)
11
11
  * @param {{ filename?: string }} options - Optional. filename defaults to 'index'.
12
- * @returns {Promise<{ siteApp: function, type: 'js' | 'static' } | null>}
12
+ * @returns {{ type: 'js' | 'static', path: string } | null}
13
13
  */
14
- async function resolveSiteApp(domainPath, options = {}) {
14
+ function locateSiteEntry(domainPath, options = {}) {
15
15
  const filename = options.filename || 'index';
16
- const possibleIndexFiles = ['js', 'mjs', 'cjs'].map(ext => `${filename}.${ext}`);
17
-
18
- for (const indexFile of possibleIndexFiles) {
19
- const indexPath = path.join(domainPath, indexFile);
20
- if (fs.existsSync(indexPath)) {
21
- try {
22
- let siteApp = await import(indexPath).catch(() => {
23
- return require(indexPath);
24
- });
25
- siteApp = siteApp.default || siteApp;
26
- return { siteApp, type: 'js' };
27
- } catch (err) {
28
- throw err;
29
- }
30
- }
16
+ for (const extension of ['js', 'mjs', 'cjs']) {
17
+ const indexPath = path.join(domainPath, `${filename}.${extension}`);
18
+ if (fs.existsSync(indexPath)) return { type: 'js', path: indexPath };
31
19
  }
32
-
33
20
  const indexHtmlPath = path.join(domainPath, 'index.html');
34
- if (fs.existsSync(indexHtmlPath)) {
35
- const siteApp = createStaticHandler(domainPath);
36
- return { siteApp, type: 'static' };
37
- }
38
-
21
+ if (fs.existsSync(indexHtmlPath)) return { type: 'static', path: indexHtmlPath };
39
22
  return null;
40
23
  }
41
24
 
42
- module.exports = { resolveSiteApp };
25
+ /**
26
+ * Resolve a site app for a domain directory.
27
+ * @param {string} domainPath - Absolute path to the domain folder (e.g. www/example.com)
28
+ * @param {{ filename?: string }} options - Optional. filename defaults to 'index'.
29
+ * @returns {Promise<{ siteApp: function, type: 'js' | 'static' } | null>}
30
+ */
31
+ async function resolveSiteApp(domainPath, options = {}) {
32
+ const entry = locateSiteEntry(domainPath, options);
33
+ if (!entry) return null;
34
+ if (entry.type === 'static') {
35
+ return { siteApp: createStaticHandler(domainPath), type: 'static' };
36
+ }
37
+ let siteApp = await import(entry.path).catch(() => {
38
+ return require(entry.path);
39
+ });
40
+ siteApp = siteApp.default || siteApp;
41
+ return { siteApp, type: 'js' };
42
+ }
43
+
44
+ module.exports = { locateSiteEntry, resolveSiteApp };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Entry point of an isolated site process, forked by IsolatedSite.
4
+ const https = require('https');
5
+ const Roster = require('../index.js');
6
+ const { resolveSiteApp } = require('./resolve-site-app.js');
7
+ const { serializeError } = require('./isolated-site.js');
8
+ const log = require('lemonlog')('roster');
9
+
10
+ class SiteWorkerRoster extends Roster {
11
+ constructor(site) {
12
+ super({ ...site.options, isolation: false, autoCertificates: false, handleSignals: false });
13
+ this._site = site;
14
+ this._httpsServers = new Map();
15
+ this._certificateRequests = new Map();
16
+ this._nextCertificateRequest = 0;
17
+ // Certificates are issued by the main process, which owns the ACME runtime.
18
+ this._greenlockRuntime = { get: ({ servername }) => this._requestCertificate(servername) };
19
+ }
20
+
21
+ async loadSites() {
22
+ const { path: sitePath, keys } = this._site;
23
+ let resolved;
24
+ try {
25
+ resolved = await resolveSiteApp(sitePath, { filename: this.filename });
26
+ } catch (error) {
27
+ log.warn(`⚠️ Error loading site in ${sitePath}:`, error);
28
+ return;
29
+ }
30
+ if (!resolved) {
31
+ log.warn(`⚠️ No index file (js/mjs/cjs or index.html) found in ${sitePath}`);
32
+ return;
33
+ }
34
+ for (const key of keys) this.sites[key] = resolved.siteApp;
35
+ }
36
+
37
+ generateConfigJson() {}
38
+
39
+ assignPortToDomain(domain, port) {
40
+ return this._site.localPorts[`${domain}:${port}`];
41
+ }
42
+
43
+ _requestCertificate(servername) {
44
+ const id = this._nextCertificateRequest++;
45
+ return new Promise((resolve, reject) => {
46
+ this._certificateRequests.set(id, { resolve, reject });
47
+ process.send({ type: 'certificate', id, servername });
48
+ });
49
+ }
50
+
51
+ rejectCertificateRequests() {
52
+ for (const request of this._certificateRequests.values()) {
53
+ request.reject(new Error('Main process disconnected'));
54
+ }
55
+ this._certificateRequests.clear();
56
+ }
57
+
58
+ settleCertificate({ id, error }) {
59
+ const request = this._certificateRequests.get(id);
60
+ this._certificateRequests.delete(id);
61
+ if (error) request.reject(new Error(error.message));
62
+ else request.resolve();
63
+ }
64
+
65
+ serveHttps() {
66
+ const ports = new Set(this._site.keys.map(key => this.parseDomainWithPort(key).port));
67
+ for (const port of ports) {
68
+ // Only the default port issues missing certificates, as in the main process.
69
+ const issue = port === this.defaultPort;
70
+ const server = https.createServer({
71
+ minVersion: this.tlsMinVersion,
72
+ maxVersion: this.tlsMaxVersion,
73
+ SNICallback: (servername, callback) => {
74
+ this._getSecureContext(servername, issue).then(context => callback(null, context), callback);
75
+ }
76
+ });
77
+ this.attach(server, { port });
78
+ this._ownedServers.add(server);
79
+ this._httpsServers.set(port, server);
80
+ }
81
+ }
82
+
83
+ accept(port, head, socket) {
84
+ const server = this._httpsServers.get(port);
85
+ if (this._closing || !server) {
86
+ socket.destroy();
87
+ return;
88
+ }
89
+ this._sockets.add(socket);
90
+ socket.once('close', () => this._sockets.delete(socket));
91
+ socket.unshift(head);
92
+ server.emit('connection', socket);
93
+ }
94
+ }
95
+
96
+ let roster = null;
97
+ let closing = false;
98
+
99
+ async function start(site) {
100
+ process.title = `roster ${site.name}`;
101
+ roster = new SiteWorkerRoster(site);
102
+ try {
103
+ for (const plugin of site.plugins) {
104
+ roster.use(require(plugin.module)[plugin.export](plugin.options));
105
+ }
106
+ if (roster.local) {
107
+ await roster.start();
108
+ } else {
109
+ await roster.init();
110
+ roster.serveHttps();
111
+ }
112
+ process.send({ type: 'ready' });
113
+ } catch (error) {
114
+ if (closing) return;
115
+ process.send({ type: 'failed', error: serializeError(error) }, () => process.exit(1));
116
+ }
117
+ }
118
+
119
+ async function shutdown() {
120
+ if (closing) return;
121
+ closing = true;
122
+ let errors = [];
123
+ if (roster && !process.connected) roster.rejectCertificateRequests();
124
+ try {
125
+ if (roster) await roster.close();
126
+ } catch (error) {
127
+ errors = error instanceof AggregateError ? error.errors : [error];
128
+ }
129
+ if (!process.connected) process.exit(0);
130
+ process.send({ type: 'closed', errors: errors.map(serializeError) }, () => process.exit(0));
131
+ }
132
+
133
+ // The main process coordinates shutdown; a terminal or process manager that
134
+ // signals the whole process group must not cut this site's drain short.
135
+ process.on('SIGINT', () => {});
136
+ process.on('SIGTERM', () => {});
137
+ process.on('disconnect', shutdown);
138
+ process.on('message', (message, handle) => {
139
+ if (message.type === 'start') start(message);
140
+ else if (message.type === 'connection') {
141
+ if (roster) roster.accept(message.port, Buffer.from(message.head, 'base64'), handle);
142
+ else handle.destroy();
143
+ } else if (message.type === 'certificate') roster.settleCertificate(message);
144
+ else if (message.type === 'close') shutdown();
145
+ });
package/package.json CHANGED
@@ -1,54 +1,54 @@
1
1
  {
2
- "name": "roster-server",
3
- "version": "2.4.13",
4
- "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
- "main": "index.js",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/clasen/RosterServer.git"
9
- },
10
- "keywords": [
11
- "greenlock",
12
- "https",
13
- "domain",
14
- "multi-domain",
15
- "letsencrypt",
16
- "hosting",
17
- "ssl",
18
- "host",
19
- "vhost",
20
- "virtual-host",
21
- "socket.io",
22
- "websocket",
23
- "express",
24
- "greenlock-express",
25
- "shotx",
26
- "bun",
27
- "clasen"
28
- ],
29
- "author": "Martin Clasen",
30
- "license": "MIT",
31
- "bugs": {
32
- "url": "https://github.com/clasen/RosterServer/issues"
33
- },
34
- "homepage": "https://github.com/clasen/RosterServer#readme",
35
- "dependencies": {
36
- "@greenlock/manager": "^3.1.0",
37
- "@root/acme": "^3.1.0",
38
- "@root/csr": "^0.8.1",
39
- "@root/encoding": "^1.0.1",
40
- "@root/keypairs": "^0.10.0",
41
- "@root/mkdirp": "^1.0.0",
42
- "@root/request": "^1.6.1",
43
- "acme-dns-01-cli": "^3.0.7",
44
- "acme-http-01-standalone": "^3.0.5",
45
- "cert-info": "^1.5.1",
46
- "greenlock-store-fs": "^3.2.2",
47
- "lemonlog": "^1.2.0",
48
- "redirect-https": "^1.3.1",
49
- "safe-replace": "^1.1.0"
50
- },
51
- "scripts": {
52
- "test": "node --test 'test/**/*.test.js'"
53
- }
54
- }
2
+ "name": "roster-server",
3
+ "version": "3.0.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
+ }
@@ -116,7 +116,7 @@ function createScannerBlocker(options = {}) {
116
116
  if (onBlock) onBlock(details);
117
117
  }
118
118
 
119
- return function scannerBlocker(req, res, context = {}) {
119
+ function scannerBlocker(req, res, context = {}) {
120
120
  const timestamp = clock();
121
121
  const ip = clientIp(req, trustProxy);
122
122
  let state = ip ? clients.get(ip) : null;
@@ -163,7 +163,15 @@ function createScannerBlocker(options = {}) {
163
163
  bannedUntil
164
164
  });
165
165
  return true;
166
+ }
167
+
168
+ // Lets Roster recreate this plugin, with its own ban state, in each isolated site process.
169
+ scannerBlocker[Symbol.for('roster-server.plugin-source')] = {
170
+ module: __filename,
171
+ export: 'createScannerBlocker',
172
+ options
166
173
  };
174
+ return scannerBlocker;
167
175
  }
168
176
 
169
177
  module.exports = { createScannerBlocker };
@@ -52,11 +52,11 @@ module.exports = (server) => {
52
52
 
53
53
  Socket.IO wraps the request listeners and captures the returned handler as its fallback. Do not add a manual endpoint exclusion to that handler; use `io.path()` when application code needs the path. Do not use `io.opts.path`.
54
54
 
55
- A virtual request listener owns its request even before it ends the response. Roster does not infer that an asynchronous listener declined the request. Thrown/rejected handler errors produce 500 or destroy an already-started response. Virtual servers share a process; they are not process isolation boundaries.
55
+ A virtual request listener owns its request even before it ends the response. Roster does not infer that an asynchronous listener declined the request. Thrown/rejected handler errors produce 500 or destroy an already-started response. Virtual servers are not process boundaries. `start()` runs each site directory in its own process unless `isolation: false` is set. Factories passed to `register()` stay in the main process; register a site directory to isolate one.
56
56
 
57
57
  ## Shutdown
58
58
 
59
- - Connect `await roster.close()` to the application's existing shutdown flow. Roster does not install signal handlers or terminate processes.
59
+ - Connect `await roster.close()` to the application's existing shutdown flow, or explicitly set `handleSignals: true` for automatic `SIGINT`/`SIGTERM` cleanup. It defaults to `false`; listeners are installed during `init()`/`start()` and removed after closure, including failure. Repeated signals during closure do not repeat hooks. Existing signal listeners are preserved; shutdown errors are logged and set `process.exitCode = 1`. Roster never forces process termination.
60
60
  - Register `virtualServer.onClose(fn)` for each site's timers, databases, and other resources. Return/await its cleanup Promise; wrap callback APIs when needed.
61
61
  - Roster signals virtual `close` at shutdown start to release integrations such as long polling, closes routed WebSockets, drains HTTP, then runs cleanup hooks concurrently. Put dependent cleanup steps in one hook.
62
62
  - Hooks run once. Failures are aggregated after other hooks are attempted. `closeTimeoutMs` bounds the complete operation; timeout cannot cancel arbitrary user Promises or in-flight ACME work.