From 8779420d0a482c425eaca8dcee6a01017e37aa7a Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 16 Dec 2025 11:44:25 +0900 Subject: [PATCH] Initial commit: Discord Bot (fail2ban) --- .gitignore | 20 +++ README.md | 31 +++++ fail2ban/.env | 8 ++ fail2ban/Dockerfile | 17 +++ fail2ban/README.md | 104 ++++++++++++++ fail2ban/discord-notify.sh | 31 +++++ fail2ban/docker-compose.yml | 23 ++++ fail2ban/main.py | 265 ++++++++++++++++++++++++++++++++++++ fail2ban/requirements.txt | 2 + 9 files changed, 501 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fail2ban/.env create mode 100644 fail2ban/Dockerfile create mode 100644 fail2ban/README.md create mode 100644 fail2ban/discord-notify.sh create mode 100644 fail2ban/docker-compose.yml create mode 100644 fail2ban/main.py create mode 100644 fail2ban/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0755fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +node_modules/ + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d914241 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# πŸ€– Discord Bot + +λ””μŠ€μ½”λ“œ 봇 λͺ¨μŒμž…λ‹ˆλ‹€. + +![Python](https://img.shields.io/badge/Python-3.11-3776AB?logo=python) +![Discord](https://img.shields.io/badge/Discord.py-2.0-5865F2?logo=discord) +![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker) + +--- + +## πŸ“ 봇 λͺ©λ‘ + +| 봇 | μ„€λͺ… | +| ----------------------- | --------------------- | +| [fail2ban](./fail2ban/) | SSH 차단 μ•Œλ¦Ό 및 관리 | + +--- + +## πŸ› οΈ 곡톡 기술 μŠ€νƒ + +| 기술 | μ„€λͺ… | +| -------------- | ------------- | +| **Python** | λŸ°νƒ€μž„ | +| **discord.py** | λ””μŠ€μ½”λ“œ API | +| **Docker** | μ»¨ν…Œμ΄λ„ˆ ν™˜κ²½ | + +--- + +## πŸ“„ λΌμ΄μ„ μŠ€ + +MIT License diff --git a/fail2ban/.env b/fail2ban/.env new file mode 100644 index 0000000..ea95c33 --- /dev/null +++ b/fail2ban/.env @@ -0,0 +1,8 @@ +# λ””μŠ€μ½”λ“œ 봇 토큰 +DISCORD_BOT_TOKEN=MTQ1MDI5MjE2NDIzMDY0MzcyNQ.GrxNZp.xg79iSfHslXoFmooH6VbFak91yC5D49k1ZSjIw + +# μ•Œλ¦Όμ„ 보낼 λ””μŠ€μ½”λ“œ 채널 ID (채널 우클릭 β†’ ID 볡사) +DISCORD_CHANNEL_ID=1450288717280575498 + +# μ›Ήν›… μ„œλ²„ 포트 +WEBHOOK_PORT=5000 diff --git a/fail2ban/Dockerfile b/fail2ban/Dockerfile new file mode 100644 index 0000000..ffbc263 --- /dev/null +++ b/fail2ban/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +# fail2ban-client μ„€μΉ˜ +RUN apt-get update && apt-get install -y --no-install-recommends \ + fail2ban \ + && rm -rf /var/lib/apt/lists/* + +# μ˜μ‘΄μ„± μ„€μΉ˜ +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# μ†ŒμŠ€ 볡사 +COPY main.py . + +CMD ["python", "main.py"] diff --git a/fail2ban/README.md b/fail2ban/README.md new file mode 100644 index 0000000..b1b1d41 --- /dev/null +++ b/fail2ban/README.md @@ -0,0 +1,104 @@ +# πŸ›‘οΈ fail2ban Discord Bot + +fail2ban IP 차단 ν˜„ν™©μ„ λ””μŠ€μ½”λ“œλ‘œ μ‹€μ‹œκ°„ μ•Œλ¦Ό 및 κ΄€λ¦¬ν•˜λŠ” λ΄‡μž…λ‹ˆλ‹€. + +![Python](https://img.shields.io/badge/Python-3.11-3776AB?logo=python) +![Discord](https://img.shields.io/badge/Discord.py-2.0-5865F2?logo=discord) + +--- + +## ✨ μ£Όμš” κΈ°λŠ₯ + +- 🚨 **μ‹€μ‹œκ°„ μ•Œλ¦Ό** - IP 차단 μ‹œ λ””μŠ€μ½”λ“œ μ±„λ„λ‘œ μ¦‰μ‹œ μ•Œλ¦Ό +- πŸ“‹ **차단 λͺ©λ‘** - `/list` λͺ…λ ΉμœΌλ‘œ ν˜„μž¬ 차단 IP 쑰회 +- πŸ”“ **차단 ν•΄μ œ** - `/unban` λͺ…λ ΉμœΌλ‘œ IP 차단 ν•΄μ œ +- πŸ“Š **μƒνƒœ 쑰회** - `/status` λͺ…λ ΉμœΌλ‘œ fail2ban μ„œλΉ„μŠ€ μƒνƒœ 확인 +- 🌍 **IP 정보** - μ°¨λ‹¨λœ IP의 μœ„μΉ˜ 및 ISP 정보 ν‘œμ‹œ + +--- + +## πŸ› οΈ 기술 μŠ€νƒ + +| 기술 | μ„€λͺ… | +| -------------- | ------------------ | +| **Python** | λŸ°νƒ€μž„ | +| **discord.py** | λ””μŠ€μ½”λ“œ API | +| **aiohttp** | 비동기 HTTP (μ›Ήν›…) | +| **ip-api.com** | IP μœ„μΉ˜ 쑰회 | + +--- + +## πŸ“‘ μŠ¬λž˜μ‹œ λͺ…λ Ήμ–΄ + +| λͺ…λ Ήμ–΄ | μ„€λͺ… | +| -------------------- | --------------------------- | +| `/list [jail]` | μ°¨λ‹¨λœ IP λͺ©λ‘ 및 톡계 쑰회 | +| `/unban [jail]` | IP 차단 ν•΄μ œ | +| `/status` | fail2ban μ„œλΉ„μŠ€ μƒνƒœ 확인 | + +--- + +## πŸ”” μ•Œλ¦Ό ν˜•μ‹ + +IP 차단 μ‹œ λ‹€μŒ 정보가 ν¬ν•¨λœ Embed μ•Œλ¦Όμ΄ μ „μ†‘λ©λ‹ˆλ‹€: + +- πŸ”’ μ°¨λ‹¨λœ IP +- πŸ“› Jail 이름 +- ❌ μ‹€νŒ¨ 횟수 +- 🌍 μœ„μΉ˜ (κ΅­κ°€, λ„μ‹œ) +- 🏒 ISP +- πŸ–₯️ μ„œλ²„ 이름 + +--- + +## πŸš€ μ‹€ν–‰ 방법 + +### Docker (ꢌμž₯) + +```bash +docker compose up -d --build +``` + +### 둜컬 μ‹€ν–‰ + +```bash +pip install -r requirements.txt +python main.py +``` + +--- + +## βš™οΈ ν™˜κ²½ λ³€μˆ˜ + +| λ³€μˆ˜ | μ„€λͺ… | +| -------------------- | --------------------------- | +| `DISCORD_BOT_TOKEN` | λ””μŠ€μ½”λ“œ 봇 토큰 | +| `DISCORD_CHANNEL_ID` | μ•Œλ¦Ό 채널 ID | +| `WEBHOOK_PORT` | μ›Ήν›… μ„œλ²„ 포트 (κΈ°λ³Έ: 5000) | + +--- + +## πŸ”§ fail2ban 연동 + +`/etc/fail2ban/action.d/` 에 μ•‘μ…˜ 슀크립트λ₯Ό μΆ”κ°€ν•˜κ³ , jail μ„€μ •μ—μ„œ ν•΄λ‹Ή μ•‘μ…˜μ„ μ‚¬μš©ν•©λ‹ˆλ‹€. + +```bash +# 차단 μ‹œ μ›Ήν›… 호좜 +curl -X POST http://127.0.0.1:5000/ban \ + -H "Content-Type: application/json" \ + -d '{"ip":"", "jail":"", "failures":""}' +``` + +--- + +## πŸ“ ꡬ쑰 + +``` +fail2ban/ +β”œβ”€β”€ main.py # 봇 메인 둜직 +β”œβ”€β”€ docker-compose.yml # Docker Compose μ„€μ • +β”œβ”€β”€ Dockerfile # μ»¨ν…Œμ΄λ„ˆ λΉŒλ“œ +β”œβ”€β”€ requirements.txt # Python μ˜μ‘΄μ„± +β”œβ”€β”€ discord-notify.sh # fail2ban μ•‘μ…˜ 슀크립트 +└── .env # ν™˜κ²½ λ³€μˆ˜ +``` diff --git a/fail2ban/discord-notify.sh b/fail2ban/discord-notify.sh new file mode 100644 index 0000000..ca81f32 --- /dev/null +++ b/fail2ban/discord-notify.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# fail2banμ—μ„œ IP 차단 μ‹œ λ””μŠ€μ½”λ“œ λ΄‡μœΌλ‘œ μ•Œλ¦Όμ„ λ³΄λ‚΄λŠ” 슀크립트 +# μ‚¬μš©λ²•: discord-notify.sh + +# 봇 μ›Ήν›… URL +BOT_WEBHOOK_URL="http://localhost:5000/ban" + +# νŒŒλΌλ―Έν„° λ°›κΈ° +IP=$1 +JAIL=$2 +FAILURES=$3 + +# JSON νŽ˜μ΄λ‘œλ“œ 생성 +JSON_PAYLOAD=$(cat < dict: + """IP 정보 쑰회 (μœ„μΉ˜, ISP)""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://ip-api.com/json/{ip}?fields=country,city,isp") as resp: + if resp.status == 200: + return await resp.json() + except Exception: + pass + return {"country": "Unknown", "city": "Unknown", "isp": "Unknown"} + + +def run_fail2ban_command(args: list) -> str: + """fail2ban-client λͺ…λ Ήμ–΄ μ‹€ν–‰""" + try: + result = subprocess.run( + ["fail2ban-client"] + args, + capture_output=True, + text=True, + timeout=10 + ) + return result.stdout or result.stderr + except subprocess.TimeoutExpired: + return "λͺ…λ Ήμ–΄ μ‹œκ°„ 초과" + except Exception as e: + return f"였λ₯˜: {str(e)}" + + +def get_jail_stats(jail: str = "sshd") -> dict: + """jail 톡계 쑰회""" + output = run_fail2ban_command(["status", jail]) + stats = { + "banned_ips": [], + "currently_failed": "0", + "total_failed": "0", + "currently_banned": "0", + "total_banned": "0" + } + + for line in output.split("\n"): + if "Banned IP list:" in line: + ip_part = line.split("Banned IP list:")[1].strip() + if ip_part: + stats["banned_ips"] = ip_part.split() + elif "Currently failed:" in line: + stats["currently_failed"] = line.split(":")[-1].strip() + elif "Total failed:" in line: + stats["total_failed"] = line.split(":")[-1].strip() + elif "Currently banned:" in line: + stats["currently_banned"] = line.split(":")[-1].strip() + elif "Total banned:" in line: + stats["total_banned"] = line.split(":")[-1].strip() + + return stats + + +@bot.event +async def on_ready(): + """봇 μ‹œμž‘ μ‹œ μ‹€ν–‰""" + print(f"βœ… 봇 둜그인: {bot.user}") + try: + synced = await bot.tree.sync() + print(f"βœ… {len(synced)}개 λͺ…λ Ήμ–΄ 동기화 μ™„λ£Œ") + except Exception as e: + print(f"❌ λͺ…λ Ήμ–΄ 동기화 μ‹€νŒ¨: {e}") + + +@bot.tree.command(name="list", description="μ°¨λ‹¨λœ IP λͺ©λ‘κ³Ό 톡계λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€") +@app_commands.describe(jail="μ‘°νšŒν•  jail 이름 (κΈ°λ³Έ: sshd)") +async def list_banned(interaction: discord.Interaction, jail: str = "sshd"): + """μ°¨λ‹¨λœ IP λͺ©λ‘ 및 톡계 쑰회""" + await interaction.response.defer() + + stats = get_jail_stats(jail) + banned_ips = stats["banned_ips"] + + embed = discord.Embed( + title=f"πŸ”’ μ°¨λ‹¨λœ IP λͺ©λ‘ ({jail})", + color=discord.Color.red() if banned_ips else discord.Color.green(), + timestamp=datetime.now() + ) + + if banned_ips: + ip_list = "\n".join([f"β€’ `{ip}`" for ip in banned_ips]) + embed.description = ip_list + else: + embed.description = "βœ… ν˜„μž¬ μ°¨λ‹¨λœ IPκ°€ μ—†μŠ΅λ‹ˆλ‹€." + + # 톡계 μΆ”κ°€ (μ—¬λ°± 포함) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field(name="πŸ”’ 영ꡬ 차단", value=f"`{stats['currently_banned']}`개 IP", inline=True) + embed.add_field(name="πŸ“ˆ λˆ„μ  μ‹€νŒ¨", value=f"`{stats['total_failed']}`회", inline=True) + embed.add_field(name="πŸ“Š λˆ„μ  차단", value=f"`{stats['total_banned']}`회", inline=True) + + await interaction.followup.send(embed=embed) + + +@bot.tree.command(name="unban", description="IP 차단을 ν•΄μ œν•©λ‹ˆλ‹€") +@app_commands.describe(ip="차단 ν•΄μ œν•  IP μ£Όμ†Œ", jail="jail 이름 (κΈ°λ³Έ: sshd)") +async def unban_ip(interaction: discord.Interaction, ip: str, jail: str = "sshd"): + """IP 차단 ν•΄μ œ""" + await interaction.response.defer() + + result = run_fail2ban_command(["set", jail, "unbanip", ip]) + + embed = discord.Embed(timestamp=datetime.now()) + + if "is not banned" in result.lower() or "not in" in result.lower(): + embed.title = "⚠️ 차단 ν•΄μ œ μ‹€νŒ¨" + embed.description = f"`{ip}`λŠ” ν˜„μž¬ μ°¨λ‹¨λ˜μ–΄ μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + embed.color = discord.Color.orange() + elif "error" in result.lower(): + embed.title = "❌ 였λ₯˜ λ°œμƒ" + embed.description = f"```{result}```" + embed.color = discord.Color.red() + else: + embed.title = "βœ… 차단 ν•΄μ œ μ™„λ£Œ" + embed.description = f"`{ip}`의 차단이 ν•΄μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." + embed.color = discord.Color.green() + + await interaction.followup.send(embed=embed) + + +@bot.tree.command(name="status", description="fail2ban μ„œλΉ„μŠ€ μƒνƒœλ₯Ό ν™•μΈν•©λ‹ˆλ‹€") +async def status(interaction: discord.Interaction): + """fail2ban μ„œλΉ„μŠ€ μƒνƒœ 쑰회""" + await interaction.response.defer() + + # 전체 μƒνƒœ 쑰회 + output = run_fail2ban_command(["status"]) + + # ν™œμ„± jail λͺ©λ‘ νŒŒμ‹± + jails = [] + for line in output.split("\n"): + if "Jail list:" in line: + jail_part = line.split("Jail list:")[1].strip() + if jail_part: + jails = [j.strip() for j in jail_part.split(",")] + + # μ„œλΉ„μŠ€ μƒνƒœ 확인 + is_running = "Number of jail:" in output + + embed = discord.Embed( + title="πŸ“Š fail2ban μ„œλΉ„μŠ€ μƒνƒœ", + color=discord.Color.green() if is_running else discord.Color.red(), + timestamp=datetime.now() + ) + + # μƒνƒœ + status_text = "🟒 μ‹€ν–‰ 쀑" if is_running else "πŸ”΄ 쀑지됨" + embed.add_field(name="μƒνƒœ", value=status_text, inline=True) + embed.add_field(name="ν™œμ„± Jail", value=f"`{len(jails)}`개", inline=True) + embed.add_field(name="\u200b", value="\u200b", inline=True) + + # Jail λͺ©λ‘ (μ—¬λ°± 포함) + embed.add_field(name="\u200b", value="\u200b", inline=False) + if jails: + jail_list = ", ".join([f"`{j}`" for j in jails]) + embed.add_field(name="πŸ“‹ Jail λͺ©λ‘", value=jail_list, inline=False) + + # μ„€μ • 정보 (μ—¬λ°± 포함) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field(name="βš™οΈ μ„€μ •", value="10λΆ„ λ‚΄ 5회 μ‹€νŒ¨ μ‹œ **영ꡬ 차단**", inline=False) + + await interaction.followup.send(embed=embed) + + +async def send_ban_notification(ip: str, jail: str, failures: str): + """IP 차단 μ•Œλ¦Ό 전솑""" + if not CHANNEL_ID: + print("❌ CHANNEL_IDκ°€ μ„€μ •λ˜μ§€ μ•ŠμŒ") + return + + channel = bot.get_channel(CHANNEL_ID) + if not channel: + print(f"❌ 채널을 찾을 수 μ—†μŒ: {CHANNEL_ID}") + return + + # IP 정보 쑰회 + ip_info = await get_ip_info(ip) + + embed = discord.Embed( + title="🚫 IP 차단 μ•Œλ¦Ό", + description="fail2ban에 μ˜ν•΄ IPκ°€ μ°¨λ‹¨λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + color=discord.Color.red(), + timestamp=datetime.now() + ) + embed.add_field(name="πŸ”’ μ°¨λ‹¨λœ IP", value=f"`{ip}`", inline=True) + embed.add_field(name="πŸ“› Jail", value=f"`{jail}`", inline=True) + embed.add_field(name="❌ μ‹€νŒ¨ 횟수", value=f"`{failures}`", inline=True) + embed.add_field(name="🌍 μœ„μΉ˜", value=f"`{ip_info.get('city', 'Unknown')}, {ip_info.get('country', 'Unknown')}`", inline=True) + embed.add_field(name="🏒 ISP", value=f"`{ip_info.get('isp', 'Unknown')}`", inline=True) + embed.add_field(name="πŸ–₯️ μ„œλ²„", value=f"`{os.uname().nodename}`", inline=True) + + await channel.send(embed=embed) + print(f"βœ… 차단 μ•Œλ¦Ό 전솑: {ip}") + + +# HTTP μ›Ήν›… μ„œλ²„ (fail2banμ—μ„œ 호좜) +async def webhook_handler(request): + """fail2banμ—μ„œ ν˜ΈμΆœν•˜λŠ” μ›Ήν›…""" + try: + data = await request.json() + ip = data.get("ip", "Unknown") + jail = data.get("jail", "sshd") + failures = data.get("failures", "?") + + await send_ban_notification(ip, jail, failures) + return web.Response(text="OK") + except Exception as e: + print(f"❌ μ›Ήν›… 였λ₯˜: {e}") + return web.Response(text="Error", status=500) + + +async def start_webhook_server(): + """μ›Ήν›… μ„œλ²„ μ‹œμž‘""" + app = web.Application() + app.router.add_post("/ban", webhook_handler) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT) + await site.start() + print(f"βœ… μ›Ήν›… μ„œλ²„ μ‹œμž‘: http://0.0.0.0:{WEBHOOK_PORT}/ban") + + +async def main(): + """메인 ν•¨μˆ˜""" + # μ›Ήν›… μ„œλ²„ μ‹œμž‘ + await start_webhook_server() + + # 봇 μ‹œμž‘ + await bot.start(BOT_TOKEN) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/fail2ban/requirements.txt b/fail2ban/requirements.txt new file mode 100644 index 0000000..64a6c9b --- /dev/null +++ b/fail2ban/requirements.txt @@ -0,0 +1,2 @@ +discord.py>=2.3.0 +aiohttp>=3.9.0