Compare commits
	
		
			6 Commits
		
	
	
		
			969ba0f64c
			...
			develop
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6f36df2195 | |||
| c991df53fa | |||
| f39d319b25 | |||
| 3e64189f8f | |||
| 45dc173ea7 | |||
| 9b85bfabdb | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,7 @@ | ||||
| venv/ | ||||
|  | ||||
| *.sw? | ||||
|  | ||||
| *.pyc | ||||
| __pycache__/ | ||||
|  | ||||
|  | ||||
							
								
								
									
										3
									
								
								CHANGELOG
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								CHANGELOG
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| - 2023.1 | ||||
|  | ||||
| * Implement #3, a /ping health check endpoint | ||||
| @ -1,11 +1,16 @@ | ||||
| ''' | ||||
| jail2ban, a remote fail2ban action plugin using OpenBSD pf(8) | ||||
| ''' | ||||
| from ipaddress import ip_address | ||||
| import re | ||||
| from subprocess import CalledProcessError | ||||
|  | ||||
| from flask import Flask, request, jsonify, current_app | ||||
| from flask_httpauth import HTTPBasicAuth | ||||
| from werkzeug.security import check_password_hash | ||||
| from ipaddress import ip_address | ||||
| import re | ||||
|  | ||||
| from jail2ban.pfctl import pfctl_table_op, pfctl_cfg_read, pfctl_cfg_write | ||||
| from jail2ban.auth import get_users | ||||
| from subprocess import CalledProcessError | ||||
|  | ||||
|  | ||||
| auth = HTTPBasicAuth() | ||||
| @ -15,6 +20,13 @@ PAT_PORT = r'^any(?:\s+port\s+{\w+(?:,\w+)*})?$' | ||||
| PAT_PROT = r'^(?:tcp|udp)$' | ||||
| PAT_NAME = r'^[\w\-]+$' | ||||
|  | ||||
| _PFCTL_TABLE_PAT = r'''\s+(?P<addr>\S+)\n | ||||
| \s+Cleared:\s+(?P<date>\S+\s+\S+\s+\d+\s+(?:\d{2}:){2}\d{2}\s+\d{4})\n | ||||
| \s+In/Block:\s+\[\s+Packets:\s+(?P<in_pckt_block>\d+)\s+Bytes:\s+(?P<in_bytes_block>\d+)\s+\]\n | ||||
| \s+In/Pass:\s+\[\s+Packets:\s+(?P<in_pckt_pass>\d+)\s+Bytes:\s+(?P<in_bytes_pass>\d+)\s+\]\n | ||||
| \s+Out/Block:\s+\[\s+Packets:\s+(?P<out_pckt_block>\d+)\s+Bytes:\s+(?P<out_bytes_block>\d+)\s+\]\n | ||||
| \s+Out/Pass:\s+\[\s+Packets:\s+(?P<out_pckt_pass>\d+)\s+Bytes:\s+(?P<out_bytes_pass>\d+)\s+\]''' | ||||
|  | ||||
|  | ||||
| def untaint(pattern, string): | ||||
|     ''' | ||||
| @ -23,11 +35,13 @@ def untaint(pattern, string): | ||||
|     match = re.match(pattern, string) | ||||
|     if match: | ||||
|         return match.string | ||||
|     else: | ||||
|     raise ValueError(f'"{string}" is tainted') | ||||
|  | ||||
|  | ||||
| def create_app(): | ||||
|     ''' | ||||
|     Create wsgi application instance | ||||
|     ''' | ||||
|     app = Flask(__name__, instance_relative_config=True) | ||||
|  | ||||
|     # load the instance config, if it exists, when not testing | ||||
| @ -41,7 +55,17 @@ def create_app(): | ||||
|         if username in users and \ | ||||
|                 check_password_hash(users.get(username), password): | ||||
|             return username | ||||
|         return None | ||||
|  | ||||
|     @app.route("/ping", methods=['GET']) | ||||
|     @auth.login_required | ||||
|     def ping(): | ||||
|         remote_user = auth.username() | ||||
|         app.logger.info('Received ping for' | ||||
|                         f' anchor f2b-jail/{remote_user}') | ||||
|         return jsonify({'anchor': f'f2b-jail/{remote_user}', | ||||
|                         'operation': 'ping', | ||||
|                         'result': 'pong'}) | ||||
|     @app.route("/flush/<name>", methods=['GET']) | ||||
|     @auth.login_required | ||||
|     def flush(name): | ||||
| @ -57,6 +81,36 @@ def create_app(): | ||||
|                         'operation': 'flush', | ||||
|                         'result': [x.decode('ascii') for x in res]}) | ||||
|  | ||||
|     @app.route("/list/<name>", methods=['GET']) | ||||
|     @auth.login_required | ||||
|     def list_table(name): | ||||
|         remote_user = auth.username() | ||||
|         name = untaint(PAT_NAME, name) | ||||
|         app.logger.info(f'Flushing table f2b-{name}' | ||||
|                         f' in anchor f2b-jail/{remote_user}') | ||||
|         reply = {'anchor': f'f2b-jail/{remote_user}', | ||||
|                         'table': f'f2b-{name}', | ||||
|                         'operation': 'list'} | ||||
|         try: | ||||
|             res = pfctl_table_op('f2b-jail/{remote_user}', | ||||
|                                  table='f2b-{name}', | ||||
|                                  operation='show', | ||||
|                                  verbose=True) | ||||
|         except CalledProcessError as err: | ||||
|             if err.stderr.find(b'pfctl: Table does not exist.') > 0: | ||||
|                 res = [] | ||||
|                 reply.update({'error': f'\'{name}\' is not a known fail2ban jail'}) | ||||
|             else: | ||||
|                 raise err | ||||
|  | ||||
|         result = [entry.groupdict() for entry in | ||||
|                   re.finditer(_PFCTL_TABLE_PAT, | ||||
|                               '\n'.join([x.decode('ascii') for x in res]), | ||||
|                               re.MULTILINE|re.VERBOSE)] | ||||
|         reply.update({'result': result}) | ||||
|  | ||||
|         return jsonify(reply), 200 if len(res) else 404 | ||||
|  | ||||
|     @app.route("/register", methods=['PUT', 'DELETE']) | ||||
|     @auth.login_required | ||||
|     def register(): | ||||
| @ -102,21 +156,21 @@ def create_app(): | ||||
|         data = request.get_json() | ||||
|         # name / ip | ||||
|         name = untaint(PAT_NAME, data['name']) | ||||
|         ip = ip_address(data['ip']) | ||||
|         ip_addr = ip_address(data['ip']) | ||||
|         if request.method == 'PUT': | ||||
|             app.logger.info(f'Add {ip} to f2b-{name}' | ||||
|             app.logger.info(f'Add {ip_addr} to f2b-{name}' | ||||
|                             f' in anchor f2b-jail/{remote_user}') | ||||
|             res = pfctl_table_op(f'f2b-jail/{remote_user}', | ||||
|                                  table=f'f2b-{name}', | ||||
|                                  operation='add', | ||||
|                                  value=str(ip)) | ||||
|                                  value=str(ip_addr)) | ||||
|         else:  # 'DELETE': | ||||
|             app.logger.info(f'Remove {ip} from f2b-{name}' | ||||
|             app.logger.info(f'Remove {ip_addr} from f2b-{name}' | ||||
|                             f' in anchor f2b-jail/{remote_user}') | ||||
|             res = pfctl_table_op(f'f2b-jail/{remote_user}', | ||||
|                                  table=f'f2b-{name}', | ||||
|                                  operation='delete', | ||||
|                                  value=str(ip)) | ||||
|                                  value=str(ip_addr)) | ||||
|         return jsonify({'anchor': f'f2b-jail/{remote_user}', | ||||
|                         'table': f'f2b-{name}', | ||||
|                         'operation': 'add' if request.method == 'PUT' | ||||
| @ -137,6 +191,8 @@ def create_app(): | ||||
|         Show a json parsable error if the value is illegal | ||||
|         ''' | ||||
|         app.logger.fatal(error) | ||||
|         app.logger.fatal('stdout: %s', error.stderr) | ||||
|         app.logger.fatal('stderr: %s', error.stderr) | ||||
|         return jsonify({'error': str(error)}), 500 | ||||
|  | ||||
|     @app.errorhandler(FileNotFoundError) | ||||
|  | ||||
| @ -1,11 +1,16 @@ | ||||
| ''' | ||||
| Lowlevel routines for calling the pf binary with passwordless sudo | ||||
| ''' | ||||
| import logging | ||||
| from subprocess import run | ||||
|  | ||||
| _SUDO = '/usr/local/bin/sudo' | ||||
| _PFCTL = '/sbin/pfctl' | ||||
|  | ||||
|  | ||||
| def pfctl_cfg_read(anchor): | ||||
|     ''' | ||||
|     Read pf rules stored under a certain anchor | ||||
|     ''' | ||||
|     cmd = [_SUDO, _PFCTL, '-a', anchor, '-sr'] | ||||
|     logging.info('Running %s', cmd) | ||||
|  | ||||
| @ -16,6 +21,9 @@ def pfctl_cfg_read(anchor): | ||||
|  | ||||
|  | ||||
| def pfctl_cfg_write(anchor, cfg): | ||||
|     ''' | ||||
|     Write pf rules under a certain anchor | ||||
|     ''' | ||||
|     cmd = [_SUDO, _PFCTL, '-a', anchor, '-f-'] | ||||
|     logging.info('Running %s', cmd) | ||||
|     logging.info('Config %s', cfg) | ||||
| @ -30,10 +38,14 @@ def pfctl_cfg_write(anchor, cfg): | ||||
|  | ||||
|  | ||||
| def pfctl_table_op(anchor, **kwargs): | ||||
|     ''' | ||||
|     pf table operation | ||||
|     ''' | ||||
|     table = kwargs['table'] | ||||
|     operation = kwargs['operation'] | ||||
|     value = kwargs['value'] if 'value' in kwargs else None | ||||
|     cmd = [_SUDO, _PFCTL, '-a', anchor, '-t', table, '-T', operation, value] | ||||
|     verbose =  '-v' if 'verbose' in kwargs and kwargs['verbose'] else None | ||||
|     cmd = [_SUDO, _PFCTL, '-a', anchor, '-t', table, verbose, '-T', operation, value] | ||||
|  | ||||
|     logging.info('Running %s', cmd) | ||||
|  | ||||
|  | ||||
							
								
								
									
										140
									
								
								tests/test_list.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								tests/test_list.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,140 @@ | ||||
