@hyperwatch/hyperwatch 4.3.1 → 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 +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/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +2 -2
- 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 +122 -11
- 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/identity.js +57 -8
- 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/aggregator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { Map, Set, fromJS, is } = require('immutable');
|
|
1
|
+
const { List, Map, Set, fromJS, is } = require('immutable');
|
|
2
2
|
|
|
3
3
|
const { Formatter, address, identity } = require('../lib/formatter');
|
|
4
4
|
const { Speed } = require('../lib/speed');
|
|
@@ -9,17 +9,35 @@ const {
|
|
|
9
9
|
md5,
|
|
10
10
|
} = require('../lib/util');
|
|
11
11
|
|
|
12
|
+
const lastSeen = (entry) => {
|
|
13
|
+
const ts =
|
|
14
|
+
entry.getIn(['speed', 'per_minute']) &&
|
|
15
|
+
entry.getIn(['speed', 'per_minute']).latest;
|
|
16
|
+
return ts ? new Date(ts * 1000).toISOString() : '';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const statusCount = (key) => (entry) =>
|
|
20
|
+
entry.getIn(['speed', key]) ? aggregateCount(entry, key) : 0;
|
|
21
|
+
|
|
12
22
|
const defaultFormatter = new Formatter();
|
|
13
23
|
defaultFormatter.setFormats([
|
|
14
24
|
['identity', identity],
|
|
15
25
|
|
|
16
|
-
['address', address],
|
|
26
|
+
['address', (entry) => entry.getIn(['address', 'value']) || ''],
|
|
27
|
+
['hostname', address],
|
|
17
28
|
|
|
18
29
|
['count15m', (entry) => aggregateCount(entry, 'per_minute')],
|
|
19
30
|
['count24h', (entry) => aggregateCount(entry, 'per_hour')],
|
|
20
31
|
|
|
32
|
+
['2xx15m', statusCount('2xx_per_minute')],
|
|
33
|
+
['2xx24h', statusCount('2xx_per_hour')],
|
|
34
|
+
['4xx15m', statusCount('4xx_per_minute')],
|
|
35
|
+
['4xx24h', statusCount('4xx_per_hour')],
|
|
36
|
+
|
|
21
37
|
['execTime15m', (entry) => formatDuration(aggregateSum(entry, 'per_minute'))],
|
|
22
38
|
['execTime24h', (entry) => formatDuration(aggregateSum(entry, 'per_hour'))],
|
|
39
|
+
|
|
40
|
+
['lastSeen', lastSeen],
|
|
23
41
|
]);
|
|
24
42
|
|
|
25
43
|
const defaultEnricher = (entry, log) => {
|
|
@@ -52,6 +70,10 @@ const defaultIdentifier = (log) => {
|
|
|
52
70
|
const defaultSorters = {
|
|
53
71
|
count15m: (entry) => aggregateCount(entry, 'per_minute'),
|
|
54
72
|
count24h: (entry) => aggregateCount(entry, 'per_hour'),
|
|
73
|
+
'2xx15m': statusCount('2xx_per_minute'),
|
|
74
|
+
'2xx24h': statusCount('2xx_per_hour'),
|
|
75
|
+
'4xx15m': statusCount('4xx_per_minute'),
|
|
76
|
+
'4xx24h': statusCount('4xx_per_hour'),
|
|
55
77
|
latest: (entry) => entry.getIn(['speed', 'per_minute']).latest,
|
|
56
78
|
execTime15m: (entry) => aggregateSum(entry, 'per_minute'),
|
|
57
79
|
execTime24h: (entry) => aggregateSum(entry, 'per_hour'),
|
|
@@ -60,10 +82,14 @@ const defaultSorters = {
|
|
|
60
82
|
class Aggregator {
|
|
61
83
|
constructor() {
|
|
62
84
|
this.entries = new Map();
|
|
63
|
-
|
|
85
|
+
// Snapshot the default formats: modules extend defaultFormatter during
|
|
86
|
+
// init, aggregators are created during start, and per-aggregator
|
|
87
|
+
// insertFormat calls must not leak into other aggregators.
|
|
88
|
+
this.formatter = defaultFormatter.clone();
|
|
64
89
|
this.enricher = defaultEnricher;
|
|
65
90
|
this.identifier = defaultIdentifier;
|
|
66
|
-
this.sorters = defaultSorters;
|
|
91
|
+
this.sorters = { ...defaultSorters };
|
|
92
|
+
this.entryGc = null;
|
|
67
93
|
this.gcSize = 1000;
|
|
68
94
|
|
|
69
95
|
setInterval(() => this.gc(), 60 * 1000).unref();
|
|
@@ -81,6 +107,13 @@ class Aggregator {
|
|
|
81
107
|
return this;
|
|
82
108
|
}
|
|
83
109
|
|
|
110
|
+
// Per-entry cleanup run on every gc pass, regardless of aggregator size
|
|
111
|
+
setEntryGc(fn) {
|
|
112
|
+
this.entryGc = fn;
|
|
113
|
+
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
84
117
|
setIdentifier(fn) {
|
|
85
118
|
this.identifier = fn;
|
|
86
119
|
|
|
@@ -93,6 +126,8 @@ class Aggregator {
|
|
|
93
126
|
const rawExecTime = Number(log.get('executionTime'));
|
|
94
127
|
const executionTime = Number.isFinite(rawExecTime) ? rawExecTime : 0;
|
|
95
128
|
|
|
129
|
+
const status = log.getIn(['response', 'status']);
|
|
130
|
+
|
|
96
131
|
if (!this.entries.has(id)) {
|
|
97
132
|
this.entries = this.entries
|
|
98
133
|
.setIn([id, 'id'], id)
|
|
@@ -104,7 +139,11 @@ class Aggregator {
|
|
|
104
139
|
.setIn(
|
|
105
140
|
[id, 'speed', 'per_hour'],
|
|
106
141
|
new Speed(3600, 24).hit(undefined, executionTime)
|
|
107
|
-
)
|
|
142
|
+
)
|
|
143
|
+
.setIn([id, 'speed', '2xx_per_minute'], new Speed(60, 15))
|
|
144
|
+
.setIn([id, 'speed', '2xx_per_hour'], new Speed(3600, 24))
|
|
145
|
+
.setIn([id, 'speed', '4xx_per_minute'], new Speed(60, 15))
|
|
146
|
+
.setIn([id, 'speed', '4xx_per_hour'], new Speed(3600, 24));
|
|
108
147
|
} else {
|
|
109
148
|
this.entries = this.entries
|
|
110
149
|
.updateIn([id, 'speed', 'per_minute'], (speed) =>
|
|
@@ -115,6 +154,16 @@ class Aggregator {
|
|
|
115
154
|
);
|
|
116
155
|
}
|
|
117
156
|
|
|
157
|
+
if (status >= 200 && status < 300) {
|
|
158
|
+
this.entries = this.entries
|
|
159
|
+
.updateIn([id, 'speed', '2xx_per_minute'], (speed) => speed.hit())
|
|
160
|
+
.updateIn([id, 'speed', '2xx_per_hour'], (speed) => speed.hit());
|
|
161
|
+
} else if (status >= 400 && status < 500) {
|
|
162
|
+
this.entries = this.entries
|
|
163
|
+
.updateIn([id, 'speed', '4xx_per_minute'], (speed) => speed.hit())
|
|
164
|
+
.updateIn([id, 'speed', '4xx_per_hour'], (speed) => speed.hit());
|
|
165
|
+
}
|
|
166
|
+
|
|
118
167
|
this.entries = this.entries.updateIn([id], (entry) =>
|
|
119
168
|
this.enricher(entry, log)
|
|
120
169
|
);
|
|
@@ -131,10 +180,8 @@ class Aggregator {
|
|
|
131
180
|
sort = 'count15m';
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
.sort()
|
|
137
|
-
.reverse()
|
|
183
|
+
const sorted = this.entries.map(this.sorters[sort]).sort().reverse();
|
|
184
|
+
const rawData = sorted
|
|
138
185
|
.slice(0, limit || 100)
|
|
139
186
|
.keySeq()
|
|
140
187
|
.map((id) => this.entries.get(id));
|
|
@@ -146,7 +193,15 @@ class Aggregator {
|
|
|
146
193
|
: rawData.map((entry) => this.formatter.formatObject(entry, output));
|
|
147
194
|
}
|
|
148
195
|
|
|
196
|
+
reset() {
|
|
197
|
+
this.entries = new Map();
|
|
198
|
+
}
|
|
199
|
+
|
|
149
200
|
gc() {
|
|
201
|
+
if (this.entryGc) {
|
|
202
|
+
this.entries = this.entries.map(this.entryGc);
|
|
203
|
+
}
|
|
204
|
+
|
|
150
205
|
if (this.entries.size < this.gcSize) {
|
|
151
206
|
return;
|
|
152
207
|
}
|
|
@@ -169,10 +224,10 @@ class Aggregator {
|
|
|
169
224
|
return this.entries
|
|
170
225
|
.map((entry) => {
|
|
171
226
|
const plain = entry.toJS();
|
|
172
|
-
plain.speed =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
227
|
+
plain.speed = entry
|
|
228
|
+
.get('speed')
|
|
229
|
+
.map((speed) => speed.toJSON())
|
|
230
|
+
.toObject();
|
|
176
231
|
return plain;
|
|
177
232
|
})
|
|
178
233
|
.valueSeq()
|
|
@@ -183,21 +238,59 @@ class Aggregator {
|
|
|
183
238
|
for (const item of data) {
|
|
184
239
|
const { speed, ...rest } = item;
|
|
185
240
|
// fromJS deep-converts everything to Immutable structures.
|
|
186
|
-
// Signature headers must stay as a plain object (used with Object.entries)
|
|
187
|
-
// and addresses must be a Set, not a List — fix both after conversion.
|
|
241
|
+
// Signature headers must stay as a plain object (used with Object.entries).
|
|
188
242
|
let entry = fromJS(rest);
|
|
189
243
|
if (entry.hasIn(['signature', 'headers'])) {
|
|
190
244
|
entry = entry.setIn(['signature', 'headers'], rest.signature.headers);
|
|
191
245
|
}
|
|
192
|
-
|
|
193
|
-
|
|
246
|
+
// addresses/signatures are Map<id, lastSeen>. Dumps from before that
|
|
247
|
+
// stored plain arrays, which the enrichers can't update — discard them
|
|
248
|
+
// and let the counts rebuild from live traffic.
|
|
249
|
+
for (const key of ['addresses', 'signatures']) {
|
|
250
|
+
if (List.isList(entry.get(key))) {
|
|
251
|
+
entry = entry.delete(key);
|
|
252
|
+
}
|
|
194
253
|
}
|
|
195
254
|
entry = entry
|
|
196
255
|
.setIn(['speed', 'per_minute'], Speed.fromJSON(speed.per_minute))
|
|
197
|
-
.setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour))
|
|
256
|
+
.setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour))
|
|
257
|
+
.setIn(
|
|
258
|
+
['speed', '2xx_per_minute'],
|
|
259
|
+
speed['2xx_per_minute']
|
|
260
|
+
? Speed.fromJSON(speed['2xx_per_minute'])
|
|
261
|
+
: new Speed(60, 15)
|
|
262
|
+
)
|
|
263
|
+
.setIn(
|
|
264
|
+
['speed', '2xx_per_hour'],
|
|
265
|
+
speed['2xx_per_hour']
|
|
266
|
+
? Speed.fromJSON(speed['2xx_per_hour'])
|
|
267
|
+
: new Speed(3600, 24)
|
|
268
|
+
)
|
|
269
|
+
.setIn(
|
|
270
|
+
['speed', '4xx_per_minute'],
|
|
271
|
+
speed['4xx_per_minute']
|
|
272
|
+
? Speed.fromJSON(speed['4xx_per_minute'])
|
|
273
|
+
: new Speed(60, 15)
|
|
274
|
+
)
|
|
275
|
+
.setIn(
|
|
276
|
+
['speed', '4xx_per_hour'],
|
|
277
|
+
speed['4xx_per_hour']
|
|
278
|
+
? Speed.fromJSON(speed['4xx_per_hour'])
|
|
279
|
+
: new Speed(3600, 24)
|
|
280
|
+
);
|
|
198
281
|
this.entries = this.entries.set(rest.id, entry);
|
|
199
282
|
}
|
|
283
|
+
// Drop members that went stale while the process was down
|
|
284
|
+
if (this.entryGc) {
|
|
285
|
+
this.entries = this.entries.map(this.entryGc);
|
|
286
|
+
}
|
|
200
287
|
}
|
|
201
288
|
}
|
|
202
289
|
|
|
203
|
-
module.exports = {
|
|
290
|
+
module.exports = {
|
|
291
|
+
Aggregator,
|
|
292
|
+
defaultFormatter,
|
|
293
|
+
defaultEnricher,
|
|
294
|
+
lastSeen,
|
|
295
|
+
statusCount,
|
|
296
|
+
};
|
package/src/lib/formatter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const chalk = require('chalk');
|
|
1
|
+
const { default: chalk } = require('chalk');
|
|
2
2
|
|
|
3
3
|
const colorize = (name, value, output) => {
|
|
4
4
|
if (output === 'console') {
|
|
@@ -65,6 +65,15 @@ class Formatter {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
clone() {
|
|
69
|
+
const formatter = new Formatter();
|
|
70
|
+
formatter.formats = [...this.formats];
|
|
71
|
+
formatter.colors = { ...this.colors };
|
|
72
|
+
formatter.output = this.output;
|
|
73
|
+
|
|
74
|
+
return formatter;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
setOutput(output) {
|
|
69
78
|
this.output = output;
|
|
70
79
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the latest `capacity` logs of a pipeline node, newest first.
|
|
3
|
+
* dump() / load() let the persistence module save it across restarts.
|
|
4
|
+
*/
|
|
5
|
+
const { fromJS } = require('immutable');
|
|
6
|
+
|
|
7
|
+
class LogBuffer {
|
|
8
|
+
constructor(capacity = 1000) {
|
|
9
|
+
this.capacity = capacity;
|
|
10
|
+
this.buffer = new Array(capacity);
|
|
11
|
+
this.pointer = 0;
|
|
12
|
+
this.size = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(item) {
|
|
16
|
+
this.buffer[this.pointer] = item;
|
|
17
|
+
this.pointer = (this.pointer + 1) % this.capacity;
|
|
18
|
+
if (this.size < this.capacity) {
|
|
19
|
+
this.size++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toArray() {
|
|
24
|
+
if (this.size < this.capacity) {
|
|
25
|
+
return this.buffer.slice(0, this.size).reverse();
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
...this.buffer.slice(this.pointer),
|
|
29
|
+
...this.buffer.slice(0, this.pointer),
|
|
30
|
+
].reverse();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Oldest first, never more than capacity
|
|
34
|
+
dump() {
|
|
35
|
+
return this.toArray()
|
|
36
|
+
.reverse()
|
|
37
|
+
.map((log) => log.toJS());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
load(data) {
|
|
41
|
+
data.slice(-this.capacity).forEach((item) => this.push(fromJS(item)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = LogBuffer;
|
package/src/lib/persistence.js
CHANGED
|
@@ -9,6 +9,16 @@ const aggregators = Object.create(null);
|
|
|
9
9
|
|
|
10
10
|
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
|
|
11
11
|
|
|
12
|
+
// Encode any string into a name accepted by register(). Characters outside
|
|
13
|
+
// [A-Za-z0-9.-], including '_' itself, become _<hex code point>_, so distinct
|
|
14
|
+
// inputs never map to the same name.
|
|
15
|
+
function safeName(name) {
|
|
16
|
+
return String(name).replace(
|
|
17
|
+
/[^A-Za-z0-9.-]/gu,
|
|
18
|
+
(c) => `_${c.codePointAt(0).toString(16)}_`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
function register(name, aggregator) {
|
|
13
23
|
if (!SAFE_NAME.test(name)) {
|
|
14
24
|
throw new Error(`Invalid aggregator name for persistence: "${name}"`);
|
|
@@ -69,4 +79,4 @@ function load(dir) {
|
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
module.exports = { register, dump, load };
|
|
82
|
+
module.exports = { register, dump, load, safeName };
|
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');
|
|
@@ -256,12 +318,61 @@ class Pipeline extends Builder {
|
|
|
256
318
|
}
|
|
257
319
|
|
|
258
320
|
registerNode(name, node) {
|
|
321
|
+
const previous = this.nodes[name];
|
|
322
|
+
if (previous && previous !== node && previous.name === name) {
|
|
323
|
+
previous.name = null;
|
|
324
|
+
}
|
|
259
325
|
this.nodes[name] = node;
|
|
260
326
|
}
|
|
261
327
|
|
|
262
328
|
getNode(name) {
|
|
263
329
|
return this.nodes[name];
|
|
264
330
|
}
|
|
331
|
+
|
|
332
|
+
getTree(node = this) {
|
|
333
|
+
const second = Math.floor(Date.now() / 1000);
|
|
334
|
+
const windowStart = second - node.rateBuckets.length + 1;
|
|
335
|
+
const rateCount = node.rateBuckets.reduce(
|
|
336
|
+
(count, bucket) =>
|
|
337
|
+
bucket.second >= windowStart && bucket.second <= second
|
|
338
|
+
? count + bucket.count
|
|
339
|
+
: count,
|
|
340
|
+
0
|
|
341
|
+
);
|
|
342
|
+
const obj = {
|
|
343
|
+
name: node.name || null,
|
|
344
|
+
op: node.op || null,
|
|
345
|
+
module: node.module || null,
|
|
346
|
+
fnName: node.fnName || null,
|
|
347
|
+
label: node._label || null,
|
|
348
|
+
count: node.counter,
|
|
349
|
+
rate: rateCount / node.rateBuckets.length,
|
|
350
|
+
children: node.children.map((child) => this.getTree(child)),
|
|
351
|
+
};
|
|
352
|
+
// Include inputs at the root level
|
|
353
|
+
if (node === this) {
|
|
354
|
+
obj.inputs = this.inputs.map((input) => {
|
|
355
|
+
const monitor = this.monitors.find(
|
|
356
|
+
(m) => m.type === 'input' && m.name === input.name
|
|
357
|
+
);
|
|
358
|
+
const accepted = monitor
|
|
359
|
+
? monitor.speeds.accepted.per_minute.compute()
|
|
360
|
+
: null;
|
|
361
|
+
const rejected = monitor
|
|
362
|
+
? monitor.speeds.rejected.per_minute.compute()
|
|
363
|
+
: null;
|
|
364
|
+
return {
|
|
365
|
+
name: input.name,
|
|
366
|
+
node: input._tap ? input._tap.name : null,
|
|
367
|
+
tree: input._tap ? this.getTree(input._tap) : null,
|
|
368
|
+
status: monitor ? monitor.status : null,
|
|
369
|
+
accepted: accepted ? accepted.reduce((a, b) => a + b, 0) : 0,
|
|
370
|
+
rejected: rejected ? rejected.reduce((a, b) => a + b, 0) : 0,
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return obj;
|
|
375
|
+
}
|
|
265
376
|
}
|
|
266
377
|
|
|
267
378
|
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
|
};
|