feat: initial upload of proxy-monitor

This commit is contained in:
dev6-ai 2026-08-23 15:41:17 -04:00
commit c8b181ea47
13 changed files with 1739 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
*.pyc
.git/

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
data/
__pycache__/
*.log
.env

10
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
fastapi
uvicorn
jinja2
docker