Commit 59d6cb2e authored by Vitaly Lipatov's avatar Vitaly Lipatov

Add auto-unban with exponential backoff

parent c5d64d07
......@@ -12,3 +12,27 @@
#i_interface2 = brlocal2
#hostname = localhost
[AutoUnban]
# Enable automatic unbanning with exponential backoff
enabled = true
# Base ban duration for first offense (seconds)
# Default: 3600 (1 hour)
base_ban_seconds = 3600
# Backoff multiplier for each subsequent offense
# Duration = base_ban_seconds * (backoff_multiplier ^ (offense_count - 1))
# With multiplier=4: 1h -> 4h -> 16h -> 64h -> permanent
backoff_multiplier = 4
# Maximum offense level before permanent ban
# offense_count > max_offense_level = permanent ban
max_offense_level = 4
# Period after which offense counter resets (days)
# If IP is clean for this period, counter resets to 0
reset_period_days = 365
# How often to check for expired bans (seconds)
check_interval_seconds = 60
......@@ -43,14 +43,41 @@ if [ "$command" = "clear" ] ; then
exit
fi
if [ "$command" = "info" ] ; then
/usr/share/eterban/autoban_cli.py info $1
exit
fi
if [ "$command" = "reset" ] ; then
/usr/share/eterban/autoban_cli.py reset $1
exit
fi
if [ "$command" = "pending" ] ; then
/usr/share/eterban/autoban_cli.py pending
exit
fi
if [ "$command" = "permanent" ] ; then
/usr/share/eterban/autoban_cli.py permanent
exit
fi
cat <<EOF
Usage:
eterban [count|list|search <ip>]
count - print count of banned IPs
list - list all banned IPs
search <ip> - search for ip in the list of banned IPs
unban <ip> - unban IP
ban <ip> - ban IP
clear - remove all IPs from ban
eterban <command> [args]
Commands:
count - print count of banned IPs
list - list all banned IPs
search <ip> - search for ip in the list of banned IPs
unban <ip> - unban IP
ban <ip> - ban IP
clear - remove all IPs from ban
Auto-unban commands:
info <ip> - show ban info and history for IP
reset <ip> - reset offense counter for IP
pending - list pending auto-unbans
permanent - list permanent bans
EOF
#!/usr/bin/python3
"""
CLI для команд автоматической разблокировки eterban.
Команды:
info <ip> - показать информацию о бане и историю
reset <ip> - сбросить счётчик нарушений
pending - показать запланированные авто-разбаны
permanent - показать постоянные баны
"""
import sys
import time
import configparser
import redis
# Пути
CONFIG_PATH = '/etc/eterban/settings.ini'
# Ключи Redis
META_PREFIX = 'eterban:meta:'
SCHEDULE_KEY = 'eterban:unban_schedule'
PERMANENT_KEY = 'eterban:permanent'
def format_duration(seconds):
"""Форматировать длительность в человекочитаемый вид."""
if seconds <= 0:
return "permanent"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def format_time(timestamp):
"""Форматировать Unix timestamp."""
if timestamp <= 0:
return "permanent"
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))
def get_redis():
"""Получить подключение к Redis."""
config = configparser.ConfigParser()
config.read(CONFIG_PATH)
redis_server = config.get('Settings', 'redis_server', fallback='localhost')
return redis.Redis(host=redis_server)
def cmd_info(ip):
"""Показать информацию о бане IP."""
r = get_redis()
meta = r.hgetall(f"{META_PREFIX}{ip}")
if not meta:
print(f"No ban history for {ip}")
return
# Декодируем значения
data = {k.decode(): v.decode() for k, v in meta.items()}
offense_count = int(data.get('offense_count', 0))
ban_time = int(data.get('ban_time', 0))
unban_time = int(data.get('unban_time', 0))
last_offense = int(data.get('last_offense', 0))
source = data.get('source', 'unknown')
reason = data.get('reason', 'unknown')
# Рассчитываем длительность
if unban_time > 0:
duration = unban_time - ban_time
status = "temporary"
remaining = unban_time - int(time.time())
if remaining < 0:
remaining_str = "expired (waiting for unban)"
else:
remaining_str = format_duration(remaining)
else:
duration = 0
status = "permanent"
remaining_str = "permanent"
# Проверяем, в каком списке
is_scheduled = r.zscore(SCHEDULE_KEY, ip) is not None
is_permanent = r.sismember(PERMANENT_KEY, ip)
print(f"Ban info for {ip}:")
print(f" Offense count: {offense_count}")
print(f" Status: {status}")
print(f" Ban duration: {format_duration(duration)}")
print(f" Remaining: {remaining_str}")
print(f" Ban time: {format_time(ban_time)}")
print(f" Unban time: {format_time(unban_time)}")
print(f" Last offense: {format_time(last_offense)}")
print(f" Source: {source}")
print(f" Reason: {reason}")
print(f" In schedule: {'yes' if is_scheduled else 'no'}")
print(f" In permanent: {'yes' if is_permanent else 'no'}")
def cmd_reset(ip):
"""Сбросить счётчик нарушений для IP."""
r = get_redis()
meta_key = f"{META_PREFIX}{ip}"
if not r.exists(meta_key):
print(f"No ban history for {ip}")
return
r.delete(meta_key)
r.zrem(SCHEDULE_KEY, ip)
r.srem(PERMANENT_KEY, ip)
print(f"Reset offense counter for {ip}")
def cmd_pending():
"""Показать запланированные авто-разбаны."""
r = get_redis()
pending = r.zrangebyscore(SCHEDULE_KEY, 0, '+inf', withscores=True)
if not pending:
print("No pending auto-unbans")
return
now = int(time.time())
print(f"Pending auto-unbans ({len(pending)} total):")
print()
for ip, unban_time in pending:
ip = ip.decode() if isinstance(ip, bytes) else ip
unban_time = int(unban_time)
remaining = unban_time - now
if remaining < 0:
remaining_str = "EXPIRED"
else:
remaining_str = format_duration(remaining)
print(f" {ip:20s} unban at {format_time(unban_time)} ({remaining_str})")
def cmd_permanent():
"""Показать постоянные баны."""
r = get_redis()
members = r.smembers(PERMANENT_KEY)
if not members:
print("No permanent bans")
return
print(f"Permanent bans ({len(members)} total):")
print()
for ip in sorted(members):
ip = ip.decode() if isinstance(ip, bytes) else ip
# Получаем дополнительную информацию
meta = r.hgetall(f"{META_PREFIX}{ip}")
if meta:
data = {k.decode(): v.decode() for k, v in meta.items()}
offense_count = data.get('offense_count', '?')
source = data.get('source', 'unknown')
print(f" {ip:20s} offenses: {offense_count} source: {source}")
else:
print(f" {ip}")
def usage():
print("""Usage: autoban_cli.py <command> [args]
Commands:
info <ip> - show ban info and history for IP
reset <ip> - reset offense counter for IP
pending - list pending auto-unbans
permanent - list permanent bans
""")
sys.exit(1)
def main():
if len(sys.argv) < 2:
usage()
cmd = sys.argv[1]
if cmd == 'info':
if len(sys.argv) < 3:
print("Error: IP address required")
sys.exit(1)
cmd_info(sys.argv[2])
elif cmd == 'reset':
if len(sys.argv) < 3:
print("Error: IP address required")
sys.exit(1)
cmd_reset(sys.argv[2])
elif cmd == 'pending':
cmd_pending()
elif cmd == 'permanent':
cmd_permanent()
else:
print(f"Unknown command: {cmd}")
usage()
if __name__ == '__main__':
main()
#!/usr/bin/python3
"""
AutoBanManager - управление автоматической разблокировкой с экспоненциальным увеличением времени бана.
Логика:
- Первый бан: 1 час
- Второй бан: 4 часа (4^1)
- Третий бан: 16 часов (4^2)
- Четвёртый бан: 64 часа (4^3)
- Пятый+ бан: постоянный
Если IP чист 1 год - счётчик сбрасывается.
"""
import time
import logging
log = logging.getLogger(__name__)
class AutoBanManager:
"""Менеджер автоматической разблокировки с экспоненциальным backoff."""
def __init__(self, redis_client, config):
"""
Инициализация менеджера.
Args:
redis_client: Подключение к Redis
config: ConfigParser с секцией [AutoUnban]
"""
self.r = redis_client
# Читаем конфигурацию с fallback значениями
self.enabled = config.getboolean('AutoUnban', 'enabled', fallback=True)
self.base_ban_seconds = config.getint('AutoUnban', 'base_ban_seconds', fallback=3600)
self.backoff_multiplier = config.getint('AutoUnban', 'backoff_multiplier', fallback=4)
self.max_offense_level = config.getint('AutoUnban', 'max_offense_level', fallback=4)
self.reset_period_seconds = config.getint('AutoUnban', 'reset_period_days', fallback=365) * 86400
self.check_interval = config.getint('AutoUnban', 'check_interval_seconds', fallback=60)
# Ключи Redis
self.META_PREFIX = 'eterban:meta:'
self.SCHEDULE_KEY = 'eterban:unban_schedule'
self.PERMANENT_KEY = 'eterban:permanent'
def calculate_ban_duration(self, offense_count):
"""
Рассчитать длительность бана.
Args:
offense_count: Номер нарушения (1, 2, 3, ...)
Returns:
Длительность в секундах, или 0 для постоянного бана
"""
if offense_count > self.max_offense_level:
return 0 # Permanent
return self.base_ban_seconds * (self.backoff_multiplier ** (offense_count - 1))
def on_ban(self, ip, source='unknown', reason='auto'):
"""
Обработка нового бана.
Args:
ip: IP-адрес
source: Источник бана (hostname сервера)
reason: Причина бана (SSH, HTTP, etc.)
Returns:
dict с метаданными бана
"""
if not self.enabled:
return None
try:
meta_key = f"{self.META_PREFIX}{ip}"
now = int(time.time())
# Получаем существующие метаданные
existing = self.r.hgetall(meta_key)
if existing:
offense_count = int(existing.get(b'offense_count', 0))
last_offense = int(existing.get(b'last_offense', 0))
# Проверяем, прошёл ли период сброса (1 год)
if now - last_offense > self.reset_period_seconds:
offense_count = 0
offense_count += 1
else:
offense_count = 1
# Рассчитываем время бана
ban_duration = self.calculate_ban_duration(offense_count)
unban_time = now + ban_duration if ban_duration > 0 else 0
# Сохраняем метаданные
metadata = {
'offense_count': offense_count,
'ban_time': now,
'unban_time': unban_time,
'last_offense': now,
'source': source,
'reason': reason
}
self.r.hset(meta_key, mapping=metadata)
# Устанавливаем TTL для очистки (1 год после последнего нарушения)
self.r.expire(meta_key, self.reset_period_seconds)
# Добавляем в расписание авто-разбана или в постоянные
if unban_time > 0:
self.r.zadd(self.SCHEDULE_KEY, {ip: unban_time})
self.r.srem(self.PERMANENT_KEY, ip) # На случай если был permanent
else:
self.r.sadd(self.PERMANENT_KEY, ip)
self.r.zrem(self.SCHEDULE_KEY, ip) # Убираем из расписания
return metadata
except Exception as e:
log.error(f"AutoBanManager.on_ban error: {e}")
return None
def on_unban(self, ip, reset_counter=False):
"""
Обработка разблокировки.
Args:
ip: IP-адрес
reset_counter: Сбросить счётчик нарушений
"""
if not self.enabled:
return
try:
# Удаляем из расписания и постоянных
self.r.zrem(self.SCHEDULE_KEY, ip)
self.r.srem(self.PERMANENT_KEY, ip)
if reset_counter:
self.r.delete(f"{self.META_PREFIX}{ip}")
except Exception as e:
log.error(f"AutoBanManager.on_unban error: {e}")
def get_expired_bans(self):
"""
Получить список IP с истёкшим временем бана.
Returns:
Список IP-адресов для разблокировки
"""
if not self.enabled:
return []
try:
now = int(time.time())
expired = self.r.zrangebyscore(self.SCHEDULE_KEY, 0, now)
return [ip.decode() if isinstance(ip, bytes) else ip for ip in expired]
except Exception as e:
log.error(f"AutoBanManager.get_expired_bans error: {e}")
return []
def remove_from_schedule(self, ip):
"""Удалить IP из расписания после авто-разбана."""
try:
self.r.zrem(self.SCHEDULE_KEY, ip)
except Exception as e:
log.error(f"AutoBanManager.remove_from_schedule error: {e}")
def get_ban_info(self, ip):
"""
Получить информацию о бане для CLI.
Args:
ip: IP-адрес
Returns:
dict с метаданными или None
"""
try:
meta = self.r.hgetall(f"{self.META_PREFIX}{ip}")
if not meta:
return None
return {k.decode(): v.decode() for k, v in meta.items()}
except Exception as e:
log.error(f"AutoBanManager.get_ban_info error: {e}")
return None
def get_pending_unbans(self):
"""
Получить список запланированных разбанов.
Returns:
Список кортежей (ip, unban_time)
"""
try:
pending = self.r.zrangebyscore(self.SCHEDULE_KEY, 0, '+inf', withscores=True)
return [(ip.decode() if isinstance(ip, bytes) else ip, int(score))
for ip, score in pending]
except Exception as e:
log.error(f"AutoBanManager.get_pending_unbans error: {e}")
return []
def get_permanent_bans(self):
"""
Получить список постоянных банов.
Returns:
Список IP-адресов
"""
try:
members = self.r.smembers(self.PERMANENT_KEY)
return [ip.decode() if isinstance(ip, bytes) else ip for ip in members]
except Exception as e:
log.error(f"AutoBanManager.get_permanent_bans error: {e}")
return []
def reset_offense_counter(self, ip):
"""
Сбросить счётчик нарушений для IP.
Args:
ip: IP-адрес
"""
try:
self.r.delete(f"{self.META_PREFIX}{ip}")
self.r.zrem(self.SCHEDULE_KEY, ip)
self.r.srem(self.PERMANENT_KEY, ip)
except Exception as e:
log.error(f"AutoBanManager.reset_offense_counter error: {e}")
def format_duration(self, seconds):
"""Форматировать длительность в человекочитаемый вид."""
if seconds <= 0:
return "permanent"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
......@@ -8,6 +8,9 @@ import os
import signal
import socket
import ipaddress
import threading
import re
from autoban_manager import AutoBanManager
path_to_config = '/etc/eterban/settings.ini'
path_to_eterban = '/usr/share/eterban/'
......@@ -229,6 +232,40 @@ except:
print ("Unable to connect redis")
sys.exit(1)
# Инициализация AutoBanManager
config = configparser.ConfigParser()
config.read(path_to_config)
auto_mgr = AutoBanManager(r, config)
def auto_unban_checker():
"""Фоновый поток для проверки и выполнения авто-разбанов."""
while True:
time.sleep(auto_mgr.check_interval)
if not auto_mgr.enabled:
continue
try:
expired = auto_mgr.get_expired_bans()
for ip in expired:
# Публикуем разбан
r.publish('unban', ip)
r.publish('by', f"{ip} auto-unbanned after ban period expired")
auto_mgr.remove_from_schedule(ip)
info = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
info += f" {ip} auto-unbanned\n"
log.write(info)
log.flush()
except Exception as e:
info = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
info += f" auto_unban_checker error: {e}\n"
log.write(info)
log.flush()
# Запуск фонового потока
if auto_mgr.enabled:
checker_thread = threading.Thread(target=auto_unban_checker, daemon=True)
checker_thread.start()
restore_ipset_eterban_1()
create_iptables_rules()
create_ip6tables_rules()
......@@ -253,22 +290,49 @@ for message in p.listen():
elif message is not None and message['type'] =='message' and message['channel'] == b'unban' :
print (message)
ip = message['data'].decode('utf-8')
ipo = ipaddress.ip_address(ip)
ipo = ipaddress.ip_network(ip, strict=False)
if isinstance(ipo, ipaddress.IPv6Address):
unban = 'ipset -D ' + ipset_eterban_1_ipv6 + ' ' + ip
else:
elif isinstance(ipo, ipaddress.IPv4Network):
unban = 'ipset -D ' + ipset_eterban_1 + ' ' + ip
else:
log.write("Not parsed as IP, skipped " + str(ip) + '\n')
#add = 'ipset -A ' + ipset_eterban_white + ' ' + ip
subprocess.call (unban, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell = True)
#subprocess.call (add, shell = True)
tcp_drop = 'conntrack -D -s ' + ip
subprocess.Popen(tcp_drop, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell = True)
# AutoBan: обновляем метаданные (убираем из расписания)
if auto_mgr.enabled:
auto_mgr.on_unban(ip)
elif message is not None and message['type'] =='message' and message['channel'] == b'by':
by_msg = message['data'].decode('utf-8')
info = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime())
info += " " + message['data'].decode('utf-8') + "\n"
info += " " + by_msg + "\n"
print (info)
log.write(info)
log.flush()
# AutoBan: парсим сообщение и сохраняем метаданные
# Формат: "IP was blocked by HOSTNAME: REASON" или "IP was blocked by HOSTNAME (...)"
if auto_mgr.enabled:
match = re.match(r'^(\S+) was blocked by ([^:]+)(?:: (.+))?$', by_msg)
if match:
ip = match.group(1)
source = match.group(2).strip()
reason = match.group(3) if match.group(3) else 'auto'
meta = auto_mgr.on_ban(ip, source=source, reason=reason)
if meta:
ban_duration = meta.get('unban_time', 0) - int(time.time())
offense = meta.get('offense_count', 1)
if ban_duration > 0:
auto_info = f" -> offense #{offense}, auto-unban in {auto_mgr.format_duration(ban_duration)}\n"
else:
auto_info = f" -> offense #{offense}, PERMANENT ban\n"
print(auto_info)
log.write(auto_info)
log.flush()
elif message is not None:
print ("AHTUNG!!1!", message)
info = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime())
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment