@hyperwatch/hyperwatch 5.0.0 → 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/package.json +1 -1
- package/src/index.js +6 -1
- package/src/input/websocket.js +55 -19
- package/src/lib/pipeline.js +18 -3
- package/src/modules/hostname.js +12 -4
- package/src/modules/identity.js +36 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -45,7 +45,12 @@ function start() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
async function stop() {
|
|
48
|
-
|
|
48
|
+
try {
|
|
49
|
+
await pipeline.stop();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Error stopping the pipeline:', err.message);
|
|
52
|
+
}
|
|
53
|
+
// Persist even if stopping the inputs failed
|
|
49
54
|
try {
|
|
50
55
|
if (constants.persistence.enabled) {
|
|
51
56
|
persistence.dump(getPersistenceDir());
|
package/src/input/websocket.js
CHANGED
|
@@ -19,12 +19,20 @@ function create({
|
|
|
19
19
|
heartbeatInterval = 30000,
|
|
20
20
|
}) {
|
|
21
21
|
let client;
|
|
22
|
+
let keepAlive;
|
|
23
|
+
let reconnectTimer;
|
|
24
|
+
// Each connection belongs to a generation. stop() and every new connection
|
|
25
|
+
// move to the next one, so events from an obsolete socket (a late close
|
|
26
|
+
// after a restart, its heartbeat) can't reconnect or touch the current one.
|
|
27
|
+
let generation = 0;
|
|
22
28
|
|
|
23
29
|
let reconnectAttempts = 0;
|
|
24
30
|
|
|
25
31
|
const setupWebSocketClient = ({ status, success, reject }) => {
|
|
32
|
+
const current = ++generation;
|
|
33
|
+
const isCurrent = () => current === generation;
|
|
26
34
|
let isAlive;
|
|
27
|
-
let
|
|
35
|
+
let heartbeat;
|
|
28
36
|
|
|
29
37
|
if (username && password) {
|
|
30
38
|
options.headers = options.headers || {};
|
|
@@ -33,32 +41,37 @@ function create({
|
|
|
33
41
|
).toString('base64')}`;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
const socket = new WebSocket(address, [], options);
|
|
45
|
+
client = socket;
|
|
37
46
|
status(null, `Waiting for connection to ${address}`);
|
|
38
47
|
|
|
39
|
-
|
|
48
|
+
socket.on('open', () => {
|
|
49
|
+
if (!isCurrent()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
40
52
|
isAlive = true;
|
|
41
53
|
reconnectAttempts = 0;
|
|
42
54
|
status(null, `Listening to ${address}`);
|
|
43
55
|
|
|
44
56
|
// Heartbeat: detect stale connections
|
|
45
|
-
|
|
57
|
+
heartbeat = setInterval(() => {
|
|
46
58
|
if (isAlive === false) {
|
|
47
|
-
|
|
48
|
-
clearInterval(
|
|
59
|
+
socket.terminate();
|
|
60
|
+
clearInterval(heartbeat);
|
|
49
61
|
} else {
|
|
50
62
|
try {
|
|
51
|
-
|
|
63
|
+
socket.ping();
|
|
52
64
|
} catch (err) {
|
|
53
65
|
status(err, 'Websocket error');
|
|
54
66
|
}
|
|
55
67
|
isAlive = false;
|
|
56
68
|
}
|
|
57
69
|
}, heartbeatInterval);
|
|
70
|
+
keepAlive = heartbeat;
|
|
58
71
|
});
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
if (sample !== 1 && Math.random() > sample) {
|
|
73
|
+
socket.on('message', (message) => {
|
|
74
|
+
if (!isCurrent() || (sample !== 1 && Math.random() > sample)) {
|
|
62
75
|
return;
|
|
63
76
|
}
|
|
64
77
|
try {
|
|
@@ -68,19 +81,22 @@ function create({
|
|
|
68
81
|
}
|
|
69
82
|
});
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
socket.on('error', (err) => {
|
|
85
|
+
if (isCurrent()) {
|
|
86
|
+
status(err, 'Websocket error');
|
|
87
|
+
}
|
|
73
88
|
});
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
socket.on('pong', () => {
|
|
76
91
|
isAlive = true;
|
|
77
92
|
});
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
94
|
+
socket.on('close', () => {
|
|
95
|
+
clearInterval(heartbeat);
|
|
96
|
+
if (!isCurrent()) {
|
|
97
|
+
return;
|
|
83
98
|
}
|
|
99
|
+
status(null, 'Websocket connection has been closed');
|
|
84
100
|
if (reconnectOnClose) {
|
|
85
101
|
reconnectAttempts++;
|
|
86
102
|
const delay = Math.min(
|
|
@@ -91,7 +107,7 @@ function create({
|
|
|
91
107
|
null,
|
|
92
108
|
`Reconnecting Websocket in ${delay / 1000}s (attempt ${reconnectAttempts})`
|
|
93
109
|
);
|
|
94
|
-
setTimeout(() => {
|
|
110
|
+
reconnectTimer = setTimeout(() => {
|
|
95
111
|
setupWebSocketClient({ status, success, reject });
|
|
96
112
|
}, delay);
|
|
97
113
|
}
|
|
@@ -117,6 +133,7 @@ function create({
|
|
|
117
133
|
return {
|
|
118
134
|
name: `${name} ${type}`,
|
|
119
135
|
start: ({ success, reject, status, log }) => {
|
|
136
|
+
reconnectAttempts = 0;
|
|
120
137
|
if (type === 'client') {
|
|
121
138
|
setupWebSocketClient({ status, success, reject });
|
|
122
139
|
} else if (type === 'server') {
|
|
@@ -126,9 +143,28 @@ function create({
|
|
|
126
143
|
log(new Error(errMsg), 'error');
|
|
127
144
|
}
|
|
128
145
|
},
|
|
146
|
+
// Never throws: a failing input must not prevent the others from stopping,
|
|
147
|
+
// nor Hyperwatch from persisting its data on shutdown.
|
|
129
148
|
stop: () => {
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
// Obsoletes the current connection: its close won't reconnect
|
|
150
|
+
generation++;
|
|
151
|
+
clearTimeout(reconnectTimer);
|
|
152
|
+
clearInterval(keepAlive);
|
|
153
|
+
if (!client) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (client.readyState === WebSocket.CONNECTING) {
|
|
158
|
+
// close() throws while the connection is not established yet
|
|
159
|
+
client.terminate();
|
|
160
|
+
} else if (client.readyState === WebSocket.OPEN) {
|
|
161
|
+
client.close();
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(
|
|
165
|
+
`${name}: error while closing the Websocket:`,
|
|
166
|
+
err.message
|
|
167
|
+
);
|
|
132
168
|
}
|
|
133
169
|
},
|
|
134
170
|
};
|
package/src/lib/pipeline.js
CHANGED
|
@@ -311,10 +311,25 @@ class Pipeline extends Builder {
|
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
// Stops every input, even if some of them throw or reject, then rejects
|
|
315
|
+
// with their errors if any failed
|
|
316
|
+
async stop() {
|
|
317
|
+
const results = await Promise.allSettled(
|
|
318
|
+
this.inputs
|
|
319
|
+
.filter((input) => input.stop)
|
|
320
|
+
.map((input) => Promise.resolve().then(() => input.stop()))
|
|
317
321
|
);
|
|
322
|
+
const errors = results
|
|
323
|
+
.filter((result) => result.status === 'rejected')
|
|
324
|
+
.map((result) => result.reason);
|
|
325
|
+
if (errors.length > 0) {
|
|
326
|
+
throw new AggregateError(
|
|
327
|
+
errors,
|
|
328
|
+
`${errors.length} input(s) failed to stop: ${errors
|
|
329
|
+
.map((err) => err.message)
|
|
330
|
+
.join('; ')}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
318
333
|
}
|
|
319
334
|
|
|
320
335
|
registerNode(name, node) {
|
package/src/modules/hostname.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const dns = require('dns').promises;
|
|
2
|
+
const net = require('net');
|
|
2
3
|
|
|
3
4
|
const debug = require('debug')('hyperwatch:hostname');
|
|
4
5
|
|
|
@@ -13,6 +14,12 @@ function ignoreError() {
|
|
|
13
14
|
return null;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
// Compare addresses in canonical form: DNS and logs may write the same IPv6
|
|
18
|
+
// address differently (zero compression, case)
|
|
19
|
+
function canonical(ip) {
|
|
20
|
+
return net.isIPv6(ip) ? new URL(`http://[${ip}]`).hostname : ip;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
function isValid(hostname) {
|
|
17
24
|
return (
|
|
18
25
|
hostname &&
|
|
@@ -40,11 +47,12 @@ async function lookup(ip, { fast = false } = {}) {
|
|
|
40
47
|
if (isValid(reverse)) {
|
|
41
48
|
entry.value = reverse;
|
|
42
49
|
debug(`Resolve ${reverse} ...`);
|
|
43
|
-
const reverseIps = await
|
|
50
|
+
const reverseIps = await (
|
|
51
|
+
net.isIPv6(ip) ? dns.resolve6(reverse) : dns.resolve4(reverse)
|
|
52
|
+
).catch(ignoreError);
|
|
44
53
|
if (reverseIps) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (reverseIp === ip) {
|
|
54
|
+
debug(`Resolve ${reverse}: ${reverseIps.join(', ')}`);
|
|
55
|
+
if (reverseIps.map(canonical).includes(canonical(ip))) {
|
|
48
56
|
entry.verified = true;
|
|
49
57
|
}
|
|
50
58
|
} else {
|
package/src/modules/identity.js
CHANGED
|
@@ -22,6 +22,11 @@ const claudeBotCidrs = claudeBotIps.map((cidr) => new IPCIDR(cidr));
|
|
|
22
22
|
function augment(log) {
|
|
23
23
|
const family = log.getIn(['agent', 'family']);
|
|
24
24
|
const hostname = log.getIn(['address', 'hostname']);
|
|
25
|
+
// Reverse DNS confirmed by a forward lookup back to the same address. The
|
|
26
|
+
// older identities below still use the unconfirmed hostname.
|
|
27
|
+
const verifiedHostname = log.getIn(['hostname', 'verified'])
|
|
28
|
+
? hostname
|
|
29
|
+
: undefined;
|
|
25
30
|
const address =
|
|
26
31
|
log.getIn(['address', 'value']) || log.getIn(['request', 'address']);
|
|
27
32
|
const signature = log.getIn(['signature', 'id']);
|
|
@@ -62,8 +67,23 @@ function augment(log) {
|
|
|
62
67
|
return hostname && hostname.endsWith('.yandex.com')
|
|
63
68
|
? log.set('identity', 'Yandex')
|
|
64
69
|
: log;
|
|
70
|
+
// https://www.semrush.com/bot/ (SemrushBot-BA and SemrushBot-SI parse
|
|
71
|
+
// as SemrushBot)
|
|
65
72
|
case 'SemrushBot':
|
|
66
|
-
|
|
73
|
+
case 'SemrushBot-SWA':
|
|
74
|
+
case 'SemrushBot-OCOB':
|
|
75
|
+
case 'SemrushBot-FT':
|
|
76
|
+
case 'SemrushBot-ESI':
|
|
77
|
+
case 'SiteAuditBot':
|
|
78
|
+
case 'SplitSignalBot':
|
|
79
|
+
case 'RyteBot':
|
|
80
|
+
// Some SemrushBot addresses have the generic PTR bot.semrush.com, which
|
|
81
|
+
// doesn't resolve back to them: accept Semrush's own range too
|
|
82
|
+
// (85.208.98.0/24, announced by AS209366)
|
|
83
|
+
if (
|
|
84
|
+
(verifiedHostname && verifiedHostname.endsWith('.semrush.com')) ||
|
|
85
|
+
(address && new IPCIDR('85.208.98.0/24').contains(address))
|
|
86
|
+
) {
|
|
67
87
|
return log.set('identity', 'Semrush');
|
|
68
88
|
}
|
|
69
89
|
break;
|
|
@@ -187,9 +207,14 @@ function augment(log) {
|
|
|
187
207
|
: log;
|
|
188
208
|
case 'Reflectionbot':
|
|
189
209
|
// https://reflection.ai/bot
|
|
190
|
-
return
|
|
210
|
+
return verifiedHostname && verifiedHostname.endsWith('.reflection.ai')
|
|
191
211
|
? log.set('identity', 'Reflection')
|
|
192
212
|
: log;
|
|
213
|
+
case 'SEOkicks':
|
|
214
|
+
// https://www.seokicks.de/robot.html
|
|
215
|
+
return verifiedHostname && verifiedHostname.endsWith('.seokicks.de')
|
|
216
|
+
? log.set('identity', 'SEOkicks')
|
|
217
|
+
: log;
|
|
193
218
|
case 'bnf.fr bot':
|
|
194
219
|
return hostname && hostname.endsWith('.bnf.fr')
|
|
195
220
|
? log.set('identity', 'BnF.fr')
|
|
@@ -305,6 +330,11 @@ function augment(log) {
|
|
|
305
330
|
return address && new IPCIDR('203.133.160.0/19').contains(address)
|
|
306
331
|
? log.set('identity', family)
|
|
307
332
|
: log;
|
|
333
|
+
case 'LinkupBot':
|
|
334
|
+
// https://www.linkup.so/linkupbot-ips.txt
|
|
335
|
+
return address && new IPCIDR('35.198.113.100/32').contains(address)
|
|
336
|
+
? log.set('identity', 'Linkup')
|
|
337
|
+
: log;
|
|
308
338
|
case 'OAI-SearchBot':
|
|
309
339
|
return openaiSearchbotIps.some((cidr) =>
|
|
310
340
|
new IPCIDR(cidr).contains(address)
|
|
@@ -463,6 +493,10 @@ function augment(log) {
|
|
|
463
493
|
if (hostname.endsWith('.qwant.com')) {
|
|
464
494
|
return log.set('identity', 'Qwant');
|
|
465
495
|
}
|
|
496
|
+
// Semrush crawlers not named above
|
|
497
|
+
if (verifiedHostname && verifiedHostname.endsWith('.semrush.com')) {
|
|
498
|
+
return log.set('identity', 'Semrush');
|
|
499
|
+
}
|
|
466
500
|
}
|
|
467
501
|
|
|
468
502
|
// Signature
|