├── geoip_provider ├── base.py └── maxminddb.py ├── Dockerfile ├── requirements.txt ├── conf.yml ├── LICENSE ├── .gitignore ├── fail2ban-exporter.py ├── dashboard-with-grouping.json ├── dashboard-no-grouping.json └── README.md /geoip_provider/base.py: -------------------------------------------------------------------------------- 1 | class BaseProvider: 2 | def __init__(self, conf): 3 | pass 4 | 5 | def annotate(self, ip): 6 | return {} 7 | 8 | def get_labels(self): 9 | return [] 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | WORKDIR /f2b-exporter 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | CMD ["python", "./fail2ban-exporter.py"] 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.4.5.1 2 | chardet==3.0.4 3 | configparser==5.0.0 4 | geoip2==3.0.0 5 | idna==2.9 6 | maxminddb==1.5.4 7 | prometheus-client==0.7.1 8 | PyYAML==5.3.1 9 | requests==2.23.0 10 | urllib3==1.25.9 11 | -------------------------------------------------------------------------------- /conf.yml: -------------------------------------------------------------------------------- 1 | server: 2 | listen_address: 3 | port: 4 | geo: 5 | enabled: True 6 | provider: 'MaxmindDB' 7 | enable_grouping: False 8 | maxmind: 9 | db_path: '/f2b-exporter/db/GeoLite2-City.mmdb' 10 | ## Uncomment the following section to set default values for IPs that are not in the database 11 | ## Otherwise entry will be discarded 12 | # on_error: 13 | # city: 'Error' 14 | # latitude: '0' 15 | # longitude: '0' 16 | f2b: 17 | conf_path: '/etc/fail2ban' 18 | db: '/var/lib/fail2ban/fail2ban.sqlite3' 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 vdcloudcraft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /geoip_provider/maxminddb.py: -------------------------------------------------------------------------------- 1 | import geoip2.database 2 | 3 | class MaxmindDB: 4 | 5 | def __init__(self, conf): 6 | self.db_path = conf['geo']['maxmind']['db_path'] 7 | self.on_error = conf['geo']['maxmind'].get('on_error', '') 8 | 9 | def annotate(self, ip): 10 | reader = geoip2.database.Reader(self.db_path) 11 | try: 12 | lookup = reader.city(ip) 13 | entry = { 14 | 'city': str(lookup.city.name), 15 | 'latitude': str(lookup.location.latitude), 16 | 'longitude': str(lookup.location.longitude) 17 | } 18 | except: 19 | if not self.on_error: 20 | entry = {} 21 | else: 22 | entry = { 23 | 'city': self.on_error.get('city', 'not set'), 24 | 'latitude': self.on_error.get('latitude', '0'), 25 | 'longitude': self.on_error.get('longitude', '0') 26 | } 27 | finally: 28 | reader.close() 29 | 30 | return entry 31 | 32 | def get_labels(self): 33 | return ['city', 'latitude', 'longitude'] 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /fail2ban-exporter.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import yaml 3 | import configparser 4 | import sqlite3 5 | from prometheus_client import make_wsgi_app 6 | from prometheus_client.core import GaugeMetricFamily, REGISTRY 7 | from wsgiref.simple_server import make_server 8 | from collections import defaultdict 9 | from pathlib import Path 10 | 11 | class Jail: 12 | def __init__(self, name): 13 | self.name = name 14 | self.ip_list = [] 15 | self.bantime = 0 16 | 17 | class F2bCollector(object): 18 | def __init__(self, conf): 19 | self.geo_provider = self._import_provider(conf) 20 | self.f2b_conf = conf['f2b'].get('conf','') 21 | self.f2b_conf_path = conf['f2b'].get('conf_path','') 22 | self.f2b_db = conf['f2b']['db'] 23 | self.jails = [] 24 | self.extra_labels = sorted(self.geo_provider.get_labels()) 25 | 26 | def _import_provider(self, conf): 27 | if conf['geo']['enabled']: 28 | class_name = conf['geo']['provider'] 29 | mod = __import__('geoip_provider.{}'.format(class_name.lower()), fromlist=[class_name]) 30 | else: 31 | class_name = 'BaseProvider' 32 | mod = __import__('geoip_provider.base', fromlist=['BaseProvider']) 33 | 34 | GeoProvider = getattr(mod, class_name) 35 | return GeoProvider(conf) 36 | 37 | def get_jailed_ips(self): 38 | self.jails.clear() 39 | 40 | conn = sqlite3.connect(self.f2b_db) 41 | cur = conn.cursor() 42 | 43 | config = configparser.ConfigParser() 44 | 45 | # Allow both configs for backwards compatibility 46 | if not self.f2b_conf_path: 47 | config.read(self.f2b_conf) 48 | else: 49 | config.read('{}/jail.local'.format(self.f2b_conf_path)) 50 | 51 | if self.f2b_conf_path: 52 | jaild = list(Path('{}/jail.d'.format(self.f2b_conf_path)).glob('*.local')) 53 | config.read(jaild) 54 | 55 | active_jails = cur.execute('SELECT name FROM jails WHERE enabled = 1').fetchall() 56 | 57 | for j in active_jails: 58 | jail = Jail(j[0]) 59 | bantime = config[j[0]]['bantime'].split(';')[0].strip() 60 | jail.bantime = int(bantime) 61 | self.jails.append(jail) 62 | 63 | for jail in self.jails: 64 | rows = cur.execute('SELECT ip FROM bans WHERE DATETIME(timeofban + ?, \'unixepoch\') > DATETIME(\'now\') AND jail = ?', [jail.bantime, jail.name]).fetchall() 65 | for row in rows: 66 | jail.ip_list.append({'ip':row[0]}) 67 | 68 | conn.close() 69 | 70 | def assign_location(self): 71 | for jail in self.jails: 72 | for entry in jail.ip_list: 73 | entry.update(self.geo_provider.annotate(entry['ip'])) 74 | 75 | def collect(self): 76 | self.get_jailed_ips() 77 | self.assign_location() 78 | 79 | if conf['geo']['enable_grouping']: 80 | yield self.expose_grouped() 81 | yield self.expose_jail_summary() 82 | else: 83 | yield self.expose_single() 84 | 85 | def expose_single(self): 86 | metric_labels = ['jail','ip'] + self.extra_labels 87 | gauge = GaugeMetricFamily('fail2ban_banned_ip', 'IP banned by fail2ban', labels=metric_labels) 88 | 89 | for jail in self.jails: 90 | for entry in jail.ip_list: 91 | # Skip if GeoProvider.annotate() did not return matching count of labels 92 | if len(entry) < len(self.extra_labels) + 1: 93 | continue 94 | values = [jail.name, entry['ip']] + [ entry[x] for x in self.extra_labels ] 95 | gauge.add_metric(values, 1) 96 | 97 | return gauge 98 | 99 | def expose_grouped(self): 100 | gauge = GaugeMetricFamily('fail2ban_location', 'Number of currently banned IPs from this location', labels=self.extra_labels) 101 | grouped = defaultdict(int) 102 | 103 | for jail in self.jails: 104 | for entry in jail.ip_list: 105 | if not entry: 106 | continue 107 | location_key = tuple([ entry[x] for x in self.extra_labels ]) 108 | grouped[location_key] += 1 109 | 110 | for labels, count in grouped.items(): 111 | gauge.add_metric(list(labels), count) 112 | 113 | return gauge 114 | 115 | def expose_jail_summary(self): 116 | gauge = GaugeMetricFamily('fail2ban_jailed_ips', 'Number of currently banned IPs per jail', labels=['jail']) 117 | 118 | for jail in self.jails: 119 | gauge.add_metric([jail.name], len(jail.ip_list)) 120 | 121 | return gauge 122 | 123 | if __name__ == '__main__': 124 | with open('conf.yml') as f: 125 | conf = yaml.load(f, Loader=yaml.FullLoader) 126 | 127 | REGISTRY.register(F2bCollector(conf)) 128 | 129 | app = make_wsgi_app() 130 | httpd = make_server(conf['server']['listen_address'], conf['server']['port'], app) 131 | httpd.serve_forever() 132 | -------------------------------------------------------------------------------- /dashboard-with-grouping.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "7.0.0" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "grafana-worldmap-panel", 22 | "name": "Worldmap Panel", 23 | "version": "0.3.2" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "prometheus", 28 | "name": "Prometheus", 29 | "version": "1.0.0" 30 | }, 31 | { 32 | "type": "panel", 33 | "id": "stat", 34 | "name": "Stat", 35 | "version": "" 36 | } 37 | ], 38 | "annotations": { 39 | "list": [ 40 | { 41 | "builtIn": 1, 42 | "datasource": "-- Grafana --", 43 | "enable": true, 44 | "hide": true, 45 | "iconColor": "rgba(0, 211, 255, 1)", 46 | "name": "Annotations & Alerts", 47 | "type": "dashboard" 48 | } 49 | ] 50 | }, 51 | "editable": true, 52 | "gnetId": null, 53 | "graphTooltip": 0, 54 | "id": null, 55 | "iteration": 1590951136241, 56 | "links": [], 57 | "panels": [ 58 | { 59 | "datasource": "${DS_PROMETHEUS}", 60 | "description": "", 61 | "fieldConfig": { 62 | "defaults": { 63 | "custom": { 64 | "align": null, 65 | "displayMode": "color-text" 66 | }, 67 | "decimals": 0, 68 | "mappings": [], 69 | "noValue": "0", 70 | "thresholds": { 71 | "mode": "absolute", 72 | "steps": [ 73 | { 74 | "color": "green", 75 | "value": null 76 | } 77 | ] 78 | }, 79 | "unit": "none" 80 | }, 81 | "overrides": [] 82 | }, 83 | "gridPos": { 84 | "h": 4, 85 | "w": 6, 86 | "x": 0, 87 | "y": 0 88 | }, 89 | "id": 4, 90 | "options": { 91 | "colorMode": "value", 92 | "graphMode": "none", 93 | "justifyMode": "auto", 94 | "orientation": "auto", 95 | "reduceOptions": { 96 | "calcs": [ 97 | "last" 98 | ], 99 | "values": false 100 | } 101 | }, 102 | "pluginVersion": "7.0.0", 103 | "repeat": "jail", 104 | "repeatDirection": "h", 105 | "targets": [ 106 | { 107 | "expr": "sum (fail2ban_jailed_ips{jail=\"$jail\"}) by (jail)", 108 | "format": "time_series", 109 | "instant": true, 110 | "interval": "", 111 | "legendFormat": "", 112 | "refId": "A" 113 | } 114 | ], 115 | "timeFrom": null, 116 | "timeShift": null, 117 | "title": "$jail", 118 | "transformations": [ 119 | { 120 | "id": "organize", 121 | "options": { 122 | "excludeByName": { 123 | "Time": true 124 | }, 125 | "indexByName": {}, 126 | "renameByName": { 127 | "Value": "Banned IPs" 128 | } 129 | } 130 | } 131 | ], 132 | "type": "stat" 133 | }, 134 | { 135 | "circleMaxSize": "30", 136 | "circleMinSize": "2", 137 | "colors": [ 138 | "#C0D8FF", 139 | "#73BF69", 140 | "#FADE2A", 141 | "#C4162A" 142 | ], 143 | "datasource": "${DS_PROMETHEUS}", 144 | "decimals": 0, 145 | "esMetric": "Count", 146 | "fieldConfig": { 147 | "defaults": { 148 | "custom": {} 149 | }, 150 | "overrides": [] 151 | }, 152 | "gridPos": { 153 | "h": 24, 154 | "w": 24, 155 | "x": 0, 156 | "y": 8 157 | }, 158 | "hideEmpty": false, 159 | "hideZero": false, 160 | "id": 2, 161 | "initialZoom": "3", 162 | "locationData": "table", 163 | "mapCenter": "custom", 164 | "mapCenterLatitude": "27", 165 | "mapCenterLongitude": 14, 166 | "maxDataPoints": 1, 167 | "mouseWheelZoom": true, 168 | "showLegend": false, 169 | "stickyLabels": false, 170 | "tableQueryOptions": { 171 | "geohashField": "geohash", 172 | "labelField": "city", 173 | "latitudeField": "latitude", 174 | "longitudeField": "longitude", 175 | "metricField": "Value", 176 | "queryType": "coordinates" 177 | }, 178 | "targets": [ 179 | { 180 | "expr": "sum(fail2ban_location) by (latitude, longitude, city)", 181 | "format": "table", 182 | "instant": true, 183 | "interval": "", 184 | "legendFormat": "", 185 | "refId": "A" 186 | } 187 | ], 188 | "thresholds": "0,10,100", 189 | "timeFrom": null, 190 | "timeShift": null, 191 | "title": "Banned IP locations", 192 | "transformations": [], 193 | "type": "grafana-worldmap-panel", 194 | "unitPlural": "IPs", 195 | "unitSingle": "", 196 | "unitSingular": "IP", 197 | "valueName": "current" 198 | } 199 | ], 200 | "refresh": "10s", 201 | "schemaVersion": 25, 202 | "style": "dark", 203 | "tags": [], 204 | "templating": { 205 | "list": [ 206 | { 207 | "allValue": "", 208 | "current": {}, 209 | "datasource": "${DS_PROMETHEUS}", 210 | "definition": "fail2ban_jailed_ips", 211 | "hide": 2, 212 | "includeAll": true, 213 | "label": null, 214 | "multi": false, 215 | "name": "jail", 216 | "options": [], 217 | "query": "fail2ban_jailed_ips", 218 | "refresh": 1, 219 | "regex": "/.*jail=\"([^\"]*).*/", 220 | "skipUrlSync": false, 221 | "sort": 5, 222 | "tagValuesQuery": "", 223 | "tags": [], 224 | "tagsQuery": "", 225 | "type": "query", 226 | "useTags": false 227 | } 228 | ] 229 | }, 230 | "time": { 231 | "from": "now-5m", 232 | "to": "now" 233 | }, 234 | "timepicker": { 235 | "refresh_intervals": [ 236 | "10s", 237 | "30s", 238 | "1m", 239 | "5m", 240 | "15m", 241 | "30m", 242 | "1h", 243 | "2h", 244 | "1d" 245 | ] 246 | }, 247 | "timezone": "", 248 | "title": "fail2ban banned locations", 249 | "uid": "pGlJmeRGk", 250 | "version": 24 251 | } -------------------------------------------------------------------------------- /dashboard-no-grouping.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "7.0.0" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "grafana-worldmap-panel", 22 | "name": "Worldmap Panel", 23 | "version": "0.3.2" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "prometheus", 28 | "name": "Prometheus", 29 | "version": "1.0.0" 30 | }, 31 | { 32 | "type": "panel", 33 | "id": "stat", 34 | "name": "Stat", 35 | "version": "" 36 | } 37 | ], 38 | "annotations": { 39 | "list": [ 40 | { 41 | "builtIn": 1, 42 | "datasource": "-- Grafana --", 43 | "enable": true, 44 | "hide": true, 45 | "iconColor": "rgba(0, 211, 255, 1)", 46 | "name": "Annotations & Alerts", 47 | "type": "dashboard" 48 | } 49 | ] 50 | }, 51 | "editable": true, 52 | "gnetId": null, 53 | "graphTooltip": 0, 54 | "id": null, 55 | "iteration": 1590437176664, 56 | "links": [], 57 | "panels": [ 58 | { 59 | "datasource": "${DS_PROMETHEUS}", 60 | "description": "", 61 | "fieldConfig": { 62 | "defaults": { 63 | "custom": { 64 | "align": null, 65 | "displayMode": "color-text" 66 | }, 67 | "decimals": 0, 68 | "mappings": [], 69 | "noValue": "0", 70 | "thresholds": { 71 | "mode": "absolute", 72 | "steps": [ 73 | { 74 | "color": "green", 75 | "value": null 76 | } 77 | ] 78 | }, 79 | "unit": "none" 80 | }, 81 | "overrides": [] 82 | }, 83 | "gridPos": { 84 | "h": 4, 85 | "w": 2, 86 | "x": 0, 87 | "y": 0 88 | }, 89 | "id": 4, 90 | "options": { 91 | "colorMode": "value", 92 | "graphMode": "area", 93 | "justifyMode": "auto", 94 | "orientation": "auto", 95 | "reduceOptions": { 96 | "calcs": [ 97 | "mean" 98 | ], 99 | "values": false 100 | } 101 | }, 102 | "pluginVersion": "7.0.0", 103 | "repeat": "jail", 104 | "repeatDirection": "v", 105 | "targets": [ 106 | { 107 | "expr": "count (fail2ban_banned_ip{jail=\"$jail\"}) by (jail)", 108 | "format": "time_series", 109 | "instant": true, 110 | "interval": "", 111 | "legendFormat": "{{ jail }}", 112 | "refId": "A" 113 | } 114 | ], 115 | "timeFrom": null, 116 | "timeShift": null, 117 | "title": "$jail", 118 | "transformations": [ 119 | { 120 | "id": "organize", 121 | "options": { 122 | "excludeByName": { 123 | "Time": true 124 | }, 125 | "indexByName": {}, 126 | "renameByName": { 127 | "Value": "Banned IPs" 128 | } 129 | } 130 | } 131 | ], 132 | "type": "stat" 133 | }, 134 | { 135 | "circleMaxSize": "30", 136 | "circleMinSize": "2", 137 | "colors": [ 138 | "#C0D8FF", 139 | "#73BF69", 140 | "#FADE2A", 141 | "#C4162A" 142 | ], 143 | "datasource": "${DS_PROMETHEUS}", 144 | "decimals": 0, 145 | "esMetric": "Count", 146 | "fieldConfig": { 147 | "defaults": { 148 | "custom": {} 149 | }, 150 | "overrides": [] 151 | }, 152 | "gridPos": { 153 | "h": 24, 154 | "w": 22, 155 | "x": 2, 156 | "y": 0 157 | }, 158 | "hideEmpty": false, 159 | "hideZero": false, 160 | "id": 2, 161 | "initialZoom": "3", 162 | "locationData": "table", 163 | "mapCenter": "custom", 164 | "mapCenterLatitude": "27", 165 | "mapCenterLongitude": 14, 166 | "maxDataPoints": 1, 167 | "mouseWheelZoom": true, 168 | "showLegend": false, 169 | "stickyLabels": false, 170 | "tableQueryOptions": { 171 | "geohashField": "geohash", 172 | "labelField": "city", 173 | "latitudeField": "latitude", 174 | "longitudeField": "longitude", 175 | "metricField": "Value", 176 | "queryType": "coordinates" 177 | }, 178 | "targets": [ 179 | { 180 | "expr": "sum(fail2ban_banned_ip) by (latitude, longitude, city)", 181 | "format": "table", 182 | "instant": true, 183 | "interval": "", 184 | "legendFormat": "", 185 | "refId": "A" 186 | } 187 | ], 188 | "thresholds": "0,10,100", 189 | "timeFrom": null, 190 | "timeShift": null, 191 | "title": "Banned IP locations", 192 | "transformations": [], 193 | "type": "grafana-worldmap-panel", 194 | "unitPlural": "IPs", 195 | "unitSingle": "", 196 | "unitSingular": "IP", 197 | "valueName": "current" 198 | } 199 | ], 200 | "refresh": "10s", 201 | "schemaVersion": 25, 202 | "style": "dark", 203 | "tags": [], 204 | "templating": { 205 | "list": [ 206 | { 207 | "allValue": "", 208 | "current": {}, 209 | "datasource": "${DS_PROMETHEUS}", 210 | "definition": "fail2ban_banned_ip", 211 | "hide": 2, 212 | "includeAll": true, 213 | "label": null, 214 | "multi": false, 215 | "name": "jail", 216 | "options": [], 217 | "query": "fail2ban_banned_ip", 218 | "refresh": 2, 219 | "regex": "/.*jail=\"([^\"]*).*/", 220 | "skipUrlSync": false, 221 | "sort": 5, 222 | "tagValuesQuery": "", 223 | "tags": [], 224 | "tagsQuery": "", 225 | "type": "query", 226 | "useTags": false 227 | } 228 | ] 229 | }, 230 | "time": { 231 | "from": "now-5m", 232 | "to": "now" 233 | }, 234 | "timepicker": { 235 | "refresh_intervals": [ 236 | "10s", 237 | "30s", 238 | "1m", 239 | "5m", 240 | "15m", 241 | "30m", 242 | "1h", 243 | "2h", 244 | "1d" 245 | ] 246 | }, 247 | "timezone": "", 248 | "title": "fail2ban banned locations", 249 | "uid": "pGlJmeRGk", 250 | "version": 18 251 | } 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fail2ban-geo-exporter 2 | 3 | It's all in the description already. This little program will expose Prometheus metrics for every IP banned by `fail2ban`. You can have them geotagged or disable tagging if you just want the IP and the jail it's coming from. 4 | 5 | NOTE: This software assumes your fail2ban jail definitions are all in a single file. Multiple definites under `jail.d/` are currently not supported. 6 | 7 | ## Disclaimer 8 | 9 | This exporter goes deliberately against best practices and ist not suitable for deployments at scale. It's intended to be used in a homelab alike setting and won't even provide any sane metric to alert on. This may change in the future, but it more than likely will not. 10 | 11 | By enabling grouping in the `conf.yml`, the growth of label cardinality can be reduced, but this is still far from ideal. 12 | 13 | ## Metrics 14 | 15 | This exporter provides a single time series named `fail2ban_banned_ip` for each IP in a fail2ban jail. 16 | Default labels are: `jail` and `ip` with their respective values. 17 | 18 | More labels can be provided by enabling geoIP annotation. At this point, the maxmind provider adds `city`, `latitude`, and `longitude` in addition the default labels. 19 | 20 | If you enable grouping in the `conf.yml`, you will instead receive two sets of metrics and no more data about single IPs. Instead you get a gauge `fail2ban_location` which counts the number of banned IPs per location. The labels are the same as above, just without `jail` and `ip`. 21 | 22 | The second metric is `fail2ban_jailed_ips` which is a gauge, that displays all currently banned IPs per jail. `jail` is the only label in this metric. 23 | 24 | It's highly recommended to enable grouping, in order to reduce the cardinality of your labels. 25 | 26 | A small guide to creating your own geoIP provider can be found in the [Extensibility](#Extensibility) section of this README. 27 | 28 | 29 | ## Configuration 30 | 31 | ```yaml 32 | server: 33 | listen_address: 34 | port: 35 | geo: 36 | enabled: True 37 | provider: 'MaxmindDB' 38 | enable_grouping: False 39 | maxmind: 40 | db_path: '/f2b-exporter/db/GeoLite2-City.mmdb' 41 | f2b: 42 | conf_path: '/etc/fail2ban' 43 | db: '/var/lib/fail2ban/fail2ban.sqlite3' 44 | ``` 45 | 46 | Just plug in the port and IPv4 address you want your exporter to be listening on. If you want to enable geotagging, there is only one method at this time and for that you will need to sign up for a free account at https://www.maxmind.com, download their city database and plug the path to the db in `geo.maxmind.db_path`. Their paid tier claims to have increased accuracy and is interchangable with their free database, so that should work as a data source for this exporter as well. At the time of writing I can neither deny, nor confirm these claims. 47 | 48 | `f2b.conf_path` assumes default directory structure of fail2ban. So your jails can be defined in `/etc/fail2ban/jail.local` or in `/etc/fail2ban/jail.d/*.local`. Default values defined in `jail.local` (i.e.: bantime) will be picked up and consequently applied to all jails defined under `jail.d`. 49 | 50 | Missing entries in the MaxmindDB will be discarded by default. If you want to keep track of missing entries, you can provide default values to be used instead. In your `conf.yml` add a section under `geo.maxmind` like so: 51 | 52 | ```yaml 53 | geo: 54 | maxmind: 55 | on_error: 56 | city: 'Atlantis' 57 | latitude: '0' 58 | longitude: '0' 59 | ``` 60 | ## Installation 61 | 62 | ### As a systemd service 63 | 64 | Pick your favourite working directory and `git clone https://github.com/vdcloudcraft/fail2ban-geo-exporter.git .` 65 | 66 | Now run these commands to set up a python virtual environment: 67 | 68 | ```bash 69 | python -m venv . 70 | . bin/activate 71 | pip install -r requirements.txt 72 | ``` 73 | 74 | Create a file called `fail2ban-geo-exporter.service` at `/etc/systemd/system/` 75 | 76 | Open that file and paste following content in there: 77 | 78 | ```bash 79 | [Unit] 80 | Description=fail2ban geo exporter 81 | After=network.target 82 | 83 | [Service] 84 | Type=simple 85 | User=root 86 | WorkingDirectory= 87 | Environment=PYTHONPATH=/bin 88 | ExecStart=/bin/python3 /fail2ban-exporter.py 89 | Restart=always 90 | 91 | [Install] 92 | WantedBy=multi-user.target 93 | ``` 94 | 95 | Make sure to replace all four(4) instances of `` in that config with your actual working directory. 96 | 97 | Make sure you have a `conf.yml` in your working directory as described in [Configuration](#configuration) 98 | 99 | When that is all done, run following commands and your exporter is running and will survive reboots: 100 | 101 | ```bash 102 | sudo systemctl daemon-reload 103 | sudo systemctl enable fail2ban-geo-exporter.service 104 | sudo service fail2ban-geo-exporter start 105 | ``` 106 | 107 | You should see a list of metrics when you run `curl http://localhost:[port]/metrics` 108 | 109 | ### As a Docker container 110 | 111 | Make sure you have prepared a config as described in [Configuration](#configuration). 112 | 113 | Docker images are provided via [Docker Hub](https://hub.docker.com/repository/docker/vdcloudcraft/fail2ban-geo-exporter) 114 | 115 | To run the exporter in a Docker container, execute the following command 116 | 117 | ```bash 118 | docker run -d \ 119 | -v /etc/fail2ban:/etc/fail2ban:ro \ 120 | -v /var/lib/fail2ban/fail2ban.sqlite3:/var/lib/fail2ban/fail2ban.sqlite3:ro \ 121 | -v //GeoLite2-City.mmdb:/f2b-exporter/db/GeoLite2-City.mmdb:ro \ 122 | -v //conf.yml:/f2b-exporter/conf.yml \ 123 | --name fail2ban-geo-exporter \ 124 | --restart unless-stopped \ 125 | vdcloudcraft/fail2ban-geo-exporter:latest 126 | ``` 127 | 128 | Make sure that your paths to `jail.local` and `fail2ban.sqlite3` are correct. 129 | 130 | ## Extensibility 131 | 132 | Currently there is only one way to geotag IPs and that is with the Maxmind db. But there is a way to provide custom geoIP providers. 133 | 134 | If you wish to implement your own method for annotating your IPs, you need to create a Python class and save the module in `./geoip_provider/` 135 | 136 | You need to ensure following requirements are fullfilled: 137 | 138 | - Your module name is a lower case version of your class name. E.g.: Class `FancyProvider`, module `./geoip_providers/fancyprovider.py` 139 | - Your class has a constructor that accepts a single parameter. This will be the parsed `conf.yml` that will be passed to the class. 140 | - Your class implements a method `annotate(ip)`, that takes in a single IP as a string and returns a dictionary with all additional labels for Prometheus to use. Do not include the IP itself as a label. 141 | - Your class implements a method `get_labels()` that returns a list of strings with the label names it's going to provide. 142 | 143 | When all that is given, you can just put your class name with[!] capitalization into the configuration under `geo.provider` and enjoy the fruits of your labour. 144 | 145 | Be aware that the `enable_grouping` setting will use only the labels provided by your class to aggregate locations. 146 | 147 | If you do create another provider class and think other people might find it useful too, I'll gladly review pull requests. 148 | 149 | ## Grafana dashboard 150 | 151 | The files `dashboard-*.json` include a complete definition for either grouping configuration to display your banned IPs on a worldmap and count all banned IPs per jail. 152 | --------------------------------------------------------------------------------