@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperwatch/hyperwatch",
3
- "version": "5.0.0",
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>",
package/src/index.js CHANGED
@@ -45,7 +45,12 @@ function start() {
45
45
  }
46
46
 
47
47
  async function stop() {
48
- await pipeline.stop();
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());
@@ -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 keepAlive;
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
- client = new WebSocket(address, [], options);
44
+ const socket = new WebSocket(address, [], options);
45
+ client = socket;
37
46
  status(null, `Waiting for connection to ${address}`);
38
47
 
39
- client.on('open', () => {
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
- keepAlive = setInterval(() => {
57
+ heartbeat = setInterval(() => {
46
58
  if (isAlive === false) {
47
- client.terminate();
48
- clearInterval(keepAlive);
59
+ socket.terminate();
60
+ clearInterval(heartbeat);
49
61
  } else {
50
62
  try {
51
- client.ping();
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
- client.on('message', (message) => {
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
- client.on('error', (err) => {
72
- status(err, 'Websocket error');
84
+ socket.on('error', (err) => {
85
+ if (isCurrent()) {
86
+ status(err, 'Websocket error');
87
+ }
73
88
  });
74
89
 
75
- client.on('pong', () => {
90
+ socket.on('pong', () => {
76
91
  isAlive = true;
77
92
  });
78
93
 
79
- client.on('close', () => {
80
- status(null, 'Websocket connection has been closed');
81
- if (keepAlive) {
82
- clearInterval(keepAlive);
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
- if (client) {
131
- client.close();
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
  };
@@ -311,10 +311,25 @@ class Pipeline extends Builder {
311
311
  });
312
312
  }
313
313
 
314
- stop() {
315
- return Promise.all(
316
- this.inputs.filter((input) => input.stop).map((input) => input.stop())
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) {
@@ -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 dns.resolve(reverse).catch(ignoreError);
50
+ const reverseIps = await (
51
+ net.isIPv6(ip) ? dns.resolve6(reverse) : dns.resolve4(reverse)
52
+ ).catch(ignoreError);
44
53
  if (reverseIps) {
45
- const reverseIp = reverseIps[0];
46
- debug(`Resolve ${reverse}: ${reverseIp}`);
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 {
@@ -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
- if (hostname && hostname.endsWith('.semrush.com')) {
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 hostname && hostname.endsWith('.reflection.ai')
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