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