@hyperwatch/hyperwatch 4.3.1 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/config/apache_syslog_example.js +1 -1
- package/config/default.js +1 -1
- package/config/example.js +4 -4
- package/config/express_websocket_example.js +1 -1
- package/config/websocket_client_example.js +52 -0
- package/docs/configuration.md +67 -6
- package/docs/express-embedding.md +137 -0
- package/docs/input.md +50 -24
- package/docs/tutorials/apache_input.md +16 -14
- package/docs/tutorials/express_input.md +10 -8
- package/package.json +16 -14
- package/scripts/fetch-anthropic-ips.js +60 -0
- package/src/app/api.js +129 -3
- package/src/app/index.js +8 -4
- package/src/app/mount.js +115 -0
- package/src/app/websocket.js +12 -9
- package/src/app/ws-server.js +123 -0
- package/src/constants.js +8 -5
- package/src/data/amazon-searchbot-ips.json +304 -0
- package/src/data/amazonbot-ips.json +775 -1
- package/src/data/chatgpt-user-ips.json +115 -112
- package/src/data/claude-bot-ips.json +28 -0
- package/src/data/cloudfront-ips.json +14 -0
- package/src/data/gptbot-ips.json +0 -3
- package/src/data/openai-searchbot-ips.json +4 -0
- package/src/index.js +6 -1
- package/src/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +57 -21
- package/src/lib/aggregator.js +112 -19
- package/src/lib/formatter.js +10 -1
- package/src/lib/log-buffer.js +45 -0
- package/src/lib/persistence.js +11 -1
- package/src/lib/pipeline.js +140 -14
- package/src/lib/recent-map.js +23 -0
- package/src/modules/address.js +59 -2
- package/src/modules/dnsbl.js +11 -1
- package/src/modules/history.js +4 -28
- package/src/modules/hostname.js +12 -4
- package/src/modules/identity.js +92 -9
- package/src/modules/index.js +14 -5
- package/src/modules/signature.js +49 -14
- package/src/modules/sparkline.js +7 -3
- package/src/modules/status.js +6 -1
|
@@ -10,10 +10,10 @@ Let's start!
|
|
|
10
10
|
|
|
11
11
|
On the same server where the Node/Express application is running, or on a server that is reachable by it, install the Hyperwatch processor.
|
|
12
12
|
|
|
13
|
-
As a
|
|
13
|
+
As a prerequisite, you'll need Node.js >= 24. We recommend [nvm](https://github.com/nvm-sh/nvm).
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
nvm install
|
|
16
|
+
nvm install 24
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
#### Install from npm
|
|
@@ -24,7 +24,7 @@ npm install -g @hyperwatch/hyperwatch
|
|
|
24
24
|
|
|
25
25
|
#### Install from Git
|
|
26
26
|
|
|
27
|
-
Alternatively, for
|
|
27
|
+
Alternatively, for development purpose, you can use Git and clone the public repository:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
git clone https://github.com/hyperwatch/hyperwatch.git
|
|
@@ -38,7 +38,7 @@ In our suggested configuration, Hyperwatch will be listening for access logs usi
|
|
|
38
38
|
|
|
39
39
|
All communications between your Node/Express application and Hyperwatch will be happening in clear, please only use that setup on your internal network. If on the public internet, we're advising to use the Websocket Secure protocol (wss) which is straightforward but out of the scope of this tutorial.
|
|
40
40
|
|
|
41
|
-
Now, you can create your own configuration in
|
|
41
|
+
Now, you can create your own configuration in `express_websocket_example.js` (a complete version is available in [`config/express_websocket_example.js`](../../config/express_websocket_example.js)):
|
|
42
42
|
|
|
43
43
|
```javascript
|
|
44
44
|
module.exports = function (hyperwatch) {
|
|
@@ -77,10 +77,10 @@ app.use(hyperwatchExpressLogger('websocket', 'ws://localhost:3000/input/log'));
|
|
|
77
77
|
|
|
78
78
|
In this example, there are 3 important things:
|
|
79
79
|
|
|
80
|
-
1. If Hyperwatch is running on the same server, we can use
|
|
81
|
-
If it's on a different server, replace
|
|
80
|
+
1. If Hyperwatch is running on the same server, we can use `localhost` as IP address.
|
|
81
|
+
If it's on a different server, replace `localhost` by the proper private or public IP address.
|
|
82
82
|
2. Replace the port (here `3000`) by the relevant one, it should be the main port where Hyperwatch is running.
|
|
83
|
-
3. Finally, the path `/input/log` should match the one configured on Hyperwatch side, If you're following this tutorial from start to
|
|
83
|
+
3. Finally, the path `/input/log` should match the one configured on Hyperwatch side, If you're following this tutorial from start to end, nothing to change!
|
|
84
84
|
|
|
85
85
|
Now, that you added and configured the Hyperwatch middleware, you can deploy and restart your application.
|
|
86
86
|
|
|
@@ -96,4 +96,6 @@ hyperwatch express_websocket_example.js
|
|
|
96
96
|
|
|
97
97
|
### Browse the interface
|
|
98
98
|
|
|
99
|
-
Now, you can point your browser to the IP/port where Hyperwatch is running. If you see
|
|
99
|
+
Now, you can point your browser to the `/status` page on the IP/port where Hyperwatch is running (e.g. `http://localhost:3000/status`). If you see traffic going through your input, congrats you made it!
|
|
100
|
+
|
|
101
|
+
To watch the logs live at `/logs/main` and explore aggregations such as `/addresses` or `/identities`, activate the corresponding modules. See [Global Configuration](../configuration.md#modules).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperwatch/hyperwatch",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Open Source HTTP Traffic Manager",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "François Hodierne <francois@hodierne.net>",
|
|
@@ -40,14 +40,13 @@
|
|
|
40
40
|
"accept-language-parser": "^1.5.0",
|
|
41
41
|
"ajv": "^8.20.0",
|
|
42
42
|
"ajv-formats": "^3.0.1",
|
|
43
|
-
"chalk": "^
|
|
43
|
+
"chalk": "^6.0.0",
|
|
44
44
|
"country-code-emoji": "^2.3.0",
|
|
45
45
|
"csv-stringify": "^6.8.3",
|
|
46
46
|
"debug": "^4.4.3",
|
|
47
|
-
"dnsbl": "^
|
|
48
|
-
"express": "^
|
|
49
|
-
"
|
|
50
|
-
"geoip-lite": "^1.4.10",
|
|
47
|
+
"dnsbl": "^5.1.1",
|
|
48
|
+
"express": "^5.2.1",
|
|
49
|
+
"geoip-lite": "^2.0.3",
|
|
51
50
|
"immutable": "^5.1.9",
|
|
52
51
|
"ip-cidr": "^4.0.2",
|
|
53
52
|
"lodash": "^4.18.1",
|
|
@@ -55,25 +54,28 @@
|
|
|
55
54
|
"micro-strptime": "^1.0.0",
|
|
56
55
|
"proxy-addr": "^2.0.7",
|
|
57
56
|
"rc": "^1.2.8",
|
|
58
|
-
"syslog-parse": "^
|
|
57
|
+
"syslog-parse": "^2.0.0",
|
|
59
58
|
"tail": "^2.2.6",
|
|
60
59
|
"ws": "^8.21.3"
|
|
61
60
|
},
|
|
62
61
|
"devDependencies": {
|
|
63
|
-
"@eslint/js": "^
|
|
62
|
+
"@eslint/js": "^10.0.1",
|
|
64
63
|
"depcheck": "^1.4.7",
|
|
65
|
-
"eslint": "^9.
|
|
66
|
-
"eslint-plugin-import": "^
|
|
67
|
-
"eslint-plugin-n": "^
|
|
64
|
+
"eslint": "^10.9.1",
|
|
65
|
+
"eslint-plugin-import-x": "^4.17.1",
|
|
66
|
+
"eslint-plugin-n": "^18.3.0",
|
|
68
67
|
"globals": "^17.11.0",
|
|
69
68
|
"husky": "^9.1.7",
|
|
70
|
-
"lint-staged": "^
|
|
71
|
-
"mocha": "^
|
|
69
|
+
"lint-staged": "^17.4.1",
|
|
70
|
+
"mocha": "^12.0.0",
|
|
72
71
|
"prettier": "^3.9.6",
|
|
73
72
|
"prettier-package-json": "^2.8.0"
|
|
74
73
|
},
|
|
75
74
|
"engines": {
|
|
76
|
-
"node": ">=
|
|
75
|
+
"node": ">=24"
|
|
76
|
+
},
|
|
77
|
+
"allowScripts": {
|
|
78
|
+
"unrs-resolver": false
|
|
77
79
|
},
|
|
78
80
|
"depcheck": {
|
|
79
81
|
"ignores": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const dataDir = path.join(__dirname, '..', 'src', 'data');
|
|
5
|
+
|
|
6
|
+
// Anthropic publishes a single list covering all Claude crawlers
|
|
7
|
+
// (ClaudeBot, Claude-User, Claude-SearchBot).
|
|
8
|
+
const sources = [
|
|
9
|
+
{ name: 'claude-bot-ips', url: 'https://claude.com/crawling/bots.json' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function extractCidrs(data) {
|
|
13
|
+
const prefixes = Array.isArray(data) ? data : data.prefixes;
|
|
14
|
+
if (!Array.isArray(prefixes)) {
|
|
15
|
+
throw new Error('unexpected payload: no prefixes array');
|
|
16
|
+
}
|
|
17
|
+
return prefixes
|
|
18
|
+
.map((prefix) => {
|
|
19
|
+
if (typeof prefix === 'string') {
|
|
20
|
+
return prefix;
|
|
21
|
+
}
|
|
22
|
+
return prefix.ipv4Prefix || prefix.ipv6Prefix || prefix.ip_prefix;
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((cidr) => {
|
|
26
|
+
if (cidr.includes('/')) {
|
|
27
|
+
return cidr;
|
|
28
|
+
}
|
|
29
|
+
return cidr.includes(':') ? `${cidr}/128` : `${cidr}/32`;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function fetchAndStore({ name, url }) {
|
|
34
|
+
const res = await fetch(url);
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
console.error(`Failed to fetch ${name}: ${res.status} ${res.statusText}`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const cidrs = extractCidrs(await res.json());
|
|
41
|
+
if (cidrs.length === 0) {
|
|
42
|
+
console.error(`Failed to fetch ${name}: empty list, keeping current file`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const filePath = path.join(dataDir, `${name}.json`);
|
|
47
|
+
fs.writeFileSync(filePath, `${JSON.stringify(cidrs, null, 2)}\n`);
|
|
48
|
+
console.log(`${name}: ${cidrs.length} CIDRs`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
for (const source of sources) {
|
|
53
|
+
await fetchAndStore(source);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch((error) => {
|
|
58
|
+
console.error(error);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
});
|
package/src/app/api.js
CHANGED
|
@@ -7,15 +7,131 @@ const express = require('express');
|
|
|
7
7
|
|
|
8
8
|
const monitoring = require('../lib/monitoring');
|
|
9
9
|
const persistence = require('../lib/persistence');
|
|
10
|
+
const pipeline = require('../lib/pipeline');
|
|
10
11
|
const { formatTable } = require('../lib/util');
|
|
11
12
|
const stylesheet = require('../stylesheet');
|
|
12
13
|
|
|
14
|
+
const wsServer = require('./ws-server');
|
|
15
|
+
|
|
13
16
|
const script = fs.readFileSync(path.join(__dirname, '..', 'script.js'));
|
|
14
17
|
|
|
15
18
|
const app = express();
|
|
16
19
|
|
|
20
|
+
// WebSocket upgrades dispatched by mount() when Hyperwatch is embedded
|
|
21
|
+
app.use(wsServer.middleware);
|
|
22
|
+
|
|
17
23
|
app.use(express.json());
|
|
18
24
|
|
|
25
|
+
function renderHtmlTree(node) {
|
|
26
|
+
const label = [];
|
|
27
|
+
if (node.name) {
|
|
28
|
+
label.push(`<strong>${node.name}</strong>`);
|
|
29
|
+
}
|
|
30
|
+
if (node.op) {
|
|
31
|
+
label.push(`<span class="op">[${node.op}]</span>`);
|
|
32
|
+
}
|
|
33
|
+
if (node.module) {
|
|
34
|
+
label.push(`<span class="module">(${node.module})</span>`);
|
|
35
|
+
}
|
|
36
|
+
if (node.fnName) {
|
|
37
|
+
label.push(`<span class="fn">${node.fnName}</span>`);
|
|
38
|
+
}
|
|
39
|
+
if (node.label) {
|
|
40
|
+
label.push(`<span class="label">${node.label}</span>`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let html = `<li>${label.join(' ')}`;
|
|
44
|
+
if (node.children && node.children.length > 0) {
|
|
45
|
+
html += '<ul>';
|
|
46
|
+
for (const child of node.children) {
|
|
47
|
+
html += renderHtmlTree(child);
|
|
48
|
+
}
|
|
49
|
+
html += '</ul>';
|
|
50
|
+
}
|
|
51
|
+
html += '</li>';
|
|
52
|
+
return html;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderHtmlInputs(inputs) {
|
|
56
|
+
if (!inputs || inputs.length === 0) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
let html = '<ul>';
|
|
60
|
+
for (const input of inputs) {
|
|
61
|
+
html += `<li><strong>${input.name}</strong> <span class="op">[input]</span>`;
|
|
62
|
+
if (input.status) {
|
|
63
|
+
html += ` <span class="module">(${input.status})</span>`;
|
|
64
|
+
}
|
|
65
|
+
html += ` accepted: ${input.accepted}, rejected: ${input.rejected}`;
|
|
66
|
+
if (input.tree) {
|
|
67
|
+
html += `<ul>${renderHtmlTree(input.tree)}</ul>`;
|
|
68
|
+
}
|
|
69
|
+
html += '</li>';
|
|
70
|
+
}
|
|
71
|
+
html += '</ul>';
|
|
72
|
+
return html;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
app.get('/nodes{.:format}', (req, res) => {
|
|
76
|
+
const nodes = Object.keys(pipeline.nodes);
|
|
77
|
+
const format = req.params.format;
|
|
78
|
+
const view = req.query.view;
|
|
79
|
+
|
|
80
|
+
if (format && !['csv', 'json'].includes(format)) {
|
|
81
|
+
res.sendStatus(404);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (format === 'csv') {
|
|
86
|
+
const csv = stringify(
|
|
87
|
+
nodes.map((name) => ({ name })),
|
|
88
|
+
{
|
|
89
|
+
header: true,
|
|
90
|
+
columns: ['name'],
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
94
|
+
res.setHeader('Content-Disposition', 'attachment; filename="nodes.csv"');
|
|
95
|
+
res.send(csv);
|
|
96
|
+
} else if (format === 'json') {
|
|
97
|
+
if (view === 'tree') {
|
|
98
|
+
res.json(pipeline.getTree());
|
|
99
|
+
} else {
|
|
100
|
+
res.json(nodes);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
104
|
+
if (view === 'tree') {
|
|
105
|
+
const tree = pipeline.getTree();
|
|
106
|
+
res.send(
|
|
107
|
+
`<!DOCTYPE html>
|
|
108
|
+
<html>
|
|
109
|
+
<head>
|
|
110
|
+
<style>${stylesheet}
|
|
111
|
+
.op { color: #666; }
|
|
112
|
+
.module { color: #0a0; }
|
|
113
|
+
.fn { color: #00a; }
|
|
114
|
+
.label { color: #a50; }
|
|
115
|
+
ul { list-style: none; padding-left: 1.5em; }
|
|
116
|
+
</style>
|
|
117
|
+
</head>
|
|
118
|
+
<body>${renderHtmlInputs(tree.inputs)}<ul>${renderHtmlTree(tree)}</ul></body>
|
|
119
|
+
</html>`
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
res.send(
|
|
123
|
+
`<!DOCTYPE html>
|
|
124
|
+
<html>
|
|
125
|
+
<head>
|
|
126
|
+
<style>${stylesheet}</style>
|
|
127
|
+
</head>
|
|
128
|
+
<body>${formatTable(nodes.map((name) => ({ name })))}</body>
|
|
129
|
+
</html>`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
19
135
|
app.streamToHttp = (
|
|
20
136
|
endpoint,
|
|
21
137
|
stream,
|
|
@@ -85,17 +201,22 @@ body { display: flex; flex-direction: column-reverse; }
|
|
|
85
201
|
res.write(`<div>${line}</div>\n`);
|
|
86
202
|
}
|
|
87
203
|
});
|
|
88
|
-
});
|
|
204
|
+
}, `http:${endpoint}`);
|
|
89
205
|
};
|
|
90
206
|
|
|
91
207
|
app.registerAggregator = (name, aggregator) => {
|
|
92
208
|
persistence.register(name, aggregator);
|
|
93
|
-
app.get(`/${name}.:format
|
|
209
|
+
app.get(`/${name}{.:format}`, (req, res) => {
|
|
94
210
|
const raw = req.query.raw ? true : false;
|
|
95
211
|
const format = req.params.format || (raw ? 'json' : null);
|
|
96
212
|
const limit = req.query.limit || 100;
|
|
97
213
|
const sort = req.query.sort || 'count15m';
|
|
98
214
|
|
|
215
|
+
if (format && !['csv', 'json'].includes(format)) {
|
|
216
|
+
res.sendStatus(404);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
99
220
|
const data = aggregator.getData({
|
|
100
221
|
sort,
|
|
101
222
|
limit,
|
|
@@ -132,7 +253,12 @@ app.registerAggregator = (name, aggregator) => {
|
|
|
132
253
|
}
|
|
133
254
|
});
|
|
134
255
|
|
|
135
|
-
app.
|
|
256
|
+
app.delete(`/${name}`, (req, res) => {
|
|
257
|
+
aggregator.reset();
|
|
258
|
+
res.send({ success: true });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
app.get(`/${name}/:identifier{.json}`, (req, res) => {
|
|
136
262
|
const entry = aggregator.get(req.params.identifier);
|
|
137
263
|
if (!entry) {
|
|
138
264
|
res.status(404).send('Not Found');
|
package/src/app/index.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
const http = require('http');
|
|
2
2
|
|
|
3
3
|
const express = require('express');
|
|
4
|
-
const expressWs = require('express-ws');
|
|
5
4
|
|
|
6
5
|
const constants = require('../constants');
|
|
7
6
|
|
|
8
7
|
const api = require('./api');
|
|
8
|
+
const mount = require('./mount');
|
|
9
9
|
const websocket = require('./websocket');
|
|
10
|
+
const wsServer = require('./ws-server');
|
|
10
11
|
|
|
11
12
|
const app = express();
|
|
12
13
|
const httpServer = http.createServer(app);
|
|
13
|
-
expressWs(app, httpServer);
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
httpServer.on('upgrade', (request, socket, head) => {
|
|
16
|
+
wsServer.handleUpgrade(request, socket, head);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.use(api);
|
|
16
20
|
|
|
17
21
|
function start() {
|
|
18
22
|
const port = process.env.PORT || constants.port;
|
|
@@ -25,4 +29,4 @@ function stop() {
|
|
|
25
29
|
httpServer.close();
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
module.exports = { api, start, stop, websocket };
|
|
32
|
+
module.exports = { api, mount, start, stop, websocket };
|
package/src/app/mount.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const api = require('./api');
|
|
2
|
+
const wsServer = require('./ws-server');
|
|
3
|
+
|
|
4
|
+
// Servers where Hyperwatch handles WebSocket upgrades, with their listener
|
|
5
|
+
const upgradeListeners = new WeakMap();
|
|
6
|
+
|
|
7
|
+
// Paths where Hyperwatch is mounted, per app. Express can't remove routes, so
|
|
8
|
+
// a path stays mounted for the life of the app.
|
|
9
|
+
const mountedPaths = new WeakMap();
|
|
10
|
+
|
|
11
|
+
// A path as the app routes it: case-insensitive unless the app says otherwise
|
|
12
|
+
function routingKey(app, path) {
|
|
13
|
+
return app.enabled('case sensitive routing') ? path : path.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validate(app, { server, path, middleware, fallback }) {
|
|
17
|
+
if (typeof app !== 'function' || typeof app.use !== 'function') {
|
|
18
|
+
throw new TypeError('mount() expects an Express app');
|
|
19
|
+
}
|
|
20
|
+
if (!server || typeof server.on !== 'function') {
|
|
21
|
+
throw new TypeError(
|
|
22
|
+
'mount() expects the HTTP server of the app: { server }'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.endsWith('/')) {
|
|
26
|
+
throw new TypeError(
|
|
27
|
+
`Invalid mount path "${path}": it must start with "/" and not end with "/"`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const middlewares = middleware === undefined ? [] : [].concat(middleware);
|
|
31
|
+
if (!middlewares.every((fn) => typeof fn === 'function')) {
|
|
32
|
+
throw new TypeError(
|
|
33
|
+
'middleware must be a function or an array of functions'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (fallback !== undefined && typeof fallback !== 'function') {
|
|
37
|
+
throw new TypeError('fallback must be a function');
|
|
38
|
+
}
|
|
39
|
+
const paths = mountedPaths.get(app);
|
|
40
|
+
if (paths && paths.has(routingKey(app, path))) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Hyperwatch is already mounted on this app at ${path}: its routes can't be removed, so mounting again can't change its options (e.g. middleware)`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (upgradeListeners.has(server)) {
|
|
46
|
+
throw new Error('Hyperwatch is already mounted on this server');
|
|
47
|
+
}
|
|
48
|
+
return middlewares;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Whether a request is under the mount path, matching case like Express
|
|
52
|
+
function isUnderPath(target, path, caseSensitive) {
|
|
53
|
+
const pathname = caseSensitive
|
|
54
|
+
? target.pathname
|
|
55
|
+
: target.pathname.toLowerCase();
|
|
56
|
+
const mountPath = caseSensitive ? path : path.toLowerCase();
|
|
57
|
+
return pathname === mountPath || pathname.startsWith(`${mountPath}/`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mount Hyperwatch in an Express app, under an explicit path:
|
|
62
|
+
*
|
|
63
|
+
* hyperwatch.app.mount(app, { server, path: '/_hyperwatch', middleware: auth });
|
|
64
|
+
*
|
|
65
|
+
* - Registers `app.use(path, ...middleware, router)` where it's called, so the
|
|
66
|
+
* app's middleware order is kept.
|
|
67
|
+
* - Adds one 'upgrade' listener to `server`. WebSocket upgrades under `path`
|
|
68
|
+
* go through the app like HTTP requests, so `middleware` applies to both.
|
|
69
|
+
* Other upgrades go to `fallback` when given, or are left to the server's
|
|
70
|
+
* other listeners. Malformed targets get 400.
|
|
71
|
+
* - Never creates a server nor listens.
|
|
72
|
+
*
|
|
73
|
+
* Mounting again on the same server, or on the same app at the same path
|
|
74
|
+
* (even after detachUpgrades()), throws before registering anything.
|
|
75
|
+
* Returns `{ path, detachUpgrades }`: detachUpgrades() removes the upgrade
|
|
76
|
+
* listener and releases the server. Express can't remove routes, so the
|
|
77
|
+
* HTTP routes stay mounted on the app, with their original middleware.
|
|
78
|
+
*/
|
|
79
|
+
function mount(app, options = {}) {
|
|
80
|
+
const middlewares = validate(app, options);
|
|
81
|
+
const { server, path, fallback } = options;
|
|
82
|
+
|
|
83
|
+
app.use(path, ...middlewares, api);
|
|
84
|
+
if (!mountedPaths.has(app)) {
|
|
85
|
+
mountedPaths.set(app, new Set());
|
|
86
|
+
}
|
|
87
|
+
mountedPaths.get(app).add(routingKey(app, path));
|
|
88
|
+
|
|
89
|
+
const listener = (req, socket, head) => {
|
|
90
|
+
const target = wsServer.parseTarget(req.url);
|
|
91
|
+
// Malformed targets are rejected before reaching the app or fallback
|
|
92
|
+
if (!target) {
|
|
93
|
+
return wsServer.reject(socket, 400, 'Bad Request');
|
|
94
|
+
}
|
|
95
|
+
if (isUnderPath(target, path, app.enabled('case sensitive routing'))) {
|
|
96
|
+
wsServer.dispatch(app, req, socket, head);
|
|
97
|
+
} else if (fallback) {
|
|
98
|
+
fallback(req, socket, head);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
server.on('upgrade', listener);
|
|
102
|
+
upgradeListeners.set(server, listener);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
path,
|
|
106
|
+
detachUpgrades() {
|
|
107
|
+
if (upgradeListeners.get(server) === listener) {
|
|
108
|
+
server.off('upgrade', listener);
|
|
109
|
+
upgradeListeners.delete(server);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = mount;
|
package/src/app/websocket.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
|
|
3
|
-
const express = require('express');
|
|
4
|
-
const expressWs = require('express-ws');
|
|
5
|
-
|
|
6
3
|
const constants = require('../constants');
|
|
7
4
|
const monitoring = require('../lib/monitoring');
|
|
8
5
|
|
|
9
|
-
const
|
|
10
|
-
|
|
6
|
+
const wsServer = require('./ws-server');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Express middleware completing the Hyperwatch WebSocket upgrades dispatched
|
|
10
|
+
* by hyperwatch.app.mount(), which already includes it. Kept for apps that
|
|
11
|
+
* mounted it explicitly: on its own, it doesn't handle any upgrade.
|
|
12
|
+
*/
|
|
13
|
+
const websocket = (req, res, next) => wsServer.middleware(req, res, next);
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
websocket.streamToWebsocket = (
|
|
13
16
|
endpoint,
|
|
14
17
|
stream,
|
|
15
18
|
{ name = `WebSocket: ${endpoint}`, monitoringEnabled = false } = {}
|
|
@@ -37,7 +40,7 @@ app.streamToWebsocket = (
|
|
|
37
40
|
};
|
|
38
41
|
updateMonitoringStatus();
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
wsServer.ws(endpoint, (client, req) => {
|
|
41
44
|
const clientId = req.query.clientId || crypto.randomUUID();
|
|
42
45
|
if (clients[clientId]) {
|
|
43
46
|
console.log(`Client '${clientId}' is already connected. Terminating.`);
|
|
@@ -70,7 +73,7 @@ app.streamToWebsocket = (
|
|
|
70
73
|
client.send(JSON.stringify(log));
|
|
71
74
|
}
|
|
72
75
|
});
|
|
73
|
-
});
|
|
76
|
+
}, `ws:${endpoint}`);
|
|
74
77
|
|
|
75
78
|
// Heartbeat: detect and clean up stale connections
|
|
76
79
|
setInterval(() => {
|
|
@@ -88,4 +91,4 @@ app.streamToWebsocket = (
|
|
|
88
91
|
}, constants.heartbeatInterval || 30000);
|
|
89
92
|
};
|
|
90
93
|
|
|
91
|
-
module.exports =
|
|
94
|
+
module.exports = websocket;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
|
|
5
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
6
|
+
|
|
7
|
+
const routes = new Map();
|
|
8
|
+
|
|
9
|
+
// Marks an upgrade request dispatched through an Express app
|
|
10
|
+
const upgradeKey = Symbol('hyperwatch.upgrade');
|
|
11
|
+
|
|
12
|
+
function ws(path, handler) {
|
|
13
|
+
routes.set(path, handler);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse the target of an upgrade request without throwing. WebSocket
|
|
18
|
+
* upgrades use the origin form ("/path?query"): anything else is malformed.
|
|
19
|
+
* `pathname` is kept as sent, like Express routing does; `urlPathname` is
|
|
20
|
+
* normalized by the URL parser.
|
|
21
|
+
*/
|
|
22
|
+
function parseTarget(url) {
|
|
23
|
+
if (typeof url !== 'string' || !url.startsWith('/')) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const { pathname: urlPathname, searchParams } = new URL(
|
|
28
|
+
url,
|
|
29
|
+
'http://localhost'
|
|
30
|
+
);
|
|
31
|
+
return {
|
|
32
|
+
pathname: url.split(/[?#]/)[0],
|
|
33
|
+
urlPathname,
|
|
34
|
+
query: Object.fromEntries(searchParams),
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reject(socket, status, message) {
|
|
42
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find the handler of a route, matching case like Express routing does
|
|
46
|
+
function findRoute(pathname, caseSensitive) {
|
|
47
|
+
if (caseSensitive) {
|
|
48
|
+
return routes.get(pathname);
|
|
49
|
+
}
|
|
50
|
+
const lowerCase = pathname.toLowerCase();
|
|
51
|
+
for (const [route, handler] of routes) {
|
|
52
|
+
if (route.toLowerCase() === lowerCase) {
|
|
53
|
+
return handler;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleUpgrade(request, socket, head) {
|
|
59
|
+
const target = parseTarget(request.url);
|
|
60
|
+
if (!target) {
|
|
61
|
+
return reject(socket, 400, 'Bad Request');
|
|
62
|
+
}
|
|
63
|
+
request.query = target.query;
|
|
64
|
+
|
|
65
|
+
// Standalone mode: exact match, stream names are case-sensitive
|
|
66
|
+
const handler = routes.get(target.urlPathname);
|
|
67
|
+
if (handler) {
|
|
68
|
+
wss.handleUpgrade(request, socket, head, (client) => {
|
|
69
|
+
handler(client, request);
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
socket.destroy();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Express middleware completing the WebSocket upgrades sent by dispatch().
|
|
78
|
+
* The route is matched on the path relative to where the middleware is
|
|
79
|
+
* mounted, so `app.use('/_hyperwatch', middleware)` serves `/_hyperwatch/logs/raw`.
|
|
80
|
+
*/
|
|
81
|
+
function middleware(req, res, next) {
|
|
82
|
+
const upgrade = req[upgradeKey];
|
|
83
|
+
if (!upgrade) {
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
const handler = findRoute(
|
|
87
|
+
req.path,
|
|
88
|
+
req.app.enabled('case sensitive routing')
|
|
89
|
+
);
|
|
90
|
+
if (!handler) {
|
|
91
|
+
// The mount path is Hyperwatch's: unknown routes end here
|
|
92
|
+
return res.status(404).end();
|
|
93
|
+
}
|
|
94
|
+
req[upgradeKey] = null;
|
|
95
|
+
res.detachSocket(upgrade.socket);
|
|
96
|
+
wss.handleUpgrade(req, upgrade.socket, upgrade.head, (client) => {
|
|
97
|
+
handler(client, req);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Send a WebSocket upgrade through an Express app, like an HTTP request: it
|
|
103
|
+
* goes through the app's middlewares (e.g. authentication) and reaches the
|
|
104
|
+
* WebSocket middleware where it's mounted. The response is bound to the
|
|
105
|
+
* socket, so a middleware can reject the upgrade with an HTTP response, and
|
|
106
|
+
* Express answers errors and unknown routes as usual (e.g. 401, 503, 404).
|
|
107
|
+
*/
|
|
108
|
+
function dispatch(app, req, socket, head) {
|
|
109
|
+
req[upgradeKey] = { socket, head };
|
|
110
|
+
const res = new http.ServerResponse(req);
|
|
111
|
+
res.assignSocket(socket);
|
|
112
|
+
res.on('finish', () => socket.end());
|
|
113
|
+
app(req, res);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
ws,
|
|
118
|
+
handleUpgrade,
|
|
119
|
+
middleware,
|
|
120
|
+
dispatch,
|
|
121
|
+
parseTarget,
|
|
122
|
+
reject,
|
|
123
|
+
};
|