init repository
This commit is contained in:
		
						commit
						9145377125
					
				
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| *.log | ||||
| 
 | ||||
| venv | ||||
| 
 | ||||
| conf.json | ||||
							
								
								
									
										18
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| ROOT_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST)))) | ||||
| PYTHON := $(ROOT_DIR)venv/bin/python | ||||
| 
 | ||||
| install: | ||||
| 	python3 -m venv venv | ||||
| 	$(PYTHON) -m pip install -r requirements-dev.txt | ||||
| 
 | ||||
| lint: | ||||
| 	$(PYTHON) -m ruff check --fix | ||||
| 
 | ||||
| format: | ||||
| 	$(PYTHON) -m ruff format | ||||
| 
 | ||||
| check-type: | ||||
| 	$(PYTHON) -m mypy *.py | ||||
| 
 | ||||
| check: format lint check-type | ||||
| 
 | ||||
							
								
								
									
										14
									
								
								conf.json.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								conf.json.example
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| { | ||||
|     "rules": { | ||||
|         "codes": [ | ||||
|             444 | ||||
|         ], | ||||
|         "contents": [ | ||||
|             "\\x", ".env", "php", ".git", ".js" | ||||
|         ], | ||||
|         "agents": [ | ||||
|             "bot" | ||||
|         ] | ||||
|     }, | ||||
|     "whitelist": [] | ||||
| } | ||||
							
								
								
									
										29
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| [project] | ||||
| name = "ngxden" | ||||
| description = "Generate a deny Nginx conf based on Nginx access logs" | ||||
| 
 | ||||
| authors = [ | ||||
| 	 {name = "rmanach", email = "manach.r@msn.com"}, | ||||
| ] | ||||
| requires-python = ">= 3.10" | ||||
| 
 | ||||
| [tool.ruff.lint] | ||||
| select = ["E", "F", "I"] | ||||
| ignore = [] | ||||
| 
 | ||||
| [tool.ruff] | ||||
| exclude = [ | ||||
|     "venv", | ||||
| ] | ||||
| 
 | ||||
| line-length = 88 | ||||
| target-version = "py311" | ||||
| 
 | ||||
| [tool.ruff.lint.mccabe] | ||||
| max-complexity = 10 | ||||
| 
 | ||||
| [tool.mypy] | ||||
| exclude = [ | ||||
|     "venv", | ||||
| ] | ||||
| ignore_missing_imports = true | ||||
							
								
								
									
										2
									
								
								requirements-dev.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								requirements-dev.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| mypy==1.10.0 | ||||
| ruff==0.4.6 | ||||
							
								
								
									
										343
									
								
								ufwban.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								ufwban.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,343 @@ | ||||
| import argparse | ||||
| import json | ||||
| import logging | ||||
| import os | ||||
| import subprocess | ||||
| import sys | ||||
| import time | ||||
| from dataclasses import dataclass | ||||
| from datetime import datetime as dt | ||||
| from logging.handlers import RotatingFileHandler | ||||
| 
 | ||||
| stdout_handler = logging.StreamHandler(stream=sys.stdout) | ||||
| rotate_handler = RotatingFileHandler("ufwban.log", maxBytes=2 * 1024 * 1024) | ||||
| logging.basicConfig( | ||||
|     format="[%(levelname)s] %(asctime)s - %(message)s", | ||||
|     level=logging.INFO, | ||||
|     handlers=(stdout_handler, rotate_handler), | ||||
| ) | ||||
| 
 | ||||
| NGINX_ACCESS_LOGS_DIR = "/var/log/nginx" | ||||
| UFW_CONF = "conf.json" | ||||
| 
 | ||||
| 
 | ||||
