1#!/usr/local/bin/python3.8
2
3"""
4=head1 NAME
5
6fail2ban_ - Wildcard plugin to monitor fail2ban blacklists
7
8=head1 ABOUT
9
10Requires Python 2.7
11Requires fail2ban 0.9.2
12
13=head1 AUTHOR
14
15Copyright (c) 2015 Lee Clemens
16
17Inspired by fail2ban plugin written by Stig Sandbeck Mathisen
18
19=head1 CONFIGURATION
20
21fail2ban-client needs to be run as root.
22
23Add the following to your @@CONFDIR@@/munin-node:
24
25  [fail2ban_*]
26    user root
27
28=head1 LICENSE
29
30GNU GPLv2 or any later version
31
32=begin comment
33
34This program is free software; you can redistribute it and/or modify
35it under the terms of the GNU General Public License as published by
36the Free Software Foundation; either version 2 of the License, or (at
37your option) any later version.
38
39This program is distributed in the hope that it will be useful, but
40WITHOUT ANY WARRANTY; without even the implied warranty of
41MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
42General Public License for more details
43
44You should have received a copy of the GNU General Public License along
45with this program; if not, write to the Free Software Foundation, Inc.,
4651 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
47
48=end comment
49
50=head1 BUGS
51
52Transient values (particularly ASNs) come and go...
53Better error handling (Popen), logging
54Optimize loops and parsing in __get_jail_status() and parse_fail2ban_status()
55Cymru ASNs aren't displayed in numerical order (internal name has alpha-prefix)
56Use JSON status once fail2ban exposes JSON status data
57
58=head1 MAGIC MARKERS
59
60 #%# family=auto
61 #%# capabilities=autoconf suggest
62
63=cut
64"""
65
66from collections import Counter
67from os import path, stat, access, X_OK, environ
68from subprocess import Popen, PIPE
69from time import time
70import re
71import sys
72
73
74PLUGIN_BASE = "fail2ban_"
75
76CACHE_DIR = environ['MUNIN_PLUGSTATE']
77CACHE_MAX_AGE = 120
78
79STATUS_FLAVORS_FIELDS = {
80    "basic": ["jail"],
81    "cymru": ["asn", "country", "rir"]
82}
83
84
85def __parse_plugin_name():
86    if path.basename(__file__).count("_") == 1:
87        return path.basename(__file__)[len(PLUGIN_BASE):], ""
88    else:
89        return (path.basename(__file__)[len(PLUGIN_BASE):].split("_")[0],
90                path.basename(__file__)[len(PLUGIN_BASE):].split("_")[1])
91
92
93def __get_jails_cache_file():
94    return "%s/%s.state" % (CACHE_DIR, path.basename(__file__))
95
96
97def __get_jail_status_cache_file(jail_name):
98    return "%s/%s__%s.state" % (CACHE_DIR, path.basename(__file__), jail_name)
99
100
101def __parse_jail_names(jails_data):
102    """
103    Parse the jails returned by `fail2ban-client status`:
104
105    Status
106    |- Number of jail:	3
107    `- Jail list:	apache-badbots, dovecot, sshd
108    """
109    jails = []
110    for line in jails_data.splitlines()[1:]:
111        if line.startswith("`- Jail list:"):
112            return [jail.strip(" ,\t") for jail in
113                    line.split(":", 1)[1].split(" ")]
114    return jails
115
116
117def __get_jail_names():
118    """
119    Read jails from cache or execute `fail2ban-client status`
120     and pass stdout to __parse_jail_names
121    """
122    cache_filename = __get_jails_cache_file()
123    try:
124        mtime = stat(cache_filename).st_mtime
125    except OSError:
126        mtime = 0
127    if time() - mtime > CACHE_MAX_AGE:
128        p = Popen(["fail2ban-client", "status"], shell=False, stdout=PIPE)
129        jails_data = p.communicate()[0]
130        with open(cache_filename, 'w') as f:
131            f.write(jails_data)
132    else:
133        with open(cache_filename, 'r') as f:
134            jails_data = f.read()
135    return __parse_jail_names(jails_data)
136
137
138def autoconf():
139    """
140    Attempt to find fail2ban-client in path (using `which`) and ping the client
141    """
142    p_which = Popen(["which", "fail2ban-client"], shell=False, stdout=PIPE,
143                    stderr=PIPE)
144    stdout, stderr = p_which.communicate()
145    if len(stdout) > 0:
146        client_path = stdout.strip()
147        if access(client_path, X_OK):
148            p_ping = Popen([client_path, "ping"], shell=False)
149            p_ping.communicate()
150            if p_ping.returncode == 0:
151                print("yes")
152            else:
153                print("no (fail2ban-server does not respond to ping)")
154        else:
155            print("no (fail2ban-client is not executable)")
156    else:
157        import os
158
159        print("no (fail2ban-client not found in path: %s)" %
160              os.environ["PATH"])
161
162
163def suggest():
164    """
165    Iterate all defined flavors (source of data) and fields (graph to display)
166    """
167    # Just use basic for autoconf/suggest
168    flavor = "basic"
169    for field in STATUS_FLAVORS_FIELDS[flavor]:
170        print("%s_%s" % (flavor, field if len(flavor) > 0 else flavor))
171
172
173def __get_jail_status(jail, flavor):
174    """
175    Return cache or execute `fail2ban-client status <jail> <flavor>`
176     and save to cache and return
177    """
178    cache_filename = __get_jail_status_cache_file(jail)
179    try:
180        mtime = stat(cache_filename).st_mtime
181    except OSError:
182        mtime = 0
183    if time() - mtime > CACHE_MAX_AGE:
184        p = Popen(["fail2ban-client", "status", jail, flavor], shell=False,
185                  stdout=PIPE)
186        jail_status_data = p.communicate()[0]
187        with open(cache_filename, 'w') as f:
188            f.write(jail_status_data)
189    else:
190        with open(cache_filename, 'r') as f:
191            jail_status_data = f.read()
192    return jail_status_data
193
194
195def __normalize(name):
196    name = re.sub("[^a-z0-9A-Z]", "_", name)
197    return name
198
199
200def __count_groups(value_str):
201    """
202    Helper method to count unique values in the space-delimited value_str
203    """
204    return Counter([key for key in value_str.split(" ") if key])
205
206
207def config(flavor, field):
208    """
209    Print config data (e.g. munin-run config), including possible labels
210     by parsing real status data
211    """
212    print("graph_title fail2ban %s %s" % (flavor, field))
213    print("graph_args --base 1000 -l 0")
214    print("graph_vlabel Hosts banned")
215    print("graph_category security")
216    print("graph_info"
217          " Number of hosts banned using status flavor %s and field %s" %
218          (flavor, field))
219    print("graph_total total")
220    munin_fields, field_labels, values = parse_fail2ban_status(flavor, field)
221    for munin_field in munin_fields:
222        print("%s.label %s" % (munin_field, field_labels[munin_field]))
223
224
225def run(flavor, field):
226    """
227    Parse the status data and print all values for a given flavor and field
228    """
229    munin_fields, field_labels, values = parse_fail2ban_status(flavor, field)
230    for munin_field in munin_fields:
231        print("%s.value %s" % (munin_field, values[munin_field]))
232
233
234def parse_fail2ban_status(flavor, field):
235    """
236    Shared method to parse jail status output and determine field names
237     and aggregate counts
238    """
239    field_labels = dict()
240    values = dict()
241    for jail in __get_jail_names():
242        jail_status = __get_jail_status(jail, flavor)
243        for line in jail_status.splitlines()[1:]:
244            if flavor == "basic":
245                if field == "jail":
246                    if line.startswith("   |- Currently banned:"):
247                        internal_name = __normalize(jail)
248                        field_labels[internal_name] = jail
249                        values[internal_name] = line.split(":", 1)[1].strip()
250                else:
251                    raise Exception(
252                        "Undefined field %s for flavor %s for jail %s" %
253                        (field, flavor, jail))
254            elif flavor == "cymru":
255                # Determine which line of output we care about
256                if field == "asn":
257                    search_string = "   |- Banned ASN list:"
258                elif field == "country":
259                    search_string = "   |- Banned Country list:"
260                elif field == "rir":
261                    search_string = "   `- Banned RIR list:"
262                else:
263                    raise Exception(
264                        "Undefined field %s for flavor %s for jail %s" %
265                        (field, flavor, jail))
266                if line.startswith(search_string):
267                    prefix = "%s_%s" % (flavor, field)
268                    # Now process/aggregate the counts
269                    counts_dict = __count_groups(line.split(":", 1)[1].strip())
270                    for key in counts_dict:
271                        internal_name = "%s_%s" % (prefix, __normalize(key))
272                        if internal_name in field_labels:
273                            values[internal_name] += counts_dict[key]
274                        else:
275                            field_labels[internal_name] = key
276                            values[internal_name] = counts_dict[key]
277            else:
278                raise Exception("Undefined flavor: %s for jail %s" %
279                                (flavor, jail))
280    return sorted(field_labels.keys()), field_labels, values
281
282
283if __name__ == "__main__":
284    if len(sys.argv) > 1:
285        command = sys.argv[1]
286    else:
287        command = ""
288    if command == "autoconf":
289        autoconf()
290    elif command == "suggest":
291        suggest()
292    elif command == 'config':
293        flavor_, field_ = __parse_plugin_name()
294        config(flavor_, field_)
295    else:
296        flavor_, field_ = __parse_plugin_name()
297        run(flavor_, field_)
298