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