roster-server 2.4.4 → 2.4.8
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 +80 -1
- package/demo/https-cluster-configurable.js +30 -19
- package/index.js +88 -2
- package/package.json +52 -52
- package/plugins/scanner-blocker.js +161 -0
- package/pnpm-workspace.yaml +2 -0
- package/skills/roster-server/SKILL.md +55 -0
- package/test/roster-server.test.js +65 -0
- package/test/scanner-blocker.test.js +189 -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/.greenlockrc +0 -1
- package/demo/cluster-sticky-worker.js +0 -60
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Welcome to **RosterServer**, the ultimate domain host router with automatic HTTP
|
|
|
11
11
|
- **Static Sites**: No code? No problem. Drop a folder with `index.html` (and assets) and RosterServer serves it automatically—modular static handler with path-traversal protection and strict 404s.
|
|
12
12
|
- **Virtual Hosting**: Serve multiple domains from a single server.
|
|
13
13
|
- **Automatic Redirects**: Redirect `www` subdomains to the root domain.
|
|
14
|
+
- **Optional Request Plugins**: Run synchronous filters before site handlers, including the bundled scanner blocker.
|
|
14
15
|
- **Zero Configuration**: Well, almost zero. Just a tiny bit of setup.
|
|
15
16
|
- **Bun compatible**: Works with both Node.js and [Bun](https://bun.sh).
|
|
16
17
|
|
|
@@ -114,6 +115,34 @@ const server = new Roster(options);
|
|
|
114
115
|
server.start();
|
|
115
116
|
```
|
|
116
117
|
|
|
118
|
+
### Blocking vulnerability scanners
|
|
119
|
+
|
|
120
|
+
RosterServer includes an optional request plugin that rejects common PHP, WordPress, repository, and sensitive-file probes before they reach a site handler. Suspicious paths always receive a `404`; after the configured number of strikes, every request from that client is rejected until the ban expires.
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
import Roster from 'roster-server';
|
|
124
|
+
import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
|
|
125
|
+
|
|
126
|
+
const roster = new Roster(options);
|
|
127
|
+
|
|
128
|
+
roster.use(createScannerBlocker({
|
|
129
|
+
windowMs: 60_000,
|
|
130
|
+
strikeThreshold: 3,
|
|
131
|
+
banDurationMs: 15 * 60_000,
|
|
132
|
+
maxTrackedClients: 10_000,
|
|
133
|
+
trustProxy: false,
|
|
134
|
+
onBlock(event) {
|
|
135
|
+
// Send event to the application's existing logger if desired.
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
roster.start();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
All operational values are required. Keep `trustProxy: false` when RosterServer receives traffic directly. Set it to `true` only when a trusted reverse proxy overwrites `X-Forwarded-For`; otherwise clients can spoof the address used for bans.
|
|
143
|
+
|
|
144
|
+
The in-memory strike and ban state is bounded by `maxTrackedClients`, belongs to one RosterServer process, and is cleared on restart. Use the optional `onBlock(event)` callback to feed a shared firewall or Fail2ban when bans must persist or span multiple workers. Routes such as `/atom` and `/articles/config` are not classified as scanner probes.
|
|
145
|
+
|
|
117
146
|
### Your Site Handlers
|
|
118
147
|
|
|
119
148
|
Each domain has its own folder under `www`. You can use:
|
|
@@ -386,6 +415,36 @@ process.on('message', (msg, connection) => {
|
|
|
386
415
|
});
|
|
387
416
|
```
|
|
388
417
|
|
|
418
|
+
### Production Pattern: Single Certificate Manager + Workers
|
|
419
|
+
|
|
420
|
+
For robust ACME behavior with cluster runtimes, run a single certificate manager process (primary) and keep workers in serving-only mode. This avoids challenge race conditions while keeping certificate lifecycle automatic.
|
|
421
|
+
|
|
422
|
+
```javascript
|
|
423
|
+
// primary
|
|
424
|
+
const certManager = new Roster({
|
|
425
|
+
email: 'admin@example.com',
|
|
426
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
427
|
+
wwwPath: '/srv/www'
|
|
428
|
+
});
|
|
429
|
+
certManager.register('example.com', () => (req, res) => res.end('manager'));
|
|
430
|
+
await certManager.start(); // enables ACME challenge lifecycle
|
|
431
|
+
await certManager.ensureCertificate('example.com');
|
|
432
|
+
|
|
433
|
+
// worker
|
|
434
|
+
const workerRoster = new Roster({
|
|
435
|
+
email: 'admin@example.com',
|
|
436
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
437
|
+
wwwPath: '/srv/www',
|
|
438
|
+
autoCertificates: false
|
|
439
|
+
});
|
|
440
|
+
workerRoster.register('example.com', () => (req, res) => res.end('worker'));
|
|
441
|
+
await workerRoster.init();
|
|
442
|
+
const server = await workerRoster.createServingHttpsServer({ servername: 'example.com' });
|
|
443
|
+
server.listen(4336);
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Reference implementation: `demo/https-cluster-configurable.js`.
|
|
447
|
+
|
|
389
448
|
### API Reference
|
|
390
449
|
|
|
391
450
|
#### `roster.init()` → `Promise<Roster>`
|
|
@@ -396,6 +455,10 @@ Loads sites, generates SSL config (production), creates VirtualServers and initi
|
|
|
396
455
|
|
|
397
456
|
Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
|
|
398
457
|
|
|
458
|
+
#### `roster.use(plugin)` → `Roster`
|
|
459
|
+
|
|
460
|
+
Registers a synchronous request plugin and returns `this`. Plugins receive `(req, res, { host, domain })`; return `true` after sending a response to stop dispatch, or `false`/`undefined` to continue. Plugins run in registration order before redirects and site handlers.
|
|
461
|
+
|
|
399
462
|
#### `roster.upgradeHandler(port?)` → `(req, socket, head) => void`
|
|
400
463
|
|
|
401
464
|
Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
|
|
@@ -404,6 +467,22 @@ Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to th
|
|
|
404
467
|
|
|
405
468
|
Returns a TLS SNI callback. It resolves certificates from `greenlockStorePath` and, when `autoCertificates` is enabled (default), can issue missing certificates automatically. Not available in local mode.
|
|
406
469
|
|
|
470
|
+
#### `roster.ensureCertificate(servername)` → `Promise<{ key, cert }>`
|
|
471
|
+
|
|
472
|
+
Ensures a certificate exists for `servername`. With `autoCertificates` enabled (default), it issues missing certificates automatically and returns PEMs.
|
|
473
|
+
|
|
474
|
+
#### `roster.loadCertificate(servername)` → `{ key, cert }`
|
|
475
|
+
|
|
476
|
+
Loads an existing certificate from `greenlockStorePath` without issuing new certificates. Useful for serving-only workers.
|
|
477
|
+
|
|
478
|
+
#### `roster.createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` → `Promise<https.Server>`
|
|
479
|
+
|
|
480
|
+
Creates an HTTPS server prewired with default cert, SNI callback, and request/upgrade routing. By default it ensures certificate issuance before returning.
|
|
481
|
+
|
|
482
|
+
#### `roster.createServingHttpsServer({ servername, port?, tlsOptions? })` → `Promise<https.Server>`
|
|
483
|
+
|
|
484
|
+
Convenience alias for serving-only workers. Equivalent to `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
485
|
+
|
|
407
486
|
#### `roster.attach(server, { port }?)` → `Roster`
|
|
408
487
|
|
|
409
488
|
Convenience method. Wires `requestHandler` and `upgradeHandler` onto `server.on('request', ...)` and `server.on('upgrade', ...)`. Returns `this` for chaining.
|
|
@@ -447,4 +526,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
|
|
447
526
|
|
|
448
527
|
---
|
|
449
528
|
|
|
450
|
-
Happy hosting! 🎈
|
|
529
|
+
Happy hosting! 🎈
|
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
const cluster = require('cluster');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const https = require('https');
|
|
5
4
|
const Roster = require('../index.js');
|
|
6
5
|
|
|
7
6
|
// Change these values to your target domain and HTTPS port.
|
|
8
7
|
const CONFIG = {
|
|
9
|
-
domain: '
|
|
8
|
+
domain: 'example.com',
|
|
10
9
|
httpsPort: 4336,
|
|
11
10
|
workers: Math.max(1, Math.min(2, os.cpus().length)),
|
|
11
|
+
certificateManagerHttpsPort: 0, // 0 = ephemeral, manager does not serve public traffic
|
|
12
12
|
wwwPath: path.join(__dirname, 'www'),
|
|
13
13
|
greenlockStorePath: path.join(__dirname, '..', 'greenlock.d'),
|
|
14
|
-
email: 'mclasen@
|
|
15
|
-
staging: false
|
|
16
|
-
autoCertificates: true
|
|
14
|
+
email: 'mclasen@example.com',
|
|
15
|
+
staging: false
|
|
17
16
|
};
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
function createRoster({ isCertificateManager }) {
|
|
20
19
|
const roster = new Roster({
|
|
21
20
|
local: false,
|
|
22
21
|
email: CONFIG.email,
|
|
23
22
|
staging: CONFIG.staging,
|
|
24
23
|
wwwPath: CONFIG.wwwPath,
|
|
25
24
|
greenlockStorePath: CONFIG.greenlockStorePath,
|
|
26
|
-
autoCertificates:
|
|
25
|
+
autoCertificates: isCertificateManager,
|
|
26
|
+
// Certificate manager can bind an ephemeral HTTPS port; workers serve real traffic.
|
|
27
|
+
port: isCertificateManager ? CONFIG.certificateManagerHttpsPort : 443
|
|
27
28
|
});
|
|
29
|
+
return roster;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startWorker() {
|
|
33
|
+
const roster = createRoster({ isCertificateManager: false });
|
|
28
34
|
|
|
29
35
|
// Domain is configured from CONFIG (single source of truth).
|
|
30
36
|
roster.register(CONFIG.domain, () => {
|
|
@@ -35,19 +41,10 @@ async function startWorker() {
|
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
await roster.init();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const server = https.createServer({
|
|
42
|
-
key: defaultPems.key,
|
|
43
|
-
cert: defaultPems.cert,
|
|
44
|
-
SNICallback: roster.sniCallback(),
|
|
45
|
-
minVersion: 'TLSv1.2',
|
|
46
|
-
maxVersion: 'TLSv1.3'
|
|
44
|
+
const server = await roster.createServingHttpsServer({
|
|
45
|
+
servername: CONFIG.domain
|
|
47
46
|
});
|
|
48
47
|
|
|
49
|
-
roster.attach(server);
|
|
50
|
-
|
|
51
48
|
server.listen(CONFIG.httpsPort, () => {
|
|
52
49
|
console.log(`[worker ${process.pid}] listening on https://0.0.0.0:${CONFIG.httpsPort} for domain ${CONFIG.domain}`);
|
|
53
50
|
});
|
|
@@ -58,7 +55,21 @@ async function startPrimary() {
|
|
|
58
55
|
console.log(`domain=${CONFIG.domain} port=${CONFIG.httpsPort}`);
|
|
59
56
|
console.log(`wwwPath=${CONFIG.wwwPath}`);
|
|
60
57
|
console.log(`greenlockStorePath=${CONFIG.greenlockStorePath}\n`);
|
|
61
|
-
console.log(
|
|
58
|
+
console.log('[primary] cert lifecycle managed by roster-server\n');
|
|
59
|
+
|
|
60
|
+
// Primary is the single certificate manager to avoid ACME race conditions.
|
|
61
|
+
// It starts Roster standalone lifecycle so ACME http-01 challenge server (:80) is active.
|
|
62
|
+
const certificateManager = createRoster({ isCertificateManager: true });
|
|
63
|
+
certificateManager.register(CONFIG.domain, () => {
|
|
64
|
+
return (req, res) => {
|
|
65
|
+
res.writeHead(200);
|
|
66
|
+
res.end('certificate-manager');
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
await certificateManager.start();
|
|
70
|
+
await certificateManager.ensureCertificate(CONFIG.domain);
|
|
71
|
+
const subject = CONFIG.domain;
|
|
72
|
+
console.log(`[primary] certificate ready for ${CONFIG.domain} (subject=${subject})\n`);
|
|
62
73
|
|
|
63
74
|
for (let i = 0; i < CONFIG.workers; i++) {
|
|
64
75
|
cluster.fork();
|
package/index.js
CHANGED
|
@@ -247,6 +247,7 @@ class Roster {
|
|
|
247
247
|
this.local = options.local || false;
|
|
248
248
|
this.domains = [];
|
|
249
249
|
this.sites = {};
|
|
250
|
+
this.plugins = [];
|
|
250
251
|
this.wildcardZones = new Set(); // Root domains that have a wildcard site (e.g. "example.com" for *.example.com)
|
|
251
252
|
this.domainServers = {}; // Store separate servers for each domain
|
|
252
253
|
this.portServers = {}; // Store servers by port
|
|
@@ -540,6 +541,11 @@ class Roster {
|
|
|
540
541
|
|
|
541
542
|
handleRequest(req, res) {
|
|
542
543
|
const host = req.headers.host || '';
|
|
544
|
+
const hostWithoutPort = host.split(':')[0];
|
|
545
|
+
const normalizedHost = hostWithoutPort.toLowerCase();
|
|
546
|
+
const domain = normalizedHost.startsWith('www.') ? normalizedHost.slice(4) : normalizedHost;
|
|
547
|
+
|
|
548
|
+
if (this._runRequestPlugins(req, res, { host: normalizedHost, domain })) return;
|
|
543
549
|
|
|
544
550
|
if (host.startsWith('www.')) {
|
|
545
551
|
const newHost = host.slice(4);
|
|
@@ -548,7 +554,6 @@ class Roster {
|
|
|
548
554
|
return;
|
|
549
555
|
}
|
|
550
556
|
|
|
551
|
-
const hostWithoutPort = host.split(':')[0];
|
|
552
557
|
const siteApp = this.getHandlerForHost(hostWithoutPort);
|
|
553
558
|
if (siteApp) {
|
|
554
559
|
siteApp(req, res);
|
|
@@ -597,6 +602,25 @@ class Roster {
|
|
|
597
602
|
return this;
|
|
598
603
|
}
|
|
599
604
|
|
|
605
|
+
use(plugin) {
|
|
606
|
+
if (typeof plugin !== 'function') {
|
|
607
|
+
throw new Error('plugin must be a function');
|
|
608
|
+
}
|
|
609
|
+
this.plugins.push(plugin);
|
|
610
|
+
return this;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_runRequestPlugins(req, res, context) {
|
|
614
|
+
for (const plugin of this.plugins) {
|
|
615
|
+
const handled = plugin(req, res, context);
|
|
616
|
+
if (handled && typeof handled.then === 'function') {
|
|
617
|
+
throw new Error('Request plugins must be synchronous');
|
|
618
|
+
}
|
|
619
|
+
if (handled === true) return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
|
|
600
624
|
parseDomainWithPort(domainString) {
|
|
601
625
|
const parts = domainString.split(':');
|
|
602
626
|
if (parts.length === 2) {
|
|
@@ -737,6 +761,8 @@ class Roster {
|
|
|
737
761
|
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
738
762
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
739
763
|
|
|
764
|
+
if (this._runRequestPlugins(req, res, { host: hostWithoutPort, domain })) return;
|
|
765
|
+
|
|
740
766
|
if (hostWithoutPort.startsWith('www.')) {
|
|
741
767
|
const protocol = this.local ? 'http' : 'https';
|
|
742
768
|
res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
|
|
@@ -935,6 +961,24 @@ class Roster {
|
|
|
935
961
|
return pems;
|
|
936
962
|
}
|
|
937
963
|
|
|
964
|
+
loadCertificate(servername) {
|
|
965
|
+
if (this.local) {
|
|
966
|
+
throw new Error('loadCertificate() is not available in local mode');
|
|
967
|
+
}
|
|
968
|
+
if (!this._initialized) {
|
|
969
|
+
throw new Error('Call init() before loadCertificate()');
|
|
970
|
+
}
|
|
971
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
972
|
+
if (!normalizedServername) {
|
|
973
|
+
throw new Error('servername is required');
|
|
974
|
+
}
|
|
975
|
+
const pems = this._resolvePemsForServername(normalizedServername);
|
|
976
|
+
if (!pems) {
|
|
977
|
+
throw new Error(`No certificate files available for ${normalizedServername}`);
|
|
978
|
+
}
|
|
979
|
+
return pems;
|
|
980
|
+
}
|
|
981
|
+
|
|
938
982
|
async init() {
|
|
939
983
|
if (this._initialized) return this;
|
|
940
984
|
await this.loadSites();
|
|
@@ -991,6 +1035,46 @@ class Roster {
|
|
|
991
1035
|
return this;
|
|
992
1036
|
}
|
|
993
1037
|
|
|
1038
|
+
async createManagedHttpsServer(options = {}) {
|
|
1039
|
+
if (this.local) throw new Error('createManagedHttpsServer() is not available in local mode');
|
|
1040
|
+
if (!this._initialized) throw new Error('Call init() before createManagedHttpsServer()');
|
|
1041
|
+
|
|
1042
|
+
const {
|
|
1043
|
+
servername,
|
|
1044
|
+
port,
|
|
1045
|
+
ensureCertificate = true,
|
|
1046
|
+
tlsOptions = {}
|
|
1047
|
+
} = options;
|
|
1048
|
+
|
|
1049
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
1050
|
+
if (!normalizedServername) {
|
|
1051
|
+
throw new Error('servername is required');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const pems = ensureCertificate
|
|
1055
|
+
? await this.ensureCertificate(normalizedServername)
|
|
1056
|
+
: this.loadCertificate(normalizedServername);
|
|
1057
|
+
|
|
1058
|
+
const server = https.createServer({
|
|
1059
|
+
minVersion: this.tlsMinVersion,
|
|
1060
|
+
maxVersion: this.tlsMaxVersion,
|
|
1061
|
+
...tlsOptions,
|
|
1062
|
+
key: pems.key,
|
|
1063
|
+
cert: pems.cert,
|
|
1064
|
+
SNICallback: this.sniCallback()
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
this.attach(server, { port });
|
|
1068
|
+
return server;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async createServingHttpsServer(options = {}) {
|
|
1072
|
+
return this.createManagedHttpsServer({
|
|
1073
|
+
...options,
|
|
1074
|
+
ensureCertificate: false
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
994
1078
|
startLocalMode() {
|
|
995
1079
|
this.domainPorts = {};
|
|
996
1080
|
|
|
@@ -1004,6 +1088,8 @@ class Roster {
|
|
|
1004
1088
|
const appHandler = portData.appHandlers[domain];
|
|
1005
1089
|
|
|
1006
1090
|
const dispatcher = (req, res) => {
|
|
1091
|
+
const host = (req.headers.host || '').split(':')[0].toLowerCase();
|
|
1092
|
+
if (this._runRequestPlugins(req, res, { host, domain })) return;
|
|
1007
1093
|
virtualServer.fallbackHandler = appHandler;
|
|
1008
1094
|
if (virtualServer.requestListeners.length > 0) {
|
|
1009
1095
|
virtualServer.processRequest(req, res);
|
|
@@ -1263,4 +1349,4 @@ module.exports.wildcardRoot = wildcardRoot;
|
|
|
1263
1349
|
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|
|
1264
1350
|
module.exports.wildcardSubjectForHost = wildcardSubjectForHost;
|
|
1265
1351
|
module.exports.buildCertLookupCandidates = buildCertLookupCandidates;
|
|
1266
|
-
module.exports.certCoversName = certCoversName;
|
|
1352
|
+
module.exports.certCoversName = certCoversName;
|
package/package.json
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
2
|
+
"name": "roster-server",
|
|
3
|
+
"version": "2.4.8",
|
|
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/keypairs": "^0.10.0",
|
|
40
|
+
"@root/mkdirp": "^1.0.0",
|
|
41
|
+
"@root/request": "^1.6.1",
|
|
42
|
+
"acme-dns-01-cli": "^3.0.7",
|
|
43
|
+
"acme-http-01-standalone": "^3.0.5",
|
|
44
|
+
"cert-info": "^1.5.1",
|
|
45
|
+
"greenlock-store-fs": "^3.2.2",
|
|
46
|
+
"lemonlog": "^1.2.0",
|
|
47
|
+
"redirect-https": "^1.3.1",
|
|
48
|
+
"safe-replace": "^1.1.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PHP_PATH = /(?:^|\/)[^/]*\.php(?:$|\/)/i;
|
|
4
|
+
const SCANNER_PATH = /(?:^|\/)(?:\.git|\.hg|\.svn|\.ssh|\.aws|cgi-bin|server-status|vendor\/phpunit|wp-admin|wp-content|wp-includes|wp-json)(?:\/|$)/i;
|
|
5
|
+
const SCANNER_FILE = /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.htaccess|composer\.(?:json|lock)|web\.config)(?:\/|$)/i;
|
|
6
|
+
|
|
7
|
+
function requirePositiveInteger(options, name) {
|
|
8
|
+
const value = options[name];
|
|
9
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
10
|
+
throw new Error(`${name} must be a positive integer`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeRequestPath(url) {
|
|
16
|
+
let requestPath = String(url || '/').split(/[?#]/, 1)[0];
|
|
17
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
const decoded = decodeURIComponent(requestPath);
|
|
20
|
+
if (decoded === requestPath) break;
|
|
21
|
+
requestPath = decoded;
|
|
22
|
+
} catch {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return requestPath.replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scannerReason(url) {
|
|
30
|
+
const requestPath = normalizeRequestPath(url);
|
|
31
|
+
if (PHP_PATH.test(requestPath)) return 'php-path';
|
|
32
|
+
if (SCANNER_PATH.test(requestPath)) return 'scanner-path';
|
|
33
|
+
if (SCANNER_FILE.test(requestPath)) return 'sensitive-file';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeIp(value) {
|
|
38
|
+
const ip = String(value || '').trim();
|
|
39
|
+
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clientIp(req, trustProxy) {
|
|
43
|
+
if (trustProxy) {
|
|
44
|
+
const forwardedFor = req.headers?.['x-forwarded-for'];
|
|
45
|
+
const firstForwardedIp = Array.isArray(forwardedFor)
|
|
46
|
+
? forwardedFor[0]
|
|
47
|
+
: String(forwardedFor || '').split(',')[0];
|
|
48
|
+
const normalizedForwardedIp = normalizeIp(firstForwardedIp);
|
|
49
|
+
if (normalizedForwardedIp) return normalizedForwardedIp;
|
|
50
|
+
}
|
|
51
|
+
return normalizeIp(req.socket?.remoteAddress);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rejectRequest(req, res) {
|
|
55
|
+
const body = 'Not Found';
|
|
56
|
+
res.writeHead(404, {
|
|
57
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
58
|
+
'Content-Length': Buffer.byteLength(body),
|
|
59
|
+
'Cache-Control': 'no-store'
|
|
60
|
+
});
|
|
61
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createScannerBlocker(options) {
|
|
65
|
+
if (!options || typeof options !== 'object') {
|
|
66
|
+
throw new Error('scanner-blocker options are required');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const windowMs = requirePositiveInteger(options, 'windowMs');
|
|
70
|
+
const strikeThreshold = requirePositiveInteger(options, 'strikeThreshold');
|
|
71
|
+
const banDurationMs = requirePositiveInteger(options, 'banDurationMs');
|
|
72
|
+
const maxTrackedClients = requirePositiveInteger(options, 'maxTrackedClients');
|
|
73
|
+
if (typeof options.trustProxy !== 'boolean') {
|
|
74
|
+
throw new Error('trustProxy must be a boolean');
|
|
75
|
+
}
|
|
76
|
+
if (options.onBlock !== undefined && typeof options.onBlock !== 'function') {
|
|
77
|
+
throw new Error('onBlock must be a function');
|
|
78
|
+
}
|
|
79
|
+
if (options.now !== undefined && typeof options.now !== 'function') {
|
|
80
|
+
throw new Error('now must be a function');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const trustProxy = options.trustProxy;
|
|
84
|
+
const onBlock = options.onBlock;
|
|
85
|
+
const now = options.now || Date.now;
|
|
86
|
+
const clients = new Map();
|
|
87
|
+
|
|
88
|
+
function removeExpiredClients(timestamp) {
|
|
89
|
+
for (const [ip, state] of clients) {
|
|
90
|
+
const banExpired = state.bannedUntil !== null && timestamp >= state.bannedUntil;
|
|
91
|
+
const windowExpired = state.bannedUntil === null && timestamp - state.windowStartedAt >= windowMs;
|
|
92
|
+
if (banExpired || windowExpired) clients.delete(ip);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addClient(ip, state, timestamp) {
|
|
97
|
+
if (!clients.has(ip) && clients.size >= maxTrackedClients) {
|
|
98
|
+
removeExpiredClients(timestamp);
|
|
99
|
+
}
|
|
100
|
+
if (!clients.has(ip) && clients.size >= maxTrackedClients) {
|
|
101
|
+
clients.delete(clients.keys().next().value);
|
|
102
|
+
}
|
|
103
|
+
clients.delete(ip);
|
|
104
|
+
clients.set(ip, state);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reportBlock(details) {
|
|
108
|
+
if (onBlock) onBlock(details);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return function scannerBlocker(req, res, context = {}) {
|
|
112
|
+
const timestamp = now();
|
|
113
|
+
const ip = clientIp(req, trustProxy);
|
|
114
|
+
let state = ip ? clients.get(ip) : null;
|
|
115
|
+
|
|
116
|
+
if (state && state.bannedUntil !== null) {
|
|
117
|
+
if (timestamp < state.bannedUntil) {
|
|
118
|
+
rejectRequest(req, res);
|
|
119
|
+
reportBlock({
|
|
120
|
+
type: 'banned-client',
|
|
121
|
+
clientIp: ip,
|
|
122
|
+
host: context.host || '',
|
|
123
|
+
url: req.url || '/',
|
|
124
|
+
bannedUntil: state.bannedUntil
|
|
125
|
+
});
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
clients.delete(ip);
|
|
129
|
+
state = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reason = scannerReason(req.url);
|
|
133
|
+
if (!reason) return false;
|
|
134
|
+
|
|
135
|
+
let bannedUntil = null;
|
|
136
|
+
if (ip) {
|
|
137
|
+
if (!state || timestamp - state.windowStartedAt >= windowMs) {
|
|
138
|
+
state = { strikes: 0, windowStartedAt: timestamp, bannedUntil: null };
|
|
139
|
+
}
|
|
140
|
+
state.strikes += 1;
|
|
141
|
+
if (state.strikes >= strikeThreshold) {
|
|
142
|
+
state.bannedUntil = timestamp + banDurationMs;
|
|
143
|
+
}
|
|
144
|
+
bannedUntil = state.bannedUntil;
|
|
145
|
+
addClient(ip, state, timestamp);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
rejectRequest(req, res);
|
|
149
|
+
reportBlock({
|
|
150
|
+
type: 'scanner-path',
|
|
151
|
+
reason,
|
|
152
|
+
clientIp: ip,
|
|
153
|
+
host: context.host || '',
|
|
154
|
+
url: req.url || '/',
|
|
155
|
+
bannedUntil
|
|
156
|
+
});
|
|
157
|
+
return true;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { createScannerBlocker };
|
|
@@ -147,6 +147,49 @@ process.on('message', (msg, connection) => {
|
|
|
147
147
|
});
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Pattern 7: Cluster Production (single cert manager + workers)
|
|
151
|
+
```javascript
|
|
152
|
+
// PRIMARY: certificate manager (single process)
|
|
153
|
+
const manager = new Roster({
|
|
154
|
+
email: 'admin@example.com',
|
|
155
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
156
|
+
wwwPath: '/srv/www'
|
|
157
|
+
});
|
|
158
|
+
manager.register('example.com', () => (req, res) => res.end('manager'));
|
|
159
|
+
await manager.start();
|
|
160
|
+
await manager.ensureCertificate('example.com');
|
|
161
|
+
|
|
162
|
+
// WORKER: serving-only process
|
|
163
|
+
const worker = new Roster({
|
|
164
|
+
email: 'admin@example.com',
|
|
165
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
166
|
+
wwwPath: '/srv/www',
|
|
167
|
+
autoCertificates: false
|
|
168
|
+
});
|
|
169
|
+
worker.register('example.com', () => (req, res) => res.end('worker'));
|
|
170
|
+
await worker.init();
|
|
171
|
+
const httpsServer = await worker.createServingHttpsServer({ servername: 'example.com' });
|
|
172
|
+
httpsServer.listen(4336);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Pattern 8: Optional Scanner Blocking
|
|
176
|
+
```javascript
|
|
177
|
+
const Roster = require('roster-server');
|
|
178
|
+
const { createScannerBlocker } = require('roster-server/plugins/scanner-blocker.js');
|
|
179
|
+
|
|
180
|
+
const roster = new Roster({ local: true, wwwPath: './www' });
|
|
181
|
+
roster.use(createScannerBlocker({
|
|
182
|
+
windowMs: 60_000,
|
|
183
|
+
strikeThreshold: 3,
|
|
184
|
+
banDurationMs: 15 * 60_000,
|
|
185
|
+
maxTrackedClients: 10_000,
|
|
186
|
+
trustProxy: false
|
|
187
|
+
}));
|
|
188
|
+
roster.start();
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The plugin blocks common PHP, WordPress, repository, and sensitive-file probes before site handlers. All operational values are required. Set `trustProxy: true` only behind a trusted reverse proxy that overwrites `X-Forwarded-For`. Ban state is per process and in memory; use `onBlock` to integrate a shared firewall or Fail2ban.
|
|
192
|
+
|
|
150
193
|
## Key Configuration Options
|
|
151
194
|
|
|
152
195
|
```javascript
|
|
@@ -194,12 +237,24 @@ Returns `(servername, callback) => void` TLS SNI callback that resolves certs fr
|
|
|
194
237
|
### `roster.ensureCertificate(servername)`
|
|
195
238
|
Forces certificate availability for a domain and returns `{ key, cert }`. With `autoCertificates` enabled (default), it issues certs automatically when missing.
|
|
196
239
|
|
|
240
|
+
### `roster.loadCertificate(servername)`
|
|
241
|
+
Loads existing `{ key, cert }` from `greenlockStorePath` without issuing new certificates.
|
|
242
|
+
|
|
243
|
+
### `roster.createManagedHttpsServer(options)`
|
|
244
|
+
Creates a pre-wired `https.Server` with default cert, SNI callback, and attached request/upgrade handlers.
|
|
245
|
+
|
|
246
|
+
### `roster.createServingHttpsServer(options)`
|
|
247
|
+
Serving-only helper for worker processes. Same as `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
248
|
+
|
|
197
249
|
### `roster.attach(server, { port }?)`
|
|
198
250
|
Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
|
|
199
251
|
|
|
200
252
|
### `roster.register(domain, handler)`
|
|
201
253
|
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
202
254
|
|
|
255
|
+
### `roster.use(plugin)`
|
|
256
|
+
Registers a synchronous request plugin. It receives `(req, res, { host, domain })` and stops dispatch when it returns `true`.
|
|
257
|
+
|
|
203
258
|
### `roster.getUrl(domain)`
|
|
204
259
|
Get environment-aware URL:
|
|
205
260
|
- Local mode: `http://localhost:{port}`
|
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const http = require('http');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const Roster = require('../index.js');
|
|
10
|
+
const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
|
|
10
11
|
const {
|
|
11
12
|
wildcardRoot,
|
|
12
13
|
hostMatchesWildcard,
|
|
@@ -432,6 +433,13 @@ describe('Roster local mode (local: true)', () => {
|
|
|
432
433
|
hostname: 'localhost'
|
|
433
434
|
});
|
|
434
435
|
const body = 'local-mode-ok';
|
|
436
|
+
roster.use(createScannerBlocker({
|
|
437
|
+
windowMs: 60_000,
|
|
438
|
+
strikeThreshold: 2,
|
|
439
|
+
banDurationMs: 300_000,
|
|
440
|
+
maxTrackedClients: 100,
|
|
441
|
+
trustProxy: false
|
|
442
|
+
}));
|
|
435
443
|
roster.register('testlocal.example', (server) => {
|
|
436
444
|
return (req, res) => {
|
|
437
445
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
@@ -446,6 +454,9 @@ describe('Roster local mode (local: true)', () => {
|
|
|
446
454
|
const result = await httpGet('localhost', port, '/');
|
|
447
455
|
assert.strictEqual(result.statusCode, 200);
|
|
448
456
|
assert.strictEqual(result.body, body);
|
|
457
|
+
const probe = await httpGet('localhost', port, '/wp-login.php');
|
|
458
|
+
assert.strictEqual(probe.statusCode, 404);
|
|
459
|
+
assert.strictEqual(probe.body, 'Not Found');
|
|
449
460
|
} finally {
|
|
450
461
|
closePortServers(roster);
|
|
451
462
|
}
|
|
@@ -933,6 +944,60 @@ describe('Roster ensureCertificate()', () => {
|
|
|
933
944
|
});
|
|
934
945
|
});
|
|
935
946
|
|
|
947
|
+
describe('Roster loadCertificate()', () => {
|
|
948
|
+
it('throws if called before init()', () => {
|
|
949
|
+
const roster = new Roster({ local: false });
|
|
950
|
+
assert.throws(() => roster.loadCertificate('example.com'), /Call init\(\) before loadCertificate/);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('throws in local mode', async () => {
|
|
954
|
+
const roster = new Roster({ local: true });
|
|
955
|
+
roster.register('local-load.example', () => () => {});
|
|
956
|
+
await roster.init();
|
|
957
|
+
assert.throws(() => roster.loadCertificate('local-load.example'), /not available in local mode/);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe('Roster createManagedHttpsServer()', () => {
|
|
962
|
+
it('throws if called before init()', async () => {
|
|
963
|
+
const roster = new Roster({ local: false });
|
|
964
|
+
await assert.rejects(
|
|
965
|
+
() => roster.createManagedHttpsServer({ servername: 'example.com' }),
|
|
966
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
967
|
+
);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('throws in local mode', async () => {
|
|
971
|
+
const roster = new Roster({ local: true });
|
|
972
|
+
roster.register('local-managed.example', () => () => {});
|
|
973
|
+
await roster.init();
|
|
974
|
+
await assert.rejects(
|
|
975
|
+
() => roster.createManagedHttpsServer({ servername: 'local-managed.example' }),
|
|
976
|
+
/not available in local mode/
|
|
977
|
+
);
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
describe('Roster createServingHttpsServer()', () => {
|
|
982
|
+
it('throws if called before init()', async () => {
|
|
983
|
+
const roster = new Roster({ local: false });
|
|
984
|
+
await assert.rejects(
|
|
985
|
+
() => roster.createServingHttpsServer({ servername: 'example.com' }),
|
|
986
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
987
|
+
);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('throws in local mode', async () => {
|
|
991
|
+
const roster = new Roster({ local: true });
|
|
992
|
+
roster.register('local-serving.example', () => () => {});
|
|
993
|
+
await roster.init();
|
|
994
|
+
await assert.rejects(
|
|
995
|
+
() => roster.createServingHttpsServer({ servername: 'local-serving.example' }),
|
|
996
|
+
/not available in local mode/
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
|
|
936
1001
|
describe('Roster attach()', () => {
|
|
937
1002
|
it('throws if called before init()', () => {
|
|
938
1003
|
const roster = new Roster({ local: true });
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const Roster = require('../index.js');
|
|
6
|
+
const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
|
|
7
|
+
|
|
8
|
+
function invoke(plugin, { url = '/', ip = '192.0.2.10', headers = {}, method = 'GET' } = {}) {
|
|
9
|
+
let statusCode;
|
|
10
|
+
let responseHeaders;
|
|
11
|
+
let body;
|
|
12
|
+
const req = {
|
|
13
|
+
method,
|
|
14
|
+
url,
|
|
15
|
+
headers,
|
|
16
|
+
socket: { remoteAddress: ip }
|
|
17
|
+
};
|
|
18
|
+
const res = {
|
|
19
|
+
writeHead(code, nextHeaders) {
|
|
20
|
+
statusCode = code;
|
|
21
|
+
responseHeaders = nextHeaders;
|
|
22
|
+
},
|
|
23
|
+
end(nextBody) {
|
|
24
|
+
body = nextBody;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const handled = plugin(req, res, { host: 'example.com', domain: 'example.com' });
|
|
28
|
+
return { handled, statusCode, responseHeaders, body };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function blocker(overrides = {}) {
|
|
32
|
+
return createScannerBlocker({
|
|
33
|
+
windowMs: 60_000,
|
|
34
|
+
strikeThreshold: 2,
|
|
35
|
+
banDurationMs: 300_000,
|
|
36
|
+
maxTrackedClients: 100,
|
|
37
|
+
trustProxy: false,
|
|
38
|
+
...overrides
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('scanner-blocker plugin', () => {
|
|
43
|
+
it('requires every operational option explicitly', () => {
|
|
44
|
+
assert.throws(() => createScannerBlocker(), /options are required/);
|
|
45
|
+
assert.throws(() => createScannerBlocker({}), /windowMs/);
|
|
46
|
+
assert.throws(() => blocker({ trustProxy: undefined }), /trustProxy/);
|
|
47
|
+
assert.throws(() => blocker({ maxTrackedClients: 0 }), /maxTrackedClients/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks PHP, WordPress, and sensitive-file probes', () => {
|
|
51
|
+
const urls = [
|
|
52
|
+
'/install.php',
|
|
53
|
+
'/user-new\\.php',
|
|
54
|
+
'/wp-json',
|
|
55
|
+
'/.git/config',
|
|
56
|
+
'/%252eenv'
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const [index, url] of urls.entries()) {
|
|
60
|
+
const result = invoke(blocker(), { url, ip: `192.0.2.${index + 1}` });
|
|
61
|
+
assert.strictEqual(result.handled, true, url);
|
|
62
|
+
assert.strictEqual(result.statusCode, 404, url);
|
|
63
|
+
assert.strictEqual(result.body, 'Not Found', url);
|
|
64
|
+
assert.strictEqual(result.responseHeaders['Cache-Control'], 'no-store');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not classify ordinary article, atom, or config routes as scanner probes', () => {
|
|
69
|
+
const plugin = blocker();
|
|
70
|
+
assert.strictEqual(invoke(plugin, { url: '/en/an-article' }).handled, false);
|
|
71
|
+
assert.strictEqual(invoke(plugin, { url: '/atom' }).handled, false);
|
|
72
|
+
assert.strictEqual(invoke(plugin, { url: '/articles/config' }).handled, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('bans a client after the configured number of probes', () => {
|
|
76
|
+
const timestamp = 1_000;
|
|
77
|
+
const events = [];
|
|
78
|
+
const plugin = blocker({
|
|
79
|
+
now: () => timestamp,
|
|
80
|
+
onBlock: event => events.push(event)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.strictEqual(invoke(plugin, { url: '/wp-login.php' }).handled, true);
|
|
84
|
+
assert.strictEqual(invoke(plugin, { url: '/xmlrpc.php' }).handled, true);
|
|
85
|
+
assert.strictEqual(invoke(plugin, { url: '/legitimate-page' }).handled, true);
|
|
86
|
+
assert.strictEqual(events[1].bannedUntil, 301_000);
|
|
87
|
+
assert.strictEqual(events[2].type, 'banned-client');
|
|
88
|
+
|
|
89
|
+
assert.strictEqual(invoke(plugin, { url: '/legitimate-page', ip: '192.0.2.11' }).handled, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('expires bans without extending them on blocked requests', () => {
|
|
93
|
+
let timestamp = 1_000;
|
|
94
|
+
const events = [];
|
|
95
|
+
const plugin = blocker({
|
|
96
|
+
strikeThreshold: 1,
|
|
97
|
+
banDurationMs: 5_000,
|
|
98
|
+
now: () => timestamp,
|
|
99
|
+
onBlock: event => events.push(event)
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
invoke(plugin, { url: '/wp-login.php' });
|
|
103
|
+
assert.strictEqual(events[0].bannedUntil, 6_000);
|
|
104
|
+
timestamp = 5_999;
|
|
105
|
+
assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, true);
|
|
106
|
+
timestamp = 6_000;
|
|
107
|
+
assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('trusts X-Forwarded-For only when explicitly configured', () => {
|
|
111
|
+
const headers = { 'x-forwarded-for': '198.51.100.1, 198.51.100.2' };
|
|
112
|
+
const directPlugin = blocker({ strikeThreshold: 1, trustProxy: false });
|
|
113
|
+
invoke(directPlugin, { url: '/install.php', headers, ip: '192.0.2.20' });
|
|
114
|
+
assert.strictEqual(invoke(directPlugin, { url: '/', headers, ip: '192.0.2.20' }).handled, true);
|
|
115
|
+
assert.strictEqual(invoke(directPlugin, { url: '/', headers, ip: '192.0.2.21' }).handled, false);
|
|
116
|
+
|
|
117
|
+
const proxyPlugin = blocker({ strikeThreshold: 1, trustProxy: true });
|
|
118
|
+
invoke(proxyPlugin, { url: '/install.php', headers, ip: '192.0.2.20' });
|
|
119
|
+
assert.strictEqual(invoke(proxyPlugin, { url: '/', headers, ip: '192.0.2.21' }).handled, true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('keeps tracked client state within the configured bound', () => {
|
|
123
|
+
const plugin = blocker({ maxTrackedClients: 1 });
|
|
124
|
+
invoke(plugin, { url: '/install.php', ip: '192.0.2.30' });
|
|
125
|
+
invoke(plugin, { url: '/install.php', ip: '192.0.2.31' });
|
|
126
|
+
invoke(plugin, { url: '/xmlrpc.php', ip: '192.0.2.30' });
|
|
127
|
+
assert.strictEqual(invoke(plugin, { url: '/ordinary', ip: '192.0.2.30' }).handled, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not send a response body for HEAD probes', () => {
|
|
131
|
+
const result = invoke(blocker(), { method: 'HEAD', url: '/wp-login.php' });
|
|
132
|
+
assert.strictEqual(result.handled, true);
|
|
133
|
+
assert.strictEqual(result.body, undefined);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('Roster request plugins', () => {
|
|
138
|
+
it('runs plugins before dispatching to the site handler', async () => {
|
|
139
|
+
let siteRequests = 0;
|
|
140
|
+
const roster = new Roster({ local: true });
|
|
141
|
+
roster.use(createScannerBlocker({
|
|
142
|
+
windowMs: 60_000,
|
|
143
|
+
strikeThreshold: 2,
|
|
144
|
+
banDurationMs: 300_000,
|
|
145
|
+
maxTrackedClients: 100,
|
|
146
|
+
trustProxy: false
|
|
147
|
+
}));
|
|
148
|
+
roster.register('example.com', () => (req, res) => {
|
|
149
|
+
siteRequests += 1;
|
|
150
|
+
res.writeHead(200);
|
|
151
|
+
res.end('site');
|
|
152
|
+
});
|
|
153
|
+
await roster.init();
|
|
154
|
+
|
|
155
|
+
const handler = roster.requestHandler();
|
|
156
|
+
const probe = invoke(handler, {
|
|
157
|
+
url: '/wp-login.php',
|
|
158
|
+
headers: { host: 'example.com' }
|
|
159
|
+
});
|
|
160
|
+
assert.strictEqual(probe.statusCode, 404);
|
|
161
|
+
assert.strictEqual(siteRequests, 0);
|
|
162
|
+
|
|
163
|
+
const ordinary = invoke(handler, {
|
|
164
|
+
url: '/ordinary',
|
|
165
|
+
ip: '192.0.2.11',
|
|
166
|
+
headers: { host: 'example.com' }
|
|
167
|
+
});
|
|
168
|
+
assert.strictEqual(ordinary.statusCode, 200);
|
|
169
|
+
assert.strictEqual(ordinary.body, 'site');
|
|
170
|
+
assert.strictEqual(siteRequests, 1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('validates plugins and rejects asynchronous request plugins', async () => {
|
|
174
|
+
const roster = new Roster({ local: true });
|
|
175
|
+
assert.throws(() => roster.use({}), /plugin must be a function/);
|
|
176
|
+
assert.strictEqual(roster.use(() => false), roster);
|
|
177
|
+
|
|
178
|
+
const asyncRoster = new Roster({ local: true });
|
|
179
|
+
asyncRoster.use(async () => false);
|
|
180
|
+
asyncRoster.register('example.com', () => () => {});
|
|
181
|
+
await asyncRoster.init();
|
|
182
|
+
assert.throws(() => {
|
|
183
|
+
asyncRoster.requestHandler()(
|
|
184
|
+
{ headers: { host: 'example.com' }, url: '/', socket: {} },
|
|
185
|
+
{ writeHead() {}, end() {} }
|
|
186
|
+
);
|
|
187
|
+
}, /must be synchronous/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/.greenlockrc
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"configDir":"/Users/martinclasen/Stuff/greenlock.d"}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const Roster = require('../index.js');
|
|
3
|
-
|
|
4
|
-
async function main() {
|
|
5
|
-
const roster = new Roster({
|
|
6
|
-
local: true,
|
|
7
|
-
minLocalPort: 19500,
|
|
8
|
-
maxLocalPort: 19550
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
roster.register('example.com', () => {
|
|
12
|
-
return (req, res) => {
|
|
13
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
14
|
-
res.end('[example.com] hello from external worker wiring');
|
|
15
|
-
};
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
roster.register('api.example.com:9000', () => {
|
|
19
|
-
return (req, res) => {
|
|
20
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
21
|
-
res.end(JSON.stringify({ ok: true, source: 'api.example.com:9000' }));
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// init() prepares virtual-host routing, but does not create/listen sockets.
|
|
26
|
-
await roster.init();
|
|
27
|
-
|
|
28
|
-
// This server represents your external runtime-owned worker server.
|
|
29
|
-
const server443 = http.createServer();
|
|
30
|
-
roster.attach(server443); // defaultPort routes (443)
|
|
31
|
-
|
|
32
|
-
const server9000 = http.createServer();
|
|
33
|
-
roster.attach(server9000, { port: 9000 }); // custom port routes
|
|
34
|
-
|
|
35
|
-
await new Promise((resolve, reject) => {
|
|
36
|
-
server443.listen(19501, 'localhost', resolve);
|
|
37
|
-
server443.on('error', reject);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
await new Promise((resolve, reject) => {
|
|
41
|
-
server9000.listen(19502, 'localhost', resolve);
|
|
42
|
-
server9000.on('error', reject);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
console.log('\n✅ Cluster-friendly worker demo running');
|
|
46
|
-
console.log('Roster did not bind ports itself. External servers own listen().\n');
|
|
47
|
-
console.log('Try requests with Host headers:');
|
|
48
|
-
console.log('curl -H "Host: example.com" http://localhost:19501/');
|
|
49
|
-
console.log('curl -H "Host: api.example.com" http://localhost:19502/\n');
|
|
50
|
-
|
|
51
|
-
// Sticky-session runtimes normally pass accepted sockets into the worker:
|
|
52
|
-
// process.on('message', (msg, socket) => {
|
|
53
|
-
// if (msg === 'sticky-session:connection') server443.emit('connection', socket);
|
|
54
|
-
// });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
main().catch((err) => {
|
|
58
|
-
console.error('Demo failed:', err);
|
|
59
|
-
process.exit(1);
|
|
60
|
-
});
|