Files
2026-10-05 13:33:59 +02:00

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")