first commit

This commit is contained in:
duffyduck
2026-10-05 13:33:59 +02:00
commit e0b4eb3fe5
18 changed files with 3166 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data
WORKDIR /opt/proxy
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 3128 1080 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD python -c "import urllib.request,os; urllib.request.urlopen('http://127.0.0.1:%s/api/openapi.json' % os.environ.get('WEB_PORT','8080'), timeout=4)"
CMD ["python", "-m", "app.main"]
+101
View File
@@ -0,0 +1,101 @@
# Proxy Manager
Ein Proxy-Server im Docker-Container, ähnlich wie Squid, aber mit WebUI und REST-API.
- **HTTP/HTTPS-Proxy** (Port 3128) für Browser, Systemproxy, Outlook/Exchange (EWS, MAPI over HTTP, OWA, ActiveSync)
- **SOCKS5-Proxy** (Port 1080) mit TCP **und UDP**. Darüber läuft jedes TCP-Protokoll, z. B. SMTP (25/465/587), IMAP (143/993), POP3 (110/995), SSH, RDP oder FTP-Steuerverbindungen.
- **Portweiterleitungen TCP/UDP** für Programme ohne Proxy-Unterstützung, z. B. WireGuard, Outlook-Mailkonten oder Geräte
- Beide Proxy-Ports verstehen **HTTP und SOCKS5**, das Protokoll wird automatisch erkannt.
- **Benutzerverwaltung** mit Login am Proxy (Basic-Auth bzw. SOCKS5-Login)
- **Black-/Whitelist** für Domains, Wildcards, Regex, IPs/Netze und Ports, global oder pro Benutzer
- **Verlauf:** Wer hat wann was aufgerufen, mit Datenmenge, Dauer und Status, als CSV exportierbar
- **Live-Verbindungen** ansehen und trennen
- **Dashboard** mit Statistik
- **REST-API** mit API-Tokens und Swagger-Doku unter `/api/docs`
- **PAC-Datei** für automatische Browser-Konfiguration unter `/proxy.pac`
- Daten liegen in einer SQLite-Datei im **Bind Mount** `./data`, es gibt keine Docker-Volumes.
## Start
```bash
cp .env.example .env # ADMIN_PASSWORD setzen (sonst wird eins generiert, siehe Log)
docker-compose up -d --build
docker-compose logs proxy # zeigt ggf. das generierte Admin-Passwort
```
WebUI: `http://<host>:8080` (Host-Ports in `.env` änderbar: `HTTP_PORT_HOST`, `SOCKS_PORT_HOST`, `WEB_PORT_HOST`)
## Ports
| Port | Zweck |
|---|---|
| 3128/tcp | HTTP/HTTPS-Proxy (+ SOCKS5) |
| 1080/tcp | SOCKS5 (+ HTTP) |
| 8080/tcp | WebUI + API |
| 10800-10819/udp | SOCKS5 UDP ASSOCIATE (ein Port pro aktiver UDP-Sitzung) |
| 40000-40019/tcp+udp | Frei für Portweiterleitungen |
Die Bereiche sind in `docker-compose.yml` einstellbar (`SOCKS_UDP_PORTS`, `FORWARD_PORTS` plus das Port-Mapping).
Mit `network_mode: host` entfällt das Mapping. Dann ist jeder Port für Weiterleitungen nutzbar.
Für SOCKS5-UDP muss `SOCKS_UDP_ADVERTISE` in `.env` die IP des Docker-Hosts enthalten, wie sie die Clients erreichen.
Im Host-Netzwerk-Modus ist das nicht nötig.
## Clients einrichten
| Client | Einstellung |
|---|---|
| Browser | HTTP-Proxy `host:3128` oder PAC-URL `http://host:8080/proxy.pac` |
| Windows-Systemproxy / Outlook (Exchange) | HTTP-Proxy `host:3128` |
| Thunderbird (SMTP/IMAP/POP3) | SOCKS5 `host:1080`, „DNS über SOCKS“ aktivieren |
| Kommandozeile | `export https_proxy=http://user:pass@host:3128` |
| WireGuard | Weiterleitung UDP `40000 → vpn.example.com:51820`, im Client `Endpoint = host:40000` |
| Outlook IMAP/SMTP-Konto | Weiterleitungen, z. B. TCP `40001 → imap.example.com:993` und `40002 → smtp.example.com:587`. Achtung: Das TLS-Zertifikat lautet auf den echten Servernamen, ggf. per hosts-Eintrag auf den Proxy zeigen lassen. |
| Programme ohne Proxy-Support | Proxifier/proxychains mit SOCKS5 oder eine Weiterleitung |
## Regeln
Auswertung in dieser Reihenfolge:
1. Loopback-Schutz (127.0.0.0/8, ::1), abschaltbar
2. Sperr-Regeln des Benutzers
3. Erlaubt-Regeln des Benutzers
4. Globale Sperr-Regeln
5. Globale Erlaubt-Regeln
6. Standardrichtlinie des Benutzers bzw. die globale Standardrichtlinie
| Host-Muster | Bedeutung |
|---|---|
| leer / `*` | alle Ziele |
| `example.com` | Domain inkl. aller Subdomains |
| `*.example.com` | Platzhalter |
| `10.0.0.0/8`, `1.2.3.4` | IP/Netz, wird gegen die aufgelöste Ziel-IP geprüft |
| `re:^ads?\.` | regulärer Ausdruck |
Ports: leer = alle, sonst z. B. `25`, `8000-9000` oder `25,465,587`.
**Whitelist für einen Benutzer:** Standardrichtlinie des Benutzers auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.
**Blocklisten importieren:** „Liste importieren“ versteht eine Domain pro Zeile und das Hosts-Datei-Format (`0.0.0.0 ads.example.com`).
## Hinweise
- Bei HTTPS sieht der Proxy nur Host und Port, nicht die komplette URL, weil es kein SSL-Aufbrechen gibt. Bei unverschlüsseltem HTTP wird die volle URL protokolliert.
- Bei SOCKS5 mit lokaler DNS-Auflösung (`socks5://` statt `socks5h://`) kennt der Proxy nur die IP. Dann greifen nur IP-Regeln, Domain-Regeln nicht. In den Clients deshalb „DNS über Proxy“ aktivieren.
- Der Verlauf wird nach `log_retention_days` (Standard 90 Tage) automatisch bereinigt. Rechtliche Vorgaben (DSGVO, Betriebsrat) beachten.
- Backup: Ordner `./data` sichern.
## API
Interaktive Doku: `http://host:8080/api/docs`. Token in der WebUI unter „API“ erzeugen.
```bash
TOKEN=...
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-X POST http://host:8080/api/users -d '{"username":"max","password":"geheim123"}'
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-X POST http://host:8080/api/rules -d '{"action":"deny","host":"tiktok.com"}'
curl -H "Authorization: Bearer $TOKEN" "http://host:8080/api/history?username=max&limit=50"
```
Wichtige Endpunkte: `/api/users`, `/api/rules` (+ `/bulk`, `/test`), `/api/forwards`, `/api/history` (+ `/export.csv`),
`/api/stats`, `/api/connections`, `/api/settings`, `/api/tokens`.
View File
+597
View File
@@ -0,0 +1,597 @@
"""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")
+104
View File
@@ -0,0 +1,104 @@
"""Passwörter, Benutzer-Cache und Brute-Force-Schutz für die Proxy-Anmeldung."""
import asyncio
import hashlib
import hmac
import os
import secrets
import time
from .db import db
ITERATIONS = 200_000
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS)
return f"pbkdf2_sha256${ITERATIONS}${salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
algo, iters, salt, digest = stored.split("$")
if algo != "pbkdf2_sha256":
return False
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iters))
return hmac.compare_digest(dk.hex(), digest)
except (ValueError, TypeError):
return False
def new_token() -> str:
return secrets.token_urlsafe(32)
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class UserCache:
"""Hält Benutzer im Speicher und merkt sich erfolgreiche Anmeldungen,
damit nicht jede Proxy-Verbindung eine teure PBKDF2-Prüfung auslöst."""
VERIFIED_TTL = 600
FAIL_WINDOW = 300
FAIL_LIMIT = 10
def __init__(self):
self.users: dict[str, dict] = {}
self.verified: dict[tuple, float] = {}
self.failures: dict[str, list[float]] = {}
self.last_seen: dict[int, float] = {}
self.reload()
def reload(self) -> None:
rows = db.query("SELECT * FROM users")
self.users = {r["username"].lower(): r for r in rows}
self.verified.clear()
def get(self, username: str) -> dict | None:
return self.users.get(username.lower())
def is_locked(self, ip: str) -> bool:
now = time.time()
fails = [t for t in self.failures.get(ip, []) if now - t < self.FAIL_WINDOW]
self.failures[ip] = fails
return len(fails) >= self.FAIL_LIMIT
def _fail(self, ip: str) -> None:
self.failures.setdefault(ip, []).append(time.time())
async def authenticate(self, username: str, password: str, ip: str) -> dict | None:
if self.is_locked(ip):
return None
user = self.get(username)
if not user or not user["enabled"]:
self._fail(ip)
return None
key = (user["id"], hashlib.sha256((password + user["password_hash"]).encode()).digest())
exp = self.verified.get(key)
now = time.time()
if exp and exp > now:
self.touch(user["id"])
return user
ok = await asyncio.to_thread(verify_password, password, user["password_hash"])
if not ok:
self._fail(ip)
return None
if len(self.verified) > 5000:
self.verified.clear()
self.verified[key] = now + self.VERIFIED_TTL
self.touch(user["id"])
return user
def touch(self, user_id: int) -> None:
self.last_seen[user_id] = time.time()
def flush_last_seen(self) -> None:
if not self.last_seen:
return
items, self.last_seen = list(self.last_seen.items()), {}
db.executemany("UPDATE users SET last_seen=? WHERE id=?", [(t, uid) for uid, t in items])
users = UserCache()
+46
View File
@@ -0,0 +1,46 @@
"""Konfiguration aus Umgebungsvariablen."""
import os
def _int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except ValueError:
return default
def _range(value: str) -> list[int]:
ports: list[int] = []
for part in value.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, b = part.split("-", 1)
ports.extend(range(int(a), int(b) + 1))
else:
ports.append(int(part))
return ports
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DB_PATH = os.path.join(DATA_DIR, "proxy.db")
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
HTTP_PORT = _int("HTTP_PROXY_PORT", 3128)
SOCKS_PORT = _int("SOCKS_PORT", 1080)
WEB_PORT = _int("WEB_PORT", 8080)
# UDP-Ports für SOCKS5 UDP ASSOCIATE (müssen im Container veröffentlicht sein)
SOCKS_UDP_PORTS = _range(os.environ.get("SOCKS_UDP_PORTS", "10800-10819"))
# Adresse, die SOCKS-Clients für UDP mitgeteilt wird (Host-IP). Leer = lokale Adresse der Verbindung
SOCKS_UDP_ADVERTISE = os.environ.get("SOCKS_UDP_ADVERTISE", "").strip()
# Ports, die für Portweiterleitungen freigegeben sind (nur Hinweis in der UI)
FORWARD_PORTS = os.environ.get("FORWARD_PORTS", "40000-40019")
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
CONNECT_TIMEOUT = _int("CONNECT_TIMEOUT", 15)
SESSION_HOURS = _int("SESSION_HOURS", 12)
+197
View File
@@ -0,0 +1,197 @@
"""Gemeinsame Bausteine: Verlauf, aktive Verbindungen, Relay, Ausgangsverbindungen."""
import asyncio
import ipaddress
import itertools
import logging
import socket
import time
from dataclasses import dataclass, field
from . import config
from .db import db
from .policy import Decision, policy
log = logging.getLogger("proxy")
# --------------------------------------------------------------------------- Verlauf
class HistoryLogger:
COLUMNS = ("ts", "username", "client_ip", "proto", "method", "host", "port", "url",
"status", "detail", "bytes_up", "bytes_down", "duration")
def __init__(self):
self.buffer: list[tuple] = []
def add(self, **e) -> None:
if policy.settings.get("log_enabled", "1") != "1":
return
e.setdefault("ts", time.time())
self.buffer.append(tuple(e.get(c, 0 if c in ("bytes_up", "bytes_down", "duration") else None)
for c in self.COLUMNS))
if len(self.buffer) > 50_000: # Notbremse, falls die DB hängt
del self.buffer[:10_000]
def flush(self) -> None:
if not self.buffer:
return
rows, self.buffer = self.buffer, []
db.executemany(f"INSERT INTO history({','.join(self.COLUMNS)}) VALUES({','.join('?' * len(self.COLUMNS))})",
rows)
history = HistoryLogger()
# --------------------------------------------------------------------------- aktive Verbindungen
_ids = itertools.count(1)
@dataclass
class Conn:
username: str | None
client_ip: str
proto: str
host: str
port: int
method: str = ""
url: str = ""
id: int = field(default_factory=lambda: next(_ids))
started: float = field(default_factory=time.time)
bytes_up: int = 0
bytes_down: int = 0
tasks: list = field(default_factory=list)
closers: list = field(default_factory=list)
def to_dict(self) -> dict:
return {"id": self.id, "username": self.username, "client_ip": self.client_ip, "proto": self.proto,
"host": self.host, "port": self.port, "method": self.method, "url": self.url,
"started": self.started, "bytes_up": self.bytes_up, "bytes_down": self.bytes_down}
def kill(self) -> None:
for t in self.tasks:
t.cancel()
for c in self.closers:
try:
c()
except Exception:
pass
def finish(self, status: str = "ok", detail: str = "") -> None:
registry.pop(self.id, None)
history.add(ts=self.started, username=self.username, client_ip=self.client_ip, proto=self.proto,
method=self.method, host=self.host, port=self.port, url=self.url, status=status,
detail=detail, bytes_up=self.bytes_up, bytes_down=self.bytes_down,
duration=round(time.time() - self.started, 3))
registry: dict[int, Conn] = {}
def register(conn: Conn) -> Conn:
registry[conn.id] = conn
return conn
# --------------------------------------------------------------------------- Relay
async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, conn: Conn, up: bool) -> None:
try:
while True:
data = await reader.read(65536)
if not data:
break
writer.write(data)
if up:
conn.bytes_up += len(data)
else:
conn.bytes_down += len(data)
await writer.drain()
except (ConnectionError, OSError):
pass
finally:
try:
if writer.can_write_eof() and not writer.is_closing():
writer.write_eof()
except (OSError, RuntimeError):
pass
async def relay(conn: Conn, c_reader, c_writer, r_reader, r_writer) -> None:
"""Leitet Daten in beide Richtungen weiter, bis eine Seite schließt."""
up = asyncio.create_task(_pipe(c_reader, r_writer, conn, True))
down = asyncio.create_task(_pipe(r_reader, c_writer, conn, False))
conn.tasks += [up, down]
conn.closers += [c_writer.close, r_writer.close]
try:
done, pending = await asyncio.wait({up, down}, return_when=asyncio.FIRST_COMPLETED)
if pending:
# Halb geschlossen: der anderen Richtung noch etwas Zeit geben
await asyncio.wait(pending, timeout=60)
for t in pending:
t.cancel()
finally:
for w in (c_writer, r_writer):
try:
w.close()
except Exception:
pass
# --------------------------------------------------------------------------- Ausgang
class Blocked(Exception):
def __init__(self, decision: Decision):
super().__init__(decision.reason)
self.decision = decision
async def resolve(host: str, port: int, kind=socket.SOCK_STREAM) -> list[tuple]:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host.strip("[]"), port, type=kind)
seen, out = set(), []
for family, _, _, _, sockaddr in infos:
if sockaddr[0] not in seen:
seen.add(sockaddr[0])
out.append((family, sockaddr))
return out
async def check_and_resolve(user: dict | None, host: str, port: int, kind=socket.SOCK_STREAM) -> list[tuple]:
"""Prüft Regeln für Hostname + jede aufgelöste IP. Gibt erlaubte Adressen zurück."""
first = policy.decide(user, host, port)
if not first.allowed:
raise Blocked(first)
try:
addrs = await resolve(host, port, kind)
except socket.gaierror as e:
raise OSError(f"DNS-Auflösung fehlgeschlagen: {e.strerror or e}")
allowed, last = [], first
for family, sockaddr in addrs:
d = policy.decide(user, host, port, ipaddress.ip_address(sockaddr[0].split("%")[0]))
if d.allowed:
allowed.append((family, sockaddr))
else:
last = d
if not allowed:
raise Blocked(last)
return allowed
async def open_upstream(user: dict | None, host: str, port: int):
addrs = await check_and_resolve(user, host, port)
err: Exception | None = None
for family, sockaddr in addrs:
try:
return await asyncio.wait_for(asyncio.open_connection(sockaddr[0], port, family=family),
timeout=config.CONNECT_TIMEOUT)
except (OSError, asyncio.TimeoutError) as e:
err = e
if isinstance(err, asyncio.TimeoutError):
raise OSError("Zeitüberschreitung beim Verbindungsaufbau")
raise err or OSError("Verbindung fehlgeschlagen")
def peer_ip(writer: asyncio.StreamWriter) -> str:
peer = writer.get_extra_info("peername")
ip = peer[0] if peer else "?"
if ip.startswith("::ffff:"):
ip = ip[7:]
return ip
+141
View File
@@ -0,0 +1,141 @@
"""SQLite-Datenbank (thread-sicher über ein Lock)."""
import os
import sqlite3
import threading
import time
from . import config
SCHEMA = """
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
policy TEXT NOT NULL DEFAULT 'inherit',
comment TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
last_seen REAL
);
CREATE TABLE IF NOT EXISTS rules(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
action TEXT NOT NULL CHECK(action IN ('allow','deny')),
host TEXT NOT NULL DEFAULT '',
ports TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rules_user ON rules(user_id);
CREATE TABLE IF NOT EXISTS forwards(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
protocol TEXT NOT NULL CHECK(protocol IN ('tcp','udp')),
listen_port INTEGER NOT NULL,
target_host TEXT NOT NULL,
target_port INTEGER NOT NULL,
allowed_sources TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE(protocol, listen_port)
);
CREATE TABLE IF NOT EXISTS history(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
username TEXT,
client_ip TEXT,
proto TEXT,
method TEXT,
host TEXT,
port INTEGER,
url TEXT,
status TEXT,
detail TEXT,
bytes_up INTEGER NOT NULL DEFAULT 0,
bytes_down INTEGER NOT NULL DEFAULT 0,
duration REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts);
CREATE INDEX IF NOT EXISTS idx_history_user ON history(username, ts);
CREATE INDEX IF NOT EXISTS idx_history_host ON history(host);
CREATE TABLE IF NOT EXISTS tokens(
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL,
last_used REAL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
DEFAULT_SETTINGS = {
"default_policy": "allow", # allow = Blacklist-Modus, deny = Whitelist-Modus
"log_enabled": "1",
"log_retention_days": "90",
"anonymous_networks": "", # CIDRs, die ohne Login surfen dürfen
"block_loopback": "1", # Zugriff auf 127.0.0.0/8 / ::1 immer sperren
"auth_realm": "Proxy",
"public_host": "", # Hostname/IP für PAC-Datei
}
class Database:
def __init__(self, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
self.lock = threading.RLock()
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA synchronous=NORMAL")
self.conn.execute("PRAGMA foreign_keys=ON")
with self.lock:
self.conn.executescript(SCHEMA)
for k, v in DEFAULT_SETTINGS.items():
self.conn.execute("INSERT OR IGNORE INTO settings(key,value) VALUES(?,?)", (k, v))
def query(self, sql: str, params=()) -> list[dict]:
with self.lock:
return [dict(r) for r in self.conn.execute(sql, params).fetchall()]
def one(self, sql: str, params=()) -> dict | None:
with self.lock:
r = self.conn.execute(sql, params).fetchone()
return dict(r) if r else None
def execute(self, sql: str, params=()) -> sqlite3.Cursor:
with self.lock:
return self.conn.execute(sql, params)
def executemany(self, sql: str, rows) -> None:
with self.lock:
self.conn.execute("BEGIN")
try:
self.conn.executemany(sql, rows)
self.conn.execute("COMMIT")
except Exception:
self.conn.execute("ROLLBACK")
raise
# Einstellungen
def settings(self) -> dict:
return {r["key"]: r["value"] for r in self.query("SELECT key, value FROM settings")}
def set_setting(self, key: str, value: str) -> None:
self.execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value))
db = Database(config.DB_PATH)
def now() -> float:
return time.time()
+201
View File
@@ -0,0 +1,201 @@
"""Portweiterleitungen (TCP/UDP) für Clients ohne Proxy-Unterstützung,
z. B. WireGuard, Outlook-SMTP/IMAP, alte Geräte.
Jeder Listener leitet an genau ein festes Ziel weiter. Zugriff wird über
erlaubte Quell-Netze gesteuert; die Regeln (Black-/Whitelist) gelten zusätzlich."""
import asyncio
import socket
import time
from . import config
from .core import Blocked, Conn, check_and_resolve, history, log, open_upstream, peer_ip, register, relay
from .db import db
from .policy import ip_in, parse_networks
UDP_IDLE = 180
class UdpSession(asyncio.DatagramProtocol):
def __init__(self, fwd, client_addr, listener):
self.fwd = fwd
self.client_addr = client_addr
self.listener = listener
self.transport = None
self.last = time.time()
self.start = time.time()
self.up = 0
self.down = 0
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
self.last = time.time()
self.down += len(data)
self.listener.transport.sendto(data, self.client_addr)
def send(self, data: bytes):
self.last = time.time()
self.up += len(data)
self.transport.sendto(data)
def close(self):
if self.transport:
self.transport.close()
cip = self.client_addr[0]
history.add(ts=self.start, username=None, client_ip=cip[7:] if cip.startswith("::ffff:") else cip,
proto="fwd-udp", method="UDP", host=self.fwd["target_host"], port=self.fwd["target_port"],
url=f'{self.fwd["target_host"]}:{self.fwd["target_port"]}', status="ok",
detail=f'Weiterleitung "{self.fwd["name"]}" (:{self.fwd["listen_port"]})',
bytes_up=self.up, bytes_down=self.down, duration=round(time.time() - self.start, 3))
class UdpListener(asyncio.DatagramProtocol):
def __init__(self, fwd, nets):
self.fwd = fwd
self.nets = nets
self.transport = None
self.sessions: dict[tuple, UdpSession] = {}
self.pending: dict[tuple, list] = {}
self.denied_logged: set = set()
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
key = (addr[0], addr[1])
s = self.sessions.get(key)
if s:
s.send(data)
return
if key in self.pending:
if len(self.pending[key]) < 32:
self.pending[key].append(data)
return
ip = addr[0][7:] if addr[0].startswith("::ffff:") else addr[0]
if self.nets and not ip_in(ip, self.nets):
if ip not in self.denied_logged:
self.denied_logged.add(ip)
history.add(client_ip=ip, proto="fwd-udp", method="UDP", host=self.fwd["target_host"],
port=self.fwd["target_port"], status="blocked",
detail=f'Quelle nicht erlaubt für "{self.fwd["name"]}"')
return
self.pending[key] = [data]
asyncio.ensure_future(self._open(key, addr))
async def _open(self, key, addr):
loop = asyncio.get_running_loop()
try:
addrs = await check_and_resolve(None, self.fwd["target_host"], self.fwd["target_port"], socket.SOCK_DGRAM)
family, sockaddr = addrs[0]
_, sess = await loop.create_datagram_endpoint(lambda: UdpSession(self.fwd, addr, self),
remote_addr=(sockaddr[0], self.fwd["target_port"]),
family=family)
except (Blocked, OSError) as e:
self.pending.pop(key, None)
reason = e.decision.reason if isinstance(e, Blocked) else str(e)
history.add(client_ip=addr[0], proto="fwd-udp", method="UDP", host=self.fwd["target_host"],
port=self.fwd["target_port"], status="blocked" if isinstance(e, Blocked) else "error",
detail=reason)
return
self.sessions[key] = sess
for d in self.pending.pop(key, []):
sess.send(d)
def cleanup(self, force=False):
now = time.time()
for key, s in list(self.sessions.items()):
if force or now - s.last > UDP_IDLE:
s.close()
del self.sessions[key]
class ForwardManager:
def __init__(self):
self.running: dict[int, dict] = {} # id -> {"sig", "server"/"transport", "proto"}
self.status: dict[int, str] = {}
@staticmethod
def _sig(f):
return (f["protocol"], f["listen_port"], f["target_host"], f["target_port"], f["allowed_sources"],
f["enabled"], f["name"])
async def reload(self):
rows = {f["id"]: f for f in db.query("SELECT * FROM forwards")}
for fid in list(self.running):
if fid not in rows or self._sig(rows[fid]) != self.running[fid]["sig"]:
await self._stop(fid)
for fid, f in rows.items():
if fid in self.running:
continue
if not f["enabled"]:
self.status[fid] = "deaktiviert"
continue
try:
await self._start(f)
self.status[fid] = "aktiv"
except OSError as e:
self.status[fid] = f"Fehler: {e.strerror or e}"
log.warning("Weiterleitung %s konnte nicht starten: %s", f["name"], e)
for fid in list(self.status):
if fid not in rows:
del self.status[fid]
async def _start(self, f):
nets = parse_networks(f["allowed_sources"])
loop = asyncio.get_running_loop()
entry = {"sig": self._sig(f)}
if f["protocol"] == "tcp":
async def handler(reader, writer, f=f, nets=nets):
await self._tcp_client(f, nets, reader, writer)
entry["server"] = await asyncio.start_server(handler, host=config.LISTEN_HOST, port=f["listen_port"])
else:
transport, proto = await loop.create_datagram_endpoint(lambda: UdpListener(f, nets),
local_addr=(config.LISTEN_HOST, f["listen_port"]))
entry["transport"], entry["proto"] = transport, proto
self.running[f["id"]] = entry
async def _stop(self, fid):
entry = self.running.pop(fid, None)
if not entry:
return
if "server" in entry:
entry["server"].close()
if "transport" in entry:
entry["proto"].cleanup(force=True)
entry["transport"].close()
async def _tcp_client(self, f, nets, reader, writer):
ip = peer_ip(writer)
conn = Conn(None, ip, "fwd-tcp", f["target_host"], f["target_port"], "FORWARD",
f'{f["target_host"]}:{f["target_port"]}')
label = f'Weiterleitung "{f["name"]}" (:{f["listen_port"]})'
try:
if nets and not ip_in(ip, nets):
conn.finish("blocked", f"Quelle nicht erlaubt – {label}")
return
try:
r_reader, r_writer = await open_upstream(None, f["target_host"], f["target_port"])
except Blocked as b:
conn.finish("blocked", b.decision.reason)
return
except OSError as e:
conn.finish("error", str(e))
return
register(conn)
try:
await relay(conn, reader, writer, r_reader, r_writer)
finally:
conn.finish("ok", label)
finally:
writer.close()
async def housekeeping(self):
while True:
await asyncio.sleep(30)
for entry in self.running.values():
if "proto" in entry:
entry["proto"].cleanup()
forwards = ForwardManager()
+113
View File
@@ -0,0 +1,113 @@
"""Einstiegspunkt: startet HTTP-/SOCKS-Proxy, Weiterleitungen und WebUI in einer Event-Loop."""
import asyncio
import logging
import secrets
import socket
import time
import uvicorn
from . import config
from .api import app
from .auth import hash_password, users
from .core import history, log, peer_ip
from .db import db
from .forwarder import forwards
from .policy import policy
from .proxy_http import handle_http
from .proxy_socks import handle_socks5
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def bootstrap() -> None:
if db.one("SELECT COUNT(*) n FROM users")["n"] == 0:
password = config.ADMIN_PASSWORD or secrets.token_urlsafe(12)
db.execute("INSERT INTO users(username,password_hash,is_admin,enabled,policy,comment,created_at) "
"VALUES(?,?,1,1,'inherit','Initialer Administrator',?)",
(config.ADMIN_USER, hash_password(password), time.time()))
log.warning("=" * 60)
log.warning("Admin-Benutzer angelegt: %s", config.ADMIN_USER)
if not config.ADMIN_PASSWORD:
log.warning("Generiertes Passwort: %s", password)
log.warning("=" * 60)
users.reload()
policy.reload()
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
"""Erkennt anhand des ersten Bytes, ob SOCKS5 oder HTTP gesprochen wird –
beide Proxy-Ports verstehen daher beide Protokolle."""
client_ip = peer_ip(writer)
sock = writer.get_extra_info("socket")
if sock is not None:
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
pass
try:
first = await asyncio.wait_for(reader.readexactly(1), timeout=60)
if first == b"\x05":
await handle_socks5(reader, writer, client_ip)
elif first == b"\x04":
pass # SOCKS4 wird nicht unterstützt
else:
await handle_http(reader, writer, first, client_ip)
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError, OSError):
pass
except Exception: # noqa: BLE001
log.exception("Fehler bei Verbindung von %s", client_ip)
finally:
try:
writer.close()
except Exception: # noqa: BLE001
pass
async def maintenance() -> None:
last_cleanup = 0.0
while True:
await asyncio.sleep(1)
try:
await asyncio.to_thread(history.flush)
if time.time() - last_cleanup > 3600:
last_cleanup = time.time()
users.flush_last_seen()
days = int(policy.settings.get("log_retention_days", "90") or 0)
if days > 0:
n = db.execute("DELETE FROM history WHERE ts<?", (time.time() - days * 86400,)).rowcount
if n:
log.info("Verlauf bereinigt: %d Einträge älter als %d Tage gelöscht", n, days)
db.execute("DELETE FROM tokens WHERE expires_at IS NOT NULL AND expires_at<?", (time.time(),))
except Exception: # noqa: BLE001
log.exception("Wartungsfehler")
async def main() -> None:
bootstrap()
servers = []
ports = dict.fromkeys([config.HTTP_PORT, config.SOCKS_PORT])
for port in ports:
srv = await asyncio.start_server(handle_client, host=config.LISTEN_HOST, port=port, limit=256 * 1024,
backlog=1024, reuse_address=True)
servers.append(srv)
log.info("Proxy lauscht auf %s:%d (HTTP + SOCKS5)", config.LISTEN_HOST, port)
await forwards.reload()
web = uvicorn.Server(uvicorn.Config(app, host=config.LISTEN_HOST, port=config.WEB_PORT, log_level="warning",
proxy_headers=True, forwarded_allow_ips="*"))
log.info("WebUI/API auf http://%s:%d (API-Doku: /api/docs)", config.LISTEN_HOST, config.WEB_PORT)
background = [asyncio.create_task(c) for c in (maintenance(), forwards.housekeeping(),
*(s.serve_forever() for s in servers))]
try:
await web.serve() # beendet sich bei SIGTERM/SIGINT
finally:
for t in background:
t.cancel()
history.flush()
users.flush_last_seen()
if __name__ == "__main__":
asyncio.run(main())
+187
View File
@@ -0,0 +1,187 @@
"""Regel-Engine für White-/Blacklists auf Host- und Port-Ebene.
Reihenfolge der Auswertung:
1. Loopback-Schutz (Einstellung block_loopback)
2. Sperr-Regeln des Benutzers -> blockiert
3. Erlaubt-Regeln des Benutzers -> erlaubt
4. Globale Sperr-Regeln -> blockiert
5. Globale Erlaubt-Regeln -> erlaubt
6. Standardrichtlinie (des Benutzers, sonst global)
Host-Muster:
"" oder "*" -> alle Ziele
example.com -> example.com und alle Subdomains
*.example.com -> Platzhalter (fnmatch), nur Subdomains
10.0.0.0/8, 1.2.3.4 -> IP/Netz (gegen aufgelöste Ziel-IP geprüft)
re:^ads?\\. -> regulärer Ausdruck
Port-Muster: "" = alle, "25", "8000-9000", "25,465,587"
"""
import fnmatch
import ipaddress
import re
from dataclasses import dataclass
from .db import db
LOOPBACK = [ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"),
ipaddress.ip_network("0.0.0.0/32")]
def parse_ports(spec: str) -> list[tuple[int, int]] | None:
"""Gibt Liste von (von, bis) zurück; None = alle Ports. Wirft ValueError bei Fehlern."""
spec = (spec or "").strip()
if not spec or spec == "*":
return None
ranges = []
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, b = (int(x) for x in part.split("-", 1))
else:
a = b = int(part)
if not (0 < a <= 65535 and 0 < b <= 65535 and a <= b):
raise ValueError(f"Ungültiger Port-Bereich: {part}")
ranges.append((a, b))
return ranges
class HostMatcher:
def __init__(self, pattern: str):
self.raw = (pattern or "").strip()
p = self.raw.lower()
self.any = p in ("", "*")
self.net = None
self.regex = None
self.glob = None
self.domain = None
if self.any:
return
if p.startswith("re:"):
self.regex = re.compile(self.raw[3:], re.IGNORECASE)
return
try:
self.net = ipaddress.ip_network(p, strict=False)
return
except ValueError:
pass
if "*" in p or "?" in p:
self.glob = p
else:
self.domain = p.lstrip(".").rstrip(".")
def match(self, host: str, ip) -> bool:
if self.any:
return True
if self.net is not None:
return ip is not None and ip.version == self.net.version and ip in self.net
h = (host or "").lower().rstrip(".")
if self.regex is not None:
return bool(self.regex.search(h))
if self.glob is not None:
return fnmatch.fnmatchcase(h, self.glob)
return h == self.domain or h.endswith("." + self.domain)
def validate_rule(host: str, ports: str) -> None:
HostMatcher(host) # wirft re.error bei kaputtem Regex
parse_ports(ports)
@dataclass
class Rule:
id: int
user_id: int | None
action: str
host: HostMatcher
ports: list | None
text: str
def match(self, host: str, port: int, ip) -> bool:
if self.ports is not None and not any(a <= port <= b for a, b in self.ports):
return False
return self.host.match(host, ip)
@dataclass
class Decision:
allowed: bool
reason: str
rule_id: int | None = None
class Policy:
def __init__(self):
self.global_rules: list[Rule] = []
self.user_rules: dict[int, list[Rule]] = {}
self.settings: dict = {}
self.anon_nets: list = []
self.reload()
def reload(self) -> None:
self.settings = db.settings()
g, u = [], {}
for r in db.query("SELECT * FROM rules WHERE enabled=1 ORDER BY id"):
try:
rule = Rule(r["id"], r["user_id"], r["action"], HostMatcher(r["host"]),
parse_ports(r["ports"]), f'{r["action"]} {r["host"] or "*"}:{r["ports"] or "*"}')
except (ValueError, re.error):
continue
(u.setdefault(r["user_id"], []) if r["user_id"] else g).append(rule)
self.global_rules, self.user_rules = g, u
self.anon_nets = parse_networks(self.settings.get("anonymous_networks", ""))
def is_anonymous_allowed(self, client_ip: str) -> bool:
return ip_in(client_ip, self.anon_nets)
def decide(self, user: dict | None, host: str, port: int, ip=None) -> Decision:
if ip is None:
try:
ip = ipaddress.ip_address(host.strip("[]"))
except ValueError:
ip = None
if ip is not None and self.settings.get("block_loopback", "1") == "1":
if getattr(ip, "ipv4_mapped", None):
ip = ip.ipv4_mapped
if any(ip.version == n.version and ip in n for n in LOOPBACK):
return Decision(False, "Loopback-Ziel gesperrt")
layers = []
if user:
layers.append(("Benutzer", self.user_rules.get(user["id"], [])))
layers.append(("Global", self.global_rules))
for label, rules in layers:
for action in ("deny", "allow"):
for r in rules:
if r.action == action and r.match(host, port, ip):
return Decision(action == "allow", f"{label}-Regel #{r.id}: {r.text}", r.id)
policy = self.settings.get("default_policy", "allow")
source = "Standardrichtlinie"
if user and user.get("policy") in ("allow", "deny"):
policy = user["policy"]
source = "Benutzer-Standardrichtlinie"
return Decision(policy == "allow", f"{source}: {'erlauben' if policy == 'allow' else 'sperren'}")
def parse_networks(text: str) -> list:
nets = []
for part in re.split(r"[\s,;]+", text or ""):
if part:
try:
nets.append(ipaddress.ip_network(part, strict=False))
except ValueError:
pass
return nets
def ip_in(ip: str, nets: list) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
if getattr(addr, "ipv4_mapped", None):
addr = addr.ipv4_mapped
return any(addr.version == n.version and addr in n for n in nets)
policy = Policy()
+333
View File
@@ -0,0 +1,333 @@
"""HTTP-Proxy (HTTP/1.1, CONNECT-Tunnel für HTTPS und beliebige TCP-Protokolle)."""
import asyncio
import base64
import html
import time
from urllib.parse import urlsplit
from .auth import users
from .core import Blocked, Conn, history, open_upstream, register, relay
MAX_HEAD = 64 * 1024
HOP_BY_HOP = {"connection", "keep-alive", "proxy-authorization", "proxy-connection", "proxy-authenticate",
"te", "trailer", "upgrade", "expect"}
class BadRequest(Exception):
pass
def parse_head(head: bytes) -> tuple[str, list[tuple[str, str]]]:
lines = head.decode("latin-1").split("\r\n")
first = lines[0]
headers = []
for line in lines[1:]:
if not line:
continue
if ":" not in line:
raise BadRequest("Ungültige Kopfzeile")
k, v = line.split(":", 1)
headers.append((k.strip(), v.strip()))
return first, headers
def get_header(headers, name: str) -> str | None:
name = name.lower()
for k, v in headers:
if k.lower() == name:
return v
return None
def split_hostport(value: str, default_port: int) -> tuple[str, int]:
value = value.strip()
if value.startswith("["):
end = value.index("]")
host, rest = value[1:end], value[end + 1:]
port = int(rest[1:]) if rest.startswith(":") else default_port
return host, port
if value.count(":") == 1:
host, port = value.rsplit(":", 1)
return host, int(port)
return value, default_port
def response(code: int, reason: str, body: str = "", extra: list[str] | None = None, close: bool = False) -> bytes:
data = body.encode()
lines = [f"HTTP/1.1 {code} {reason}", "Content-Type: text/html; charset=utf-8",
f"Content-Length: {len(data)}", "Connection: close" if close else "Connection: keep-alive",
*(extra or [])]
return ("\r\n".join(lines) + "\r\n\r\n").encode() + data
def error_page(title: str, text: str) -> str:
return (f"<!doctype html><html lang=de><meta charset=utf-8><title>{html.escape(title)}</title>"
"<body style='font-family:system-ui,sans-serif;background:#f4f5f7;color:#1d2330;display:flex;"
"align-items:center;justify-content:center;min-height:90vh'>"
"<div style='background:#fff;border-radius:12px;padding:32px 40px;max-width:560px;"
"box-shadow:0 4px 24px rgba(0,0,0,.08)'>"
f"<h1 style='margin:0 0 12px;font-size:22px'>{html.escape(title)}</h1>"
f"<p style='margin:0;color:#4a5263;line-height:1.5'>{html.escape(text)}</p></div></body></html>")
# --------------------------------------------------------------------------- Body-Übertragung
async def copy_exact(reader, writer, n: int, counter) -> None:
while n > 0:
data = await reader.read(min(n, 65536))
if not data:
raise ConnectionError("Verbindung vorzeitig beendet")
n -= len(data)
if writer:
writer.write(data)
await writer.drain()
counter(len(data))
async def copy_chunked(reader, writer, counter) -> None:
while True:
line = await reader.readuntil(b"\r\n")
size = int(line.split(b";", 1)[0].strip() or b"0", 16)
if writer:
writer.write(line)
counter(len(line))
if size == 0:
# Trailer bis zur Leerzeile
while True:
t = await reader.readuntil(b"\r\n")
if writer:
writer.write(t)
counter(len(t))
if t == b"\r\n":
break
if writer:
await writer.drain()
return
await copy_exact(reader, writer, size + 2, counter)
async def copy_until_eof(reader, writer, counter) -> None:
while True:
data = await reader.read(65536)
if not data:
return
writer.write(data)
counter(len(data))
await writer.drain()
async def transfer_body(headers, reader, writer, counter) -> None:
te = (get_header(headers, "transfer-encoding") or "").lower()
if "chunked" in te:
await copy_chunked(reader, writer, counter)
return
cl = get_header(headers, "content-length")
if cl:
await copy_exact(reader, writer, int(cl), counter)
def body_is_discardable(headers) -> bool:
if "chunked" in (get_header(headers, "transfer-encoding") or "").lower():
return False
cl = get_header(headers, "content-length")
return not cl or int(cl) <= 1_000_000
# --------------------------------------------------------------------------- Handler
async def authenticate(headers, client_ip: str):
"""(user, anonym erlaubt?, Zugangsdaten angegeben?)"""
from .policy import policy
value = get_header(headers, "proxy-authorization")
if value and value.lower().startswith("basic "):
try:
raw = base64.b64decode(value[6:].strip()).decode("utf-8", "replace")
username, _, password = raw.partition(":")
except (ValueError, UnicodeError):
return None, False, True
user = await users.authenticate(username, password, client_ip)
return user, False, True
if policy.is_anonymous_allowed(client_ip):
return None, True, False
return None, False, False
async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, prefix: bytes, client_ip: str):
from .policy import policy
realm = policy.settings.get("auth_realm", "Proxy")
while True:
try:
head = prefix + await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=300)
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
return
except asyncio.LimitOverrunError:
writer.write(response(431, "Request Header Fields Too Large", close=True))
return
prefix = b""
try:
first, headers = parse_head(head[:-4])
method, target, version = first.split(" ", 2)
except (BadRequest, ValueError):
writer.write(response(400, "Bad Request", error_page("Ungültige Anfrage", "Anfrage nicht lesbar."),
close=True))
return
client_close = (get_header(headers, "proxy-connection") or get_header(headers, "connection") or "").lower() \
== "close" or version == "HTTP/1.0"
user, anonymous, gave_credentials = await authenticate(headers, client_ip)
if not user and not anonymous:
if gave_credentials:
history.add(username=None, client_ip=client_ip, proto="http", method=method, url=target[:2000],
status="auth_failed", detail="Anmeldung fehlgeschlagen")
if not body_is_discardable(headers):
client_close = True
else:
await transfer_body(headers, reader, None, lambda n: None)
writer.write(response(407, "Proxy Authentication Required",
error_page("Anmeldung erforderlich", "Bitte am Proxy anmelden."),
[f'Proxy-Authenticate: Basic realm="{realm}"'], close=client_close))
await writer.drain()
if client_close:
return
continue
username = user["username"] if user else "anonym"
if method.upper() == "CONNECT":
keep = await handle_connect(reader, writer, user, username, client_ip, target)
else:
keep = await handle_plain(reader, writer, user, username, client_ip, method, target, version, headers)
if not keep or client_close:
return
async def handle_connect(reader, writer, user, username, client_ip, target) -> bool:
try:
host, port = split_hostport(target, 443)
except (ValueError, IndexError):
writer.write(response(400, "Bad Request", close=True))
return False
conn = Conn(username, client_ip, "https" if port == 443 else "connect", host, port, "CONNECT", f"{host}:{port}")
try:
r_reader, r_writer = await open_upstream(user, host, port)
except Blocked as b:
conn.finish("blocked", b.decision.reason)
writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt",
f"{host}:{port} ist durch die Proxy-Richtlinie gesperrt.")))
await writer.drain()
return True
except OSError as e:
conn.finish("error", str(e))
writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"),
close=True))
await writer.drain()
return False
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
register(conn)
try:
await writer.drain()
await relay(conn, reader, writer, r_reader, r_writer)
finally:
conn.finish("ok")
return False
async def handle_plain(reader, writer, user, username, client_ip, method, target, version, headers) -> bool:
url = urlsplit(target)
if not url.scheme:
body = error_page("Proxy-Server", "Dies ist ein Proxy-Server. Bitte im Browser als HTTP-Proxy eintragen.")
writer.write(response(400, "Bad Request", body, close=True))
return False
if url.scheme.lower() != "http":
writer.write(response(501, "Not Implemented",
error_page("Nicht unterstützt", f"Protokoll {url.scheme} wird nicht unterstützt."),
close=True))
return False
host = url.hostname or ""
port = url.port or 80
conn = Conn(username, client_ip, "http", host, port, method, target[:2000])
try:
r_reader, r_writer = await open_upstream(user, host, port)
except Blocked as b:
conn.finish("blocked", b.decision.reason)
keep = body_is_discardable(headers)
if keep:
await transfer_body(headers, reader, None, lambda n: None)
writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt",
f"{host} ist durch die Proxy-Richtlinie gesperrt."),
close=not keep))
await writer.drain()
return keep
except OSError as e:
conn.finish("error", str(e))
writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"),
close=True))
await writer.drain()
return False
register(conn)
status = "ok"
detail = ""
def up(n):
conn.bytes_up += n
def down(n):
conn.bytes_down += n
conn.closers += [writer.close, r_writer.close]
try:
# Hop-by-Hop-Header entfernen (inkl. der in "Connection" genannten)
conn_tokens = {t.strip().lower() for t in (get_header(headers, "connection") or "").split(",")}
out_headers = [(k, v) for k, v in headers if k.lower() not in HOP_BY_HOP and k.lower() not in conn_tokens]
if not get_header(out_headers, "host"):
out_headers.insert(0, ("Host", url.netloc.rsplit("@", 1)[-1]))
path = url.path or "/"
if url.query:
path += "?" + url.query
head = f"{method} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in out_headers) + \
"Connection: close\r\n\r\n"
r_writer.write(head.encode("latin-1"))
if (get_header(headers, "expect") or "").lower() == "100-continue":
writer.write(b"HTTP/1.1 100 Continue\r\n\r\n")
await transfer_body(headers, reader, r_writer, up)
await r_writer.drain()
# Antwort lesen (1xx durchreichen)
while True:
rhead = await asyncio.wait_for(r_reader.readuntil(b"\r\n\r\n"), timeout=120)
rfirst, rheaders = parse_head(rhead[:-4])
parts = rfirst.split(" ", 2)
code = int(parts[1])
if 100 <= code < 200 and code != 101:
writer.write(rhead)
continue
break
detail = str(code)
kept = [(k, v) for k, v in rheaders if k.lower() not in ("connection", "keep-alive", "proxy-connection")]
writer.write((rfirst + "\r\n" + "".join(f"{k}: {v}\r\n" for k, v in kept) + "Connection: close\r\n\r\n")
.encode("latin-1"))
down(len(rhead))
no_body = method.upper() == "HEAD" or code in (204, 304)
if not no_body:
te = (get_header(rheaders, "transfer-encoding") or "").lower()
if "chunked" in te or get_header(rheaders, "content-length"):
await transfer_body(rheaders, r_reader, writer, down)
else:
await copy_until_eof(r_reader, writer, down)
await writer.drain()
except asyncio.CancelledError:
status, detail = "error", "Verbindung beendet"
raise
except (OSError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
ValueError, BadRequest) as e:
status, detail = "error", (str(e) or e.__class__.__name__)[:300]
if conn.bytes_down == 0:
try:
writer.write(response(502, "Bad Gateway", error_page("Fehler", detail), close=True))
except Exception:
pass
finally:
try:
r_writer.close()
except Exception:
pass
conn.finish(status, detail)
return False
+272
View File
@@ -0,0 +1,272 @@
"""SOCKS5-Proxy (RFC 1928/1929): CONNECT (TCP) und UDP ASSOCIATE."""
import asyncio
import ipaddress
import socket
import struct
import time
from . import config
from .auth import users
from .core import Blocked, Conn, check_and_resolve, history, log, open_upstream, register, relay
from .policy import policy
REP_OK, REP_FAIL, REP_NOT_ALLOWED, REP_NET_UNREACH, REP_HOST_UNREACH, REP_REFUSED, REP_TTL, REP_CMD, REP_ATYP = range(9)
_udp_in_use: set[int] = set()
def _reply(rep: int, bind_ip: str = "0.0.0.0", bind_port: int = 0) -> bytes:
ip = ipaddress.ip_address(bind_ip)
atyp = 1 if ip.version == 4 else 4
return bytes([5, rep, 0, atyp]) + ip.packed + struct.pack("!H", bind_port)
def _decode_host(raw: bytes) -> str:
try:
return raw.decode("idna")
except UnicodeError:
return raw.decode("ascii", "replace")
async def _read_addr(reader, atyp: int) -> str:
if atyp == 1:
return socket.inet_ntop(socket.AF_INET, await reader.readexactly(4))
if atyp == 4:
return socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
if atyp == 3:
n = (await reader.readexactly(1))[0]
return _decode_host(await reader.readexactly(n))
raise ValueError("Adresstyp")
def _rep_for(exc: Exception) -> int:
if isinstance(exc, ConnectionRefusedError):
return REP_REFUSED
if "DNS" in str(exc):
return REP_HOST_UNREACH
if "Zeitüberschreitung" in str(exc):
return REP_TTL
return REP_HOST_UNREACH
async def handle_socks5(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, client_ip: str) -> None:
# Begrüßung (Versionsbyte bereits gelesen)
n = (await reader.readexactly(1))[0]
methods = await reader.readexactly(n)
user = None
if 2 in methods:
writer.write(b"\x05\x02")
await writer.drain()
ver = (await reader.readexactly(1))[0]
ulen = (await reader.readexactly(1))[0]
username = (await reader.readexactly(ulen)).decode("utf-8", "replace")
plen = (await reader.readexactly(1))[0]
password = (await reader.readexactly(plen)).decode("utf-8", "replace")
user = await users.authenticate(username, password, client_ip) if ver == 1 else None
if not user:
history.add(username=username[:100], client_ip=client_ip, proto="socks", status="auth_failed",
detail="Anmeldung fehlgeschlagen")
writer.write(b"\x01\x01")
await writer.drain()
return
writer.write(b"\x01\x00")
elif 0 in methods and policy.is_anonymous_allowed(client_ip):
writer.write(b"\x05\x00")
else:
writer.write(b"\x05\xff")
await writer.drain()
return
await writer.drain()
username = user["username"] if user else "anonym"
ver, cmd, _, atyp = await reader.readexactly(4)
try:
host = await _read_addr(reader, atyp)
except ValueError:
writer.write(_reply(REP_ATYP))
return
port = struct.unpack("!H", await reader.readexactly(2))[0]
if cmd == 1:
await _connect(reader, writer, user, username, client_ip, host, port)
elif cmd == 3:
await _udp_associate(reader, writer, user, username, client_ip)
else:
writer.write(_reply(REP_CMD))
await writer.drain()
async def _connect(reader, writer, user, username, client_ip, host, port) -> None:
conn = Conn(username, client_ip, "socks", host, port, "CONNECT", f"{host}:{port}")
try:
r_reader, r_writer = await open_upstream(user, host, port)
except Blocked as b:
conn.finish("blocked", b.decision.reason)
writer.write(_reply(REP_NOT_ALLOWED))
await writer.drain()
return
except OSError as e:
conn.finish("error", str(e))
writer.write(_reply(_rep_for(e)))
await writer.drain()
return
sock = r_writer.get_extra_info("sockname")
try:
writer.write(_reply(REP_OK, sock[0].split("%")[0], sock[1]))
except ValueError:
writer.write(_reply(REP_OK))
register(conn)
try:
await writer.drain()
await relay(conn, reader, writer, r_reader, r_writer)
finally:
conn.finish("ok")
# --------------------------------------------------------------------------- UDP
class UdpRelay(asyncio.DatagramProtocol):
def __init__(self, user, username, client_ip):
self.user = user
self.username = username
self.client_ip = client_ip
self.client_addr = None
self.transport = None
self.flows: dict[tuple, dict] = {} # (host, port) -> Statistik
self.addr_map: dict[tuple, tuple] = {} # (ip, port) -> (host, port) für Antworten
self.decisions: dict[tuple, object] = {}
self.pending: set = set()
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data: bytes, addr):
ip = addr[0][7:] if addr[0].startswith("::ffff:") else addr[0]
if ip == self.client_ip and (self.client_addr is None or addr == self.client_addr) \
and (addr[0], addr[1]) not in self.addr_map:
self.client_addr = addr
t = asyncio.ensure_future(self._from_client(data))
self.pending.add(t)
t.add_done_callback(self.pending.discard)
else:
self._from_remote(data, addr)
async def _from_client(self, data: bytes):
try:
if len(data) < 4 or data[2] != 0: # Fragmentierung nicht unterstützt
return
atyp = data[3]
pos = 4
if atyp == 1:
host = socket.inet_ntop(socket.AF_INET, data[pos:pos + 4]); pos += 4
elif atyp == 4:
host = socket.inet_ntop(socket.AF_INET6, data[pos:pos + 16]); pos += 16
elif atyp == 3:
n = data[pos]; host = _decode_host(data[pos + 1:pos + 1 + n]); pos += 1 + n
else:
return
port = struct.unpack("!H", data[pos:pos + 2])[0]
payload = data[pos + 2:]
key = (host, port)
flow = self.flows.get(key)
if flow is None:
flow = self.flows[key] = {"start": time.time(), "up": 0, "down": 0, "status": "ok", "detail": "",
"target": None}
try:
addrs = await check_and_resolve(self.user, host, port, socket.SOCK_DGRAM)
family = self.transport.get_extra_info("socket").family
pick = next((a for f, a in addrs if f == family or family == socket.AF_INET6), None)
if pick is None:
raise OSError("Keine passende Adressfamilie")
target = (pick[0], port)
if family == socket.AF_INET6 and ":" not in pick[0]:
target = ("::ffff:" + pick[0], port)
flow["target"] = target
self.addr_map[target] = key
except Blocked as b:
flow["status"], flow["detail"] = "blocked", b.decision.reason
except OSError as e:
flow["status"], flow["detail"] = "error", str(e)
if flow["target"] is None:
return
self.transport.sendto(payload, flow["target"])
flow["up"] += len(payload)
except Exception as e: # noqa: BLE001
log.debug("UDP-Relay: %s", e)
def _from_remote(self, data: bytes, addr):
key = self.addr_map.get((addr[0], addr[1]))
if key is None or self.client_addr is None:
return
self.flows[key]["down"] += len(data)
host, port = addr[0], addr[1]
if host.startswith("::ffff:"):
host = host[7:]
ip = ipaddress.ip_address(host)
header = b"\x00\x00\x00" + (b"\x01" if ip.version == 4 else b"\x04") + ip.packed + struct.pack("!H", port)
self.transport.sendto(header + data, self.client_addr)
def finish(self):
for (host, port), f in self.flows.items():
history.add(ts=f["start"], username=self.username, client_ip=self.client_ip, proto="socks-udp",
method="UDP", host=host, port=port, url=f"{host}:{port}", status=f["status"],
detail=f["detail"], bytes_up=f["up"], bytes_down=f["down"],
duration=round(time.time() - f["start"], 3))
async def _udp_associate(reader, writer, user, username, client_ip) -> None:
loop = asyncio.get_running_loop()
transport = proto = None
bound_port = 0
for p in config.SOCKS_UDP_PORTS:
if p in _udp_in_use:
continue
try:
transport, proto = await loop.create_datagram_endpoint(
lambda: UdpRelay(user, username, client_ip), local_addr=("::", p), family=socket.AF_INET6)
except OSError:
try:
transport, proto = await loop.create_datagram_endpoint(
lambda: UdpRelay(user, username, client_ip), local_addr=("0.0.0.0", p))
except OSError:
continue
bound_port = p
_udp_in_use.add(p)
break
if transport is None:
writer.write(_reply(REP_FAIL))
await writer.drain()
return
advertise = config.SOCKS_UDP_ADVERTISE
if not advertise:
local = writer.get_extra_info("sockname")[0]
advertise = local[7:] if local.startswith("::ffff:") else local.split("%")[0]
try:
ipaddress.ip_address(advertise)
except ValueError:
try:
advertise = socket.gethostbyname(advertise)
except OSError:
advertise = "0.0.0.0"
conn = register(Conn(username, client_ip, "socks-udp", "*", bound_port, "UDP ASSOCIATE",
f"UDP-Relay Port {bound_port}"))
conn.closers.append(writer.close)
try:
writer.write(_reply(REP_OK, advertise, bound_port))
await writer.drain()
# Assoziation lebt, solange die TCP-Verbindung offen ist
while True:
data = await reader.read(1024)
if not data:
break
conn.bytes_up = sum(f["up"] for f in proto.flows.values())
conn.bytes_down = sum(f["down"] for f in proto.flows.values())
except (ConnectionError, OSError):
pass
finally:
transport.close()
_udp_in_use.discard(bound_port)
proto.finish()
from .core import registry
registry.pop(conn.id, None)
+624
View File
@@ -0,0 +1,624 @@
"use strict";
// ------------------------------------------------------------------ Grundlagen
const $ = (sel, el = document) => el.querySelector(sel);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const state = { token: null, me: null, users: [], timer: null };
try { state.token = localStorage.getItem("proxy_token"); } catch (_) { /* ignore */ }
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (state.token) headers.Authorization = "Bearer " + state.token;
let body = opts.body;
if (body && typeof body !== "string") { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; }
const res = await fetch(path, { ...opts, headers, body });
if (res.status === 401 && !path.startsWith("/api/auth/login")) { logout(true); throw new Error("Sitzung abgelaufen"); }
if (opts.raw) { if (!res.ok) throw new Error(res.statusText); return res; }
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : await res.text();
if (!res.ok) {
let msg = data?.detail ?? res.statusText;
if (Array.isArray(msg)) msg = msg.map((d) => `${(d.loc || []).slice(1).join(".")}: ${d.msg}`).join("; ");
throw new Error(msg);
}
return data;
}
function toast(msg, err = false) {
const t = $("#toast");
t.textContent = msg;
t.className = "toast" + (err ? " err" : "");
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => (t.hidden = true), err ? 6000 : 2800);
}
const fmtBytes = (n) => {
n = Number(n) || 0;
const u = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + " " + u[i];
};
const fmtNum = (n) => Number(n || 0).toLocaleString("de-DE");
const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString("de-DE") : "–";
const fmtDur = (s) => { s = Number(s) || 0; if (s < 60) return s.toFixed(1) + " s"; if (s < 3600) return Math.floor(s / 60) + " min"; return (s / 3600).toFixed(1) + " h"; };
const ago = (ts) => { if (!ts) return "nie"; const s = Date.now() / 1000 - ts; if (s < 60) return "gerade eben"; if (s < 3600) return `vor ${Math.floor(s / 60)} min`; if (s < 86400) return `vor ${Math.floor(s / 3600)} h`; return `vor ${Math.floor(s / 86400)} Tagen`; };
const STATUS = {
ok: ["ok", "OK"], blocked: ["bad", "Gesperrt"], error: ["warn", "Fehler"], auth_failed: ["bad", "Login fehlgeschl."],
};
const statusBadge = (s) => { const [c, l] = STATUS[s] || ["", s]; return `<span class="badge ${c}">${esc(l)}</span>`; };
const PROTO = { http: "HTTP", https: "HTTPS", connect: "CONNECT", socks: "SOCKS5", "socks-udp": "SOCKS5 UDP", "fwd-tcp": "Weiterl. TCP", "fwd-udp": "Weiterl. UDP" };
const protoBadge = (p) => `<span class="badge info">${esc(PROTO[p] || p || "–")}</span>`;
const policyLabel = { inherit: "Wie global", allow: "Alles erlauben (Blacklist)", deny: "Alles sperren (Whitelist)" };
// ------------------------------------------------------------------ Modal
const KEEP_OPEN = Symbol("keep");
// onSubmit(data) darf werfen (Fehler wird angezeigt) oder KEEP_OPEN liefern (Dialog bleibt offen).
function modal(title, fieldsHtml, { submit = "Speichern", danger = false, onSubmit = null } = {}) {
const dlg = $("#modal");
const form = $("#modal-form");
form.innerHTML = `<h2>${esc(title)}</h2>${fieldsHtml}
<p class="error" id="modal-error"></p>
<div class="modal-actions">
<button class="btn" value="cancel" formnovalidate>${submit ? "Abbrechen" : "Schließen"}</button>
${submit ? `<button class="btn ${danger ? "danger" : "primary"}" value="ok">${esc(submit)}</button>` : ""}
</div>`;
dlg.showModal();
return new Promise((resolve) => {
let busy = false;
form.onsubmit = async (e) => {
if (e.submitter?.value !== "ok") return; // Abbrechen schließt per method=dialog
e.preventDefault();
if (busy) return;
const data = {};
for (const el of form.elements) if (el.name) data[el.name] = el.type === "checkbox" ? el.checked : el.value;
if (!onSubmit) { resolve(data); dlg.close(); return; }
busy = true;
$("#modal-error").textContent = "";
try {
const out = await onSubmit(data);
if (out !== KEEP_OPEN) { resolve(out ?? true); dlg.close(); }
} catch (err) { $("#modal-error").textContent = err.message; }
busy = false;
};
dlg.onclose = () => resolve(null);
});
}
const modalLoop = (title, html, action, opts = {}) => modal(title, html, { ...opts, onSubmit: action });
const confirmBox = (text, submit = "Löschen") => modal("Bitte bestätigen", `<p>${esc(text)}</p>`, { submit, danger: true });
// ------------------------------------------------------------------ Navigation
const PAGES_ADMIN = [
["dashboard", "◧", "Übersicht"], ["users", "☺", "Benutzer"], ["rules", "⛨", "Regeln"],
["forwards", "⇆", "Weiterleitungen"], ["history", "☰", "Verlauf"], ["connections", "⚡", "Live-Verbindungen"],
["settings", "⚙", "Einstellungen"], ["api", "⌘", "API"],
];
const PAGES_USER = [["myhistory", "☰", "Mein Verlauf"], ["help", "?", "Einrichtung"]];
function renderNav() {
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
const current = location.hash.slice(1).split("?")[0] || pages[0][0];
$("#nav").innerHTML = pages.map(([id, ico, label]) =>
`<a href="#${id}" class="${id === current ? "active" : ""}"><span class="ico">${ico}</span>${label}</a>`).join("");
}
async function route() {
clearInterval(state.timer);
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
const [page, query] = location.hash.slice(1).split("?");
const id = pages.some((p) => p[0] === page) ? page : pages[0][0];
renderNav();
const params = new URLSearchParams(query || "");
try { await VIEWS[id]($("#main"), params); }
catch (e) { $("#main").innerHTML = `<div class="card error">${esc(e.message)}</div>`; }
}
const head = (title, sub = "", actions = "") =>
`<div class="page-head"><div><h1>${esc(title)}</h1>${sub ? `<p>${sub}</p>` : ""}</div><div class="toolbar" style="margin:0">${actions}</div></div>`;
async function loadUsers() { state.users = await api("/api/users"); return state.users; }
const userOptions = (selected, withGlobal = true) =>
(withGlobal ? `<option value="">Global (alle Benutzer)</option>` : "") +
state.users.map((u) => `<option value="${u.id}" ${String(selected) === String(u.id) ? "selected" : ""}>${esc(u.username)}</option>`).join("");
// ------------------------------------------------------------------ Diagramm
function barChart(el, points, bucket) {
if (!points.length) { el.innerHTML = `<div class="empty">Noch keine Daten im Zeitraum</div>`; return; }
const W = Math.max(el.clientWidth, 300), H = 180, padL = 40, padB = 22, padT = 8;
const max = Math.max(...points.map((p) => p.n), 1);
const step = (W - padL) / points.length;
const bw = Math.max(Math.min(step - 2, 28), 2);
const y = (v) => padT + (H - padT - padB) * (1 - v / max);
const ticks = [0, Math.round(max / 2), max];
const fmtLabel = (ts) => bucket >= 86400
? new Date(ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
: new Date(ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const every = Math.ceil(points.length / Math.floor((W - padL) / 60));
let svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Anfragen pro ${bucket >= 86400 ? "Tag" : "Stunde"}">`;
for (const t of ticks) svg += `<line class="grid-line" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${fmtNum(t)}</text>`;
points.forEach((p, i) => {
const x = padL + i * step + (step - bw) / 2;
const top = y(p.n), h = Math.max(H - padB - top, p.n ? 1 : 0);
const r = Math.min(4, bw / 2, h);
// oben abgerundet, unten an der Grundlinie gerade
const path = h > 0 ? `M${x},${top + h} V${top + r} Q${x},${top} ${x + r},${top} H${x + bw - r} Q${x + bw},${top} ${x + bw},${top + r} V${top + h} Z` : "";
svg += `<path class="bar" data-i="${i}" d="${path}"/>`;
svg += `<rect data-i="${i}" x="${padL + i * step}" y="${padT}" width="${step}" height="${H - padT - padB}" fill="transparent"/>`;
if (i % every === 0) svg += `<text class="axis" x="${x + bw / 2}" y="${H - 6}" text-anchor="middle">${fmtLabel(p.b)}</text>`;
});
svg += "</svg>";
el.innerHTML = svg + `<div class="chart-tip" hidden></div>`;
const tip = $(".chart-tip", el);
el.querySelector("svg").addEventListener("mousemove", (e) => {
const i = e.target.dataset?.i;
el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover"));
if (i === undefined) { tip.hidden = true; return; }
const p = points[i];
el.querySelector(`.bar[data-i="${i}"]`)?.classList.add("hover");
const rect = el.getBoundingClientRect();
tip.hidden = false;
tip.style.left = (e.clientX - rect.left) + "px";
tip.style.top = (e.clientY - rect.top) + "px";
tip.innerHTML = `<b>${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}</b><br>${fmtNum(p.n)} Anfragen · ${fmtNum(p.blocked)} gesperrt`;
});
el.querySelector("svg").addEventListener("mouseleave", () => { tip.hidden = true; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); });
}
function fillBuckets(timeline, hours, bucket) {
const map = new Map(timeline.map((t) => [t.b, t]));
const end = Math.floor(Date.now() / 1000 / bucket) * bucket;
const n = Math.ceil(hours * 3600 / bucket);
const out = [];
for (let i = n - 1; i >= 0; i--) { const b = end - i * bucket; out.push(map.get(b) || { b, n: 0, blocked: 0 }); }
return out;
}
// ------------------------------------------------------------------ Seiten
const VIEWS = {};
VIEWS.dashboard = async (main, params) => {
const hours = Number(params.get("h") || 24);
const s = await api(`/api/stats?hours=${hours}`);
const t = s.totals;
const rangeSel = `<select id="range">${[[24, "Letzte 24 Stunden"], [168, "Letzte 7 Tage"], [720, "Letzte 30 Tage"]].map(([v, l]) => `<option value="${v}" ${v === hours ? "selected" : ""}>${l}</option>`).join("")}</select>`;
const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0";
const list = (rows, cols) => rows.length ? `<div class="table-wrap"><table class="mini"><tbody>${rows.map(cols).join("")}</tbody></table></div>` : `<div class="empty">Keine Daten</div>`;
main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
<div class="stack">
<div class="grid tiles">
<div class="card tile"><div class="label">Anfragen</div><div class="value">${fmtNum(t.total)}</div><div class="sub">${fmtNum(t.users)} aktive Benutzer</div></div>
<div class="card tile"><div class="label">Gesperrt</div><div class="value">${fmtNum(t.blocked)}</div><div class="sub">${blockedPct} % aller Anfragen</div></div>
<div class="card tile"><div class="label">Datenvolumen</div><div class="value">${fmtBytes(t.up + t.down)}</div><div class="sub">↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}</div></div>
<div class="card tile"><div class="label">Live-Verbindungen</div><div class="value">${fmtNum(s.active_connections)}</div><div class="sub">${fmtNum(s.user_count)} Benutzer angelegt</div></div>
<div class="card tile"><div class="label">Fehler / Fehllogins</div><div class="value">${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}</div><div class="sub">Verbindungsfehler / Anmeldung</div></div>
</div>
<div class="card"><h2>Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}</h2><div class="chart" id="chart"></div></div>
<div class="grid two">
<div class="card"><h2>Meistbesuchte Ziele</h2>${list(s.top_hosts, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
<div class="card"><h2>Meistgesperrte Ziele</h2>${list(s.top_blocked, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td></tr>`)}</div>
<div class="card"><h2>Benutzer nach Datenvolumen</h2>${list(s.top_users, (r) => `<tr><td><a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a></td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
</div>
</div>`;
$("#range").onchange = (e) => (location.hash = `dashboard?h=${e.target.value}`);
barChart($("#chart"), fillBuckets(s.timeline, hours, s.bucket), s.bucket);
};
// ---- Benutzer
function userForm(u = {}) {
const pol = u.policy || "inherit";
return `<div class="form-grid">
<label>Benutzername<input name="username" value="${esc(u.username)}" required pattern="[A-Za-z0-9._@\\-]+"></label>
<label>Passwort${u.id ? ' <span class="hint">(leer = unverändert)</span>' : ""}<input name="password" type="password" minlength="6" ${u.id ? "" : "required"} autocomplete="new-password"></label>
</div>
<label>Standardrichtlinie
<select name="policy">${Object.entries(policyLabel).map(([k, l]) => `<option value="${k}" ${k === pol ? "selected" : ""}>${l}</option>`).join("")}</select>
<span class="hint">Greift, wenn keine Regel passt. „Alles sperren“ + Erlaubt-Regeln = Whitelist für diesen Benutzer.</span>
</label>
<label>Kommentar<input name="comment" value="${esc(u.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${u.enabled !== 0 && u.enabled !== false ? "checked" : ""}> Aktiv (darf den Proxy benutzen)</label>
<label class="check"><input type="checkbox" name="is_admin" ${u.is_admin ? "checked" : ""}> Administrator (Zugriff auf diese Oberfläche)</label>`;
}
VIEWS.users = async (main) => {
const users = await loadUsers();
main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", `<button class="btn primary" id="add">+ Benutzer anlegen</button>`) +
`<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Status</th><th>Richtlinie</th><th class="num">Eigene Regeln</th><th>Zuletzt aktiv</th><th>Kommentar</th><th></th></tr></thead><tbody>
${users.map((u) => `<tr>
<td><b>${esc(u.username)}</b> ${u.is_admin ? '<span class="badge info">Admin</span>' : ""}</td>
<td>${u.enabled ? '<span class="badge ok">Aktiv</span>' : '<span class="badge bad">Gesperrt</span>'}</td>
<td>${esc(policyLabel[u.policy])}</td>
<td class="num"><a href="#rules?user=${u.id}">${u.rule_count}</a></td>
<td title="${esc(fmtTime(u.last_seen))}">${ago(u.last_seen)}</td>
<td class="wrap">${esc(u.comment)}</td>
<td class="actions">
<a class="btn small ghost" href="#history?username=${encodeURIComponent(u.username)}">Verlauf</a>
<button class="btn small" data-edit="${u.id}">Bearbeiten</button>
<button class="btn small danger" data-del="${u.id}">Löschen</button>
</td></tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Benutzer</td></tr>`}
</tbody></table></div>`;
$("#add").onclick = async () => {
const ok = await modalLoop("Benutzer anlegen", userForm(), (d) => api("/api/users", { method: "POST", body: d }));
if (ok) { toast("Benutzer angelegt"); route(); }
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const u = users.find((x) => x.id == edit);
const ok = await modalLoop(`Benutzer „${u.username}“ bearbeiten`, userForm(u), (d) => {
if (!d.password) delete d.password;
return api(`/api/users/${edit}`, { method: "PUT", body: d });
});
if (ok) { toast("Gespeichert"); route(); }
}
if (del) {
const u = users.find((x) => x.id == del);
if (await confirmBox(`Benutzer „${u.username}“ inkl. seiner Regeln löschen?`)) {
try { await api(`/api/users/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } catch (err) { toast(err.message, true); }
}
}
};
};
// ---- Regeln
function ruleForm(r = {}) {
return `<label>Gilt für<select name="user_id">${userOptions(r.user_id ?? "")}</select></label>
<label>Aktion<select name="action">
<option value="deny" ${r.action !== "allow" ? "selected" : ""}>Sperren (Blacklist)</option>
<option value="allow" ${r.action === "allow" ? "selected" : ""}>Erlauben (Whitelist)</option></select></label>
<div class="form-grid">
<label>Host / Domain / Netz<input name="host" value="${esc(r.host)}" placeholder="z. B. facebook.com">
<span class="hint">leer = alle · example.com (inkl. Subdomains) · *.example.com · 10.0.0.0/8 · re:regex</span></label>
<label>Ports<input name="ports" value="${esc(r.ports)}" placeholder="z. B. 25 oder 6000-7000,8080">
<span class="hint">leer = alle Ports</span></label>
</div>
<label>Kommentar<input name="comment" value="${esc(r.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${r.enabled !== 0 ? "checked" : ""}> Regel aktiv</label>`;
}
const ruleBody = (d) => ({ ...d, user_id: d.user_id ? Number(d.user_id) : null });
VIEWS.rules = async (main, params) => {
await loadUsers();
const userFilter = params.get("user") || "";
const rules = await api(userFilter ? `/api/rules?user_id=${userFilter}` : "/api/rules");
const filterSel = `<select id="rule-filter"><option value="">Alle Regeln</option>${state.users.map((u) => `<option value="${u.id}" ${userFilter == u.id ? "selected" : ""}>nur ${esc(u.username)}</option>`).join("")}</select>`;
main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports",
`${filterSel}<button class="btn" id="test">Regel testen</button><button class="btn" id="bulk">Liste importieren</button><button class="btn primary" id="add">+ Regel</button>`) +
`<div class="card" style="margin-bottom:16px"><b>So wird ausgewertet:</b> Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie.
Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine <b>Whitelist</b> die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.</div>
<div class="table-wrap"><table><thead><tr><th>Gilt für</th><th>Aktion</th><th>Host / Netz</th><th>Ports</th><th>Kommentar</th><th>Aktiv</th><th></th></tr></thead><tbody>
${rules.map((r) => `<tr>
<td>${r.user_id ? `<span class="badge info">${esc(r.username)}</span>` : '<span class="badge">Global</span>'}</td>
<td>${r.action === "deny" ? '<span class="badge bad">Sperren</span>' : '<span class="badge ok">Erlauben</span>'}</td>
<td class="wrap mono">${esc(r.host || "* (alle)")}</td>
<td class="mono">${esc(r.ports || "alle")}</td>
<td class="wrap">${esc(r.comment)}</td>
<td>${r.enabled ? "✓" : "–"}</td>
<td class="actions"><button class="btn small" data-edit="${r.id}">Bearbeiten</button> <button class="btn small danger" data-del="${r.id}">Löschen</button></td>
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Regeln – alles wird nach der Standardrichtlinie behandelt.</td></tr>`}
</tbody></table></div>`;
$("#rule-filter").onchange = (e) => (location.hash = e.target.value ? `rules?user=${e.target.value}` : "rules");
$("#add").onclick = async () => {
const ok = await modalLoop("Regel anlegen", ruleForm({ user_id: userFilter || null }), (d) => api("/api/rules", { method: "POST", body: ruleBody(d) }));
if (ok) { toast("Regel angelegt"); route(); }
};
$("#bulk").onclick = async () => {
const html = `<label>Gilt für<select name="user_id">${userOptions(userFilter)}</select></label>
<label>Aktion<select name="action"><option value="deny">Sperren (Blacklist)</option><option value="allow">Erlauben (Whitelist)</option></select></label>
<label>Hosts (einer pro Zeile)<textarea name="hosts" placeholder="facebook.com&#10;*.tiktok.com&#10;0.0.0.0 ads.example.com" required></textarea>
<span class="hint">Auch Hosts-Datei-Format und #-Kommentare werden verstanden.</span></label>
<label>Ports<input name="ports" placeholder="leer = alle"></label>
<label>Kommentar<input name="comment" placeholder="z. B. Social Media"></label>`;
const out = await modalLoop("Liste importieren", html, (d) => api("/api/rules/bulk", { method: "POST", body: { ...ruleBody(d), hosts: d.hosts.split(/\r?\n/) } }), { submit: "Importieren" });
if (out) { toast(`${out.added} Regeln importiert, ${out.skipped} übersprungen`); route(); }
};
$("#test").onclick = async () => {
const html = `<label>Benutzer<select name="username"><option value="">(anonym / nur globale Regeln)</option>${state.users.map((u) => `<option>${esc(u.username)}</option>`).join("")}</select></label>
<div class="form-grid"><label>Host<input name="host" required placeholder="www.example.com"></label><label>Port<input name="port" type="number" value="443" min="1" max="65535"></label></div>
<div id="test-result"></div>`;
await modal("Regel testen", html, { submit: "Prüfen", onSubmit: async (d) => {
const res = await api("/api/rules/test", { method: "POST", body: { username: d.username || null, host: d.host, port: Number(d.port) } });
$("#test-result").innerHTML = `<div class="card">${res.allowed ? '<span class="badge ok">Erlaubt</span>' : '<span class="badge bad">Gesperrt</span>'} ${esc(res.reason)}
${res.addresses.length ? `<div class="hint" style="margin-top:8px">${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("<br>")}</div>` : ""}
${res.dns_error ? `<div class="hint">DNS: ${esc(res.dns_error)}</div>` : ""}</div>`;
return KEEP_OPEN;
} });
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const rule = rules.find((x) => x.id == edit);
const ok = await modalLoop("Regel bearbeiten", ruleForm(rule), (d) => api(`/api/rules/${edit}`, { method: "PUT", body: ruleBody(d) }));
if (ok) { toast("Gespeichert"); route(); }
}
if (del && await confirmBox("Regel löschen?")) {
await api(`/api/rules/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
}
};
};
// ---- Weiterleitungen
function forwardForm(f = {}) {
return `<label>Name<input name="name" value="${esc(f.name)}" required placeholder="z. B. WireGuard Büro"></label>
<div class="form-grid">
<label>Protokoll<select name="protocol"><option value="tcp" ${f.protocol !== "udp" ? "selected" : ""}>TCP</option><option value="udp" ${f.protocol === "udp" ? "selected" : ""}>UDP</option></select></label>
<label>Lokaler Port<input name="listen_port" type="number" min="1" max="65535" value="${esc(f.listen_port)}" required></label>
</div>
<div class="form-grid">
<label>Ziel-Host<input name="target_host" value="${esc(f.target_host)}" required placeholder="vpn.example.com"></label>
<label>Ziel-Port<input name="target_port" type="number" min="1" max="65535" value="${esc(f.target_port)}" required></label>
</div>
<label>Erlaubte Quell-Netze<input name="allowed_sources" value="${esc(f.allowed_sources)}" placeholder="z. B. 192.168.1.0/24 10.8.0.5">
<span class="hint">Leer = jeder darf (das Ziel ist fest, es ist also kein offener Proxy). Globale Regeln gelten zusätzlich.</span></label>
<label>Kommentar<input name="comment" value="${esc(f.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${f.enabled !== 0 ? "checked" : ""}> Aktiv</label>`;
}
const fwdBody = (d) => ({ ...d, listen_port: Number(d.listen_port), target_port: Number(d.target_port) });
VIEWS.forwards = async (main) => {
const { items, ports_hint } = await api("/api/forwards");
main.innerHTML = head("Weiterleitungen", `Für Programme ohne Proxy-Unterstützung (WireGuard, Outlook-Mailkonten, Geräte …): Port am Proxy → festes Ziel. Im Docker-Setup freigegebene Ports: <b>${esc(ports_hint)}</b> (TCP & UDP).`,
`<button class="btn primary" id="add">+ Weiterleitung</button>`) +
`<div class="table-wrap"><table><thead><tr><th>Name</th><th>Proto</th><th>Proxy-Port</th><th>Ziel</th><th>Erlaubte Quellen</th><th>Status</th><th></th></tr></thead><tbody>
${items.map((f) => `<tr>
<td><b>${esc(f.name)}</b>${f.comment ? `<div class="hint">${esc(f.comment)}</div>` : ""}</td>
<td><span class="badge">${f.protocol.toUpperCase()}</span></td>
<td class="mono">${f.listen_port}</td>
<td class="mono">${esc(f.target_host)}:${f.target_port}</td>
<td class="mono wrap">${esc(f.allowed_sources || "alle")}</td>
<td>${f.status === "aktiv" ? '<span class="badge ok">aktiv</span>' : f.status === "deaktiviert" ? '<span class="badge">deaktiviert</span>' : `<span class="badge bad">${esc(f.status)}</span>`}</td>
<td class="actions"><button class="btn small" data-edit="${f.id}">Bearbeiten</button> <button class="btn small danger" data-del="${f.id}">Löschen</button></td>
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820</td></tr>`}
</tbody></table></div>`;
$("#add").onclick = async () => {
const ok = await modalLoop("Weiterleitung anlegen", forwardForm(), (d) => api("/api/forwards", { method: "POST", body: fwdBody(d) }));
if (ok) { toast("Weiterleitung angelegt"); route(); }
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const f = items.find((x) => x.id == edit);
const ok = await modalLoop("Weiterleitung bearbeiten", forwardForm(f), (d) => api(`/api/forwards/${edit}`, { method: "PUT", body: fwdBody(d) }));
if (ok) { toast("Gespeichert"); route(); }
}
if (del && await confirmBox("Weiterleitung löschen?")) {
await api(`/api/forwards/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
}
};
};
// ---- Verlauf
function historyView(admin) {
return async (main, params) => {
if (admin) await loadUsers();
const f = Object.fromEntries(params.entries());
const limit = 100, offset = Number(f.offset || 0);
const q = new URLSearchParams();
for (const k of ["username", "q", "status", "proto"]) if (f[k]) q.set(k, f[k]);
if (f.from) q.set("since", new Date(f.from).getTime() / 1000);
if (f.to) q.set("until", new Date(f.to).getTime() / 1000);
const base = admin ? "/api/history" : "/api/me/history";
const data = await api(`${base}?${q}&limit=${limit}&offset=${offset}`);
const sel = (name, opts) => `<select name="${name}"><option value="">${name === "username" ? "Alle Benutzer" : name === "status" ? "Alle Status" : "Alle Protokolle"}</option>${opts.map(([v, l]) => `<option value="${esc(v)}" ${f[name] === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen",
admin ? `<button class="btn" id="csv">CSV-Export</button><button class="btn danger" id="clear">Verlauf leeren</button>` : "") +
`<form class="toolbar" id="filter">
${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""}
<input name="q" placeholder="Suche Host, URL, IP …" value="${esc(f.q)}">
${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))}
${admin ? sel("proto", Object.entries(PROTO)) : ""}
<label>Von<input type="datetime-local" name="from" value="${esc(f.from)}"></label>
<label>Bis<input type="datetime-local" name="to" value="${esc(f.to)}"></label>
<button class="btn primary">Filtern</button>
<a class="btn ghost" href="#${admin ? "history" : "myhistory"}">Zurücksetzen</a>
</form>
<div class="table-wrap"><table><thead><tr><th>Zeit</th>${admin ? "<th>Benutzer</th>" : ""}<th>Client</th><th>Protokoll</th><th>Ziel</th><th>Status</th><th class="num">↑</th><th class="num">↓</th><th class="num">Dauer</th></tr></thead><tbody>
${data.items.map((r) => `<tr>
<td style="white-space:nowrap">${fmtTime(r.ts)}</td>
${admin ? `<td>${r.username ? `<a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a>` : '<span class="hint">–</span>'}</td>` : ""}
<td class="mono">${esc(r.client_ip)}</td>
<td>${protoBadge(r.proto)}</td>
<td class="wrap"><span class="mono">${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}</span>${r.detail ? `<div class="hint">${esc(r.detail)}</div>` : ""}</td>
<td>${statusBadge(r.status)}</td>
<td class="num">${fmtBytes(r.bytes_up)}</td><td class="num">${fmtBytes(r.bytes_down)}</td><td class="num">${fmtDur(r.duration)}</td>
</tr>`).join("") || `<tr><td colspan="9" class="empty">Keine Einträge</td></tr>`}
</tbody></table></div>
<div class="pager">${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))}
<button class="btn small" id="prev" ${offset <= 0 ? "disabled" : ""}>‹ Zurück</button>
<button class="btn small" id="next" ${offset + limit >= data.total ? "disabled" : ""}>Weiter ›</button></div>`;
const go = (extra = {}) => {
const p = new URLSearchParams();
const form = new FormData($("#filter"));
for (const [k, v] of form.entries()) if (v) p.set(k, v);
for (const [k, v] of Object.entries(extra)) p.set(k, v);
location.hash = `${admin ? "history" : "myhistory"}?${p}`;
};
$("#filter").onsubmit = (e) => { e.preventDefault(); go(); };
$("#prev").onclick = () => go({ offset: Math.max(0, offset - limit) });
$("#next").onclick = () => go({ offset: offset + limit });
if (admin) {
$("#csv").onclick = async () => {
try {
const res = await api(`/api/history/export.csv?${q}`, { raw: true });
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement("a"), { href: url, download: "proxy-verlauf.csv" });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (e) { toast(e.message, true); }
};
$("#clear").onclick = async () => {
if (await confirmBox("Den kompletten Verlauf unwiderruflich löschen?", "Alles löschen")) {
const r = await api("/api/history", { method: "DELETE" }); toast(`${fmtNum(r.deleted)} Einträge gelöscht`); route();
}
};
}
};
}
VIEWS.history = historyView(true);
VIEWS.myhistory = historyView(false);
// ---- Live-Verbindungen
VIEWS.connections = async (main) => {
main.innerHTML = head("Live-Verbindungen", "Aktualisiert sich alle 3 Sekunden") + `<div id="conns"></div>`;
const draw = async () => {
const list = await api("/api/connections");
const el = $("#conns");
if (!el) return;
el.innerHTML = `<div class="table-wrap"><table><thead><tr><th>Seit</th><th>Benutzer</th><th>Client</th><th>Protokoll</th><th>Ziel</th><th class="num">↑</th><th class="num">↓</th><th></th></tr></thead><tbody>
${list.map((c) => `<tr><td>${fmtDur(Date.now() / 1000 - c.started)}</td><td>${esc(c.username || "–")}</td><td class="mono">${esc(c.client_ip)}</td>
<td>${protoBadge(c.proto)}</td><td class="wrap mono">${esc(c.url || `${c.host}:${c.port}`)}</td>
<td class="num">${fmtBytes(c.bytes_up)}</td><td class="num">${fmtBytes(c.bytes_down)}</td>
<td class="actions"><button class="btn small danger" data-kill="${c.id}">Trennen</button></td></tr>`).join("") || `<tr><td colspan="8" class="empty">Keine aktiven Verbindungen</td></tr>`}
</tbody></table></div>`;
};
await draw();
state.timer = setInterval(() => draw().catch(() => {}), 3000);
main.onclick = async (e) => {
if (e.target.dataset.kill) { await api(`/api/connections/${e.target.dataset.kill}`, { method: "DELETE" }).catch((x) => toast(x.message, true)); draw(); }
};
};
// ---- Einstellungen
function setupHelp(s) {
const host = s.public_host || location.hostname;
return `<div class="card"><h2>Clients einrichten</h2>
<dl class="kv">
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.http}</dd>
<dt>SOCKS5-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.socks}</dd>
<dt>Auto-Konfiguration (PAC)</dt><dd class="mono">http://${esc(host)}:${s.ports.web}/proxy.pac</dd>
<dt>SOCKS5 UDP-Ports</dt><dd class="mono">${esc(s.ports.socks_udp)}</dd>
<dt>Weiterleitungs-Ports</dt><dd class="mono">${esc(s.ports.forwards)}</dd>
</dl>
<p class="hint" style="margin-top:12px">Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen.
Thunderbird &amp; Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy);
für SMTP/IMAP in Outlook oder WireGuard eine <a href="#forwards">Weiterleitung</a> anlegen.
Kommandozeile: <code>export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}</code></p></div>`;
}
VIEWS.settings = async (main) => {
const s = await api("/api/settings");
main.innerHTML = head("Einstellungen") + `<div class="stack">
<form class="card stack" id="settings">
<label>Globale Standardrichtlinie
<select name="default_policy">
<option value="allow" ${s.default_policy === "allow" ? "selected" : ""}>Alles erlauben, Sperrliste anwenden (Blacklist-Modus)</option>
<option value="deny" ${s.default_policy === "deny" ? "selected" : ""}>Alles sperren, nur Freigaben erlauben (Whitelist-Modus)</option>
</select></label>
<div class="form-grid">
<label>Verlauf aufbewahren (Tage)<input type="number" name="log_retention_days" min="0" value="${s.log_retention_days}"><span class="hint">0 = unbegrenzt</span></label>
<label>Öffentlicher Hostname<input name="public_host" value="${esc(s.public_host)}" placeholder="proxy.firma.local"><span class="hint">Für PAC-Datei und Anleitung</span></label>
<label>Anmelde-Bereich (Realm)<input name="auth_realm" value="${esc(s.auth_realm)}"></label>
</div>
<label>Netze ohne Anmeldung<input name="anonymous_networks" value="${esc(s.anonymous_networks)}" placeholder="z. B. 192.168.10.0/24">
<span class="hint">Clients aus diesen Netzen dürfen ohne Login surfen (erscheinen als „anonym“, nur globale Regeln). Leer = Anmeldung immer Pflicht.</span></label>
<label class="check"><input type="checkbox" name="log_enabled" ${s.log_enabled ? "checked" : ""}> Verlauf protokollieren</label>
<label class="check"><input type="checkbox" name="block_loopback" ${s.block_loopback ? "checked" : ""}> Zugriff auf Loopback (127.0.0.1, ::1) sperren – schützt diese Oberfläche</label>
<div><button class="btn primary">Speichern</button></div>
</form>
${setupHelp(s)}
</div>`;
$("#settings").onsubmit = async (e) => {
e.preventDefault();
const el = e.target.elements;
try {
await api("/api/settings", { method: "PUT", body: {
default_policy: el.default_policy.value, log_retention_days: Number(el.log_retention_days.value),
public_host: el.public_host.value, auth_realm: el.auth_realm.value, anonymous_networks: el.anonymous_networks.value,
log_enabled: el.log_enabled.checked, block_loopback: el.block_loopback.checked } });
toast("Einstellungen gespeichert");
} catch (err) { toast(err.message, true); }
};
};
VIEWS.help = async (main) => {
const host = location.hostname;
main.innerHTML = head("Einrichtung") + `<div class="card"><dl class="kv">
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:3128</dd>
<dt>SOCKS5</dt><dd class="mono">${esc(host)}:1080</dd>
<dt>PAC-Datei</dt><dd class="mono">http://${esc(host)}:${esc(location.port || 80)}/proxy.pac</dd>
<dt>Anmeldung</dt><dd>Dein Benutzername und Passwort</dd></dl></div>`;
};
// ---- API
VIEWS.api = async (main) => {
const tokens = await api("/api/tokens");
main.innerHTML = head("API", `Alle Funktionen der Oberfläche sind per REST-API nutzbar. <a href="/api/docs" target="_blank">Interaktive API-Doku öffnen ↗</a>`,
`<button class="btn primary" id="add">+ API-Token</button>`) + `<div class="stack">
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Besitzer</th><th>Erstellt</th><th>Läuft ab</th><th>Zuletzt benutzt</th><th></th></tr></thead><tbody>
${tokens.map((t) => `<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.username)}</td><td>${fmtTime(t.created_at)}</td><td>${t.expires_at ? fmtTime(t.expires_at) : "nie"}</td><td>${ago(t.last_used)}</td>
<td class="actions"><button class="btn small danger" data-del="${t.id}">Widerrufen</button></td></tr>`).join("") || `<tr><td colspan="6" class="empty">Noch keine API-Tokens</td></tr>`}
</tbody></table></div>
<div class="card"><h2>Beispiele</h2><pre class="mono" style="white-space:pre-wrap;margin:0"># Benutzer anlegen
curl -X POST ${esc(location.origin)}/api/users \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"username":"max","password":"geheim123"}'
# Domain global sperren
curl -X POST ${esc(location.origin)}/api/rules \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"action":"deny","host":"facebook.com"}'
# Verlauf eines Benutzers
curl "${esc(location.origin)}/api/history?username=max&amp;limit=50" -H "Authorization: Bearer &lt;TOKEN&gt;"</pre></div></div>`;
$("#add").onclick = async () => {
const out = await modalLoop("API-Token erstellen", `<label>Name<input name="name" required placeholder="z. B. Monitoring"></label>
<label>Gültigkeit (Tage)<input name="days" type="number" min="1" placeholder="leer = unbegrenzt"></label>`,
(d) => api("/api/tokens", { method: "POST", body: { name: d.name, days: d.days ? Number(d.days) : null } }), { submit: "Erstellen" });
if (out) {
await modal("Token erstellt", `<p>Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:</p><div class="secret mono">${esc(out.token)}</div>
<p class="hint">Verwendung: Header <code>Authorization: Bearer &lt;Token&gt;</code> oder <code>X-API-Key: &lt;Token&gt;</code></p>`, { submit: "" });
route();
}
};
main.onclick = async (e) => {
if (e.target.dataset.del && await confirmBox("Token widerrufen?", "Widerrufen")) {
await api(`/api/tokens/${e.target.dataset.del}`, { method: "DELETE" }); toast("Widerrufen"); route();
}
};
};
// ------------------------------------------------------------------ Start / Login
function logout(silent) {
if (!silent && state.token) api("/api/auth/logout", { method: "POST" }).catch(() => {});
state.token = null;
state.me = null;
try { localStorage.removeItem("proxy_token"); } catch (_) { /* ignore */ }
clearInterval(state.timer);
$("#app").hidden = true;
$("#login").hidden = false;
}
async function start() {
if (!state.token) { logout(true); return; }
try { state.me = await api("/api/me"); } catch (_) { return; }
$("#login").hidden = true;
$("#app").hidden = false;
$("#whoami").innerHTML = `Angemeldet als <b>${esc(state.me.username)}</b>`;
route();
}
$("#login-form").onsubmit = async (e) => {
e.preventDefault();
const f = e.target.elements;
$("#login-error").textContent = "";
try {
const r = await api("/api/auth/login", { method: "POST", body: { username: f.username.value, password: f.password.value } });
state.token = r.token;
try { localStorage.setItem("proxy_token", r.token); } catch (_) { /* ignore */ }
f.password.value = "";
start();
} catch (err) { $("#login-error").textContent = err.message; }
};
$("#btn-logout").onclick = () => logout(false);
$("#btn-password").onclick = async () => {
const ok = await modalLoop("Passwort ändern", `<label>Aktuelles Passwort<input type="password" name="old_password" required autocomplete="current-password"></label>
<label>Neues Passwort<input type="password" name="new_password" required minlength="6" autocomplete="new-password"></label>`,
(d) => api("/api/me/password", { method: "POST", body: d }));
if (ok) toast("Passwort geändert");
};
window.addEventListener("hashchange", () => state.me && route());
start();
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proxy Manager</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 rx=%227%22 fill=%22%232a78d6%22/%3E%3Ctext x=%2216%22 y=%2223%22 font-size=%2219%22 text-anchor=%22middle%22 fill=%22white%22%3E%E2%87%84%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Login -->
<section id="login" class="login" hidden>
<form id="login-form" class="login-card">
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn primary" type="submit">Anmelden</button>
<p class="error" id="login-error"></p>
</form>
</section>
<!-- App -->
<div id="app" class="app" hidden>
<aside class="sidebar">
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
<nav id="nav"></nav>
<div class="sidebar-foot">
<div class="whoami" id="whoami"></div>
<button class="btn ghost small" id="btn-password">Passwort ändern</button>
<button class="btn ghost small" id="btn-logout">Abmelden</button>
</div>
</aside>
<main id="main"></main>
</div>
<dialog id="modal"><form method="dialog" id="modal-form"></form></dialog>
<div id="toast" class="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
:root {
color-scheme: light;
--bg: #f4f5f7;
--surface: #ffffff;
--surface-2: #f8f9fb;
--border: #e3e6eb;
--text: #1d2330;
--text-2: #52596a;
--muted: #8a91a0;
--accent: #2a78d6;
--accent-ink: #ffffff;
--series-1: #2a78d6;
--good: #1a7f37;
--good-bg: #e6f4ea;
--bad: #c62828;
--bad-bg: #fdecea;
--warn: #9a6700;
--warn-bg: #fff4d6;
--sidebar: #1b2130;
--sidebar-text: #c9cfdb;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #12151c;
--surface: #1a1e27;
--surface-2: #20252f;
--border: #2c3240;
--text: #e8ebf1;
--text-2: #b4bccb;
--muted: #7d8697;
--accent: #3987e5;
--series-1: #3987e5;
--good: #4ac26b;
--good-bg: #16301f;
--bad: #f07070;
--bad-bg: #3a1c1c;
--warn: #e0b341;
--warn-bg: #352b10;
--sidebar: #0d1016;
--shadow: none;
}
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); }
[hidden] { display: none !important; }
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; }
a { color: var(--accent); }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
/* Login */
.login { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 32px; width: 100%; max-width: 360px; display: grid; gap: 14px; box-shadow: var(--shadow); }
.login-card .brand { color: var(--text); margin-bottom: 8px; }
.error { color: var(--bad); min-height: 1em; margin: 0; }
/* Layout */
.app { display: grid; grid-template-columns: 230px minmax(0, 1fr); min-height: 100vh; }
.sidebar { background: var(--sidebar); color: var(--sidebar-text); display: flex; flex-direction: column; padding: 18px 12px; position: sticky; top: 0; height: 100vh; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; color: #fff; padding: 0 8px 18px; }
.logo { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 16px; }
nav { display: grid; gap: 2px; }
nav a { color: var(--sidebar-text); text-decoration: none; padding: 9px 12px; border-radius: 8px; display: flex; gap: 10px; align-items: center; }
nav a:hover { background: rgba(255, 255, 255, .06); color: #fff; }
nav a.active { background: rgba(255, 255, 255, .1); color: #fff; font-weight: 600; }
nav .ico { width: 18px; text-align: center; opacity: .85; }
.sidebar-foot { margin-top: auto; display: grid; gap: 6px; padding: 12px 4px 0; border-top: 1px solid rgba(255, 255, 255, .08); }
.whoami { font-size: 13px; padding: 0 8px 4px; }
.sidebar .btn.ghost { color: var(--sidebar-text); border-color: rgba(255, 255, 255, .12); }
main { padding: 24px 28px 48px; min-width: 0; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
.page-head p { margin: 4px 0 0; color: var(--text-2); }
/* Cards & grid */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); min-width: 0; }
.grid { display: grid; gap: 16px; }
.grid.tiles { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.tile .label { color: var(--text-2); font-size: 12.5px; }
.tile .value { font-size: 26px; font-weight: 650; margin-top: 4px; font-variant-numeric: tabular-nums; }
.tile .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.stack { display: grid; gap: 16px; }
/* Buttons & forms */
a.btn { text-decoration: none; display: inline-block; }
.btn { border: 1px solid var(--border); background: var(--surface); color: var(--text); padding: 8px 14px; border-radius: 8px; cursor: pointer; font: inherit; font-weight: 550; white-space: nowrap; }
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn.primary:hover { filter: brightness(1.07); }
.btn.danger { color: var(--bad); }
.btn.ghost { background: transparent; }
.btn.small { padding: 5px 10px; font-size: 12.5px; }
.btn:disabled { opacity: .5; cursor: default; }
label { display: grid; gap: 5px; font-size: 13px; color: var(--text-2); font-weight: 550; }
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%; }
textarea { min-height: 120px; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; }
input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); border-color: var(--accent); }
.check { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--text); }
.check input { width: auto; }
.hint { color: var(--muted); font-size: 12px; font-weight: 400; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; margin-bottom: 14px; }
.toolbar > * { flex: 0 1 auto; }
.toolbar input, .toolbar select { width: auto; min-width: 140px; }
.form-grid { display: grid; gap: 14px; align-items: start; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
/* Tables */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-size: 12px; color: var(--text-2); font-weight: 600; background: var(--surface-2); white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.actions { text-align: right; white-space: nowrap; }
td.wrap { max-width: 420px; overflow-wrap: anywhere; }
.empty { padding: 28px; text-align: center; color: var(--muted); }
.card .table-wrap { border: 0; }
.mini td, .mini th { padding: 6px 4px; }
/* Badges */
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border); white-space: nowrap; }
.badge.ok { background: var(--good-bg); color: var(--good); border-color: transparent; }
.badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
.badge.info { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); border-color: transparent; }
/* Bar chart */
.chart { position: relative; }
.chart svg { display: block; width: 100%; height: 180px; overflow: visible; }
.chart .bar { fill: var(--series-1); }
.chart .bar.hover { fill: color-mix(in srgb, var(--series-1) 75%, var(--text)); }
.chart .axis { fill: var(--muted); font-size: 11px; }
.chart .grid-line { stroke: var(--border); stroke-width: 1; }
.chart-tip { position: absolute; pointer-events: none; background: var(--text); color: var(--bg); padding: 6px 9px; border-radius: 6px; font-size: 12px; white-space: nowrap; transform: translate(-50%, -110%); }
/* Modal & toast */
dialog { border: 1px solid var(--border); border-radius: 14px; padding: 0; background: var(--surface); color: var(--text); width: min(560px, calc(100vw - 32px)); box-shadow: 0 20px 60px rgba(0, 0, 0, .25); }
dialog::backdrop { background: rgba(10, 14, 22, .45); }
#modal-form { padding: 22px; display: grid; gap: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); z-index: 100; max-width: calc(100vw - 40px); }
.toast.err { background: var(--bad); color: #fff; }
.secret { padding: 10px; background: var(--surface-2); border: 1px dashed var(--border); border-radius: 8px; overflow-wrap: anywhere; user-select: all; }
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 12px; color: var(--text-2); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }
.kv dt { color: var(--text-2); }
.kv dd { margin: 0; }
@media (max-width: 820px) {
.app { grid-template-columns: minmax(0, 1fr); }
.sidebar { position: static; height: auto; padding: 12px; }
nav { grid-auto-flow: column; overflow-x: auto; }
nav a { white-space: nowrap; }
.sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; margin-top: 12px; }
.toolbar input, .toolbar select { min-width: 0; flex: 1 1 140px; }
main { padding: 16px; }
}
+30
View File
@@ -0,0 +1,30 @@
services:
proxy:
build: .
image: proxy-manager:latest
container_name: proxy-manager
restart: unless-stopped
env_file: .env
environment:
HTTP_PROXY_PORT: 3128
SOCKS_PORT: 1080
WEB_PORT: 8080
SOCKS_UDP_PORTS: 10800-10819
FORWARD_PORTS: 40000-40019
volumes:
- ./data:/data # Bind Mount: Datenbank (Benutzer, Regeln, Verlauf)
ports:
- "${HTTP_PORT_HOST:-3128}:3128" # HTTP/HTTPS-Proxy (versteht auch SOCKS5)
- "${SOCKS_PORT_HOST:-1080}:1080" # SOCKS5 (versteht auch HTTP)
- "${WEB_PORT_HOST:-8080}:8080" # WebUI + API
- "10800-10819:10800-10819/udp" # SOCKS5 UDP ASSOCIATE
- "40000-40019:40000-40019/tcp" # Portweiterleitungen TCP
- "40000-40019:40000-40019/udp" # Portweiterleitungen UDP (z. B. WireGuard)
# Alternative: Host-Netzwerk, dann sind alle Weiterleitungs-Ports ohne Mapping nutzbar.
# Dazu "ports:" entfernen und folgende Zeile aktivieren:
# network_mode: host
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+2
View File
@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn==0.34.0