@hyperwatch/hyperwatch 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/config/apache_syslog_example.js +29 -0
- package/config/default.js +2 -2
- package/config/example.js +5 -5
- package/config/express_websocket_example.js +28 -0
- 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 +42 -35
- package/scripts/fetch-anthropic-ips.js +60 -0
- package/scripts/fetch-cloudflare-ips.js +27 -0
- package/scripts/fetch-cloudfront-ips.js +29 -0
- package/scripts/fetch-openai-ips.js +34 -0
- package/src/app/api.js +149 -8
- package/src/app/index.js +8 -4
- package/src/app/mount.js +115 -0
- package/src/app/websocket.js +39 -10
- package/src/app/ws-server.js +123 -0
- package/src/constants.js +17 -5
- package/src/data/amazon-searchbot-ips.json +818 -0
- package/src/data/amazon-user-ips.json +1025 -0
- package/src/data/amazonbot-ips.json +1294 -0
- package/src/data/chatgpt-user-ips.json +231 -0
- package/src/data/claude-bot-ips.json +28 -0
- package/src/data/cloudflare-ips.json +24 -0
- package/src/data/cloudfront-ips.json +245 -0
- package/src/data/gptbot-ips.json +20 -0
- package/src/data/openai-searchbot-ips.json +41 -0
- package/src/index.js +24 -3
- package/src/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +35 -19
- package/src/lib/aggregator.js +169 -20
- package/src/lib/formatter.js +10 -1
- package/src/lib/index.js +2 -0
- package/src/lib/log-buffer.js +45 -0
- package/src/lib/persistence.js +82 -0
- package/src/lib/pipeline.js +122 -11
- package/src/lib/recent-map.js +23 -0
- package/src/lib/speed.js +43 -3
- package/src/lib/util.js +17 -1
- package/src/modules/address.js +59 -2
- package/src/modules/agent.js +1 -1
- package/src/modules/dnsbl.js +11 -1
- package/src/modules/history.js +62 -0
- package/src/modules/identity.js +154 -26
- package/src/modules/index.js +16 -5
- package/src/modules/language.js +1 -1
- package/src/modules/signature.js +64 -18
- package/src/modules/sparkline.js +7 -3
- package/src/modules/status.js +13 -8
- package/src/plugins/proxy.js +10 -203
- package/src/script.js +0 -1
- package/scripts/cloudfront-ips.js +0 -56
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,13 +1,18 @@
|
|
|
1
|
-
const
|
|
2
|
-
const expressWs = require('express-ws');
|
|
3
|
-
const uuid = require('uuid');
|
|
1
|
+
const crypto = require('crypto');
|
|
4
2
|
|
|
3
|
+
const constants = require('../constants');
|
|
5
4
|
const monitoring = require('../lib/monitoring');
|
|
6
5
|
|
|
7
|
-
const
|
|
8
|
-
expressWs(app);
|
|
6
|
+
const wsServer = require('./ws-server');
|
|
9
7
|
|
|
10
|
-
|
|
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);
|
|
14
|
+
|
|
15
|
+
websocket.streamToWebsocket = (
|
|
11
16
|
endpoint,
|
|
12
17
|
stream,
|
|
13
18
|
{ name = `WebSocket: ${endpoint}`, monitoringEnabled = false } = {}
|
|
@@ -35,19 +40,28 @@ app.streamToWebsocket = (
|
|
|
35
40
|
};
|
|
36
41
|
updateMonitoringStatus();
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
const clientId = req.query.clientId ||
|
|
43
|
+
wsServer.ws(endpoint, (client, req) => {
|
|
44
|
+
const clientId = req.query.clientId || crypto.randomUUID();
|
|
40
45
|
if (clients[clientId]) {
|
|
41
46
|
console.log(`Client '${clientId}' is already connected. Terminating.`);
|
|
42
47
|
client.terminate();
|
|
43
48
|
return;
|
|
44
49
|
}
|
|
45
50
|
clients[clientId] = client;
|
|
51
|
+
client.isAlive = true;
|
|
46
52
|
updateMonitoringStatus();
|
|
53
|
+
client.on('pong', () => {
|
|
54
|
+
client.isAlive = true;
|
|
55
|
+
});
|
|
47
56
|
client.on('close', () => {
|
|
57
|
+
console.log(`Client '${clientId}' closed.`);
|
|
48
58
|
delete clients[clientId];
|
|
49
59
|
updateMonitoringStatus();
|
|
50
60
|
});
|
|
61
|
+
client.on('error', (error) => {
|
|
62
|
+
console.log(`Client '${clientId}' error.`);
|
|
63
|
+
console.log(error);
|
|
64
|
+
});
|
|
51
65
|
});
|
|
52
66
|
|
|
53
67
|
stream.map((log) => {
|
|
@@ -59,7 +73,22 @@ app.streamToWebsocket = (
|
|
|
59
73
|
client.send(JSON.stringify(log));
|
|
60
74
|
}
|
|
61
75
|
});
|
|
62
|
-
});
|
|
76
|
+
}, `ws:${endpoint}`);
|
|
77
|
+
|
|
78
|
+
// Heartbeat: detect and clean up stale connections
|
|
79
|
+
setInterval(() => {
|
|
80
|
+
Object.entries(clients).forEach(([clientId, client]) => {
|
|
81
|
+
if (!client.isAlive) {
|
|
82
|
+
console.log(`Client '${clientId}' stale. Terminating.`);
|
|
83
|
+
client.terminate();
|
|
84
|
+
delete clients[clientId];
|
|
85
|
+
updateMonitoringStatus();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
client.isAlive = false;
|
|
89
|
+
client.ping();
|
|
90
|
+
});
|
|
91
|
+
}, constants.heartbeatInterval || 30000);
|
|
63
92
|
};
|
|
64
93
|
|
|
65
|
-
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
|
+
};
|
package/src/constants.js
CHANGED
|
@@ -11,6 +11,7 @@ const constants = {
|
|
|
11
11
|
active: false,
|
|
12
12
|
priority: 200,
|
|
13
13
|
},
|
|
14
|
+
// --- Enrichment: independent modules (no dependencies) ---
|
|
14
15
|
cloudflare: {
|
|
15
16
|
active: false,
|
|
16
17
|
priority: 500,
|
|
@@ -23,18 +24,19 @@ const constants = {
|
|
|
23
24
|
active: false,
|
|
24
25
|
priority: 501,
|
|
25
26
|
},
|
|
26
|
-
language: {
|
|
27
|
-
active: false,
|
|
28
|
-
priority: 503,
|
|
29
|
-
},
|
|
30
27
|
hostname: {
|
|
31
28
|
active: false,
|
|
32
29
|
priority: 502,
|
|
33
30
|
},
|
|
31
|
+
language: {
|
|
32
|
+
active: false,
|
|
33
|
+
priority: 503,
|
|
34
|
+
},
|
|
34
35
|
dnsbl: {
|
|
35
36
|
active: false,
|
|
36
37
|
priority: 503,
|
|
37
38
|
},
|
|
39
|
+
// --- Classification: depends on enrichment above ---
|
|
38
40
|
address: {
|
|
39
41
|
active: false,
|
|
40
42
|
priority: 600,
|
|
@@ -45,13 +47,23 @@ const constants = {
|
|
|
45
47
|
},
|
|
46
48
|
identity: {
|
|
47
49
|
active: false,
|
|
48
|
-
priority: 620,
|
|
50
|
+
priority: 620, // depends on: agent, hostname, signature, address
|
|
51
|
+
},
|
|
52
|
+
// --- Output: depends on full enrichment ---
|
|
53
|
+
history: {
|
|
54
|
+
active: false,
|
|
55
|
+
priority: 700,
|
|
49
56
|
},
|
|
50
57
|
sparkline: {
|
|
51
58
|
active: false,
|
|
52
59
|
priority: 800,
|
|
53
60
|
},
|
|
54
61
|
},
|
|
62
|
+
persistence: {
|
|
63
|
+
enabled: false,
|
|
64
|
+
path: null,
|
|
65
|
+
namespace: null,
|
|
66
|
+
},
|
|
55
67
|
};
|
|
56
68
|
|
|
57
69
|
module.exports = rc('hyperwatch', constants);
|