feat: initial upload of proxy-monitor
This commit is contained in:
commit
c8b181ea47
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
data/
|
||||
__pycache__/
|
||||
*.log
|
||||
.env
|
||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssh-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ app/
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
10
Dockerfile.agent
Normal file
10
Dockerfile.agent
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
iptables ipset && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ app/
|
||||
EXPOSE 8787
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8787"]
|
||||
124
app/docker_ops.py
Normal file
124
app/docker_ops.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import docker
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _c():
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url="unix:///var/run/docker.sock", timeout=15)
|
||||
return _client
|
||||
|
||||
|
||||
def host_info():
|
||||
try:
|
||||
import shutil
|
||||
client = _c()
|
||||
info = client.info()
|
||||
containers = client.containers.list(all=True, sparse=True)
|
||||
running = sum(1 for c in containers if c.status == "running")
|
||||
images = len(client.images.list())
|
||||
mem_total = info.get("MemTotal", 0)
|
||||
mem_used = 0
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
mem = {}
|
||||
for line in f.read().splitlines():
|
||||
if ":" in line:
|
||||
parts = line.split()
|
||||
mem[parts[0].rstrip(":")] = int(parts[1]) * 1024
|
||||
mem_total = mem.get("MemTotal", mem_total)
|
||||
mem_used = mem_total - mem.get("MemAvailable", mem.get("MemFree", 0))
|
||||
except Exception:
|
||||
pass
|
||||
disk_total = disk_used = 0
|
||||
try:
|
||||
d = shutil.disk_usage("/")
|
||||
disk_total, disk_used = d.total, d.used
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"name": info.get("Name", "?"),
|
||||
"os": info.get("OperatingSystem", "?"),
|
||||
"version": info.get("ServerVersion", "?"),
|
||||
"kernel": info.get("KernelVersion", "?"),
|
||||
"cpu": info.get("NCPU", 0),
|
||||
"memory": mem_total,
|
||||
"mem_used": mem_used,
|
||||
"mem_total": mem_total,
|
||||
"running": running,
|
||||
"containers": len(containers),
|
||||
"images": images,
|
||||
"disk_total": disk_total,
|
||||
"disk_used": disk_used,
|
||||
"storage": info.get("DriverStatus", [["?", "?"]])[0][1] if info.get("DriverStatus") else "?",
|
||||
"error": None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"name": "?", "running": 0, "containers": 0, "images": 0, "error": str(e)}
|
||||
|
||||
|
||||
def list_containers():
|
||||
try:
|
||||
client = _c()
|
||||
return [
|
||||
{
|
||||
"id": c.short_id,
|
||||
"name": c.name,
|
||||
"image": c.image.tags[0] if c.image.tags else c.image.short_id[:19],
|
||||
"status": c.status,
|
||||
"ports": c.ports,
|
||||
"created": c.attrs.get("Created", "")[:19],
|
||||
}
|
||||
for c in client.containers.list(all=True)
|
||||
]
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def list_images():
|
||||
try:
|
||||
client = _c()
|
||||
return [{"id": i.short_id, "tags": i.tags, "size": i.attrs.get("Size", 0)} for i in client.images.list()]
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def container_action(cid, action):
|
||||
try:
|
||||
client = _c()
|
||||
c = client.containers.get(cid)
|
||||
if action == "start":
|
||||
c.start()
|
||||
elif action == "stop":
|
||||
c.stop(timeout=10)
|
||||
elif action == "restart":
|
||||
c.restart(timeout=10)
|
||||
else:
|
||||
return {"error": f"unknown action {action}"}
|
||||
return {"status": f"{action}ed", "name": c.name}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def container_stats(cid):
|
||||
try:
|
||||
client = _c()
|
||||
c = client.containers.get(cid)
|
||||
s = c.stats(stream=False)
|
||||
mem_usage = s["memory_stats"].get("usage", 0)
|
||||
mem_limit = s["memory_stats"].get("limit", 1)
|
||||
cpu_delta = s["cpu_stats"]["cpu_usage"]["total_usage"] - s["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||
system_delta = s["cpu_stats"]["system_cpu_usage"] - s["precpu_stats"]["system_cpu_usage"]
|
||||
cpu_percent = (cpu_delta / system_delta) * 100.0 * s["cpu_stats"].get("online_cpus", 1) if system_delta > 0 else 0
|
||||
return {
|
||||
"memory_usage": mem_usage,
|
||||
"memory_limit": mem_limit,
|
||||
"memory_pct": round(mem_usage / mem_limit * 100, 1) if mem_limit else 0,
|
||||
"cpu_pct": round(cpu_percent, 1),
|
||||
"pids": s["pids_stats"].get("current", 0),
|
||||
"network_rx": s.get("networks", {}).get("eth0", {}).get("rx_bytes", 0),
|
||||
"network_tx": s.get("networks", {}).get("eth0", {}).get("tx_bytes", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
163
app/firewall.py
Normal file
163
app/firewall.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SERVERS_FILE = Path(os.environ.get("SERVERS_FILE", "/cache/servers.json"))
|
||||
|
||||
_fw_cache = {}
|
||||
|
||||
DEFAULT_SERVERS = [
|
||||
{"name": "ubuntu-web (.65)", "host": "192.168.0.65", "user": "rob", "key": "/root/.ssh/id_ed25519", "sudo": True},
|
||||
{"name": "ubuntu VPS (.2)", "host": "66.179.94.204", "user": "rob", "key": "/root/.ssh/id_ed25519", "sudo": True},
|
||||
{"name": "jetson-nano (.23)", "host": "192.168.0.23", "user": "rob", "key": "/root/.ssh/id_ed25519", "sudo": True},
|
||||
]
|
||||
|
||||
|
||||
def load_servers():
|
||||
if SERVERS_FILE.exists():
|
||||
try:
|
||||
servers = json.loads(SERVERS_FILE.read_text())
|
||||
if servers:
|
||||
return servers
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_SERVERS
|
||||
|
||||
|
||||
def ssh_exec(server, cmd, timeout=30):
|
||||
# Local agent mode: run directly on this host (no SSH)
|
||||
if server.get("host") == "local":
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
host = server.get("host")
|
||||
user = server.get("user", "rob")
|
||||
key = server.get("key", "/root/.ssh/id_ed25519")
|
||||
ssh_cmd = [
|
||||
"ssh", "-i", key,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "BatchMode=yes",
|
||||
f"{user}@{host}", cmd,
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def _sudo(server):
|
||||
return "sudo " if server.get("sudo", True) else ""
|
||||
|
||||
|
||||
def firewall_status(server):
|
||||
key = f"{server.get('name')}:{server.get('host')}"
|
||||
now = time.time()
|
||||
item = _fw_cache.get(key)
|
||||
if item and now - item[0] < 120:
|
||||
return item[1]
|
||||
s = _sudo(server)
|
||||
status = {
|
||||
"name": server.get("name", server.get("host")),
|
||||
"host": server.get("host"),
|
||||
"reachable": False,
|
||||
"country_block": False,
|
||||
"scanner_block": False,
|
||||
"scanner_ips": 0,
|
||||
"country_cidrs": 0,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# single combined SSH call for all firewall checks
|
||||
cmd = (
|
||||
"echo ok; "
|
||||
f"{s}ipset list blocked_countries 2>/dev/null | grep -c /; "
|
||||
f"{s}iptables -C INPUT -m set --match-set blocked_countries src -j DROP 2>/dev/null && echo cb_active || echo cb_inactive; "
|
||||
f"{s}iptables -C INPUT -j SCANNER-BLOCK 2>/dev/null && echo sb_active || echo sb_inactive; "
|
||||
f"{s}iptables -S SCANNER-BLOCK 2>/dev/null | grep -c DROP"
|
||||
)
|
||||
rc, out, err = ssh_exec(server, cmd)
|
||||
if rc != 0 or "ok" not in out:
|
||||
status["error"] = (err or out or "unreachable").strip()
|
||||
return status
|
||||
status["reachable"] = True
|
||||
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
||||
try:
|
||||
status["country_cidrs"] = int(lines[1]) if len(lines) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
status["country_cidrs"] = 0
|
||||
if len(lines) > 2:
|
||||
status["country_block"] = lines[2] == "cb_active"
|
||||
if len(lines) > 3:
|
||||
status["scanner_block"] = lines[3] == "sb_active"
|
||||
if len(lines) > 4:
|
||||
try:
|
||||
status["scanner_ips"] = int(lines[4])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
_fw_cache[key] = (now, status)
|
||||
return status
|
||||
|
||||
|
||||
def block_ip(server, ip):
|
||||
s = _sudo(server)
|
||||
# validate IP to avoid shell injection
|
||||
if not ip or any(c not in "0123456789." for c in ip):
|
||||
return {"error": "invalid IP"}
|
||||
rc, out, err = ssh_exec(server, f"{s}iptables -C SCANNER-BLOCK -s {ip} -j DROP 2>/dev/null || {s}iptables -A SCANNER-BLOCK -s {ip} -j DROP")
|
||||
if rc != 0:
|
||||
return {"error": (err or out).strip()}
|
||||
return {"status": "blocked", "ip": ip}
|
||||
|
||||
|
||||
def unblock_ip(server, ip):
|
||||
s = _sudo(server)
|
||||
if not ip or any(c not in "0123456789." for c in ip):
|
||||
return {"error": "invalid IP"}
|
||||
rc, out, err = ssh_exec(server, f"{s}iptables -D SCANNER-BLOCK -s {ip} -j DROP 2>/dev/null")
|
||||
if rc != 0:
|
||||
return {"error": (err or out).strip()}
|
||||
return {"status": "unblocked", "ip": ip}
|
||||
|
||||
|
||||
def toggle_country(server, enabled):
|
||||
s = _sudo(server)
|
||||
rule_in = f"{s}iptables -C INPUT -m set --match-set blocked_countries src -j DROP 2>/dev/null"
|
||||
rule_du = f"{s}iptables -C DOCKER-USER -m set --match-set blocked_countries src -j DROP 2>/dev/null"
|
||||
add_in = f"{s}iptables -I INPUT 1 -m set --match-set blocked_countries src -j DROP"
|
||||
add_du = f"{s}iptables -I DOCKER-USER 1 -m set --match-set blocked_countries src -j DROP"
|
||||
del_in = f"{s}iptables -D INPUT -m set --match-set blocked_countries src -j DROP 2>/dev/null"
|
||||
del_du = f"{s}iptables -D DOCKER-USER -m set --match-set blocked_countries src -j DROP 2>/dev/null"
|
||||
if enabled:
|
||||
cmd = f"({rule_in}) || {add_in}; ({rule_du}) || {add_du}"
|
||||
else:
|
||||
cmd = f"{del_in}; {del_du}"
|
||||
rc, out, err = ssh_exec(server, cmd)
|
||||
if rc != 0:
|
||||
return {"error": (err or out).strip()}
|
||||
return {"status": "enabled" if enabled else "disabled", "type": "country"}
|
||||
|
||||
|
||||
def toggle_scanner(server, enabled):
|
||||
s = _sudo(server)
|
||||
rule_in = f"{s}iptables -C INPUT -j SCANNER-BLOCK 2>/dev/null"
|
||||
rule_du = f"{s}iptables -C DOCKER-USER -j SCANNER-BLOCK 2>/dev/null"
|
||||
add_in = f"{s}iptables -I INPUT 1 -j SCANNER-BLOCK"
|
||||
add_du = f"{s}iptables -I DOCKER-USER 1 -j SCANNER-BLOCK"
|
||||
del_in = f"{s}iptables -D INPUT -j SCANNER-BLOCK 2>/dev/null"
|
||||
del_du = f"{s}iptables -D DOCKER-USER -j SCANNER-BLOCK 2>/dev/null"
|
||||
if enabled:
|
||||
cmd = f"({rule_in}) || {add_in}; ({rule_du}) || {add_du}"
|
||||
else:
|
||||
cmd = f"{del_in}; {del_du}"
|
||||
rc, out, err = ssh_exec(server, cmd)
|
||||
if rc != 0:
|
||||
return {"error": (err or out).strip()}
|
||||
return {"status": "enabled" if enabled else "disabled", "type": "scanner"}
|
||||
296
app/main.py
Normal file
296
app/main.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import csv
|
||||
from io import StringIO
|
||||
from collections import Counter
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import security
|
||||
from app import firewall
|
||||
from app import vps
|
||||
from app import docker_ops
|
||||
|
||||
LOGS_DIR = Path(os.environ.get("LOGS_DIR", "/logs"))
|
||||
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "/config"))
|
||||
PROXIES_FILE = CONFIG_DIR / "proxies.json"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if os.environ.get("WEBHOOK_URL"):
|
||||
t = threading.Thread(target=security.alert_loop, daemon=True)
|
||||
t.start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Apache Proxy Monitor", lifespan=lifespan)
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||
|
||||
API_TOKEN = os.environ.get("API_TOKEN", "")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def token_auth(request: Request, call_next):
|
||||
if API_TOKEN and request.url.path.startswith("/api/"):
|
||||
if request.headers.get("X-API-Token") != API_TOKEN:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
# Combined log format, with optional "%v:%p" vhost prefix (other_vhosts_access.log)
|
||||
ACCESS_RE = re.compile(
|
||||
r"^(?:(?P<vhost>\S+:\d+)\s+)?"
|
||||
r"(?P<ip>\S+)\s+\S+\s+\S+\s+"
|
||||
r"\[(?P<ts>[^\]]+)\]\s+"
|
||||
r'"(?P<method>\S+)\s+(?P<path>\S+)(?:\s+(?P<proto>[^"]*))?"\s+'
|
||||
r"(?P<status>\d{3})\s+(?P<bytes>\S+)\s+"
|
||||
r'"(?P<referer>[^"]*)"\s+"(?P<agent>[^"]*)"'
|
||||
)
|
||||
|
||||
ERROR_RE = re.compile(
|
||||
r"\[(?P<ts>[^\]]+)\]\s+\[(?P<module>[^\]]+)\]\s+\[pid \d+[^\]]*\]\s*(?P<msg>.*)"
|
||||
)
|
||||
|
||||
|
||||
def load_vhosts():
|
||||
if not PROXIES_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(PROXIES_FILE.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def vhost_from_filename(path):
|
||||
name = path.name
|
||||
for suffix in ("-access.log", "_access.log"):
|
||||
if name.endswith(suffix):
|
||||
return name[: -len(suffix)]
|
||||
return name.replace(".log", "")
|
||||
|
||||
|
||||
def parse_ts(s):
|
||||
try:
|
||||
return datetime.strptime(s, "%d/%b/%Y:%H:%M:%S %z")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_requests(max_lines=200):
|
||||
requests = []
|
||||
for path in sorted(LOGS_DIR.glob("*access*.log")):
|
||||
fname_vhost = vhost_from_filename(path)
|
||||
try:
|
||||
lines = path.read_text(errors="replace").splitlines()
|
||||
except Exception:
|
||||
continue
|
||||
for line in lines:
|
||||
m = ACCESS_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
dt = parse_ts(m.group("ts"))
|
||||
requests.append(
|
||||
{
|
||||
"vhost": m.group("vhost") or fname_vhost,
|
||||
"ip": m.group("ip"),
|
||||
"ts": m.group("ts"),
|
||||
"time": dt.timestamp() if dt else 0,
|
||||
"method": m.group("method"),
|
||||
"path": m.group("path"),
|
||||
"proto": m.group("proto") or "",
|
||||
"status": m.group("status"),
|
||||
"bytes": m.group("bytes"),
|
||||
"referer": m.group("referer"),
|
||||
"agent": m.group("agent"),
|
||||
}
|
||||
)
|
||||
requests.sort(key=lambda r: r["time"], reverse=True)
|
||||
return requests[:max_lines]
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/api/vhosts")
|
||||
async def api_vhosts():
|
||||
return load_vhosts()
|
||||
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def api_logs(lines: int = Query(200, ge=1, le=2000)):
|
||||
return read_requests(lines)
|
||||
|
||||
|
||||
@app.get("/api/logs/ip/{ip}")
|
||||
async def api_logs_ip(ip: str):
|
||||
reqs = read_requests(max_lines=100000)
|
||||
return [r for r in reqs if r["ip"] == ip][:1000]
|
||||
|
||||
|
||||
@app.get("/api/vps/logs/ip/{ip}")
|
||||
async def api_vps_logs_ip(ip: str):
|
||||
return vps.fetch_logs_ip(ip)
|
||||
|
||||
|
||||
@app.get("/api/docker/info")
|
||||
async def api_docker_info():
|
||||
return docker_ops.host_info()
|
||||
|
||||
|
||||
@app.get("/api/docker/containers")
|
||||
async def api_docker_containers():
|
||||
return docker_ops.list_containers()
|
||||
|
||||
|
||||
@app.get("/api/docker/images")
|
||||
async def api_docker_images():
|
||||
return docker_ops.list_images()
|
||||
|
||||
|
||||
@app.post("/api/docker/containers/{cid}/{action}")
|
||||
async def api_docker_action(cid: str, action: str):
|
||||
return docker_ops.container_action(cid, action)
|
||||
|
||||
|
||||
@app.get("/api/docker/containers/{cid}/stats")
|
||||
async def api_docker_stats(cid: str):
|
||||
return docker_ops.container_stats(cid)
|
||||
|
||||
|
||||
@app.get("/api/errors")
|
||||
async def api_errors(lines: int = Query(50, ge=1, le=500)):
|
||||
path = LOGS_DIR / "error.log"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = path.read_text(errors="replace").splitlines()
|
||||
except Exception:
|
||||
return []
|
||||
return raw[-lines:][::-1]
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def api_stats():
|
||||
reqs = read_requests(max_lines=100000)
|
||||
vhosts = load_vhosts()
|
||||
status_counts = Counter(r["status"] for r in reqs)
|
||||
ip_counts = Counter(r["ip"] for r in reqs)
|
||||
vhost_counts = Counter(r["vhost"] for r in reqs)
|
||||
return {
|
||||
"total": len(reqs),
|
||||
"unique_ips": len(ip_counts),
|
||||
"status": dict(status_counts.most_common()),
|
||||
"top_ips": [{"ip": ip, "count": c} for ip, c in ip_counts.most_common(15)],
|
||||
"per_vhost": [{"vhost": v, "count": c} for v, c in vhost_counts.most_common()],
|
||||
"errors_4xx": sum(c for s, c in status_counts.items() if s.startswith("4")),
|
||||
"errors_5xx": sum(c for s, c in status_counts.items() if s.startswith("5")),
|
||||
"vhosts": vhosts,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/security")
|
||||
async def api_security(hours: int = Query(168, ge=1, le=720)):
|
||||
return security.security_summary(hours=hours)
|
||||
|
||||
|
||||
@app.get("/api/security/ip/{ip}")
|
||||
async def api_security_ip(ip: str):
|
||||
return security.ip_attempts(ip)
|
||||
|
||||
|
||||
@app.get("/api/geo/{ip}")
|
||||
async def api_geo(ip: str):
|
||||
return security.geolocate([ip]).get(ip, {})
|
||||
|
||||
|
||||
@app.post("/api/block/{ip}")
|
||||
async def api_block_ip(ip: str):
|
||||
results = []
|
||||
for s in firewall.load_servers():
|
||||
if s.get("host") == "local":
|
||||
continue
|
||||
results.append({"server": s.get("name"), "result": firewall.block_ip(s, ip)})
|
||||
results.append({"server": "ubuntu VPS (.2)", "result": vps.block_ip(ip)})
|
||||
return results
|
||||
|
||||
|
||||
@app.get("/api/threats/export")
|
||||
async def api_threats_export():
|
||||
sec = security.security_summary(hours=720)
|
||||
buf = StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["ip", "country", "cc", "org", "asn", "attempts", "first_seen", "last_seen"])
|
||||
for a in sec.get("attempts", []):
|
||||
w.writerow([a["ip"], a["country"], a["cc"], a["org"], a["as"], a["count"], a["first"], a["last"]])
|
||||
return PlainTextResponse(buf.getvalue(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=threats.csv"})
|
||||
|
||||
|
||||
@app.get("/api/security/nodes")
|
||||
async def api_security_nodes():
|
||||
return security.registered_nodes()
|
||||
|
||||
|
||||
@app.get("/api/security/status")
|
||||
async def api_security_status():
|
||||
return security.compromise_status()
|
||||
|
||||
|
||||
@app.get("/api/firewall")
|
||||
async def api_firewall():
|
||||
results = [firewall.firewall_status(s) for s in firewall.load_servers()]
|
||||
try:
|
||||
agent_fw = vps.fetch_firewall()
|
||||
agent_names = {a.get("name") for a in agent_fw}
|
||||
results = [r for r in results if r.get("name") not in agent_names] + agent_fw
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/api/firewall/{server_id}/block")
|
||||
async def api_firewall_block(server_id: int, data: dict):
|
||||
servers = firewall.load_servers()
|
||||
if server_id < 0 or server_id >= len(servers):
|
||||
raise HTTPException(404, "Server not found")
|
||||
return firewall.block_ip(servers[server_id], data.get("ip", ""))
|
||||
|
||||
|
||||
@app.post("/api/firewall/{server_id}/unblock")
|
||||
async def api_firewall_unblock(server_id: int, data: dict):
|
||||
servers = firewall.load_servers()
|
||||
if server_id < 0 or server_id >= len(servers):
|
||||
raise HTTPException(404, "Server not found")
|
||||
return firewall.unblock_ip(servers[server_id], data.get("ip", ""))
|
||||
|
||||
|
||||
@app.post("/api/firewall/{server_id}/toggle")
|
||||
async def api_firewall_toggle(server_id: int, data: dict):
|
||||
servers = firewall.load_servers()
|
||||
if server_id < 0 or server_id >= len(servers):
|
||||
raise HTTPException(404, "Server not found")
|
||||
t = data.get("type")
|
||||
enabled = bool(data.get("enabled", True))
|
||||
if t == "country":
|
||||
return firewall.toggle_country(servers[server_id], enabled)
|
||||
if t == "scanner":
|
||||
return firewall.toggle_scanner(servers[server_id], enabled)
|
||||
raise HTTPException(400, "Unknown toggle type")
|
||||
|
||||
|
||||
@app.get("/api/vps/logs")
|
||||
async def api_vps_logs(lines: int = Query(500, ge=1, le=5000)):
|
||||
return vps.fetch_logs(lines)
|
||||
|
||||
|
||||
@app.get("/api/vps/stats")
|
||||
async def api_vps_stats():
|
||||
return vps.fetch_stats()
|
||||
388
app/security.py
Normal file
388
app/security.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import glob
|
||||
import gzip
|
||||
import ipaddress
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import docker
|
||||
except ImportError:
|
||||
docker = None
|
||||
|
||||
GEO_CACHE = Path(os.environ.get("GEO_CACHE", "/cache/geo.json"))
|
||||
HEADSCALE_CONTAINER = os.environ.get("HEADSCALE_CONTAINER", "headscale")
|
||||
|
||||
_client = None
|
||||
_cache = {}
|
||||
|
||||
|
||||
def _ttl(seconds):
|
||||
def deco(fn):
|
||||
def wrapper(*a, **kw):
|
||||
key = fn.__name__
|
||||
now = time.time()
|
||||
item = _cache.get(key)
|
||||
if item and now - item[0] < seconds:
|
||||
return item[1]
|
||||
val = fn(*a, **kw)
|
||||
_cache[key] = (now, val)
|
||||
return val
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
def _docker_client():
|
||||
global _client
|
||||
if docker is None:
|
||||
return None
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url="unix:///var/run/docker.sock", timeout=30)
|
||||
return _client
|
||||
|
||||
|
||||
# Two timestamp formats appear in headscale logs (old + new)
|
||||
TLS_ERR_RE = re.compile(
|
||||
r"(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) .*TLS handshake error from (\d+\.\d+\.\d+\.\d+):\d+"
|
||||
)
|
||||
AUTH_ERR_RE = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) .*client_address=(\d+\.\d+\.\d+\.\d+):\d+"
|
||||
)
|
||||
|
||||
PRIVATE_PREFIXES = ("10.", "172.", "192.168.", "127.", "100.64.")
|
||||
|
||||
|
||||
def _is_public(ip):
|
||||
return not ip.startswith(PRIVATE_PREFIXES)
|
||||
|
||||
|
||||
def _load_geo_cache():
|
||||
if GEO_CACHE.exists():
|
||||
try:
|
||||
return json.loads(GEO_CACHE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _save_geo_cache(cache):
|
||||
try:
|
||||
GEO_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
GEO_CACHE.write_text(json.dumps(cache))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def geolocate(ips):
|
||||
cache = _load_geo_cache()
|
||||
missing = [ip for ip in ips if ip not in cache and _is_public(ip)]
|
||||
for i in range(0, len(missing), 100):
|
||||
batch = missing[i : i + 100]
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"http://ip-api.com/batch",
|
||||
data=json.dumps(batch).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
data = json.loads(urllib.request.urlopen(req, timeout=20).read())
|
||||
for ip, info in zip(batch, data):
|
||||
if info and info.get("status") == "success":
|
||||
cache[ip] = {
|
||||
"country": info.get("country"),
|
||||
"cc": info.get("countryCode"),
|
||||
"org": info.get("org") or info.get("isp"),
|
||||
"as": info.get("as"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.1)
|
||||
if missing:
|
||||
_save_geo_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def fetch_headscale_logs(hours=168, tail=300000):
|
||||
client = _docker_client()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
c = client.containers.get(HEADSCALE_CONTAINER)
|
||||
since = datetime.utcnow() - timedelta(hours=hours)
|
||||
return c.logs(since=since, tail=tail, timestamps=False).decode(errors="replace")
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def parse_attempts(logs):
|
||||
attempts = defaultdict(lambda: {"count": 0, "first": None, "last": None})
|
||||
for line in logs.splitlines():
|
||||
m = TLS_ERR_RE.search(line) or AUTH_ERR_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
ts_str, ip = m.group(1), m.group(2)
|
||||
a = attempts[ip]
|
||||
a["count"] += 1
|
||||
a["last"] = ts_str
|
||||
if a["first"] is None:
|
||||
a["first"] = ts_str
|
||||
return attempts
|
||||
|
||||
|
||||
def _flagged_countries():
|
||||
raw = os.environ.get("FLAGGED_COUNTRIES", "China,Russia,Iran,North Korea")
|
||||
return {c.strip() for c in raw.split(",") if c.strip()}
|
||||
|
||||
|
||||
def send_webhook(url, text):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps({"content": text}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return True
|
||||
except Exception as e:
|
||||
print("webhook error:", e)
|
||||
return False
|
||||
|
||||
|
||||
def check_alerts():
|
||||
webhook = os.environ.get("WEBHOOK_URL", "")
|
||||
if not webhook:
|
||||
return {"status": "disabled"}
|
||||
threshold = int(os.environ.get("ALERT_THRESHOLD", "50"))
|
||||
state_file = Path("/cache/alerts_state.json")
|
||||
|
||||
geo_cache = _load_geo_cache()
|
||||
known = set(geo_cache.keys())
|
||||
state = json.loads(state_file.read_text()) if state_file.exists() else {}
|
||||
|
||||
logs = fetch_headscale_logs(24, 200000)
|
||||
if logs is None:
|
||||
return {"status": "error", "msg": "headscale unreachable"}
|
||||
attempts = parse_attempts(logs)
|
||||
|
||||
geo = geolocate(list(attempts.keys()))
|
||||
fired = []
|
||||
|
||||
for ip, a in attempts.items():
|
||||
if not _is_public(ip) or ip == "73.201.12.182":
|
||||
continue
|
||||
g = geo.get(ip, {})
|
||||
entry = state.setdefault(ip, {})
|
||||
if ip not in known and not entry.get("seen"):
|
||||
entry["seen"] = True
|
||||
send_webhook(webhook, f"🆕 New IP probing headscale: {ip} — {g.get('country','?')} / {g.get('org','?')} — {a['count']} attempts")
|
||||
fired.append({"ip": ip, "type": "new"})
|
||||
if a["count"] >= threshold and not entry.get("thresh"):
|
||||
entry["thresh"] = True
|
||||
send_webhook(webhook, f"⚠️ High attempts: {ip} — {g.get('country','?')} / {g.get('org','?')} — {a['count']} attempts")
|
||||
fired.append({"ip": ip, "type": "threshold"})
|
||||
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_file.write_text(json.dumps(state))
|
||||
return {"status": "ok", "fired": fired}
|
||||
|
||||
|
||||
def alert_loop():
|
||||
interval = int(os.environ.get("ALERT_INTERVAL", "300"))
|
||||
while True:
|
||||
try:
|
||||
check_alerts()
|
||||
except Exception as e:
|
||||
print("alert loop error:", e)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def security_summary(hours=168, tail=300000):
|
||||
logs = fetch_headscale_logs(hours, tail)
|
||||
if logs is None:
|
||||
return {"error": "headscale container not reachable"}
|
||||
attempts = parse_attempts(logs)
|
||||
geo = geolocate(list(attempts.keys()))
|
||||
|
||||
total_attempts = sum(a["count"] for a in attempts.values())
|
||||
public = [(ip, a) for ip, a in attempts.items() if _is_public(ip)]
|
||||
|
||||
rows = []
|
||||
for ip, a in public:
|
||||
g = geo.get(ip, {})
|
||||
rows.append(
|
||||
{
|
||||
"ip": ip,
|
||||
"country": g.get("country", "?"),
|
||||
"cc": g.get("cc", ""),
|
||||
"org": g.get("org", "?"),
|
||||
"as": g.get("as", "?"),
|
||||
"count": a["count"],
|
||||
"first": a["first"],
|
||||
"last": a["last"],
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: r["count"], reverse=True)
|
||||
|
||||
flagged = _flagged_countries()
|
||||
for r in rows:
|
||||
r["flagged"] = r["country"] in flagged
|
||||
flagged_attempts = sum(r["count"] for r in rows if r["flagged"])
|
||||
flagged_ips = sum(1 for r in rows if r["flagged"])
|
||||
|
||||
country_counts = Counter(r["country"] for r in rows)
|
||||
org_counts = Counter(r["org"] for r in rows if r["org"] and r["org"] != "?")
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"total_attempts": total_attempts,
|
||||
"unique_ips": len(rows),
|
||||
"countries": [{"country": c, "count": n} for c, n in country_counts.most_common()],
|
||||
"top_orgs": [{"org": o, "count": n} for o, n in org_counts.most_common(15)],
|
||||
"flagged": {"countries": sorted(flagged), "attempts": flagged_attempts, "ips": flagged_ips},
|
||||
"attempts": rows[:300],
|
||||
}
|
||||
|
||||
|
||||
@_ttl(60)
|
||||
def ip_attempts(ip, hours=168):
|
||||
logs = fetch_headscale_logs(hours, 300000)
|
||||
if logs is None:
|
||||
return {"ip": ip, "count": 0, "attempts": [], "error": "headscale unreachable"}
|
||||
attempts = []
|
||||
for line in logs.splitlines():
|
||||
m = TLS_ERR_RE.search(line) or AUTH_ERR_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
ts, src = m.group(1), m.group(2)
|
||||
if src != ip:
|
||||
continue
|
||||
evt = "TLS handshake failure" if "TLS" in line else ("auth error" if "client_address" in line else "connection attempt")
|
||||
attempts.append({"time": ts, "event": evt})
|
||||
attempts.reverse()
|
||||
return {"ip": ip, "count": len(attempts), "attempts": attempts[:500]}
|
||||
|
||||
|
||||
def registered_nodes():
|
||||
client = _docker_client()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
c = client.containers.get(HEADSCALE_CONTAINER)
|
||||
exit_code, output = c.exec_run(["headscale", "nodes", "list", "-o", "json"])
|
||||
if exit_code != 0:
|
||||
return []
|
||||
return json.loads(output.decode(errors="replace"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
HOSTLOGS_DIR = Path(os.environ.get("HOSTLOGS_DIR", "/hostlogs"))
|
||||
ACCEPTED_RE = re.compile(r"Accepted (?:password|publickey|keyboard-interactive) for (\S+) from ([0-9.]+)")
|
||||
FAILED_RE = re.compile(r"Failed password for (?:invalid user )?(\S+) from ([0-9.]+)")
|
||||
|
||||
|
||||
@_ttl(30)
|
||||
def ssh_logins():
|
||||
accepted = Counter()
|
||||
failed = Counter()
|
||||
for path in sorted(glob.glob(str(HOSTLOGS_DIR / "auth.log*"))):
|
||||
try:
|
||||
if path.endswith(".gz"):
|
||||
content = gzip.open(path, "rt", errors="replace").read()
|
||||
else:
|
||||
content = open(path, errors="replace").read()
|
||||
except Exception:
|
||||
continue
|
||||
for line in content.splitlines():
|
||||
m = ACCEPTED_RE.search(line)
|
||||
if m:
|
||||
accepted[m.group(2)] += 1
|
||||
continue
|
||||
m = FAILED_RE.search(line)
|
||||
if m:
|
||||
failed[m.group(2)] += 1
|
||||
return {
|
||||
"accepted": [{"ip": ip, "count": c} for ip, c in accepted.most_common(20)],
|
||||
"failed": [{"ip": ip, "count": c} for ip, c in failed.most_common(20)],
|
||||
"total_accepted": sum(accepted.values()),
|
||||
"total_failed": sum(failed.values()),
|
||||
}
|
||||
|
||||
|
||||
@_ttl(120)
|
||||
def nextcloud_status():
|
||||
client = _docker_client()
|
||||
if client is None:
|
||||
return {"error": "docker not available"}
|
||||
try:
|
||||
c = client.containers.get("nextcloud")
|
||||
except Exception:
|
||||
return {"error": "nextcloud container not found"}
|
||||
result = {"users": [], "failed_logins": [], "total_failed": 0}
|
||||
try:
|
||||
_, out = c.exec_run(
|
||||
["sh", "-c", "grep -iE 'Login failed' /var/www/html/data/nextcloud.log 2>/dev/null | tail -100"],
|
||||
stdout=True,
|
||||
)
|
||||
for line in out.decode(errors="replace").splitlines():
|
||||
try:
|
||||
d = json.loads(line)
|
||||
result["failed_logins"].append({
|
||||
"time": d.get("time"),
|
||||
"ip": d.get("remoteAddr"),
|
||||
"user": d.get("user"),
|
||||
"message": d.get("message"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
result["total_failed"] = len(result["failed_logins"])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_, out = c.exec_run(
|
||||
["sh", "-c", "cd /var/www/html && php occ user:list --no-warnings 2>/dev/null"],
|
||||
user="www-data", stdout=True,
|
||||
)
|
||||
for line in out.decode(errors="replace").splitlines():
|
||||
m = re.search(r"-\s+(\S+):", line)
|
||||
if m:
|
||||
result["users"].append(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _is_local(ip):
|
||||
try:
|
||||
a = ipaddress.ip_address(ip)
|
||||
return a.is_private or a.is_loopback or a.is_link_local or ip == "73.201.12.182"
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def compromise_status():
|
||||
ssh = ssh_logins()
|
||||
nodes = registered_nodes()
|
||||
nc = nextcloud_status()
|
||||
warnings = []
|
||||
|
||||
unknown_ssh = [a["ip"] for a in ssh["accepted"] if not _is_local(a["ip"])]
|
||||
if unknown_ssh:
|
||||
warnings.append(f"SSH logins from unknown IPs: {', '.join(unknown_ssh)}")
|
||||
if ssh["total_failed"] > 100:
|
||||
warnings.append(f"High SSH failed-password attempts: {ssh['total_failed']}")
|
||||
if nc.get("total_failed", 0) > 20:
|
||||
warnings.append(f"Nextcloud failed logins: {nc['total_failed']}")
|
||||
|
||||
return {
|
||||
"verdict": "review" if warnings else "clean",
|
||||
"warnings": warnings,
|
||||
"ssh": ssh,
|
||||
"headscale_nodes": nodes,
|
||||
"nextcloud": nc,
|
||||
}
|
||||
617
app/templates/index.html
Normal file
617
app/templates/index.html
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DockVisor — Firewall Monitor</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0e14; --bg2: #11161f; --bg3: #1a2130; --bg4: #232c3d;
|
||||
--border: #1f2937; --border2: #2b3648;
|
||||
--text: #e6edf3; --text2: #8b98a9; --text3: #5b6675;
|
||||
--accent: #3b82f6; --accent2: #2563eb;
|
||||
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --cyan: #06b6d4; --purple: #8b5cf6;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px; line-height: 1.5;
|
||||
display: flex; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
.sidebar {
|
||||
width: 248px; min-width: 248px; height: 100vh;
|
||||
background: var(--bg2); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; padding: 20px 14px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 20px; }
|
||||
.brand .logo {
|
||||
width: 34px; height: 34px; border-radius: 9px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff;
|
||||
}
|
||||
.brand .name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.brand .sub { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: 1px; }
|
||||
|
||||
.nav-group-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text3); font-weight: 600; margin: 16px 8px 6px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||||
border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
color: var(--text2); transition: all .15s; margin-bottom: 2px; border: 1px solid transparent;
|
||||
}
|
||||
.nav-item:hover { background: var(--bg3); color: var(--text); }
|
||||
.nav-item.active { background: var(--bg4); color: var(--text); border-color: var(--border2); }
|
||||
.nav-item svg { color: var(--text3); flex-shrink: 0; }
|
||||
.nav-item.active svg { color: var(--accent); }
|
||||
.nav-item .badge { margin-left: auto; background: var(--bg4); color: var(--text2); font-size: 10px; padding: 1px 7px; border-radius: 9px; font-weight: 600; }
|
||||
.nav-item.active .badge { background: rgba(59,130,246,.15); color: var(--accent); }
|
||||
|
||||
.servers { margin-top: 4px; }
|
||||
.server-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--text2); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.online { background: var(--green); box-shadow: 0 0 6px rgba(34,197,94,.6); }
|
||||
.dot.offline { background: var(--text3); }
|
||||
|
||||
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.refresh-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text3); padding: 0 8px; }
|
||||
select {
|
||||
background: var(--bg3); color: var(--text); border: 1px solid var(--border2);
|
||||
border-radius: 6px; padding: 4px 8px; font-family: inherit; font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== MAIN ===== */
|
||||
.main { flex: 1; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 28px; background: rgba(10,14,20,.9); backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.topbar .subtitle { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||||
.topbar .actions { display: flex; gap: 8px; align-items: center; }
|
||||
.clock { font-size: 12px; color: var(--text3); margin-right: 8px; }
|
||||
|
||||
.content { padding: 24px 28px; }
|
||||
|
||||
/* ===== COMPONENTS ===== */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 22px; }
|
||||
.card {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 18px; position: relative; overflow: hidden;
|
||||
}
|
||||
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); font-weight: 600; margin-bottom: 6px; }
|
||||
.card .value { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.card .sub { font-size: 12px; color: var(--text2); margin-top: 4px; }
|
||||
.card .icon {
|
||||
position: absolute; top: 14px; right: 14px; width: 34px; height: 34px; border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center; opacity: .9;
|
||||
}
|
||||
.icon.blue { background: rgba(59,130,246,.14); color: var(--accent); }
|
||||
.icon.green { background: rgba(34,197,94,.14); color: var(--green); }
|
||||
.icon.red { background: rgba(239,68,68,.14); color: var(--red); }
|
||||
.icon.yellow { background: rgba(245,158,11,.14); color: var(--yellow); }
|
||||
.icon.cyan { background: rgba(6,182,212,.14); color: var(--cyan); }
|
||||
.icon.purple { background: rgba(139,92,246,.14); color: var(--purple); }
|
||||
|
||||
.section {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||||
margin-bottom: 20px; overflow: hidden;
|
||||
}
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); }
|
||||
.section-header h3 { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.section-header .count { font-size: 11px; color: var(--text3); font-weight: 500; }
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 18px; color: var(--text3); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); font-weight: 600; white-space: nowrap; }
|
||||
td { padding: 11px 18px; border-bottom: 1px solid var(--bg3); white-space: nowrap; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tbody tr { transition: background .12s; }
|
||||
tbody tr:hover { background: var(--bg3); }
|
||||
tbody tr.flagged { background: rgba(239,68,68,.06); }
|
||||
|
||||
.tag { background: var(--bg4); color: var(--text2); padding: 2px 9px; border-radius: 10px; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
|
||||
.badge { padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.badge.green { background: rgba(34,197,94,.15); color: var(--green); }
|
||||
.badge.blue { background: rgba(59,130,246,.15); color: var(--accent); }
|
||||
.badge.yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||||
.badge.red { background: rgba(239,68,68,.15); color: var(--red); }
|
||||
.badge.gray { background: var(--bg4); color: var(--text2); }
|
||||
|
||||
.method { font-weight: 600; font-size: 11px; padding: 2px 7px; border-radius: 5px; }
|
||||
.method.GET { background: rgba(34,197,94,.15); color: var(--green); }
|
||||
.method.POST { background: rgba(59,130,246,.15); color: var(--accent); }
|
||||
.method.PUT, .method.PATCH { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||||
.method.DELETE { background: rgba(239,68,68,.15); color: var(--red); }
|
||||
|
||||
.verdict-banner {
|
||||
display: flex; align-items: center; gap: 14px; padding: 20px;
|
||||
border-radius: 12px; margin-bottom: 22px; border: 1px solid;
|
||||
}
|
||||
.verdict-banner.clean { background: rgba(34,197,94,.07); border-color: rgba(34,197,94,.3); }
|
||||
.verdict-banner.warn { background: rgba(239,68,68,.07); border-color: rgba(239,68,68,.3); }
|
||||
.verdict-banner .big { font-size: 20px; font-weight: 700; }
|
||||
.verdict-banner .msg { font-size: 13px; color: var(--text2); }
|
||||
|
||||
.btn {
|
||||
background: var(--bg3); border: 1px solid var(--border2); color: var(--text);
|
||||
border-radius: 7px; padding: 6px 13px; cursor: pointer; font-family: inherit;
|
||||
font-size: 12px; font-weight: 500; transition: all .15s; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--bg4); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent2); }
|
||||
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-xs { padding: 4px 10px; font-size: 11px; }
|
||||
.fw-input {
|
||||
background: var(--bg3); border: 1px solid var(--border2); border-radius: 7px;
|
||||
padding: 6px 11px; color: var(--text); font-family: inherit; font-size: 12px; min-width: 180px;
|
||||
}
|
||||
.fw-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.empty { padding: 34px; text-align: center; color: var(--text3); font-size: 13px; }
|
||||
.error-line { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text2); padding: 7px 18px; border-bottom: 1px solid var(--bg3); white-space: pre-wrap; }
|
||||
.shimmer { color: var(--text3); text-align: center; padding: 36px; animation: pulse 1.2s infinite; font-size: 13px; }
|
||||
@keyframes pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 20px 16px; }
|
||||
.footer { text-align: center; padding: 18px; color: var(--text3); font-size: 11px; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="logo">D</div>
|
||||
<div>
|
||||
<div class="name">DockVisor</div>
|
||||
<div class="sub">Firewall Monitor</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-group-label">Monitor</div>
|
||||
<div class="nav-item active" id="nav-overview" onclick="switchView('overview')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
||||
Overview
|
||||
</div>
|
||||
<div class="nav-item" id="nav-firewall" onclick="switchView('firewall')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Firewall Control
|
||||
</div>
|
||||
<div class="nav-item" id="nav-intrusion" onclick="switchView('intrusion')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||
Intrusion Watch
|
||||
</div>
|
||||
<div class="nav-item" id="nav-web" onclick="switchView('web')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Web Traffic
|
||||
</div>
|
||||
|
||||
<div class="nav-group-label">Servers</div>
|
||||
<div class="servers" id="sidebar-servers"><div class="server-row" style="color:var(--text3)">Loading…</div></div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="refresh-row" style="margin-bottom:8px">
|
||||
<span>Auto-refresh</span>
|
||||
<select id="interval" onchange="setInterval(this.value)">
|
||||
<option value="15">15s</option>
|
||||
<option value="30" selected>30s</option>
|
||||
<option value="60">1m</option>
|
||||
<option value="300">5m</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" onclick="refreshAll()" style="width:100%">↻ Refresh now</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 id="page-title">Overview</h1>
|
||||
<div class="subtitle" id="page-subtitle">Security & firewall status across all servers</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="clock" id="clock"></span>
|
||||
<button class="btn btn-primary" onclick="refreshAll()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" id="view-container"><div class="shimmer">Loading…</div></div>
|
||||
</main>
|
||||
|
||||
<!-- IP logs modal -->
|
||||
<div id="ip-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.65);z-index:100;align-items:center;justify-content:center">
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;max-width:720px;width:92%;max-height:80vh;display:flex;flex-direction:column">
|
||||
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 id="ip-modal-title" style="font-size:15px;font-weight:600">Connection attempts</h3>
|
||||
<button class="btn btn-ghost btn-xs" onclick="closeIpModal()">✕ Close</button>
|
||||
</div>
|
||||
<div id="ip-modal-body" style="padding:16px 20px;overflow-y:auto;font-family:'SF Mono',Menlo,monospace;font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentView = 'overview';
|
||||
let timer = null;
|
||||
let cachedFirewall = [];
|
||||
|
||||
const TITLES = {
|
||||
overview: ['Overview', 'Security & firewall status across all servers'],
|
||||
firewall: ['Firewall Control', 'Manage blocks, countries, and IPs per server'],
|
||||
intrusion: ['Intrusion Watch', 'Live VPN connection attempts — geo-located'],
|
||||
web: ['Web Traffic', 'Apache proxy requests and client IPs'],
|
||||
};
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
function switchView(v) {
|
||||
currentView = v;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.getElementById(`nav-${v}`).classList.add('active');
|
||||
document.getElementById('page-title').textContent = TITLES[v][0];
|
||||
document.getElementById('page-subtitle').textContent = TITLES[v][1];
|
||||
renderView();
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
const c = document.getElementById('view-container');
|
||||
c.innerHTML = '<div class="shimmer">Loading…</div>';
|
||||
if (currentView === 'overview') renderOverview();
|
||||
else if (currentView === 'firewall') loadFirewall();
|
||||
else if (currentView === 'intrusion') loadIntrusion();
|
||||
else if (currentView === 'web') loadWeb();
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
||||
loadServers();
|
||||
renderView();
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const s = await fetch('/api/firewall').then(r => r.json());
|
||||
cachedFirewall = s;
|
||||
document.getElementById('sidebar-servers').innerHTML = s.map(x =>
|
||||
`<div class="server-row"><span class="dot ${x.reachable ? 'online' : 'offline'}"></span>${esc(x.name.split(' ')[0])}</div>`
|
||||
).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function setInterval(sec) {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(refreshAll, sec * 1000);
|
||||
}
|
||||
|
||||
/* ===== OVERVIEW ===== */
|
||||
async function renderOverview() {
|
||||
const c = document.getElementById('view-container');
|
||||
c.innerHTML = '<div class="shimmer">Loading overview…</div>';
|
||||
try {
|
||||
const [status, fw, sec, vpsStats, webStats] = await Promise.all([
|
||||
fetch('/api/security/status').then(r => r.json()),
|
||||
fetch('/api/firewall').then(r => r.json()),
|
||||
fetch('/api/security').then(r => r.json()),
|
||||
fetch('/api/vps/stats').then(r => r.json()),
|
||||
fetch('/api/stats').then(r => r.json()),
|
||||
]);
|
||||
cachedFirewall = fw;
|
||||
const clean = status.verdict === 'clean';
|
||||
const ssh = status.ssh || {};
|
||||
const nodes = status.headscale_nodes || [];
|
||||
const nc = status.nextcloud || {};
|
||||
const fl = sec.flagged || {};
|
||||
const onlineNodes = nodes.filter(n => n.online).length;
|
||||
const topCountry = sec.countries && sec.countries[0];
|
||||
|
||||
const serverCards = fw.map(s => `
|
||||
<div class="card">
|
||||
<div class="icon ${s.reachable ? 'green' : 'red'}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></div>
|
||||
<div class="label">${s.reachable ? '🟢' : '🔴'} ${esc(s.name)}</div>
|
||||
<div class="value">${(s.country_cidrs + s.scanner_ips).toLocaleString()}</div>
|
||||
<div class="sub">${s.country_block ? '🌍' : ''}${s.country_cidrs.toLocaleString()} CIDRs · ${s.scanner_block ? '🛡️' : ''}${s.scanner_ips} IPs</div>
|
||||
</div>`).join('');
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="verdict-banner ${clean ? 'clean' : 'warn'}">
|
||||
<div style="font-size:32px">${clean ? '✅' : '⚠️'}</div>
|
||||
<div>
|
||||
<div class="big">${clean ? 'No signs of compromise' : 'Review needed'}</div>
|
||||
<div class="msg">${(status.warnings || []).join(' · ') || 'All systems verified clean'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🖥️ Servers</h3><span class="count">${fw.length} monitored</span></div>
|
||||
<div class="grid" style="padding:18px;margin-bottom:0">${serverCards}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">VPN Nodes</div><div class="value">${onlineNodes}/${nodes.length}</div><div class="sub">online / authorized</div></div>
|
||||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">SSH Logins</div><div class="value">${ssh.total_accepted ?? 0}</div><div class="sub">${ssh.total_failed ?? 0} failed</div></div>
|
||||
<div class="card"><div class="icon purple"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div><div class="label">Nextcloud Users</div><div class="value">${(nc.users || []).length}</div><div class="sub">${(nc.users || []).join(', ')}</div></div>
|
||||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Intrusion Attempts</div><div class="value">${(sec.total_attempts || 0).toLocaleString()}</div><div class="sub">${sec.unique_ips || 0} IPs · top: ${topCountry ? esc(topCountry.country) : '—'}</div></div>
|
||||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attacks</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${(fl.countries || []).join(', ')}</div></div>
|
||||
<div class="card"><div class="icon cyan"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">VPS Web Requests</div><div class="value">${(vpsStats.total || 0).toLocaleString()}</div><div class="sub">${vpsStats.errors_4xx || 0} 4xx · ${vpsStats.errors_5xx || 0} 5xx</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🛡️ Security Details</h3><span class="count">live</span></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Check</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="font-weight:500">SSH source IPs</td><td style="color:var(--text2)">${(ssh.accepted || []).slice(0,6).map(a => `${esc(a.ip)} ×${a.count}`).join(', ') || 'none'}</td></tr>
|
||||
<tr><td style="font-weight:500">VPN nodes</td><td style="color:var(--text2)">${nodes.map(n => `${esc(n.given_name || n.name)} → ${(n.ip_addresses || []).map(esc).join(', ')}`).join(' | ') || 'none'}</td></tr>
|
||||
<tr><td style="font-weight:500">Top attacker countries</td><td style="color:var(--text2)">${(sec.countries || []).slice(0,6).map(x => `${esc(x.country)} (${x.count})`).join(', ') || 'none'}</td></tr>
|
||||
<tr><td style="font-weight:500">Nextcloud failed logins</td><td style="color:var(--text2)">${(nc.failed_logins || []).slice(0,6).map(f => `${esc(f.ip || '?')} (${esc(f.user || '?')})`).join(', ') || 'none'}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
`;
|
||||
} catch(e) {
|
||||
c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== FIREWALL ===== */
|
||||
async function loadFirewall() {
|
||||
const c = document.getElementById('view-container');
|
||||
c.innerHTML = '<div class="shimmer">Loading firewall…</div>';
|
||||
try {
|
||||
const servers = await fetch('/api/firewall').then(r => r.json());
|
||||
cachedFirewall = servers;
|
||||
renderFirewall(servers, c);
|
||||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderFirewall(servers, c) {
|
||||
c.innerHTML = servers.map((s, i) => `
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3>${s.reachable ? '🟢' : '🔴'} ${esc(s.name)} <span style="font-weight:400;color:var(--text3);font-size:12px">(${esc(s.host)})</span></h3>
|
||||
<span class="count">${s.reachable ? '' : 'unreachable'}</span>
|
||||
</div>
|
||||
<div style="padding:18px 20px">
|
||||
<div class="grid" style="margin-bottom:16px">
|
||||
<div class="card"><div class="label">Country Block</div><div class="value" style="color:${s.country_block ? 'var(--green)' : 'var(--text3)'}">${s.country_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.country_cidrs} CIDRs</div></div>
|
||||
<div class="card"><div class="label">Scanner Block</div><div class="value" style="color:${s.scanner_block ? 'var(--green)' : 'var(--text3)'}">${s.scanner_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.scanner_ips} IPs</div></div>
|
||||
<div class="card"><div class="label">Total Blocked</div><div class="value">${s.country_cidrs + s.scanner_ips}</div><div class="sub">combined rules</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
|
||||
<button class="btn ${s.country_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'country',${!s.country_block})">${s.country_block ? '⏸ Disable Country' : '▶ Enable Country'}</button>
|
||||
<button class="btn ${s.scanner_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'scanner',${!s.scanner_block})">${s.scanner_block ? '⏸ Disable Scanner' : '▶ Enable Scanner'}</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input id="fw-ip-${i}" class="fw-input" placeholder="IP to block/unblock">
|
||||
<button class="btn" onclick="blockIp(${i})">Block IP</button>
|
||||
<button class="btn" onclick="unblockIp(${i})">Unblock IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty">No servers configured</div>';
|
||||
}
|
||||
|
||||
async function toggleFw(i, type, enabled) {
|
||||
await fetch(`/api/firewall/${i}/toggle`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ type, enabled }) });
|
||||
loadFirewall();
|
||||
}
|
||||
async function blockIp(i) {
|
||||
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
|
||||
if (!ip) return;
|
||||
await fetch(`/api/firewall/${i}/block`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
|
||||
loadFirewall();
|
||||
}
|
||||
async function unblockIp(i) {
|
||||
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
|
||||
if (!ip) return;
|
||||
await fetch(`/api/firewall/${i}/unblock`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
|
||||
loadFirewall();
|
||||
}
|
||||
|
||||
/* ===== INTRUSION ===== */
|
||||
async function loadIntrusion() {
|
||||
const c = document.getElementById('view-container');
|
||||
c.innerHTML = '<div class="shimmer">Scanning intrusion attempts…</div>';
|
||||
try {
|
||||
const sec = await fetch('/api/security').then(r => r.json());
|
||||
renderIntrusion(sec, c);
|
||||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderIntrusion(sec, c) {
|
||||
if (sec.error) { c.innerHTML = `<div class="empty">⚠️ ${esc(sec.error)}</div>`; return; }
|
||||
const top = sec.countries[0];
|
||||
const fl = sec.flagged || {};
|
||||
c.innerHTML = `
|
||||
<div class="grid">
|
||||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Failed Attempts</div><div class="value">${sec.total_attempts}</div><div class="sub">last ${sec.window_hours}h</div></div>
|
||||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div><div class="label">Unique Source IPs</div><div class="value">${sec.unique_ips}</div><div class="sub">all failed handshakes</div></div>
|
||||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div><div class="label">Top Country</div><div class="value">${top ? esc(top.country) : '—'}</div><div class="sub">${top ? top.count + ' hits' : ''}</div></div>
|
||||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attempts</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${fl.ips ?? 0} IPs · ${(fl.countries || []).join(', ')}</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🌍 Source Countries</h3><span class="count">${sec.countries.length} distinct</span></div>
|
||||
<div class="chips">${sec.countries.slice(0,24).map(x => `<span class="tag">${esc(x.country)} · ${x.count}</span>`).join('')}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🚨 Intrusion Sources</h3><span class="count">${sec.unique_ips} IPs · <a href="/api/threats/export" download style="color:var(--accent)">⬇ Export CSV</a></span></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Source IP</th><th>Country</th><th>Organization</th><th>Attempts</th><th>First</th><th>Last</th></tr></thead>
|
||||
<tbody>${sec.attempts.map(a => `
|
||||
<tr class="${a.flagged ? 'flagged' : ''}">
|
||||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showIpLogs('${a.ip}')">${esc(a.ip)}</span></td>
|
||||
<td>${a.flagged ? '🔴 ' : ''}${a.cc ? `<span class="badge yellow">${esc(a.cc)}</span> ` : ''}${esc(a.country)}</td>
|
||||
<td style="color:var(--text2);max-width:260px;overflow:hidden;text-overflow:ellipsis">${esc(a.org)}</td>
|
||||
<td><b>${a.count}</b></td>
|
||||
<td style="color:var(--text3);font-size:12px">${esc(a.first)}</td>
|
||||
<td style="color:var(--text3);font-size:12px">${esc(a.last)}</td>
|
||||
</tr>`).join('') || '<tr><td class="empty" colspan="6">No intrusion attempts</td></tr>'}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ===== WEB TRAFFIC ===== */
|
||||
async function loadWeb() {
|
||||
const c = document.getElementById('view-container');
|
||||
c.innerHTML = '<div class="shimmer">Loading web traffic…</div>';
|
||||
try {
|
||||
const [vpsStats, vpsLogs, stats, logs, errors] = await Promise.all([
|
||||
fetch('/api/vps/stats').then(r => r.json()),
|
||||
fetch('/api/vps/logs?lines=300').then(r => r.json()),
|
||||
fetch('/api/stats').then(r => r.json()),
|
||||
fetch('/api/logs?lines=200').then(r => r.json()),
|
||||
fetch('/api/errors?lines=20').then(r => r.json()),
|
||||
]);
|
||||
renderWeb(vpsStats, vpsLogs, stats, logs, errors, c);
|
||||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function statusBadge(s) {
|
||||
const cls = s.startsWith('5') ? 'red' : s.startsWith('4') ? 'yellow' : s.startsWith('3') ? 'blue' : 'green';
|
||||
return `<span class="badge ${cls}">${s}</span>`;
|
||||
}
|
||||
|
||||
function renderWeb(vpsStats, vpsLogs, stats, logs, errors, c) {
|
||||
const vs = vpsStats.error ? null : vpsStats;
|
||||
const vr = Array.isArray(vpsLogs) ? vpsLogs : [];
|
||||
const vhosts = stats.vhosts || [];
|
||||
const hits = Object.fromEntries((stats.per_vhost || []).map(v => [v.vhost, v.count]));
|
||||
c.innerHTML = `
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🌐 Public Web Server — ubuntu VPS (100.64.0.2)</h3><span class="count">Apache</span></div>
|
||||
${vs ? `
|
||||
<div class="grid" style="padding:18px 20px 0">
|
||||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">Recent Requests</div><div class="value">${vs.total}</div><div class="sub">sampled</div></div>
|
||||
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">Unique Clients</div><div class="value">${vs.unique_ips}</div><div class="sub">distinct IPs</div></div>
|
||||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">4xx</div><div class="value">${vs.errors_4xx}</div><div class="sub">client errors</div></div>
|
||||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">5xx</div><div class="value">${vs.errors_5xx}</div><div class="sub">server errors</div></div>
|
||||
</div>
|
||||
` : `<div class="empty">❌ ${esc(vpsStats.error)}</div>`}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th><th>Bytes</th></tr></thead>
|
||||
<tbody>${vr.map(r => `<tr>
|
||||
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
|
||||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
|
||||
<td><span class="method ${r.method}">${r.method}</span></td>
|
||||
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
|
||||
<td>${statusBadge(r.status)}</td>
|
||||
<td style="color:var(--text2)">${esc(r.bytes)}</td>
|
||||
</tr>`).join('') || '<tr><td class="empty" colspan="6">No requests</td></tr>'}</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>🔀 Internal Proxy — ubuntu-web (.65)</h3><span class="count">apache-proxy-manager</span></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Domain</th><th>Target</th><th>SSL</th><th>Hits</th></tr></thead>
|
||||
<tbody>${vhosts.map(v => `<tr><td style="font-weight:500">${esc(v.domain)}</td><td><span class="tag">${esc(v.target)}</span></td><td>${v.ssl ? '<span class="badge green">HTTPS</span>' : '<span class="badge blue">HTTP</span>'}</td><td>${hits[v.domain] || 0}</td></tr>`).join('') || '<tr><td class="empty" colspan="4">No proxies.json</td></tr>'}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th></tr></thead>
|
||||
<tbody>${(logs || []).map(r => `<tr>
|
||||
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
|
||||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
|
||||
<td><span class="method ${r.method}">${r.method}</span></td>
|
||||
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
|
||||
<td>${statusBadge(r.status)}</td>
|
||||
</tr>`).join('') || '<tr><td class="empty" colspan="5">No requests</td></tr>'}</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h3>⚠️ Internal Error Log</h3><span class="count">${errors.length} lines</span></div>
|
||||
${errors.length ? errors.map(e => `<div class="error-line">${esc(e)}</div>`).join('') : '<div class="empty">No errors</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function geoHeader(ip, g) {
|
||||
const geo = (g && g.country) ? `<span class="tag">${esc(g.country)}</span> <span class="tag">${esc(g.org || g.as || '')}</span>` : '';
|
||||
return `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:10px;flex-wrap:wrap">
|
||||
<div>${geo}</div>
|
||||
<button class="btn btn-danger btn-xs" onclick="blockIpModal('${ip}')">🛡️ Block IP</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function blockIpModal(ip) {
|
||||
try {
|
||||
const r = await fetch(`/api/block/${ip}`, { method: 'POST' }).then(r => r.json());
|
||||
const msg = r.map(x => `${x.server}: ${x.result.status || x.result.error || '?'}`).join(' · ');
|
||||
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin',
|
||||
`<div style="padding:10px 14px;border-radius:8px;background:rgba(34,197,94,.12);color:var(--green);margin-bottom:12px;font-size:12px">🛡️ ${esc(msg)}</div>`);
|
||||
} catch(e) {
|
||||
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin', `<div class="empty">❌ ${esc(e.message)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== IP LOGS MODAL ===== */
|
||||
async function showIpLogs(ip) {
|
||||
document.getElementById('ip-modal-title').textContent = `Connection attempts from ${ip}`;
|
||||
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
|
||||
document.getElementById('ip-modal').style.display = 'flex';
|
||||
try {
|
||||
const [d, g] = await Promise.all([
|
||||
fetch(`/api/security/ip/${ip}`).then(r => r.json()),
|
||||
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
|
||||
]);
|
||||
if (d.error) {
|
||||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(d.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
const rows = (d.attempts || []).map(a =>
|
||||
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(a.time)}</span> — <span style="color:var(--red)">${esc(a.event)}</span></div>`
|
||||
).join('');
|
||||
document.getElementById('ip-modal-body').innerHTML =
|
||||
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${d.count}</b> attempts (last 7 days, max 500 shown)</div>${rows || '<div class="empty">No attempts found</div>'}`;
|
||||
} catch(e) {
|
||||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeIpModal() {
|
||||
document.getElementById('ip-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function showApacheLogs(ip) {
|
||||
document.getElementById('ip-modal-title').textContent = `Apache requests from ${ip}`;
|
||||
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
|
||||
document.getElementById('ip-modal').style.display = 'flex';
|
||||
try {
|
||||
const [local, vps, g] = await Promise.all([
|
||||
fetch(`/api/logs/ip/${ip}`).then(r => r.json()),
|
||||
fetch(`/api/vps/logs/ip/${ip}`).then(r => r.json()),
|
||||
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
|
||||
]);
|
||||
const all = [...(Array.isArray(local) ? local : []), ...(Array.isArray(vps) ? vps : [])];
|
||||
const rows = all.map(r =>
|
||||
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(r.ts)}</span> — <span class="method ${r.method}" style="font-size:11px">${r.method}</span> <span style="color:var(--accent)">${esc(r.path)}</span> <span class="badge ${r.status.startsWith('5') ? 'red' : r.status.startsWith('4') ? 'yellow' : 'green'}">${r.status}</span> <span style="color:var(--text3)">${esc(r.bytes || '')}</span></div>`
|
||||
).join('');
|
||||
document.getElementById('ip-modal-body').innerHTML =
|
||||
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${all.length}</b> requests</div>${rows || '<div class="empty">No Apache requests for this IP</div>'}`;
|
||||
} catch(e) {
|
||||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== INIT ===== */
|
||||
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
||||
setInterval(() => { document.getElementById('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
|
||||
loadServers();
|
||||
renderView();
|
||||
setInterval(30);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
75
app/vps.py
Normal file
75
app/vps.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
AGENT_URL = os.environ.get("VPS_AGENT_URL", "http://100.64.0.2:8787")
|
||||
AGENT_TOKEN = os.environ.get("VPS_AGENT_TOKEN", "")
|
||||
|
||||
_cache = {}
|
||||
|
||||
|
||||
def _cached(key, ttl, fn):
|
||||
now = time.time()
|
||||
item = _cache.get(key)
|
||||
if item and now - item[0] < ttl:
|
||||
return item[1]
|
||||
val = fn()
|
||||
_cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
|
||||
def _agent_get(path):
|
||||
req = urllib.request.Request(
|
||||
f"{AGENT_URL}{path}",
|
||||
headers={"X-API-Token": AGENT_TOKEN},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def fetch_logs(lines=500):
|
||||
def _load():
|
||||
try:
|
||||
return _agent_get(f"/api/logs?lines={lines}")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return _cached(f"logs:{lines}", 30, _load)
|
||||
|
||||
|
||||
def fetch_stats():
|
||||
def _load():
|
||||
try:
|
||||
return _agent_get("/api/stats")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return _cached("stats", 30, _load)
|
||||
|
||||
|
||||
def fetch_firewall():
|
||||
def _load():
|
||||
try:
|
||||
return _agent_get("/api/firewall")
|
||||
except Exception as e:
|
||||
return [{"name": "ubuntu VPS (.2)", "host": AGENT_URL, "reachable": False, "error": str(e)}]
|
||||
return _cached("firewall", 30, _load)
|
||||
|
||||
|
||||
def fetch_logs_ip(ip):
|
||||
try:
|
||||
return _agent_get(f"/api/logs/ip/{ip}")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def block_ip(ip):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{AGENT_URL}/api/firewall/0/block",
|
||||
data=json.dumps({"ip": ip}).encode(),
|
||||
headers={"Content-Type": "application/json", "X-API-Token": AGENT_TOKEN},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
21
docker-compose.agent.yml
Normal file
21
docker-compose.agent.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
dockvisor-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.agent
|
||||
container_name: dockvisor-agent
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- /var/log/apache2:/logs:ro
|
||||
- /var/log:/hostlogs:ro
|
||||
- /data/dockvisor-agent:/cache
|
||||
environment:
|
||||
- API_TOKEN=L-04xXAHo3mN-q9N27H3Wwe1hSQKynm_rZHtjnKYJ0o
|
||||
- LOGS_DIR=/logs
|
||||
- HOSTLOGS_DIR=/hostlogs
|
||||
- SERVERS_FILE=/cache/servers.json
|
||||
- GEO_CACHE=/cache/geo.json
|
||||
- HEADSCALE_CONTAINER=
|
||||
restart: unless-stopped
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
services:
|
||||
proxy-monitor:
|
||||
build: .
|
||||
container_name: proxy-monitor
|
||||
network_mode: host
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9091"]
|
||||
volumes:
|
||||
- /data/apache-proxy-logs:/logs:ro
|
||||
- /data/apache-proxy:/config:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /data/proxy-monitor-cache:/cache
|
||||
- /var/log:/hostlogs:ro
|
||||
- ~/.ssh:/root/.ssh:ro
|
||||
environment:
|
||||
- GEO_CACHE=/cache/geo.json
|
||||
- HEADSCALE_CONTAINER=headscale
|
||||
- FLAGGED_COUNTRIES=China,Russia,Iran,North Korea
|
||||
- VPS_AGENT_URL=http://100.64.0.2:8787
|
||||
- VPS_AGENT_TOKEN=L-04xXAHo3mN-q9N27H3Wwe1hSQKynm_rZHtjnKYJ0o
|
||||
# Discord/Slack webhook URL. Leave empty to disable alerts.
|
||||
- WEBHOOK_URL=
|
||||
- ALERT_THRESHOLD=50
|
||||
- ALERT_INTERVAL=300
|
||||
restart: unless-stopped
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
jinja2
|
||||
docker
|
||||
Loading…
Reference in a new issue