| ''' | ||||
| Tests for /list route | ||||
| ''' | ||||
| from types import SimpleNamespace | ||||
| from subprocess import CalledProcessError | ||||
|  | ||||
|  | ||||
| _PF_TABLE_LIST = b'''   192.0.2.66 | ||||
|         Cleared:     Sat Jan  7 12:50:36 2023 | ||||
|         In/Block:    [ Packets: 0                  Bytes: 0                  ] | ||||
|         In/Pass:     [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Block:   [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Pass:    [ Packets: 0                  Bytes: 0                  ] | ||||
|    2001:db8::abad:cafe | ||||
|         Cleared:     Sat Jan  7 05:13:53 2023 | ||||
|         In/Block:    [ Packets: 4                  Bytes: 240                ] | ||||
|         In/Pass:     [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Block:   [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Pass:    [ Packets: 0                  Bytes: 0                  ] | ||||
|    2001:db8::abad:f00d:cafe | ||||
|         Cleared:     Sat Jan  7 05:05:16 2023 | ||||
|         In/Block:    [ Packets: 48                 Bytes: 2880               ] | ||||
|         In/Pass:     [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Block:   [ Packets: 0                  Bytes: 0                  ] | ||||
|         Out/Pass:    [ Packets: 0                  Bytes: 0                  ]''' | ||||
|  | ||||
| _LIST_RESULT = [{'addr': '192.0.2.66', | ||||
|   'date': 'Sat Jan  7 12:50:36 2023', | ||||
|   'in_pckt_block': '0', | ||||
|   'in_bytes_block': '0', | ||||
|   'in_pckt_pass': '0', | ||||
|   'in_bytes_pass': '0', | ||||
|   'out_pckt_block': '0', | ||||
|   'out_bytes_block': '0', | ||||
|   'out_pckt_pass': '0', | ||||
|   'out_bytes_pass': '0'}, | ||||
|  {'addr': '2001:db8::abad:cafe', | ||||
|   'date': 'Sat Jan  7 05:13:53 2023', | ||||
|   'in_pckt_block': '4', | ||||
|   'in_bytes_block': '240', | ||||
|   'in_pckt_pass': '0', | ||||
|   'in_bytes_pass': '0', | ||||
|   'out_pckt_block': '0', | ||||
|   'out_bytes_block': '0', | ||||
|   'out_pckt_pass': '0', | ||||
|   'out_bytes_pass': '0'}, | ||||
|  {'addr': '2001:db8::abad:f00d:cafe', | ||||
|   'date': 'Sat Jan  7 05:05:16 2023', | ||||
|   'in_pckt_block': '48', | ||||
|   'in_bytes_block': '2880', | ||||
|   'in_pckt_pass': '0', | ||||
|   'in_bytes_pass': '0', | ||||
|   'out_pckt_block': '0', | ||||
|   'out_bytes_block': '0', | ||||
|   'out_pckt_pass': '0', | ||||
|   'out_bytes_pass': '0'}] | ||||
|  | ||||
|  | ||||
| def test_list_single_table(client, mocker, valid_credentials): | ||||
|     ''' | ||||
|     List a single pf table using the fail2ban jail name | ||||
|     ''' | ||||
|  | ||||
|     def noop(): | ||||
|         pass | ||||
|  | ||||
|     run_res = SimpleNamespace() | ||||
|     run_res.stdout = _PF_TABLE_LIST | ||||
|     run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' | ||||
|     run_res.returncode = 0 | ||||
|     run_res.check_returncode = noop | ||||
|  | ||||
|     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||
|  | ||||
|     response = client.get("/list/sshd", | ||||
|                           headers={"Authorization": | ||||
|                                    "Basic " + valid_credentials}) | ||||
|  | ||||
|     assert response.json['table'] == 'f2b-sshd' | ||||
|     assert response.json['result'] == _LIST_RESULT | ||||
|  | ||||
|  | ||||
| def test_list_nonexistent_table(client, mocker, valid_credentials): | ||||
|     ''' | ||||
|     Test for nonexistent table. Should result in a 404 not found | ||||
|     ''' | ||||
|     def noop(): | ||||
|         pass | ||||
|  | ||||
|     run_res = SimpleNamespace() | ||||
|     run_res.stdout = b'' | ||||
|     run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' \ | ||||
|                      b'pfctl: Table does not exist.\n' | ||||
|     run_res.returncode = 255 | ||||
|     run_res.check_returncode = noop | ||||
|  | ||||
|     mocker.patch('jail2ban.pfctl.run', | ||||
|                  return_value=run_res, | ||||
|                  side_effect=CalledProcessError(run_res.returncode, | ||||
|                                                 'foobar', | ||||
|                                                  output=run_res.stdout, | ||||
|                                                  stderr=run_res.stderr) | ||||
|                  ) | ||||
|  | ||||
|     response = client.get("/list/nonexistent", | ||||
|                           headers={"Authorization": | ||||
|                                    "Basic " + valid_credentials}) | ||||
|  | ||||
|     assert response.status_code == 404 | ||||
|     assert response.json['error'] == "'nonexistent' is not " \ | ||||
|                                      "a known fail2ban jail" | ||||
|  | ||||
| def test_list_wrong_table_name(client, mocker, valid_credentials): | ||||
|     ''' | ||||
|     Test for an wrong table name that lets pfctl fail. should result in a 500 | ||||
|     ''' | ||||
|     def noop(): | ||||
|         pass | ||||
|  | ||||
|     run_res = SimpleNamespace() | ||||
|     run_res.stdout = b'' | ||||
|     run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' \ | ||||
|             b'pfctl: Invalid argument.\n' | ||||
|     run_res.returncode = 255 | ||||
|     run_res.check_returncode = noop | ||||
|  | ||||
|     mocker.patch('jail2ban.pfctl.run', | ||||
|                  return_value=run_res, | ||||
|                  side_effect=CalledProcessError(run_res.returncode, | ||||
|                                                 'foobar', | ||||
|                                                  output=run_res.stdout, | ||||
|                                                  stderr=run_res.stderr) | ||||
|                  ) | ||||
|  | ||||
|     response = client.get("/list/notanerrorbuttestneedstofail", | ||||
|                           headers={"Authorization": | ||||
|                                    "Basic " + valid_credentials}) | ||||
|  | ||||
|     assert response.status_code == 500 | ||||
|     assert response.json['error'] == "Command 'foobar' returned non-zero exit status 255." | ||||
							
								
								
									
										10
									
								
								tests/test_ping.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								tests/test_ping.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| def test_ping(client, mocker, valid_credentials): | ||||
|     ''' | ||||
|     Test application health check | ||||
|     ''' | ||||
|  | ||||
|     response = client.get("/ping", | ||||
|                           headers={"Authorization": | ||||
|                                    "Basic " + valid_credentials}) | ||||
|  | ||||
|     assert response.json['operation'] == 'ping' | ||||
		Reference in New Issue
	
	Block a user