Browse Source

Initial commit

Yann Weber 6 days ago
commit
a3d4e0adea
4 changed files with 458 additions and 0 deletions
  1. 14
    0
      copying
  2. 441
    0
      rspamd_
  3. 1
    0
      rspamd_.py
  4. 2
    0
      tests.sh

+ 14
- 0
copying View File

@@ -0,0 +1,14 @@
1
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+                    Version 2, December 2004
3
+
4
+ Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+  0. You just DO WHAT THE FUCK YOU WANT TO.
14
+

+ 441
- 0
rspamd_ View File

@@ -0,0 +1,441 @@
1
+#!/usr/bin/env python3
2
+# %# family=auto
3
+# %# capabilities=autoconf suggest
4
+
5
+"""
6
+Rpsamd munin plugin
7
+
8
+Can be use as
9
+- rspamd_actions
10
+- rspamd_results
11
+- rspamd_scantime
12
+- rspamd_uptime
13
+- rspamd_memory_usage
14
+- rspamd_memory_stats
15
+- rspamd_connections
16
+- rspamd_ctrl_connections
17
+- rspamd_statfiles
18
+- rspamd_fuzzy_hashes
19
+
20
+Config :
21
+
22
+[rspamd_*]
23
+env.rspamc = "/usr/bin/rspamc"
24
+env.cache_file = "/tmp/munin_rspamd.cache"
25
+env.cache_time = 240
26
+"""
27
+
28
+import json
29
+import os
30
+import re
31
+import shlex
32
+import sys
33
+import time
34
+import io
35
+import unittest
36
+import unittest.mock
37
+import tempfile
38
+from typing import Final
39
+import contextlib
40
+
41
+
42
+def usage():
43
+    """Return simple help"""
44
+    return """You should ln -s /usr/share/munin/plugins/rspamd_ /etc/munin/plugins/rspamd_FUNC
45
+With FUNC one of %s""" % (
46
+        ", ".join(CONFIGS.keys())
47
+    )
48
+
49
+
50
+def avg_scantime(scantimes):
51
+    """Returns average, min, max scantimes"""
52
+    total = [float(t) for t in scantimes if t]
53
+    return sum(total) / len(total), min(total), max(total)
54
+
55
+
56
+def storage2name(name):
57
+    """Format storage name"""
58
+    return re.sub(r"[\.\- ]", "_", name)
59
+
60
+
61
+CONFIGS: Final = {
62
+    "actions": [
63
+        lambda data: """\
64
+graph_title Rspamd actions
65
+graph_vlabel mails
66
+graph_args --base 1000 -l 0
67
+graph_category antispam
68
+graph_scale yes
69
+scanned.label Scanned/total
70
+learned.label Learned
71
+%s"""
72
+        % (
73
+            "\n".join(
74
+                [
75
+                    "%s.label %s" % (action.replace(" ", "_"), action.title())
76
+                    for action in data["actions"]
77
+                ]
78
+            )
79
+        ),
80
+        lambda data: """\
81
+scanned.value %d
82
+learned.value %d
83
+%s"""
84
+        % (
85
+            data["scanned"],
86
+            data["learned"],
87
+            "\n".join(
88
+                [
89
+                    "%s.value %d" % (action.replace(" ", "_"), value)
90
+                    for action, value in data["actions"].items()
91
+                ]
92
+            ),
93
+        ),
94
+    ],
95
+    "results": [
96
+        """\
97
+graph_title Rspamd results
98
+graph_vlabel mails
99
+graph_args --base 1000 -l 0
100
+graph_category antispam
101
+graph_scale yes
102
+spam.label Spams
103
+spam.draw STACK
104
+ham.label Ham
105
+ham.draw STACK""",
106
+        lambda data: """\
107
+spam.value %d
108
+ham.value %d"""
109
+        % (data["spam_count"], data["ham_count"]),
110
+    ],
111
+    "scantime": [
112
+        """\
113
+graph_title Rspamd scantime
114
+graph_args --base 1000 -l 0
115
+graph_category antispam
116
+graph_scale yes
117
+graph_vlabel seconds
118
+scantime.label Average scantime
119
+min.label Minimum scantime
120
+max.label Maximum scantime
121
+""",
122
+        lambda data: """\
123
+scantime.value %f
124
+min.value %f
125
+max.value %f"""
126
+        % avg_scantime(data["scan_times"]),
127
+    ],
128
+    "uptime": [
129
+        """\
130
+graph_title Rspamd uptime
131
+graph_args --base 1000 -l 0
132
+graph_category antispam
133
+graph_scale no
134
+graph_vlabel uptime in days
135
+uptime.label uptime
136
+uptime.draw AREA
137
+""",
138
+        lambda data: "uptime.value %f" % (data["uptime"] / (3600 * 24)),
139
+    ],
140
+    "memory_usage": [
141
+        """\
142
+graph_title Rspamd memory usage
143
+graph_args --base 1024 -l 0
144
+graph_category antispam
145
+graph_scale yes
146
+graph_vlabel bytes
147
+memory.label Memory usage
148
+""",
149
+        lambda data: "memory.value %d" % data["bytes_allocated"],
150
+    ],
151
+    "memory_stats": [
152
+        """\
153
+graph_title Rspamd memory stats
154
+graph_args --base 1000 -l 0
155
+graph_category antispam
156
+graph_scale yes
157
+pools.label Pools allocated - freed
158
+chunks.label Chunks allocated - freed
159
+shared_chunks_allocated.label Shared chunks allocated
160
+""",
161
+        lambda data: """\
162
+pools.value %d
163
+chunks.value %d
164
+shared_chunks_allocated.value %d"""
165
+        % (
166
+            data["pools_allocated"] - data["pools_freed"],
167
+            data["chunks_allocated"] - data["chunks_freed"],
168
+            data["shared_chunks_allocated"],
169
+        ),
170
+    ],
171
+    "connections": [
172
+        """\
173
+graph_title Rspamd connections
174
+graph_args --base 1000 -l 0
175
+graph_category antispam
176
+graph_scale yes
177
+graph_vlabel connections
178
+connections.label opened connections
179
+""",
180
+        lambda data: "connections.value %d" % data["connections"],
181
+    ],
182
+    "ctrl_connections": [
183
+        """\
184
+graph_title Rspamd control connections count
185
+graph_args --base 1000 -l 0
186
+graph_category antispam
187
+graph_scale yes
188
+graph_vlabel connections
189
+connections.label control connections count
190
+""",
191
+        lambda data: "connections.value %d" % data["control_connections"],
192
+    ],
193
+    "statfiles": [
194
+        lambda data: """\
195
+graph_title rspamd bayes statfiles
196
+graph_args --base 1000 -l 0
197
+graph_scale yes
198
+graph_category antispam
199
+graph_vlabel learned mails
200
+%s"""
201
+        % (
202
+            "\n".join(
203
+                [
204
+                    """learned_{sym}.label {sym} learned
205
+users_{sym}.label {sym} users""".format(
206
+                        sym=stat["symbol"]
207
+                    )
208
+                    for stat in data["statfiles"]
209
+                ]
210
+            )
211
+        ),
212
+        lambda data: "\n".join(
213
+            """\
214
+learned_{sym}.value {lv}
215
+users_{sym}.value {uv}""".format(
216
+                sym=stat["symbol"], lv=stat["revision"], uv=stat["users"]
217
+            )
218
+            for stat in data["statfiles"]
219
+        ),
220
+    ],
221
+    "fuzzy_hashes": [
222
+        lambda data: """\
223
+graph_title rspamd fuzzy hases storage
224
+graph_args --base 1000 -l 0
225
+graph_scale yes
226
+graph_category antispam
227
+graph_vlabel hashes
228
+%s"""
229
+        % (
230
+            "\n".join(
231
+                [
232
+                    "%s.label %s" % (storage2name(storage), storage)
233
+                    for storage in data["fuzzy_hashes"]
234
+                ]
235
+            )
236
+        ),
237
+        lambda data: "\n".join(
238
+            [
239
+                "%s.value %d" % (storage2name(storage), count)
240
+                for storage, count in data["fuzzy_hashes"].items()
241
+            ]
242
+        ),
243
+    ],
244
+}
245
+
246
+
247
+def main(argv, environ):
248
+    """main function"""
249
+    rspamc = environ.get("rspamc", "/usr/bin/rspamc")
250
+    cache_file = environ.get("cache_file", "/tmp/munin_rspamd.cache")
251
+    cache_time = int(environ.get("cache_time", 60 * 4))
252
+
253
+    rspamc_cmd = shlex.join([rspamc, "--json", "stat"])
254
+
255
+    if len(argv) > 1:
256
+        if argv[1] == "autoconf":
257
+            if os.path.exists(rspamc):
258
+                return "yes"
259
+            return "no (%s not found)" % rspamc
260
+        if argv[1] == "suggest":
261
+            return "#./rspamd_ suggest\n" + ("\n".join(CONFIGS.keys()))
262
+
263
+    data = None
264
+    if os.path.exists(cache_file):
265
+        cache_stat = os.stat(cache_file)
266
+        if cache_stat.st_mtime > time.time() - cache_time:
267
+            with open(cache_file, "r", encoding="utf-8") as cache:
268
+                try:
269
+                    data = json.loads(cache.read())
270
+                except json.JSONDecodeError:
271
+                    pass
272
+
273
+    if data is None:
274
+        with open(cache_file, "w", encoding="utf-8") as cache:
275
+            with os.popen(rspamc_cmd, mode="r") as pipe:
276
+                data = pipe.read()
277
+                cache.write(data)
278
+                data = json.loads(data)
279
+
280
+    spl = argv[0].split("_")[1:]
281
+    if len(spl) == 0:
282
+        raise NameError(usage())
283
+    cmd = "_".join(spl)
284
+    if cmd not in CONFIGS:
285
+        raise NameError("Unknown command %r. %s" % (cmd, usage()))
286
+
287
+    config = CONFIGS[cmd][0]
288
+    act = CONFIGS[cmd][1]
289
+
290
+    if len(argv) > 1 and argv[1] == "config":
291
+        if not isinstance(config, str):
292
+            config = config(data)
293
+        return config
294
+
295
+    return act(data)
296
+
297
+
298
+if __name__ == "__main__":
299
+    try:
300
+        print(main(sys.argv, os.environ))
301
+    except NameError as main_expt:
302
+        print(main_expt)
303
+
304
+#
305
+# Tests
306
+#
307
+
308
+JSON: Final = r"""{\
309
+"version":"3.4","config_id":"1234","uptime":16991,"read_only":false,\
310
+"scanned":43,"learned":43,"actions":\
311
+{"reject":0,"soft reject":0,"rewrite subject":0,"add header":5,\
312
+"greylist":4,"no action":34},"scan_times":\
313
+[0.910916,1.291352,1.241323,null,null,null,null,null,null,null,\
314
+null,null,null,null,null,null,null,null,null,null,null,null,null\
315
+,null,null,null,null,null,null,null,null],\
316
+"spam_count":5,"ham_count":38,"connections":2,\
317
+"control_connections":265,"pools_allocated":538,"pools_freed":512,\
318
+"bytes_allocated":27817136,"chunks_allocated":110,\
319
+"shared_chunks_allocated":3,"chunks_freed":0,"chunks_oversized":1,\
320
+"fragmented":0,"total_learns":40,"statfiles":\
321
+[{"revision":33,"used":0,"total":0,"size":0,"symbol":"BAYES_SPAM",\
322
+"type":"redis","languages":0,"users":1},\
323
+{"revision":7,"used":0,"total":0,"size":0,"symbol":"BAYES_HAM",\
324
+"type":"redis","languages":0,"users":1}],\
325
+"fuzzy_hashes":{"local":0,"rspamd.com":1872513971},"scan_time":0.02}"""
326
+
327
+EXPTS: Final = {
328
+    "actions": [
329
+        "reject.value 0",
330
+        "soft_reject.value 0",
331
+        "rewrite_subject.value 0",
332
+        "add_header.value 5",
333
+        "greylist.value 4",
334
+        "no_action.value 34",
335
+        "learned.value 43",
336
+        "scanned.value 43",
337
+    ],
338
+    "results": ["spam.value 5", "ham.value 38"],
339
+    "scantime": ["scantime.value 1.147864", "min.value 0.910916", "max.value 1.291352"],
340
+    "uptime": [
341
+        "uptime.value 0.196655",
342
+    ],
343
+    "memory_usage": [
344
+        "memory.value 27817136",
345
+    ],
346
+    "memory_stats": [
347
+        "chunks.value 110",
348
+        "shared_chunks_allocated.value 3",
349
+        "pools.value 26",
350
+    ],
351
+    "connections": ["connections.value 2"],
352
+    "ctrl_connections": ["connections.value 265"],
353
+    "statfiles": [
354
+        "users_BAYES_SPAM.value 1",
355
+        "learned_BAYES_HAM.value 7",
356
+        "learned_BAYES_SPAM.value 33",
357
+        "users_BAYES_HAM.value 1",
358
+    ],
359
+    "fuzzy_hashes": ["local.value 0", "rspamd_com.value 1872513971"],
360
+}
361
+
362
+
363
+@contextlib.contextmanager
364
+def json_sample():
365
+    """Mock rspamc --json stat result"""
366
+    return io.StringIO(JSON)
367
+
368
+
369
+class MuninRspamdTests(unittest.TestCase):
370
+    """Plugin selftest"""
371
+
372
+    def setUp(self):
373
+        """Prepare cache file & environment"""
374
+        self.json = lambda: io.StringIO(JSON)
375
+        tmpfd, self.tmpcache = tempfile.mkstemp()
376
+        os.close(tmpfd)
377
+        os.unlink(self.tmpcache)
378
+        self.environ = {"cache_file": self.tmpcache}
379
+
380
+    def tearDown(self):
381
+        """Remove cache file"""
382
+        if os.path.isfile(self.tmpcache):
383
+            os.unlink(self.tmpcache)
384
+
385
+    def test_configs(self):
386
+        """check plugin config runs without error and returns
387
+        expected fields
388
+        """
389
+        for cmd in CONFIGS:
390
+            with self.subTest(cmd=cmd):
391
+                with unittest.mock.patch("os.popen", return_value=self.json()):
392
+                    ret = main(["rspamd_%s" % cmd, "config"], self.environ)
393
+                    self.assertIn("graph_title", ret)
394
+                    self.assertIn("graph_args", ret)
395
+                    self.assertIn("graph_scale", ret)
396
+                    self.assertIn("graph_category antispam", ret)
397
+                self.tearDown()
398
+
399
+    def test_values(self):
400
+        """check plugin runs without error"""
401
+        for cmd in CONFIGS:
402
+            with self.subTest(cmd=cmd):
403
+                with unittest.mock.patch("os.popen", return_value=self.json()):
404
+                    ret = main(["rspamd_%s" % cmd], self.environ)
405
+                    self.assertNotEqual(len(ret), 0)
406
+
407
+    def test_consistency(self):
408
+        """check config & values matches"""
409
+        for cmd in CONFIGS:
410
+            with self.subTest(cmd=cmd):
411
+                with unittest.mock.patch("os.popen", return_value=self.json()):
412
+                    conf = main([f"rspamd_{cmd}", "config"], self.environ)
413
+                    values = main([f"rspamd_{cmd}"], self.environ)
414
+
415
+                    keys = []
416
+                    for line in conf.split("\n"):
417
+                        prefix = line.split(" ")[0]
418
+                        if "." not in prefix:
419
+                            continue
420
+                        spl = prefix.split(".")
421
+                        if len(spl) < 2 or spl[1] != "label":
422
+                            continue
423
+                        keys.append(spl[0])
424
+
425
+                    for name in keys:
426
+                        self.assertIn(f"{name}.value ", values)
427
+
428
+    def test_results(self):
429
+        """munin values"""
430
+        for cmd, expt in EXPTS.items():
431
+            with self.subTest(cmd=cmd):
432
+                with unittest.mock.patch("os.popen", return_value=self.json()):
433
+                    ret = main([f"rspamd_{cmd}"], self.environ)
434
+                    self.assertEqual(set(ret.split("\n")), set(expt))
435
+
436
+    def test_suggest(self):
437
+        """munin suggest autoconf test"""
438
+        ret = main(["rspamd_", "suggest"], self.environ)
439
+        self.assertEqual(
440
+            set(ret.split("\n")), set(CONFIGS.keys()) | set(["#./rspamd_ suggest"])
441
+        )

+ 1
- 0
rspamd_.py View File

@@ -0,0 +1 @@
1
+rspamd_

+ 2
- 0
tests.sh View File

@@ -0,0 +1,2 @@
1
+#!/bin/sh
2
+/usr/bin/env python3 -m unittest rspamd_ $@

Loading…
Cancel
Save