149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""DDoS detector for public web ports (80/443).
|
|
Runs every minute via cron. Counts active connections per IP on 80/443,
|
|
alerts Telegram if a single IP exceeds CONN_PER_IP or total exceeds CONN_TOTAL.
|
|
State file prevents alert spam (only alerts on threshold crossing).
|
|
"""
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import datetime
|
|
|
|
# Load telegram creds from stock-monitor .env (or env)
|
|
def load_env(path):
|
|
env = {}
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
env[k.strip()] = v.strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
return env
|
|
|
|
ENV = load_env("/root/stock-monitor/.env")
|
|
BOT = os.environ.get("TELEGRAM_BOT_TOKEN") or ENV.get("TELEGRAM_BOT_TOKEN", "")
|
|
CHAT = os.environ.get("TELEGRAM_CHAT_ID") or ENV.get("TELEGRAM_CHAT_ID", "5722352727")
|
|
|
|
PORTS = [80, 443]
|
|
CONN_PER_IP = int(os.environ.get("DDOS_PER_IP", "100")) # alert if 1 IP > this
|
|
CONN_TOTAL = int(os.environ.get("DDOS_TOTAL", "500")) # alert if total on a port > this
|
|
STATE_FILE = "/root/ddos-detector/ddos_state.json"
|
|
|
|
# ports currently exposed to public (from ss output)
|
|
def get_connections():
|
|
"""Return dict: port -> {ip: count}"""
|
|
out = subprocess.run(
|
|
["ss", "-tn", "src", ":80", "or", "src", ":443"],
|
|
capture_output=True, text=True
|
|
).stdout
|
|
result = {p: {} for p in PORTS}
|
|
# ss -tn src :80 or src :443 -> lines like:
|
|
# ESTAB 0 0 127.0.0.1:80 1.2.3.4:55555
|
|
for line in out.splitlines()[1:]:
|
|
parts = line.split()
|
|
if len(parts) < 4:
|
|
continue
|
|
local = parts[3] # e.g. 0.0.0.0:80 or [::]:443
|
|
remote = parts[4]
|
|
# extract port from local
|
|
m = re.search(r":(\d+)$", local)
|
|
if not m:
|
|
continue
|
|
port = int(m.group(1))
|
|
if port not in result:
|
|
continue
|
|
# extract IP from remote (strip port)
|
|
ip = remote.rsplit(":", 1)[0]
|
|
ip = ip.strip("[]")
|
|
result[port][ip] = result[port].get(ip, 0) + 1
|
|
return result
|
|
|
|
|
|
def send_telegram(msg):
|
|
if not BOT:
|
|
print("NO BOT TOKEN, skip send")
|
|
return
|
|
import urllib.request
|
|
import urllib.parse
|
|
url = f"https://api.telegram.org/bot{BOT}/sendMessage"
|
|
data = urllib.parse.urlencode({"chat_id": CHAT, "text": msg}).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
try:
|
|
urllib.request.urlopen(req, timeout=10)
|
|
except Exception as e:
|
|
print(f"telegram send failed: {e}")
|
|
|
|
|
|
def load_state():
|
|
import json
|
|
try:
|
|
with open(STATE_FILE) as f:
|
|
return json.load(f)
|
|
except (FileNotFoundError, ValueError):
|
|
return {}
|
|
|
|
|
|
def save_state(s):
|
|
import json
|
|
with open(STATE_FILE, "w") as f:
|
|
json.dump(s, f)
|
|
|
|
|
|
def main():
|
|
conns = get_connections()
|
|
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
alerts = []
|
|
for port in PORTS:
|
|
ip_map = conns[port]
|
|
total = sum(ip_map.values())
|
|
# top offenders
|
|
top = sorted(ip_map.items(), key=lambda x: -x[1])[:5]
|
|
for ip, cnt in top:
|
|
if cnt > CONN_PER_IP:
|
|
alerts.append(
|
|
f"⚠️ Port {port}: IP {ip} = {cnt} koneksi (batas {CONN_PER_IP})"
|
|
)
|
|
if total > CONN_TOTAL:
|
|
alerts.append(
|
|
f"⚠️ Port {port}: TOTAL {total} koneksi (batas {CONN_TOTAL})"
|
|
)
|
|
|
|
state = load_state()
|
|
new_alert = False
|
|
if alerts:
|
|
# only alert if we weren't already in alert state (avoid spam)
|
|
if not state.get("alerting"):
|
|
new_alert = True
|
|
state["alerting"] = True
|
|
state["last_alerts"] = alerts
|
|
else:
|
|
if state.get("alerting"):
|
|
# send "cleared" message once
|
|
send_telegram(
|
|
f"✅ DDoS alert cleared @ {now}\n"
|
|
f"Port 80: {sum(conns[80].values())} koneksi\n"
|
|
f"Port 443: {sum(conns[443].values())} koneksi\n"
|
|
f"(batas per-IP {CONN_PER_IP}, total {CONN_TOTAL})"
|
|
)
|
|
state["alerting"] = False
|
|
|
|
if new_alert:
|
|
top80 = sorted(conns[80].items(), key=lambda x: -x[1])[:5]
|
|
top443 = sorted(conns[443].items(), key=lambda x: -x[1])[:5]
|
|
msg = f"🚨 DDoS ALERT @ {now}\n\n" + "\n".join(alerts) + "\n\n"
|
|
msg += f"Top IP port 80: {top80}\n"
|
|
msg += f"Top IP port 443: {top443}\n"
|
|
msg += f"(batas per-IP {CONN_PER_IP}, total {CONN_TOTAL})"
|
|
send_telegram(msg)
|
|
|
|
save_state(state)
|
|
# debug log (no secrets)
|
|
print(f"{now} 80={sum(conns[80].values())} 443={sum(conns[443].values())} alert={state.get('alerting')}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|