@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
package/src/lib/pipeline.js
CHANGED
|
@@ -128,35 +128,79 @@ class Builder {
|
|
|
128
128
|
this.children = [];
|
|
129
129
|
this.name = null;
|
|
130
130
|
this.pipeline = pipeline;
|
|
131
|
+
this.parent = null;
|
|
132
|
+
this.op = null;
|
|
133
|
+
this.module = null;
|
|
134
|
+
this.fnName = null;
|
|
135
|
+
this._label = null;
|
|
136
|
+
// Lightweight per-node counter for throughput tracking
|
|
137
|
+
this.counter = 0;
|
|
138
|
+
// Fixed one-second buckets keep rate tracking bounded and O(1) per event.
|
|
139
|
+
this.rateBuckets = Array.from({ length: 10 }, () => ({
|
|
140
|
+
second: null,
|
|
141
|
+
count: 0,
|
|
142
|
+
}));
|
|
131
143
|
}
|
|
132
144
|
|
|
133
|
-
add(xf) {
|
|
145
|
+
add(xf, { op = null, fnName = null, label = null } = {}) {
|
|
134
146
|
const child = new Builder(xf, this.pipeline);
|
|
147
|
+
child.parent = this;
|
|
148
|
+
child.op = op;
|
|
149
|
+
child.module = this.pipeline ? this.pipeline.currentModule : null;
|
|
150
|
+
child.fnName = fnName || null;
|
|
151
|
+
child._label = label || null;
|
|
135
152
|
this.children.push(child);
|
|
136
153
|
return child;
|
|
137
154
|
}
|
|
138
155
|
|
|
139
|
-
map(f) {
|
|
140
|
-
return this.add(map(f));
|
|
156
|
+
map(f, label) {
|
|
157
|
+
return this.add(map(f), { op: 'map', fnName: f.name, label });
|
|
141
158
|
}
|
|
142
159
|
|
|
143
|
-
filter(pred) {
|
|
144
|
-
return this.add(filter(pred));
|
|
160
|
+
filter(pred, label) {
|
|
161
|
+
return this.add(filter(pred), { op: 'filter', fnName: pred.name, label });
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
split(pred) {
|
|
148
|
-
return [
|
|
164
|
+
split(pred, labels) {
|
|
165
|
+
return [
|
|
166
|
+
this.add(filter(pred), {
|
|
167
|
+
op: 'split-true',
|
|
168
|
+
fnName: pred.name,
|
|
169
|
+
label: labels && labels[0],
|
|
170
|
+
}),
|
|
171
|
+
this.add(filter(complement(pred)), {
|
|
172
|
+
op: 'split-false',
|
|
173
|
+
fnName: pred.name,
|
|
174
|
+
label: labels && labels[1],
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
149
177
|
}
|
|
150
178
|
|
|
151
|
-
by(f) {
|
|
152
|
-
return this.add(by(f));
|
|
179
|
+
by(f, label) {
|
|
180
|
+
return this.add(by(f), { op: 'by', fnName: f.name, label });
|
|
153
181
|
}
|
|
154
182
|
|
|
155
183
|
create() {
|
|
184
|
+
const self = this;
|
|
185
|
+
const countXf = (stream) => (event) => {
|
|
186
|
+
self.counter++;
|
|
187
|
+
const second = Math.floor(Date.now() / 1000);
|
|
188
|
+
const bucket = self.rateBuckets[second % self.rateBuckets.length];
|
|
189
|
+
if (bucket.second !== second) {
|
|
190
|
+
bucket.second = second;
|
|
191
|
+
bucket.count = 0;
|
|
192
|
+
}
|
|
193
|
+
bucket.count++;
|
|
194
|
+
forward(stream, event);
|
|
195
|
+
};
|
|
156
196
|
if (this.children.length === 0) {
|
|
157
|
-
return this.xf;
|
|
197
|
+
return comp([this.xf, countXf]);
|
|
158
198
|
}
|
|
159
|
-
return comp([
|
|
199
|
+
return comp([
|
|
200
|
+
this.xf,
|
|
201
|
+
countXf,
|
|
202
|
+
multiplex(this.children.map((b) => b.create())),
|
|
203
|
+
]);
|
|
160
204
|
}
|
|
161
205
|
|
|
162
206
|
// Helpers
|
|
@@ -194,6 +238,9 @@ class Pipeline extends Builder {
|
|
|
194
238
|
main: this,
|
|
195
239
|
};
|
|
196
240
|
this.pipeline = this;
|
|
241
|
+
this.name = 'raw';
|
|
242
|
+
this.currentModule = null;
|
|
243
|
+
this.op = 'identity';
|
|
197
244
|
}
|
|
198
245
|
|
|
199
246
|
registerInput(input) {
|
|
@@ -204,6 +251,16 @@ class Pipeline extends Builder {
|
|
|
204
251
|
type: 'input',
|
|
205
252
|
});
|
|
206
253
|
this.monitors.push(monitor);
|
|
254
|
+
// Create a tap node so this input gets its own /logs/ WebSocket endpoint
|
|
255
|
+
const inputIndex = this.inputs.length;
|
|
256
|
+
const nodeName = `input-${inputIndex}`;
|
|
257
|
+
const tap = new Builder(identity(), this);
|
|
258
|
+
tap.name = nodeName;
|
|
259
|
+
tap.op = 'input';
|
|
260
|
+
tap.module = null;
|
|
261
|
+
tap.fnName = null;
|
|
262
|
+
this.registerNode(nodeName, tap);
|
|
263
|
+
input._tap = tap;
|
|
207
264
|
}
|
|
208
265
|
|
|
209
266
|
start() {
|
|
@@ -213,6 +270,8 @@ class Pipeline extends Builder {
|
|
|
213
270
|
const monitor = this.monitors.find(
|
|
214
271
|
(monitor) => monitor.type === 'input' && monitor.name === input.name
|
|
215
272
|
);
|
|
273
|
+
// Build the tap stream so it can forward logs to WebSocket clients
|
|
274
|
+
const tapStream = input._tap ? input._tap.create()(() => {}) : null;
|
|
216
275
|
input.start({
|
|
217
276
|
success: (log) => {
|
|
218
277
|
const valid = validate(log.toJS());
|
|
@@ -224,6 +283,9 @@ class Pipeline extends Builder {
|
|
|
224
283
|
data: log,
|
|
225
284
|
});
|
|
226
285
|
monitor.hit('accepted');
|
|
286
|
+
if (tapStream) {
|
|
287
|
+
forward(tapStream, event);
|
|
288
|
+
}
|
|
227
289
|
forward(stream, event);
|
|
228
290
|
} else {
|
|
229
291
|
monitor.hit('rejected');
|
|
@@ -249,19 +311,83 @@ class Pipeline extends Builder {
|
|
|
249
311
|
});
|
|
250
312
|
}
|
|
251
313
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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()))
|
|
255
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
|
+
}
|
|
256
333
|
}
|
|
257
334
|
|
|
258
335
|
registerNode(name, node) {
|
|
336
|
+
const previous = this.nodes[name];
|
|
337
|
+
if (previous && previous !== node && previous.name === name) {
|
|
338
|
+
previous.name = null;
|
|
339
|
+
}
|
|
259
340
|
this.nodes[name] = node;
|
|
260
341
|
}
|
|
261
342
|
|
|
262
343
|
getNode(name) {
|
|
263
344
|
return this.nodes[name];
|
|
264
345
|
}
|
|
346
|
+
|
|
347
|
+
getTree(node = this) {
|
|
348
|
+
const second = Math.floor(Date.now() / 1000);
|
|
349
|
+
const windowStart = second - node.rateBuckets.length + 1;
|
|
350
|
+
const rateCount = node.rateBuckets.reduce(
|
|
351
|
+
(count, bucket) =>
|
|
352
|
+
bucket.second >= windowStart && bucket.second <= second
|
|
353
|
+
? count + bucket.count
|
|
354
|
+
: count,
|
|
355
|
+
0
|
|
356
|
+
);
|
|
357
|
+
const obj = {
|
|
358
|
+
name: node.name || null,
|
|
359
|
+
op: node.op || null,
|
|
360
|
+
module: node.module || null,
|
|
361
|
+
fnName: node.fnName || null,
|
|
362
|
+
label: node._label || null,
|
|
363
|
+
count: node.counter,
|
|
364
|
+
rate: rateCount / node.rateBuckets.length,
|
|
365
|
+
children: node.children.map((child) => this.getTree(child)),
|
|
366
|
+
};
|
|
367
|
+
// Include inputs at the root level
|
|
368
|
+
if (node === this) {
|
|
369
|
+
obj.inputs = this.inputs.map((input) => {
|
|
370
|
+
const monitor = this.monitors.find(
|
|
371
|
+
(m) => m.type === 'input' && m.name === input.name
|
|
372
|
+
);
|
|
373
|
+
const accepted = monitor
|
|
374
|
+
? monitor.speeds.accepted.per_minute.compute()
|
|
375
|
+
: null;
|
|
376
|
+
const rejected = monitor
|
|
377
|
+
? monitor.speeds.rejected.per_minute.compute()
|
|
378
|
+
: null;
|
|
379
|
+
return {
|
|
380
|
+
name: input.name,
|
|
381
|
+
node: input._tap ? input._tap.name : null,
|
|
382
|
+
tree: input._tap ? this.getTree(input._tap) : null,
|
|
383
|
+
status: monitor ? monitor.status : null,
|
|
384
|
+
accepted: accepted ? accepted.reduce((a, b) => a + b, 0) : 0,
|
|
385
|
+
rejected: rejected ? rejected.reduce((a, b) => a + b, 0) : 0,
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return obj;
|
|
390
|
+
}
|
|
265
391
|
}
|
|
266
392
|
|
|
267
393
|
module.exports = new Pipeline();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track distinct keys with the time they were last seen, bounded to a
|
|
3
|
+
* rolling window. Backs the addressCount15m/24h and signatureCount15m/24h
|
|
4
|
+
* counters.
|
|
5
|
+
*/
|
|
6
|
+
const { Map } = require('immutable');
|
|
7
|
+
|
|
8
|
+
const { now } = require('./util');
|
|
9
|
+
|
|
10
|
+
// Rolling window in seconds
|
|
11
|
+
const WINDOW = 24 * 3600;
|
|
12
|
+
|
|
13
|
+
// Record `key` as seen at `time` in an Immutable Map<key, unix seconds>
|
|
14
|
+
const touch = (map = Map(), key, time = now()) => map.set(key, time);
|
|
15
|
+
|
|
16
|
+
// Drop keys not seen in the last WINDOW seconds
|
|
17
|
+
const prune = (map, time = now()) => map.filter((seen) => seen > time - WINDOW);
|
|
18
|
+
|
|
19
|
+
// Number of keys seen in the last `window` seconds
|
|
20
|
+
const countRecent = (map, window = WINDOW, time = now()) =>
|
|
21
|
+
map ? map.count((seen) => seen > time - window) : 0;
|
|
22
|
+
|
|
23
|
+
module.exports = { WINDOW, touch, prune, countRecent };
|
package/src/modules/address.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
const { is } = require('immutable');
|
|
2
|
+
|
|
1
3
|
const api = require('../app/api');
|
|
2
4
|
const { Aggregator } = require('../lib/aggregator');
|
|
3
5
|
const pipeline = require('../lib/pipeline');
|
|
6
|
+
const { touch, prune, countRecent } = require('../lib/recent-map');
|
|
4
7
|
|
|
5
8
|
const identifier = (log) => log.getIn(['address', 'value']);
|
|
6
9
|
|
|
10
|
+
const signatureCount15m = (entry) =>
|
|
11
|
+
countRecent(entry.get('signatures'), 15 * 60);
|
|
12
|
+
const signatureCount24h = (entry) => countRecent(entry.get('signatures'));
|
|
13
|
+
|
|
14
|
+
let aggregator;
|
|
15
|
+
|
|
7
16
|
function fill(log) {
|
|
8
17
|
if (!log.hasIn(['address', 'value'])) {
|
|
9
18
|
log = log.setIn(['address', 'value'], log.getIn(['request', 'address']));
|
|
@@ -16,11 +25,56 @@ function init() {
|
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
function start() {
|
|
19
|
-
|
|
28
|
+
aggregator = new Aggregator();
|
|
20
29
|
|
|
21
30
|
aggregator.setIdentifier(identifier);
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
const enricher = (entry, log) => {
|
|
33
|
+
for (const field of [
|
|
34
|
+
'address',
|
|
35
|
+
'identity',
|
|
36
|
+
'cloudflare',
|
|
37
|
+
'dnsbl',
|
|
38
|
+
'geoip',
|
|
39
|
+
'hostname',
|
|
40
|
+
'agent',
|
|
41
|
+
'language',
|
|
42
|
+
'signature',
|
|
43
|
+
]) {
|
|
44
|
+
if (log.has(field) && !is(log.get(field), entry.get(field))) {
|
|
45
|
+
entry = entry.set(field, log.get(field));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Distinct signature IDs with last-seen time, pruned to 24h; backs both
|
|
50
|
+
// the 15m and 24h counts
|
|
51
|
+
const signatureId = log.getIn(['signature', 'id']);
|
|
52
|
+
if (signatureId) {
|
|
53
|
+
entry = entry.update('signatures', (map) => touch(map, signatureId));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return entry;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
aggregator.setEnricher(enricher);
|
|
60
|
+
|
|
61
|
+
aggregator.setEntryGc((entry) =>
|
|
62
|
+
entry.has('signatures') ? entry.update('signatures', prune) : entry
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
aggregator.formatter.insertFormat('signatureCount15m', signatureCount15m, {
|
|
66
|
+
before: 'count15m',
|
|
67
|
+
});
|
|
68
|
+
aggregator.formatter.insertFormat('signatureCount24h', signatureCount24h, {
|
|
69
|
+
before: 'count15m',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
aggregator.sorters.signatureCount15m = signatureCount15m;
|
|
73
|
+
aggregator.sorters.signatureCount24h = signatureCount24h;
|
|
74
|
+
|
|
75
|
+
pipeline
|
|
76
|
+
.getNode('main')
|
|
77
|
+
.map((log) => aggregator.processLog(log), 'aggregator');
|
|
24
78
|
|
|
25
79
|
api.registerAggregator('addresses', aggregator);
|
|
26
80
|
}
|
|
@@ -28,4 +82,7 @@ function start() {
|
|
|
28
82
|
module.exports = {
|
|
29
83
|
init,
|
|
30
84
|
start,
|
|
85
|
+
get aggregator() {
|
|
86
|
+
return aggregator;
|
|
87
|
+
},
|
|
31
88
|
};
|
package/src/modules/dnsbl.js
CHANGED
|
@@ -4,11 +4,19 @@ const aggregator = require('../lib/aggregator');
|
|
|
4
4
|
const cache = require('../lib/cache');
|
|
5
5
|
const pipeline = require('../lib/pipeline');
|
|
6
6
|
|
|
7
|
+
// Indirection so tests can swap the DNS lookup without hitting the network.
|
|
8
|
+
const defaultLookup = (ip, blacklist) => dnsbl.lookup(ip, blacklist);
|
|
9
|
+
let lookup = defaultLookup;
|
|
10
|
+
|
|
11
|
+
function setLookup(fn = defaultLookup) {
|
|
12
|
+
lookup = fn;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
async function xblLookup(ip) {
|
|
8
16
|
if (await cache.has(`xbl-${ip}`)) {
|
|
9
17
|
return cache.get(`xbl-${ip}`);
|
|
10
18
|
}
|
|
11
|
-
const result = await
|
|
19
|
+
const result = await lookup(ip, 'xbl.spamhaus.org');
|
|
12
20
|
cache.set(`xbl-${ip}`, result);
|
|
13
21
|
return result;
|
|
14
22
|
}
|
|
@@ -45,4 +53,6 @@ function init() {
|
|
|
45
53
|
module.exports = {
|
|
46
54
|
augment,
|
|
47
55
|
init,
|
|
56
|
+
setLookup,
|
|
57
|
+
xblFormat,
|
|
48
58
|
};
|
package/src/modules/history.js
CHANGED
|
@@ -1,42 +1,18 @@
|
|
|
1
1
|
const { api } = require('../app');
|
|
2
2
|
const constants = require('../constants');
|
|
3
|
+
const LogBuffer = require('../lib/log-buffer');
|
|
4
|
+
const persistence = require('../lib/persistence');
|
|
3
5
|
const pipeline = require('../lib/pipeline');
|
|
4
6
|
|
|
5
|
-
class CircularBuffer {
|
|
6
|
-
constructor(capacity = 1000) {
|
|
7
|
-
this.capacity = capacity;
|
|
8
|
-
this.buffer = new Array(capacity);
|
|
9
|
-
this.pointer = 0;
|
|
10
|
-
this.size = 0;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
push(item) {
|
|
14
|
-
this.buffer[this.pointer] = item;
|
|
15
|
-
this.pointer = (this.pointer + 1) % this.capacity;
|
|
16
|
-
if (this.size < this.capacity) {
|
|
17
|
-
this.size++;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
toArray() {
|
|
22
|
-
if (this.size < this.capacity) {
|
|
23
|
-
return this.buffer.slice(0, this.size).reverse();
|
|
24
|
-
}
|
|
25
|
-
return [
|
|
26
|
-
...this.buffer.slice(this.pointer),
|
|
27
|
-
...this.buffer.slice(0, this.pointer),
|
|
28
|
-
].reverse();
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
7
|
function start() {
|
|
33
8
|
const capacity =
|
|
34
9
|
(constants.modules.history && constants.modules.history.capacity) || 1000;
|
|
35
10
|
const buffers = {};
|
|
36
11
|
|
|
37
12
|
function registerNodeHistory(name, node) {
|
|
38
|
-
const buffer = new
|
|
13
|
+
const buffer = new LogBuffer(capacity);
|
|
39
14
|
buffers[name] = buffer;
|
|
15
|
+
persistence.register(`history-${persistence.safeName(name)}`, buffer);
|
|
40
16
|
|
|
41
17
|
node.map((log) => {
|
|
42
18
|
buffer.push(log);
|
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
|
@@ -3,21 +3,34 @@ const IPCIDR = require('ip-cidr').default;
|
|
|
3
3
|
const api = require('../app/api');
|
|
4
4
|
// Bot IP lists for identity verification
|
|
5
5
|
// Run `node scripts/fetch-openai-ips.js` to update OpenAI lists
|
|
6
|
+
// Run `node scripts/fetch-anthropic-ips.js` to update the Claude list
|
|
6
7
|
const amazonSearchBotIps = require('../data/amazon-searchbot-ips.json');
|
|
7
8
|
const amazonUserIps = require('../data/amazon-user-ips.json');
|
|
8
9
|
const amazonBotIps = require('../data/amazonbot-ips.json');
|
|
9
10
|
const chatgptUserIps = require('../data/chatgpt-user-ips.json');
|
|
11
|
+
const claudeBotIps = require('../data/claude-bot-ips.json');
|
|
10
12
|
const gptbotIps = require('../data/gptbot-ips.json');
|
|
11
13
|
const openaiSearchbotIps = require('../data/openai-searchbot-ips.json');
|
|
12
14
|
const { Aggregator } = require('../lib/aggregator');
|
|
13
15
|
const pipeline = require('../lib/pipeline');
|
|
14
16
|
|
|
17
|
+
// Anthropic publishes one list of ranges covering all Claude crawlers.
|
|
18
|
+
// Reverse DNS is not usable here: Claude crawlers run on shared cloud
|
|
19
|
+
// infrastructure, so their PTR records are not Anthropic-controlled.
|
|
20
|
+
const claudeBotCidrs = claudeBotIps.map((cidr) => new IPCIDR(cidr));
|
|
21
|
+
|
|
15
22
|
function augment(log) {
|
|
16
23
|
const family = log.getIn(['agent', 'family']);
|
|
17
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;
|
|
18
30
|
const address =
|
|
19
31
|
log.getIn(['address', 'value']) || log.getIn(['request', 'address']);
|
|
20
32
|
const signature = log.getIn(['signature', 'id']);
|
|
33
|
+
const signatureAgent = log.getIn(['request', 'headers', 'signature-agent']);
|
|
21
34
|
|
|
22
35
|
switch (family) {
|
|
23
36
|
// Per hostname
|
|
@@ -54,8 +67,23 @@ function augment(log) {
|
|
|
54
67
|
return hostname && hostname.endsWith('.yandex.com')
|
|
55
68
|
? log.set('identity', 'Yandex')
|
|
56
69
|
: log;
|
|
70
|
+
// https://www.semrush.com/bot/ (SemrushBot-BA and SemrushBot-SI parse
|
|
71
|
+
// as SemrushBot)
|
|
57
72
|
case 'SemrushBot':
|
|
58
|
-
|
|
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
|
+
) {
|
|
59
87
|
return log.set('identity', 'Semrush');
|
|
60
88
|
}
|
|
61
89
|
break;
|
|
@@ -177,6 +205,16 @@ function augment(log) {
|
|
|
177
205
|
return hostname && hostname.endsWith('.babbar.eu')
|
|
178
206
|
? log.set('identity', 'Babbar')
|
|
179
207
|
: log;
|
|
208
|
+
case 'Reflectionbot':
|
|
209
|
+
// https://reflection.ai/bot
|
|
210
|
+
return verifiedHostname && verifiedHostname.endsWith('.reflection.ai')
|
|
211
|
+
? log.set('identity', 'Reflection')
|
|
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;
|
|
180
218
|
case 'bnf.fr bot':
|
|
181
219
|
return hostname && hostname.endsWith('.bnf.fr')
|
|
182
220
|
? log.set('identity', 'BnF.fr')
|
|
@@ -237,6 +275,22 @@ function augment(log) {
|
|
|
237
275
|
return hostname && hostname.endsWith('.blex.seranking.com')
|
|
238
276
|
? log.set('identity', 'SE Ranking')
|
|
239
277
|
: log;
|
|
278
|
+
case 'SofyaBot':
|
|
279
|
+
return hostname && hostname.endsWith('.sofya.co')
|
|
280
|
+
? log.set('identity', 'Sofya')
|
|
281
|
+
: log;
|
|
282
|
+
case 'YouBot':
|
|
283
|
+
// https://docs.you.com/youbot
|
|
284
|
+
return hostname && hostname.endsWith('.search.you.com')
|
|
285
|
+
? log.set('identity', 'You.com')
|
|
286
|
+
: log;
|
|
287
|
+
case 'AIWebIndex':
|
|
288
|
+
case 'AIWebIndex-Agent':
|
|
289
|
+
// Lyrenth AI-readable web index, forward-confirmed rDNS under lyrenth.com
|
|
290
|
+
// https://lyrenth.com/bot
|
|
291
|
+
return hostname && hostname.endsWith('.lyrenth.com')
|
|
292
|
+
? log.set('identity', 'Lyrenth')
|
|
293
|
+
: log;
|
|
240
294
|
|
|
241
295
|
// Per hostname + CIDR
|
|
242
296
|
case 'Twitterbot':
|
|
@@ -276,6 +330,11 @@ function augment(log) {
|
|
|
276
330
|
return address && new IPCIDR('203.133.160.0/19').contains(address)
|
|
277
331
|
? log.set('identity', family)
|
|
278
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;
|
|
279
338
|
case 'OAI-SearchBot':
|
|
280
339
|
return openaiSearchbotIps.some((cidr) =>
|
|
281
340
|
new IPCIDR(cidr).contains(address)
|
|
@@ -291,11 +350,18 @@ function augment(log) {
|
|
|
291
350
|
return chatgptUserIps.some((cidr) => new IPCIDR(cidr).contains(address))
|
|
292
351
|
? log.set('identity', 'ChatGPT')
|
|
293
352
|
: log;
|
|
353
|
+
case 'ClaudeBot':
|
|
354
|
+
case 'Claude-User':
|
|
355
|
+
case 'Claude-SearchBot':
|
|
356
|
+
case 'Claude-Web':
|
|
357
|
+
case 'anthropic-ai':
|
|
358
|
+
// https://claude.com/crawling/bots.json
|
|
359
|
+
return address && claudeBotCidrs.some((cidr) => cidr.contains(address))
|
|
360
|
+
? log.set('identity', 'Claude')
|
|
361
|
+
: log;
|
|
294
362
|
case 'meta-externalagent':
|
|
295
363
|
case 'meta-webindexer':
|
|
296
|
-
return address &&
|
|
297
|
-
(new IPCIDR('2a03:2880::/29').contains(address) ||
|
|
298
|
-
new IPCIDR('2a06:98c0:3600::/48').contains(address))
|
|
364
|
+
return address && new IPCIDR('2a03:2880::/29').contains(address)
|
|
299
365
|
? log.set('identity', 'Meta')
|
|
300
366
|
: log;
|
|
301
367
|
|
|
@@ -335,10 +401,6 @@ function augment(log) {
|
|
|
335
401
|
hostname.endsWith('.eu-central-1.compute.amazonaws.com')
|
|
336
402
|
? log.set('identity', 'Wise')
|
|
337
403
|
: log;
|
|
338
|
-
case 'ClaudeBot':
|
|
339
|
-
return hostname && hostname.endsWith('.us-east-2.compute.amazonaws.com')
|
|
340
|
-
? log.set('identity', 'Claude')
|
|
341
|
-
: log;
|
|
342
404
|
case 'PerplexityBot':
|
|
343
405
|
return hostname && hostname.endsWith('.compute-1.amazonaws.com')
|
|
344
406
|
? log.set('identity', 'Perplexity')
|
|
@@ -365,6 +427,12 @@ function augment(log) {
|
|
|
365
427
|
return hostname && hostname.endsWith('.googleusercontent.com')
|
|
366
428
|
? log.set('identity', 'Dolfe')
|
|
367
429
|
: log;
|
|
430
|
+
case 'ShapBot':
|
|
431
|
+
// Parallel Web Systems crawler, runs on GCE
|
|
432
|
+
// https://docs.parallel.ai/resources/crawler
|
|
433
|
+
return hostname && hostname.endsWith('.googleusercontent.com')
|
|
434
|
+
? log.set('identity', 'Parallel')
|
|
435
|
+
: log;
|
|
368
436
|
|
|
369
437
|
// Hetzner
|
|
370
438
|
case 'Ubermetrics':
|
|
@@ -391,6 +459,15 @@ function augment(log) {
|
|
|
391
459
|
return hostname && hostname.endsWith('.flipboard.com')
|
|
392
460
|
? log.set('identity', 'Flipboard')
|
|
393
461
|
: log;
|
|
462
|
+
|
|
463
|
+
// Web Bot Auth
|
|
464
|
+
case 'ExaSearchBot':
|
|
465
|
+
// Exa search crawler. No published IP ranges or reverse DNS: requests
|
|
466
|
+
// are signed (RFC 9421) and carry a `Signature-Agent` header
|
|
467
|
+
// https://crawler.exa.ai/
|
|
468
|
+
return signatureAgent && signatureAgent.includes('https://crawler.exa.ai')
|
|
469
|
+
? log.set('identity', 'Exa')
|
|
470
|
+
: log;
|
|
394
471
|
}
|
|
395
472
|
|
|
396
473
|
// Hostname only
|
|
@@ -416,6 +493,10 @@ function augment(log) {
|
|
|
416
493
|
if (hostname.endsWith('.qwant.com')) {
|
|
417
494
|
return log.set('identity', 'Qwant');
|
|
418
495
|
}
|
|
496
|
+
// Semrush crawlers not named above
|
|
497
|
+
if (verifiedHostname && verifiedHostname.endsWith('.semrush.com')) {
|
|
498
|
+
return log.set('identity', 'Semrush');
|
|
499
|
+
}
|
|
419
500
|
}
|
|
420
501
|
|
|
421
502
|
// Signature
|
|
@@ -448,7 +529,9 @@ function start() {
|
|
|
448
529
|
|
|
449
530
|
aggregator.setIdentifier(identifier);
|
|
450
531
|
|
|
451
|
-
pipeline
|
|
532
|
+
pipeline
|
|
533
|
+
.getNode('main')
|
|
534
|
+
.map((log) => aggregator.processLog(log), 'aggregator');
|
|
452
535
|
|
|
453
536
|
api.registerAggregator('identities', aggregator);
|
|
454
537
|
}
|
package/src/modules/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const debug = require('debug');
|
|
2
2
|
|
|
3
3
|
const constants = require('../constants');
|
|
4
|
+
const pipeline = require('../lib/pipeline');
|
|
4
5
|
|
|
5
6
|
const debugModules = debug('hyperwatch:modules');
|
|
6
7
|
|
|
@@ -38,27 +39,35 @@ function get(module) {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function
|
|
42
|
+
function activeModulesWithKeys() {
|
|
42
43
|
return Object.keys(constants.modules)
|
|
43
44
|
.map((key) => Object.assign({ key }, constants.modules[key]))
|
|
44
45
|
.sort((a, b) => a.priority - b.priority)
|
|
45
46
|
.filter((m) => m.active === true)
|
|
46
|
-
.map((m) => get(m.key))
|
|
47
|
-
.filter((m) => m);
|
|
47
|
+
.map((m) => ({ key: m.key, module: get(m.key) }))
|
|
48
|
+
.filter((m) => m.module);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function activeModules() {
|
|
52
|
+
return activeModulesWithKeys().map((m) => m.module);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function init() {
|
|
51
|
-
for (const module of
|
|
56
|
+
for (const { key, module } of activeModulesWithKeys()) {
|
|
52
57
|
if (module && module.init) {
|
|
58
|
+
pipeline.currentModule = key;
|
|
53
59
|
module.init();
|
|
60
|
+
pipeline.currentModule = null;
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
function start() {
|
|
59
|
-
for (const module of
|
|
66
|
+
for (const { key, module } of activeModulesWithKeys()) {
|
|
60
67
|
if (module && module.start) {
|
|
68
|
+
pipeline.currentModule = key;
|
|
61
69
|
module.start();
|
|
70
|
+
pipeline.currentModule = null;
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
}
|