From 9de740cea2374b01f981c15b5f02c1b48a28a068 Mon Sep 17 00:00:00 2001 From: Manny <107129718+mlgtcode@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:26:16 +0200 Subject: [PATCH 1/2] Add Rspamd monitor plugin --- agent360/plugins/rspamd.py | 200 +++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 agent360/plugins/rspamd.py diff --git a/agent360/plugins/rspamd.py b/agent360/plugins/rspamd.py new file mode 100644 index 0000000..ee604e3 --- /dev/null +++ b/agent360/plugins/rspamd.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""agent360 Rspamd monitor plugin. + +Returned metric groups: +- Health: available, auth_ok, read_only, version, uptime_seconds +- Volume: scanned, learned +- Mail classification: spam_count, ham_count +- Actions: reject_count, soft_reject_count, greylist_count, + add_header_count, rewrite_subject_count, no_action_count +- Ratios: spam_ratio_pct, reject_ratio_pct, greylist_ratio_pct +- Performance: scan_time_avg_sec, scan_time_last_sec + +Configuration section example: +[rspamd] +enabled = yes + +Notes: +- This plugin intentionally omits low-value/raw internals to keep payloads + compact for monitoring. +""" + +from __future__ import print_function, unicode_literals + +import json +import os +import subprocess + +import plugins + + +class Plugin(plugins.BasePlugin): + __name__ = 'rspamd' + + @staticmethod + def _which(command): + # Compatible replacement for shutil.which for older Python versions. + path = os.environ.get('PATH', '') + if not path: + return None + + is_windows = os.name == 'nt' + exts = [''] + if is_windows: + pathext = os.environ.get('PATHEXT', '.EXE;.BAT;.CMD;.COM') + exts = [ext.lower() for ext in pathext.split(os.pathsep) if ext] + + for folder in path.split(os.pathsep): + folder = folder.strip('"') + if not folder: + continue + + candidate = os.path.join(folder, command) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + + if is_windows and not os.path.splitext(command)[1]: + for ext in exts: + candidate_ext = candidate + ext + if os.path.isfile(candidate_ext) and os.access(candidate_ext, os.X_OK): + return candidate_ext + return None + + def _command(self, subcommand): + rspamc_path = self._which('rspamc') + if not rspamc_path: + return None + + cmd = [rspamc_path] + cmd.extend([subcommand, '-j']) + return cmd + + @staticmethod + def _to_text(raw_value): + if isinstance(raw_value, bytes): + try: + return raw_value.decode('utf-8') + except Exception: + return raw_value.decode('utf-8', 'replace') + return raw_value or '' + + def _run_json(self, cmd): + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False) + stdout_raw, stderr_raw = proc.communicate() + stdout = self._to_text(stdout_raw).strip() + stderr = self._to_text(stderr_raw).strip() + + if proc.returncode != 0: + return None, 'command failed (%s): %s' % (proc.returncode, stderr or stdout) + + try: + payload = json.loads(stdout) + except Exception: + return None, 'invalid JSON from rspamc' + return payload, None + + @staticmethod + def _as_int(value, default=0): + try: + return int(value) + except Exception: + return default + + @staticmethod + def _as_float(value, default=0.0): + try: + return float(value) + except Exception: + return default + + @staticmethod + def _pct(part, total): + if total <= 0: + return 0.0 + return round((float(part) * 100.0) / float(total), 4) + + @staticmethod + def _avg(values): + if not values: + return 0.0 + numeric = [] + for value in values: + try: + numeric.append(float(value)) + except Exception: + continue + if not numeric: + return 0.0 + return round(sum(numeric) / float(len(numeric)), 6) + + def run(self, config): + stat_cmd = self._command('stat') + uptime_cmd = self._command('uptime') + + if not stat_cmd or not uptime_cmd: + return { + 'available': 0, + 'error': 'rspamc binary not found in PATH', + } + + stat, stat_error = self._run_json(stat_cmd) + uptime, uptime_error = self._run_json(uptime_cmd) + + if not stat and not uptime: + return { + 'available': 0, + 'error': stat_error or uptime_error or 'rspamc unavailable', + } + + actions = (stat or {}).get('actions', {}) + + scanned = self._as_int((stat or {}).get('scanned', (uptime or {}).get('scanned', 0))) + learned = self._as_int((stat or {}).get('learned', (uptime or {}).get('learned', 0))) + spam_count = self._as_int((stat or {}).get('spam_count', 0)) + ham_count = self._as_int((stat or {}).get('ham_count', 0)) + reject_count = self._as_int(actions.get('reject', (uptime or {}).get('reject', 0))) + soft_reject_count = self._as_int(actions.get('soft reject', (uptime or {}).get('soft_reject', 0))) + greylist_count = self._as_int(actions.get('greylist', (uptime or {}).get('greylist', 0))) + add_header_count = self._as_int(actions.get('add header', (uptime or {}).get('probable', 0))) + rewrite_subject_count = self._as_int(actions.get('rewrite subject', 0)) + no_action_count = self._as_int(actions.get('no action', (uptime or {}).get('clean', 0))) + + result = { + # Basic health and lifecycle state. + 'available': 1, + 'auth_ok': 1 if (uptime or {}).get('auth') == 'ok' else 0, + 'read_only': 1 if bool((stat or {}).get('read_only', (uptime or {}).get('read_only', False))) else 0, + 'version': (stat or {}).get('version', (uptime or {}).get('version', 'unknown')), + 'uptime_seconds': self._as_int((stat or {}).get('uptime', (uptime or {}).get('uptime', 0))), + # Throughput and classification totals. + 'scanned': scanned, + 'learned': learned, + 'spam_count': spam_count, + 'ham_count': ham_count, + # Action counters most useful for mail-flow monitoring. + 'reject_count': reject_count, + 'soft_reject_count': soft_reject_count, + 'greylist_count': greylist_count, + 'add_header_count': add_header_count, + 'rewrite_subject_count': rewrite_subject_count, + 'no_action_count': no_action_count, + # Ratios for alerting without shipping heavy raw details. + 'spam_ratio_pct': self._pct(spam_count, scanned), + 'reject_ratio_pct': self._pct(reject_count, scanned), + 'greylist_ratio_pct': self._pct(greylist_count, scanned), + # Latency signal from rspamc stat JSON. + 'scan_time_avg_sec': self._avg((stat or {}).get('scan_times', [])), + 'scan_time_last_sec': self._as_float((stat or {}).get('scan_time', (uptime or {}).get('scan_time', 0.0)), 0.0), + } + + if stat_error: + result['stat_warning'] = stat_error + if uptime_error: + result['uptime_warning'] = uptime_error + + return result + + +if __name__ == '__main__': + Plugin().execute() From a56fb2eb712d339876a90213f2413b916cd3f793 Mon Sep 17 00:00:00 2001 From: Manny <107129718+mlgtcode@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:46:46 +0200 Subject: [PATCH 2/2] fixes --- agent360/plugins/rspamd.py | 93 +++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/agent360/plugins/rspamd.py b/agent360/plugins/rspamd.py index ee604e3..dedb9be 100644 --- a/agent360/plugins/rspamd.py +++ b/agent360/plugins/rspamd.py @@ -24,6 +24,7 @@ import json import os +import re import subprocess import plugins @@ -79,6 +80,26 @@ def _to_text(raw_value): return raw_value.decode('utf-8', 'replace') return raw_value or '' + @staticmethod + def _extract_json_text(text): + if not text: + return None + + start = text.find('{') + end = text.rfind('}') + if start == -1 or end == -1 or end < start: + return None + + return text[start:end + 1] + + @staticmethod + def _sanitize_json_text(text): + if not text: + return text + + # rspamc may emit non-JSON float tokens such as lowercase nan/inf. + return re.sub(r'(?