598 lines
26 KiB
Python
598 lines
26 KiB
Python
"""REST-API und WebUI."""
|
|
import csv
|
|
import io
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import config
|
|
from .auth import hash_password, new_token, token_hash, users, verify_password
|
|
from .core import registry
|
|
from .db import db
|
|
from .forwarder import forwards
|
|
from .policy import parse_networks, policy, validate_rule
|
|
|
|
STATIC = Path(__file__).parent / "static"
|
|
|
|
app = FastAPI(title="Proxy Manager API", version="1.0", docs_url="/api/docs", redoc_url=None,
|
|
openapi_url="/api/openapi.json")
|
|
|
|
|
|
# --------------------------------------------------------------------------- Auth
|
|
def current_user(request: Request) -> dict:
|
|
header = request.headers.get("authorization", "")
|
|
token = header[7:].strip() if header.lower().startswith("bearer ") else request.headers.get("x-api-key", "")
|
|
if not token:
|
|
raise HTTPException(401, "Nicht angemeldet")
|
|
row = db.one("SELECT t.id tid, t.expires_at, u.* FROM tokens t JOIN users u ON u.id=t.user_id "
|
|
"WHERE t.token_hash=?", (token_hash(token),))
|
|
if not row or not row["enabled"] or (row["expires_at"] and row["expires_at"] < time.time()):
|
|
raise HTTPException(401, "Sitzung ungültig oder abgelaufen")
|
|
db.execute("UPDATE tokens SET last_used=? WHERE id=?", (time.time(), row["tid"]))
|
|
return row
|
|
|
|
|
|
def admin_user(user: dict = Depends(current_user)) -> dict:
|
|
if not user["is_admin"]:
|
|
raise HTTPException(403, "Nur für Administratoren")
|
|
return user
|
|
|
|
|
|
def public_user(u: dict) -> dict:
|
|
return {k: u[k] for k in ("id", "username", "is_admin", "enabled", "policy", "comment", "created_at",
|
|
"last_seen")}
|
|
|
|
|
|
class LoginIn(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@app.post("/api/auth/login", tags=["Auth"])
|
|
def login(data: LoginIn, request: Request):
|
|
ip = request.client.host if request.client else "?"
|
|
if users.is_locked(ip):
|
|
raise HTTPException(429, "Zu viele Fehlversuche, bitte später erneut versuchen")
|
|
u = db.one("SELECT * FROM users WHERE username=?", (data.username,))
|
|
if not u or not u["enabled"] or not verify_password(data.password, u["password_hash"]):
|
|
users._fail(ip)
|
|
raise HTTPException(401, "Benutzername oder Passwort falsch")
|
|
token = new_token()
|
|
db.execute("INSERT INTO tokens(token_hash,user_id,name,kind,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
|
(token_hash(token), u["id"], "WebUI", "session", time.time(),
|
|
time.time() + config.SESSION_HOURS * 3600))
|
|
db.execute("DELETE FROM tokens WHERE kind='session' AND expires_at<?", (time.time(),))
|
|
return {"token": token, "user": public_user(u)}
|
|
|
|
|
|
@app.post("/api/auth/logout", tags=["Auth"])
|
|
def logout(request: Request, user: dict = Depends(current_user)):
|
|
header = request.headers.get("authorization", "")
|
|
db.execute("DELETE FROM tokens WHERE token_hash=? AND kind='session'", (token_hash(header[7:].strip()),))
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/me", tags=["Auth"])
|
|
def me(user: dict = Depends(current_user)):
|
|
return public_user(user)
|
|
|
|
|
|
class PasswordIn(BaseModel):
|
|
old_password: str
|
|
new_password: str = Field(min_length=6)
|
|
|
|
|
|
@app.post("/api/me/password", tags=["Auth"])
|
|
def change_own_password(data: PasswordIn, user: dict = Depends(current_user)):
|
|
if not verify_password(data.old_password, user["password_hash"]):
|
|
raise HTTPException(400, "Altes Passwort ist falsch")
|
|
db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(data.new_password), user["id"]))
|
|
users.reload()
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Benutzer
|
|
class UserIn(BaseModel):
|
|
username: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
|
password: str | None = Field(default=None, min_length=6)
|
|
is_admin: bool = False
|
|
enabled: bool = True
|
|
policy: Literal["inherit", "allow", "deny"] = "inherit"
|
|
comment: str = ""
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
username: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
|
password: str | None = Field(default=None, min_length=6)
|
|
is_admin: bool | None = None
|
|
enabled: bool | None = None
|
|
policy: Literal["inherit", "allow", "deny"] | None = None
|
|
comment: str | None = None
|
|
|
|
|
|
def _reload_all():
|
|
users.reload()
|
|
policy.reload()
|
|
|
|
|
|
@app.get("/api/users", tags=["Benutzer"])
|
|
def list_users(_: dict = Depends(admin_user)):
|
|
rows = db.query("SELECT u.*, (SELECT COUNT(*) FROM rules r WHERE r.user_id=u.id) rule_count FROM users u "
|
|
"ORDER BY username")
|
|
out = []
|
|
for r in rows:
|
|
d = public_user(r)
|
|
d["rule_count"] = r["rule_count"]
|
|
d["last_seen"] = max(r["last_seen"] or 0, users.last_seen.get(r["id"], 0)) or None
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
@app.post("/api/users", tags=["Benutzer"], status_code=201)
|
|
def create_user(data: UserIn, _: dict = Depends(admin_user)):
|
|
if not data.password:
|
|
raise HTTPException(400, "Passwort erforderlich")
|
|
if db.one("SELECT id FROM users WHERE username=?", (data.username,)):
|
|
raise HTTPException(409, "Benutzername existiert bereits")
|
|
cur = db.execute("INSERT INTO users(username,password_hash,is_admin,enabled,policy,comment,created_at) "
|
|
"VALUES(?,?,?,?,?,?,?)",
|
|
(data.username, hash_password(data.password), int(data.is_admin), int(data.enabled),
|
|
data.policy, data.comment, time.time()))
|
|
_reload_all()
|
|
return public_user(db.one("SELECT * FROM users WHERE id=?", (cur.lastrowid,)))
|
|
|
|
|
|
@app.get("/api/users/{uid}", tags=["Benutzer"])
|
|
def get_user(uid: int, _: dict = Depends(admin_user)):
|
|
u = db.one("SELECT * FROM users WHERE id=?", (uid,))
|
|
if not u:
|
|
raise HTTPException(404, "Benutzer nicht gefunden")
|
|
return public_user(u)
|
|
|
|
|
|
@app.put("/api/users/{uid}", tags=["Benutzer"])
|
|
def update_user(uid: int, data: UserUpdate, me_: dict = Depends(admin_user)):
|
|
u = db.one("SELECT * FROM users WHERE id=?", (uid,))
|
|
if not u:
|
|
raise HTTPException(404, "Benutzer nicht gefunden")
|
|
fields = data.model_dump(exclude_unset=True, exclude_none=True)
|
|
if uid == me_["id"] and (fields.get("is_admin") is False or fields.get("enabled") is False):
|
|
raise HTTPException(400, "Du kannst dich nicht selbst sperren oder entmachten")
|
|
if "username" in fields and db.one("SELECT id FROM users WHERE username=? AND id<>?", (fields["username"], uid)):
|
|
raise HTTPException(409, "Benutzername existiert bereits")
|
|
if "password" in fields:
|
|
fields["password_hash"] = hash_password(fields.pop("password"))
|
|
for k in ("is_admin", "enabled"):
|
|
if k in fields:
|
|
fields[k] = int(fields[k])
|
|
if fields:
|
|
db.execute(f"UPDATE users SET {', '.join(f'{k}=?' for k in fields)} WHERE id=?", (*fields.values(), uid))
|
|
if fields.get("enabled") == 0 or "password_hash" in fields:
|
|
db.execute("DELETE FROM tokens WHERE user_id=? AND kind='session'", (uid,))
|
|
_reload_all()
|
|
return public_user(db.one("SELECT * FROM users WHERE id=?", (uid,)))
|
|
|
|
|
|
@app.delete("/api/users/{uid}", tags=["Benutzer"])
|
|
def delete_user(uid: int, me_: dict = Depends(admin_user)):
|
|
if uid == me_["id"]:
|
|
raise HTTPException(400, "Du kannst dich nicht selbst löschen")
|
|
db.execute("DELETE FROM users WHERE id=?", (uid,))
|
|
_reload_all()
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Regeln
|
|
class RuleIn(BaseModel):
|
|
user_id: int | None = None
|
|
action: Literal["allow", "deny"]
|
|
host: str = ""
|
|
ports: str = ""
|
|
comment: str = ""
|
|
enabled: bool = True
|
|
|
|
|
|
class RuleBulkIn(BaseModel):
|
|
user_id: int | None = None
|
|
action: Literal["allow", "deny"]
|
|
hosts: list[str]
|
|
ports: str = ""
|
|
comment: str = ""
|
|
|
|
|
|
class RuleTestIn(BaseModel):
|
|
username: str | None = None
|
|
host: str
|
|
port: int = 443
|
|
|
|
|
|
def _check_rule(host: str, ports: str, user_id):
|
|
try:
|
|
validate_rule(host, ports)
|
|
except Exception as e: # noqa: BLE001
|
|
raise HTTPException(400, f"Ungültige Regel: {e}")
|
|
if user_id is not None and not db.one("SELECT id FROM users WHERE id=?", (user_id,)):
|
|
raise HTTPException(400, "Benutzer existiert nicht")
|
|
|
|
|
|
@app.get("/api/rules", tags=["Regeln"])
|
|
def list_rules(user_id: int | None = None, scope: Literal["all", "global", "user"] = "all",
|
|
_: dict = Depends(admin_user)):
|
|
sql = "SELECT r.*, u.username FROM rules r LEFT JOIN users u ON u.id=r.user_id WHERE 1=1"
|
|
params: list = []
|
|
if user_id is not None:
|
|
sql += " AND r.user_id=?"
|
|
params.append(user_id)
|
|
elif scope == "global":
|
|
sql += " AND r.user_id IS NULL"
|
|
elif scope == "user":
|
|
sql += " AND r.user_id IS NOT NULL"
|
|
return db.query(sql + " ORDER BY r.user_id IS NOT NULL, u.username, r.action, r.host", params)
|
|
|
|
|
|
@app.post("/api/rules", tags=["Regeln"], status_code=201)
|
|
def create_rule(data: RuleIn, _: dict = Depends(admin_user)):
|
|
_check_rule(data.host, data.ports, data.user_id)
|
|
cur = db.execute("INSERT INTO rules(user_id,action,host,ports,comment,enabled,created_at) VALUES(?,?,?,?,?,?,?)",
|
|
(data.user_id, data.action, data.host.strip(), data.ports.strip(), data.comment,
|
|
int(data.enabled), time.time()))
|
|
policy.reload()
|
|
return db.one("SELECT * FROM rules WHERE id=?", (cur.lastrowid,))
|
|
|
|
|
|
@app.post("/api/rules/bulk", tags=["Regeln"], status_code=201)
|
|
def bulk_rules(data: RuleBulkIn, _: dict = Depends(admin_user)):
|
|
hosts = []
|
|
for h in data.hosts:
|
|
h = h.split("#", 1)[0].strip()
|
|
# Hosts-Datei-Format "0.0.0.0 ads.example.com" unterstützen
|
|
parts = h.split()
|
|
if len(parts) == 2 and parts[0] in ("0.0.0.0", "127.0.0.1", "::"):
|
|
h = parts[1]
|
|
if h:
|
|
_check_rule(h, data.ports, data.user_id)
|
|
hosts.append(h)
|
|
existing = {(r["host"].lower(), r["ports"]) for r in
|
|
db.query("SELECT host, ports FROM rules WHERE action=? AND user_id IS ?", (data.action, data.user_id))}
|
|
rows = [(data.user_id, data.action, h, data.ports.strip(), data.comment, 1, time.time())
|
|
for h in dict.fromkeys(hosts) if (h.lower(), data.ports.strip()) not in existing]
|
|
if rows:
|
|
db.executemany("INSERT INTO rules(user_id,action,host,ports,comment,enabled,created_at) VALUES(?,?,?,?,?,?,?)",
|
|
rows)
|
|
policy.reload()
|
|
return {"added": len(rows), "skipped": len(hosts) - len(rows)}
|
|
|
|
|
|
@app.put("/api/rules/{rid}", tags=["Regeln"])
|
|
def update_rule(rid: int, data: RuleIn, _: dict = Depends(admin_user)):
|
|
if not db.one("SELECT id FROM rules WHERE id=?", (rid,)):
|
|
raise HTTPException(404, "Regel nicht gefunden")
|
|
_check_rule(data.host, data.ports, data.user_id)
|
|
db.execute("UPDATE rules SET user_id=?, action=?, host=?, ports=?, comment=?, enabled=? WHERE id=?",
|
|
(data.user_id, data.action, data.host.strip(), data.ports.strip(), data.comment, int(data.enabled),
|
|
rid))
|
|
policy.reload()
|
|
return db.one("SELECT * FROM rules WHERE id=?", (rid,))
|
|
|
|
|
|
@app.delete("/api/rules/{rid}", tags=["Regeln"])
|
|
def delete_rule(rid: int, _: dict = Depends(admin_user)):
|
|
db.execute("DELETE FROM rules WHERE id=?", (rid,))
|
|
policy.reload()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/rules/test", tags=["Regeln"])
|
|
async def test_rule(data: RuleTestIn, _: dict = Depends(admin_user)):
|
|
"""Prüft, ob ein Benutzer ein Ziel erreichen dürfte (inkl. DNS-Auflösung)."""
|
|
import ipaddress
|
|
from .core import resolve
|
|
user = users.get(data.username) if data.username else None
|
|
if data.username and not user:
|
|
raise HTTPException(404, "Benutzer nicht gefunden")
|
|
d = policy.decide(user, data.host, data.port)
|
|
result = {"allowed": d.allowed, "reason": d.reason, "addresses": []}
|
|
if d.allowed:
|
|
try:
|
|
for _, sockaddr in await resolve(data.host, data.port):
|
|
dd = policy.decide(user, data.host, data.port, ipaddress.ip_address(sockaddr[0].split("%")[0]))
|
|
result["addresses"].append({"ip": sockaddr[0], "allowed": dd.allowed, "reason": dd.reason})
|
|
if result["addresses"] and not any(a["allowed"] for a in result["addresses"]):
|
|
result["allowed"] = False
|
|
result["reason"] = result["addresses"][0]["reason"]
|
|
except OSError as e:
|
|
result["dns_error"] = str(e)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------- Weiterleitungen
|
|
class ForwardIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=64)
|
|
protocol: Literal["tcp", "udp"]
|
|
listen_port: int = Field(ge=1, le=65535)
|
|
target_host: str = Field(min_length=1)
|
|
target_port: int = Field(ge=1, le=65535)
|
|
allowed_sources: str = ""
|
|
enabled: bool = True
|
|
comment: str = ""
|
|
|
|
|
|
def _check_forward(data: ForwardIn, fid: int | None = None):
|
|
reserved = {config.HTTP_PORT, config.SOCKS_PORT, config.WEB_PORT}
|
|
if data.listen_port in reserved or (data.protocol == "udp" and data.listen_port in config.SOCKS_UDP_PORTS):
|
|
raise HTTPException(400, "Port ist bereits vom Proxy belegt")
|
|
if db.one("SELECT id FROM forwards WHERE protocol=? AND listen_port=? AND id IS NOT ?",
|
|
(data.protocol, data.listen_port, fid)):
|
|
raise HTTPException(409, "Port wird bereits für eine andere Weiterleitung verwendet")
|
|
for part in data.allowed_sources.replace(",", " ").split():
|
|
if not parse_networks(part):
|
|
raise HTTPException(400, f"Ungültiges Netz: {part}")
|
|
|
|
|
|
def _forward_out(f):
|
|
f = dict(f)
|
|
f["status"] = forwards.status.get(f["id"], "unbekannt")
|
|
return f
|
|
|
|
|
|
@app.get("/api/forwards", tags=["Weiterleitungen"])
|
|
def list_forwards(_: dict = Depends(admin_user)):
|
|
return {"ports_hint": config.FORWARD_PORTS,
|
|
"items": [_forward_out(f) for f in db.query("SELECT * FROM forwards ORDER BY listen_port")]}
|
|
|
|
|
|
@app.post("/api/forwards", tags=["Weiterleitungen"], status_code=201)
|
|
async def create_forward(data: ForwardIn, _: dict = Depends(admin_user)):
|
|
_check_forward(data)
|
|
cur = db.execute("INSERT INTO forwards(name,protocol,listen_port,target_host,target_port,allowed_sources,enabled,"
|
|
"comment,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
(data.name, data.protocol, data.listen_port, data.target_host.strip(), data.target_port,
|
|
data.allowed_sources.strip(), int(data.enabled), data.comment, time.time()))
|
|
await forwards.reload()
|
|
return _forward_out(db.one("SELECT * FROM forwards WHERE id=?", (cur.lastrowid,)))
|
|
|
|
|
|
@app.put("/api/forwards/{fid}", tags=["Weiterleitungen"])
|
|
async def update_forward(fid: int, data: ForwardIn, _: dict = Depends(admin_user)):
|
|
if not db.one("SELECT id FROM forwards WHERE id=?", (fid,)):
|
|
raise HTTPException(404, "Weiterleitung nicht gefunden")
|
|
_check_forward(data, fid)
|
|
db.execute("UPDATE forwards SET name=?, protocol=?, listen_port=?, target_host=?, target_port=?, "
|
|
"allowed_sources=?, enabled=?, comment=? WHERE id=?",
|
|
(data.name, data.protocol, data.listen_port, data.target_host.strip(), data.target_port,
|
|
data.allowed_sources.strip(), int(data.enabled), data.comment, fid))
|
|
await forwards.reload()
|
|
return _forward_out(db.one("SELECT * FROM forwards WHERE id=?", (fid,)))
|
|
|
|
|
|
@app.delete("/api/forwards/{fid}", tags=["Weiterleitungen"])
|
|
async def delete_forward(fid: int, _: dict = Depends(admin_user)):
|
|
db.execute("DELETE FROM forwards WHERE id=?", (fid,))
|
|
await forwards.reload()
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Verlauf
|
|
def _history_filter(username, q, status, proto, since, until):
|
|
where, params = ["1=1"], []
|
|
if username:
|
|
where.append("username=?")
|
|
params.append(username)
|
|
if q:
|
|
where.append("(host LIKE ? OR url LIKE ? OR client_ip LIKE ? OR detail LIKE ?)")
|
|
params += [f"%{q}%"] * 4
|
|
if status:
|
|
where.append("status=?")
|
|
params.append(status)
|
|
if proto:
|
|
where.append("proto=?")
|
|
params.append(proto)
|
|
if since:
|
|
where.append("ts>=?")
|
|
params.append(since)
|
|
if until:
|
|
where.append("ts<=?")
|
|
params.append(until)
|
|
return " AND ".join(where), params
|
|
|
|
|
|
def _history(username, q, status, proto, since, until, limit, offset):
|
|
where, params = _history_filter(username, q, status, proto, since, until)
|
|
total = db.one(f"SELECT COUNT(*) n FROM history WHERE {where}", params)["n"]
|
|
rows = db.query(f"SELECT * FROM history WHERE {where} ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?",
|
|
(*params, limit, offset))
|
|
return {"total": total, "items": rows}
|
|
|
|
|
|
@app.get("/api/history", tags=["Verlauf"])
|
|
def get_history(username: str | None = None, q: str | None = None, status: str | None = None,
|
|
proto: str | None = None, since: float | None = None, until: float | None = None,
|
|
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0),
|
|
_: dict = Depends(admin_user)):
|
|
return _history(username, q, status, proto, since, until, limit, offset)
|
|
|
|
|
|
@app.get("/api/me/history", tags=["Verlauf"])
|
|
def my_history(q: str | None = None, status: str | None = None, since: float | None = None,
|
|
until: float | None = None, limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0),
|
|
user: dict = Depends(current_user)):
|
|
return _history(user["username"], q, status, None, since, until, limit, offset)
|
|
|
|
|
|
@app.get("/api/history/export.csv", tags=["Verlauf"])
|
|
def export_history(username: str | None = None, q: str | None = None, status: str | None = None,
|
|
proto: str | None = None, since: float | None = None, until: float | None = None,
|
|
_: dict = Depends(admin_user)):
|
|
where, params = _history_filter(username, q, status, proto, since, until)
|
|
|
|
def gen():
|
|
buf = io.StringIO()
|
|
w = csv.writer(buf, delimiter=";")
|
|
w.writerow(["Zeit", "Benutzer", "Client-IP", "Protokoll", "Methode", "Host", "Port", "URL", "Status",
|
|
"Detail", "Bytes hoch", "Bytes runter", "Dauer (s)"])
|
|
last_id = None
|
|
while True:
|
|
extra = " AND id<?" if last_id else ""
|
|
rows = db.query(f"SELECT * FROM history WHERE {where}{extra} ORDER BY id DESC LIMIT 5000",
|
|
(*params, last_id) if last_id else params)
|
|
if not rows:
|
|
break
|
|
for r in rows:
|
|
w.writerow([time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])), r["username"],
|
|
r["client_ip"], r["proto"], r["method"], r["host"], r["port"], r["url"], r["status"],
|
|
r["detail"], r["bytes_up"], r["bytes_down"], r["duration"]])
|
|
last_id = rows[-1]["id"]
|
|
yield buf.getvalue()
|
|
buf.seek(0)
|
|
buf.truncate()
|
|
yield buf.getvalue()
|
|
|
|
return StreamingResponse(gen(), media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": "attachment; filename=proxy-verlauf.csv"})
|
|
|
|
|
|
@app.delete("/api/history", tags=["Verlauf"])
|
|
def clear_history(before: float | None = None, _: dict = Depends(admin_user)):
|
|
if before:
|
|
n = db.execute("DELETE FROM history WHERE ts<?", (before,)).rowcount
|
|
else:
|
|
n = db.execute("DELETE FROM history").rowcount
|
|
return {"deleted": n}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Statistik & Verbindungen
|
|
@app.get("/api/stats", tags=["Statistik"])
|
|
def stats(hours: int = Query(24, ge=1, le=24 * 90), _: dict = Depends(admin_user)):
|
|
since = time.time() - hours * 3600
|
|
tot = db.one("SELECT COUNT(*) total, SUM(status='blocked') blocked, SUM(status='error') errors, "
|
|
"SUM(status='auth_failed') auth_failed, COALESCE(SUM(bytes_up),0) up, "
|
|
"COALESCE(SUM(bytes_down),0) down, COUNT(DISTINCT username) users FROM history WHERE ts>=?",
|
|
(since,))
|
|
bucket = 3600 if hours <= 72 else 86400
|
|
timeline = db.query("SELECT CAST(ts/? AS INTEGER)*? b, COUNT(*) n, SUM(status='blocked') blocked "
|
|
"FROM history WHERE ts>=? GROUP BY b ORDER BY b", (bucket, bucket, since))
|
|
top_hosts = db.query("SELECT host, COUNT(*) n, SUM(bytes_up+bytes_down) bytes FROM history "
|
|
"WHERE ts>=? AND host IS NOT NULL GROUP BY host ORDER BY n DESC LIMIT 10", (since,))
|
|
top_blocked = db.query("SELECT host, COUNT(*) n FROM history WHERE ts>=? AND status='blocked' "
|
|
"GROUP BY host ORDER BY n DESC LIMIT 10", (since,))
|
|
top_users = db.query("SELECT username, COUNT(*) n, SUM(bytes_up+bytes_down) bytes FROM history "
|
|
"WHERE ts>=? AND username IS NOT NULL GROUP BY username ORDER BY bytes DESC LIMIT 10",
|
|
(since,))
|
|
return {"hours": hours, "bucket": bucket, "totals": {k: v or 0 for k, v in tot.items()},
|
|
"timeline": timeline, "top_hosts": top_hosts, "top_blocked": top_blocked, "top_users": top_users,
|
|
"active_connections": len(registry),
|
|
"user_count": db.one("SELECT COUNT(*) n FROM users")["n"]}
|
|
|
|
|
|
@app.get("/api/connections", tags=["Verbindungen"])
|
|
def connections(_: dict = Depends(admin_user)):
|
|
return sorted((c.to_dict() for c in registry.values()), key=lambda c: c["started"], reverse=True)
|
|
|
|
|
|
@app.delete("/api/connections/{cid}", tags=["Verbindungen"])
|
|
def kill_connection(cid: int, _: dict = Depends(admin_user)):
|
|
c = registry.get(cid)
|
|
if not c:
|
|
raise HTTPException(404, "Verbindung nicht gefunden")
|
|
c.kill()
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Einstellungen
|
|
class SettingsIn(BaseModel):
|
|
default_policy: Literal["allow", "deny"] | None = None
|
|
log_enabled: bool | None = None
|
|
log_retention_days: int | None = Field(default=None, ge=0, le=3650)
|
|
anonymous_networks: str | None = None
|
|
block_loopback: bool | None = None
|
|
auth_realm: str | None = Field(default=None, max_length=64)
|
|
public_host: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
def _settings_out():
|
|
s = db.settings()
|
|
return {"default_policy": s["default_policy"], "log_enabled": s["log_enabled"] == "1",
|
|
"log_retention_days": int(s["log_retention_days"]), "anonymous_networks": s["anonymous_networks"],
|
|
"block_loopback": s["block_loopback"] == "1", "auth_realm": s["auth_realm"],
|
|
"public_host": s["public_host"],
|
|
"ports": {"http": config.HTTP_PORT, "socks": config.SOCKS_PORT, "web": config.WEB_PORT,
|
|
"socks_udp": f"{min(config.SOCKS_UDP_PORTS)}-{max(config.SOCKS_UDP_PORTS)}"
|
|
if config.SOCKS_UDP_PORTS else "", "forwards": config.FORWARD_PORTS}}
|
|
|
|
|
|
@app.get("/api/settings", tags=["Einstellungen"])
|
|
def get_settings(_: dict = Depends(admin_user)):
|
|
return _settings_out()
|
|
|
|
|
|
@app.put("/api/settings", tags=["Einstellungen"])
|
|
def put_settings(data: SettingsIn, _: dict = Depends(admin_user)):
|
|
for k, v in data.model_dump(exclude_none=True).items():
|
|
if isinstance(v, bool):
|
|
v = "1" if v else "0"
|
|
if k == "anonymous_networks":
|
|
for part in str(v).replace(",", " ").split():
|
|
if not parse_networks(part):
|
|
raise HTTPException(400, f"Ungültiges Netz: {part}")
|
|
db.set_setting(k, str(v))
|
|
policy.reload()
|
|
return _settings_out()
|
|
|
|
|
|
# --------------------------------------------------------------------------- API-Tokens
|
|
class TokenIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=64)
|
|
days: int | None = Field(default=None, ge=1, le=3650)
|
|
|
|
|
|
@app.get("/api/tokens", tags=["API-Tokens"])
|
|
def list_tokens(_: dict = Depends(admin_user)):
|
|
return db.query("SELECT t.id, t.name, t.created_at, t.expires_at, t.last_used, u.username FROM tokens t "
|
|
"JOIN users u ON u.id=t.user_id WHERE t.kind='api' ORDER BY t.created_at DESC")
|
|
|
|
|
|
@app.post("/api/tokens", tags=["API-Tokens"], status_code=201)
|
|
def create_token(data: TokenIn, user: dict = Depends(admin_user)):
|
|
token = new_token()
|
|
exp = time.time() + data.days * 86400 if data.days else None
|
|
db.execute("INSERT INTO tokens(token_hash,user_id,name,kind,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
|
(token_hash(token), user["id"], data.name, "api", time.time(), exp))
|
|
return {"token": token, "name": data.name, "expires_at": exp}
|
|
|
|
|
|
@app.delete("/api/tokens/{tid}", tags=["API-Tokens"])
|
|
def delete_token(tid: int, _: dict = Depends(admin_user)):
|
|
db.execute("DELETE FROM tokens WHERE id=? AND kind='api'", (tid,))
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- PAC & WebUI
|
|
@app.get("/proxy.pac", include_in_schema=False)
|
|
@app.get("/wpad.dat", include_in_schema=False)
|
|
def pac(request: Request):
|
|
host = policy.settings.get("public_host") or (request.url.hostname or "proxy")
|
|
js = f"""function FindProxyForURL(url, host) {{
|
|
if (isPlainHostName(host) || shExpMatch(host, "localhost") ||
|
|
isInNet(dnsResolve(host), "10.0.0.0", "255.0.0.0") ||
|
|
isInNet(dnsResolve(host), "172.16.0.0", "255.240.0.0") ||
|
|
isInNet(dnsResolve(host), "192.168.0.0", "255.255.0.0") ||
|
|
isInNet(dnsResolve(host), "127.0.0.0", "255.0.0.0"))
|
|
return "DIRECT";
|
|
return "PROXY {host}:{config.HTTP_PORT}; SOCKS5 {host}:{config.SOCKS_PORT}";
|
|
}}
|
|
"""
|
|
return PlainTextResponse(js, media_type="application/x-ns-proxy-autoconfig")
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
def index():
|
|
return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|