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