| class UFW: | ||||
|     @staticmethod | ||||
|     def drop_all(): | ||||
|         logging.info("dropping all deny rules...") | ||||
|         while (ufw_deny_ips := UFW.list_deny()) and len(ufw_deny_ips): | ||||
|             for ip, id_ in ufw_deny_ips.items(): | ||||
|                 logging.info(f"delete rule: id_: {id_} for {ip}") | ||||
|                 UFW.delete_deny_ip(id_) | ||||
|                 break | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def reload(): | ||||
|         logging.info("reloading ufw...") | ||||
|         process = subprocess.run(["ufw", "reload"], capture_output=True) | ||||
| 
 | ||||
|         if process.returncode != 0: | ||||
|             raise Exception(f"unable to reload ufw, err={process.stderr!r}") | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def delete_deny_ip(id_: str): | ||||
|         logging.info(f"cmd running: ufw delete {id_}") | ||||
|         process = subprocess.run( | ||||
|             ["ufw", "delete", id_], input=b"y\n", capture_output=True | ||||
|         ) | ||||
| 
 | ||||
|         if process.returncode != 0: | ||||
|             raise Exception( | ||||
|                 f"unable to delete deny rule for id: {id_}, err={process.stderr!r}" | ||||
|             ) | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def ban_ip(ip: str): | ||||
|         logging.info(f"cmd running: ufw deny from {ip}") | ||||
|         process = subprocess.run(["ufw", "deny", "from", ip], capture_output=True) | ||||
| 
 | ||||
|         if process.returncode != 0: | ||||
|             raise Exception(f"unable to deny for ip: {ip}, err={process.stderr!r}") | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def list_deny() -> dict[str, str]: | ||||
|         ips = {} | ||||
|         cp = subprocess.run(["ufw", "status", "numbered"], capture_output=True) | ||||
|         if cp.returncode != 0: | ||||
|             raise Exception(f"unable to get ufw rules, err={cp.stderr!r}") | ||||
| 
 | ||||
|         idx = 0 | ||||
|         for rule in cp.stdout.decode().split("\n"): | ||||
|             # cut header | ||||
|             if idx <= 3: | ||||
|                 idx += 1 | ||||
|                 continue | ||||
| 
 | ||||
|             if "Anywhere" in rule and "DENY IN" in rule: | ||||
|                 id_ = "" | ||||
|                 ip = "" | ||||
|                 feed_id = False | ||||
|                 feed_ip = False | ||||
| 
 | ||||
|                 for c in rule: | ||||
|                     if c == "[": | ||||
|                         feed_id = True | ||||
|                         continue | ||||
| 
 | ||||
|                     if c == "]": | ||||
|                         feed_id = False | ||||
|                         continue | ||||
| 
 | ||||
|                     if not feed_id and id_ != "" and c >= "0" and c <= "9": | ||||
|                         feed_ip = True | ||||
| 
 | ||||
|                     if feed_id and c != " ": | ||||
|                         id_ += c | ||||
| 
 | ||||
|                     if feed_ip and c != " ": | ||||
|                         ip += c | ||||
| 
 | ||||
|                 ips[ip] = id_ | ||||
|         return ips | ||||
| 
 | ||||
| 
 | ||||
| @dataclass(frozen=True, slots=True) | ||||
| class Rules: | ||||
|     http_codes: list[str] | ||||
|     contents: list[str] | ||||
|     user_agents: list[str] | ||||
|     whitelist: list[str] | ||||
| 
 | ||||
|     @classmethod | ||||
|     def from_conf(cls) -> "Rules": | ||||
|         try: | ||||
|             with open(UFW_CONF, "r") as f: | ||||
|                 conf = json.load(f) | ||||
|         except Exception as e: | ||||
|             raise Exception(f"unable to read {UFW_CONF}, err={e}") | ||||
| 
 | ||||
|         try: | ||||
|             return Rules( | ||||
|                 conf["rules"]["codes"], | ||||
|                 conf["rules"]["contents"], | ||||
|                 conf["rules"]["agents"], | ||||
|                 conf["whitelist"], | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             raise Exception(f"unable to parse conf {UFW_CONF}, err={e}") | ||||
| 
 | ||||
|     def code_allowed(self, code: int) -> bool: | ||||
|         return code not in self.http_codes | ||||
| 
 | ||||
|     def content_allowed(self, content: str) -> bool: | ||||
|         for c in self.contents: | ||||
|             if c in content: | ||||
|                 return False | ||||
|         return True | ||||
| 
 | ||||
|     def user_agent_allowed(self, user_agent: str) -> bool: | ||||
|         for u in self.user_agents: | ||||
|             if u in user_agent: | ||||
|                 return False | ||||
|         return True | ||||
| 
 | ||||
|     def is_whitelist(self, ip: str) -> bool: | ||||
|         return ip in self.whitelist | ||||
| 
 | ||||
| 
 | ||||
| @dataclass(frozen=True, slots=True) | ||||
| class NginxLog: | ||||
|     ip: str | ||||
|     date: dt | ||||
|     request: str | ||||
|     code: int | ||||
|     source: str | ||||
|     user_agent: str | ||||
| 
 | ||||
|     def __repr__(self): | ||||
|         return f"""{self.date} | {self.ip}  | ||||
|         code={self.code}  | ||||
|         request={self.request}  | ||||
|         source={self.source} | ||||
|         user-agent={self.user_agent}""" | ||||
| 
 | ||||
|     @classmethod | ||||
|     def from_raw(cls, raw: str) -> "NginxLog": | ||||
|         raw = raw.replace("\n", "") | ||||
|         content = [] | ||||
|         buf = "" | ||||
|         is_str = False | ||||
| 
 | ||||
|         for c in raw: | ||||
|             if c == '"': | ||||
|                 if is_str: | ||||
|                     is_str = False | ||||
|                     content.append(buf) | ||||
|                     buf = "" | ||||
|                     continue | ||||
| 
 | ||||
|                 is_str = True | ||||
|                 continue | ||||
| 
 | ||||
|             if c == " " and not is_str: | ||||
|                 content.append(buf) | ||||
|                 buf = "" | ||||
|                 continue | ||||
|             buf += c | ||||
| 
 | ||||
|         content = [c for c in content if c not in ("")] | ||||
| 
 | ||||
|         try: | ||||
|             source = content[8] | ||||
|         except IndexError: | ||||
|             logging.warning( | ||||
|                 "unable to parse source from raw: %s", raw.replace("\n", "") | ||||
|             ) | ||||
|             source = "" | ||||
| 
 | ||||
|         try: | ||||
|             user_agent = content[9] | ||||
|         except IndexError: | ||||
|             logging.warning("unable to parse user-agent from raw: %s", raw) | ||||
|             user_agent = "" | ||||
| 
 | ||||
|         return NginxLog( | ||||
|             content[0], | ||||
|             dt.strptime(content[3], "[%d/%B/%Y:%H:%M:%S"), | ||||
|             content[5], | ||||
|             int(content[6]), | ||||
|             source, | ||||
|             user_agent, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| def parse_nginx_logs() -> list[NginxLog]: | ||||
|     logs: list[NginxLog] = [] | ||||
| 
 | ||||
|     files = [ | ||||
|         os.path.abspath(os.path.join(NGINX_ACCESS_LOGS_DIR, f)) | ||||
|         for f in os.listdir(NGINX_ACCESS_LOGS_DIR) | ||||
|         if all((f.startswith("access"), not f.endswith(".gz"))) | ||||
|     ] | ||||
|     for file in files: | ||||
|         with open(file, "r") as f: | ||||
|             while line := f.readline(): | ||||
|                 logs.append(NginxLog.from_raw(line)) | ||||
| 
 | ||||
|     return logs | ||||
| 
 | ||||
| 
 | ||||
| def get_logs_to_deny(logs: list[NginxLog], rules: Rules) -> dict[str, NginxLog]: | ||||
|     filter_logs: dict[str, NginxLog] = {} | ||||
| 
 | ||||
|     for log in logs: | ||||
|         if rules.is_whitelist(log.ip): | ||||
|             continue | ||||
| 
 | ||||
|         if filter_logs.get(log.ip) is not None: | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.code_allowed(log.code): | ||||
|             filter_logs[log.ip] = log | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.content_allowed(log.request.lower()): | ||||
|             filter_logs[log.ip] = log | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.user_agent_allowed(log.user_agent.lower()): | ||||
|             filter_logs[log.ip] = log | ||||
|             continue | ||||
| 
 | ||||
|     return filter_logs | ||||
| 
 | ||||
| 
 | ||||
| def main(refresh: bool = False, reload: bool = False, dry_run: bool = False): | ||||
|     rules = Rules.from_conf() | ||||
| 
 | ||||
|     logs = parse_nginx_logs() | ||||
|     logs_to_deny = get_logs_to_deny(logs, rules) | ||||
| 
 | ||||
|     if args.refresh and not args.dry_run: | ||||
|         UFW.drop_all() | ||||
|         return | ||||
| 
 | ||||
|     for ip, log in logs_to_deny.items(): | ||||
|         print(f"> banning log: {log}") | ||||
|         if not args.dry_run: | ||||
|             UFW.ban_ip(ip) | ||||
| 
 | ||||
|     if reload: | ||||
|         UFW.reload() | ||||
|     logging.info(f"{len(logs_to_deny)} ip banned") | ||||
| 
 | ||||
| 
 | ||||
| def live(dry_run: bool = False): | ||||
|     rules = Rules.from_conf() | ||||
| 
 | ||||
|     for line in sys.stdin: | ||||
|         try: | ||||
|             log = NginxLog.from_raw(line) | ||||
|         except Exception as e: | ||||
|             logging.error(f"unable to parse Nginx log: {line}, err={e}") | ||||
|             continue | ||||
| 
 | ||||
|         if rules.is_whitelist(log.ip): | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.code_allowed(log.code): | ||||
|             logging.info(f"banning log (http code not allowed): {log}") | ||||
|             if not dry_run: | ||||
|                 UFW.ban_ip(log.ip) | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.content_allowed(log.request.lower()): | ||||
|             logging.info(f"banning log (contents not allowed): {log}") | ||||
|             if not dry_run: | ||||
|                 UFW.ban_ip(log.ip) | ||||
|             continue | ||||
| 
 | ||||
|         if not rules.user_agent_allowed(log.user_agent.lower()): | ||||
|             logging.info(f"banning log (user agent not allowed): {log}") | ||||
|             if not dry_run: | ||||
|                 UFW.ban_ip(log.ip) | ||||
|             continue | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     parser = argparse.ArgumentParser( | ||||
|         "ufwban", description="Ban ip from Nginx access logs based on simple rules." | ||||
|     ) | ||||
| 
 | ||||
|     parser.add_argument("--dry-run", action="store_true", default=False) | ||||
|     parser.add_argument( | ||||
|         "--refresh", | ||||
|         action="store_true", | ||||
|         default=False, | ||||
|         help="Drop all the deny ip in the UFW table and return", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--reload", action="store_true", default=False, help="Reload the UFW firewall" | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--live", action="store_true", default=False, help="Read inputs from stdin" | ||||
|     ) | ||||
|     args = parser.parse_args() | ||||
| 
 | ||||
|     logging.info("collecting and denying ip from Nginx access logs...") | ||||
|     start = time.perf_counter() | ||||
| 
 | ||||
|     exit_code = 0 | ||||
|     try: | ||||
|         if args.live: | ||||
|             live(args.dry_run) | ||||
|         else: | ||||
|             main(args.refresh, args.reload, args.dry_run) | ||||
|     except Exception as e: | ||||
|         exit_code = 1 | ||||
|         logging.fatal(f"unexpected error occurred, err={e}") | ||||
|     except KeyboardInterrupt: | ||||
|         logging.warning("ok, you just kill me..., bye") | ||||
| 
 | ||||
|     logging.info(f"ufwban done in elapsed time: {time.perf_counter() - start:.2f}s") | ||||
|     exit(exit_code) | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 root
						root