commit e0b4eb3fe53254bb375e24d457823fd144e8d42a Author: duffyduck Date: Mon Oct 5 13:33:59 2026 +0200 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0842a1c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3d2203 --- /dev/null +++ b/README.md @@ -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://: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`. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..cfb5d61 --- /dev/null +++ b/app/api.py @@ -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?", (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=?", + (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") diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..592e7dc --- /dev/null +++ b/app/auth.py @@ -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() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..cc9cc8a --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/core.py b/app/core.py new file mode 100644 index 0000000..74255e6 --- /dev/null +++ b/app/core.py @@ -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 diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..baecd07 --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/forwarder.py b/app/forwarder.py new file mode 100644 index 0000000..f7ddc19 --- /dev/null +++ b/app/forwarder.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1e8b5b5 --- /dev/null +++ b/app/main.py @@ -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 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()) diff --git a/app/policy.py b/app/policy.py new file mode 100644 index 0000000..1b5a576 --- /dev/null +++ b/app/policy.py @@ -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() diff --git a/app/proxy_http.py b/app/proxy_http.py new file mode 100644 index 0000000..ccb9e4c --- /dev/null +++ b/app/proxy_http.py @@ -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"{html.escape(title)}" + "" + "
" + f"

{html.escape(title)}

" + f"

{html.escape(text)}

") + + +# --------------------------------------------------------------------------- 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 diff --git a/app/proxy_socks.py b/app/proxy_socks.py new file mode 100644 index 0000000..a5e2459 --- /dev/null +++ b/app/proxy_socks.py @@ -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) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..4248c0c --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,624 @@ +"use strict"; + +// ------------------------------------------------------------------ Grundlagen +const $ = (sel, el = document) => el.querySelector(sel); +const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 `${esc(l)}`; }; +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) => `${esc(PROTO[p] || p || "–")}`; +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 = `

${esc(title)}

${fieldsHtml} + + `; + 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", `

${esc(text)}

`, { 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]) => + `${ico}${label}`).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 = `
${esc(e.message)}
`; } +} + +const head = (title, sub = "", actions = "") => + `

${esc(title)}

${sub ? `

${sub}

` : ""}
${actions}
`; + +async function loadUsers() { state.users = await api("/api/users"); return state.users; } +const userOptions = (selected, withGlobal = true) => + (withGlobal ? `` : "") + + state.users.map((u) => ``).join(""); + +// ------------------------------------------------------------------ Diagramm +function barChart(el, points, bucket) { + if (!points.length) { el.innerHTML = `
Noch keine Daten im Zeitraum
`; 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 = ``; + for (const t of ticks) svg += `${fmtNum(t)}`; + 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 += ``; + svg += ``; + if (i % every === 0) svg += `${fmtLabel(p.b)}`; + }); + svg += ""; + el.innerHTML = svg + ``; + 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 = `${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}
${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 = ``; + const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0"; + const list = (rows, cols) => rows.length ? `
${rows.map(cols).join("")}
` : `
Keine Daten
`; + main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + ` +
+
+
Anfragen
${fmtNum(t.total)}
${fmtNum(t.users)} aktive Benutzer
+
Gesperrt
${fmtNum(t.blocked)}
${blockedPct} % aller Anfragen
+
Datenvolumen
${fmtBytes(t.up + t.down)}
↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}
+
Live-Verbindungen
${fmtNum(s.active_connections)}
${fmtNum(s.user_count)} Benutzer angelegt
+
Fehler / Fehllogins
${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}
Verbindungsfehler / Anmeldung
+
+

Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}

+
+

Meistbesuchte Ziele

${list(s.top_hosts, (r) => `${esc(r.host)}${fmtNum(r.n)}${fmtBytes(r.bytes)}`)}
+

Meistgesperrte Ziele

${list(s.top_blocked, (r) => `${esc(r.host)}${fmtNum(r.n)}`)}
+

Benutzer nach Datenvolumen

${list(s.top_users, (r) => `${esc(r.username)}${fmtNum(r.n)}${fmtBytes(r.bytes)}`)}
+
+
`; + $("#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 `
+ + +
+ + + + `; +} + +VIEWS.users = async (main) => { + const users = await loadUsers(); + main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", ``) + + `
+ ${users.map((u) => ` + + + + + + + `).join("") || ``} +
BenutzerStatusRichtlinieEigene RegelnZuletzt aktivKommentar
${esc(u.username)} ${u.is_admin ? 'Admin' : ""}${u.enabled ? 'Aktiv' : 'Gesperrt'}${esc(policyLabel[u.policy])}${u.rule_count}${ago(u.last_seen)}${esc(u.comment)} + Verlauf + + +
Keine Benutzer
`; + $("#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 ` + +
+ + +
+ + `; +} +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 = ``; + main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports", + `${filterSel}`) + + `
So wird ausgewertet: Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie. + Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine Whitelist die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.
+
+ ${rules.map((r) => ` + + + + + + + + `).join("") || ``} +
Gilt fürAktionHost / NetzPortsKommentarAktiv
${r.user_id ? `${esc(r.username)}` : 'Global'}${r.action === "deny" ? 'Sperren' : 'Erlauben'}${esc(r.host || "* (alle)")}${esc(r.ports || "alle")}${esc(r.comment)}${r.enabled ? "✓" : "–"}
Keine Regeln – alles wird nach der Standardrichtlinie behandelt.
`; + $("#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 = ` + + + + `; + 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 = ` +
+
`; + 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 = `
${res.allowed ? 'Erlaubt' : 'Gesperrt'} ${esc(res.reason)} + ${res.addresses.length ? `
${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("
")}
` : ""} + ${res.dns_error ? `
DNS: ${esc(res.dns_error)}
` : ""}
`; + 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 ` +
+ + +
+
+ + +
+ + + `; +} +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: ${esc(ports_hint)} (TCP & UDP).`, + ``) + + `
+ ${items.map((f) => ` + + + + + + + + `).join("") || ``} +
NameProtoProxy-PortZielErlaubte QuellenStatus
${esc(f.name)}${f.comment ? `
${esc(f.comment)}
` : ""}
${f.protocol.toUpperCase()}${f.listen_port}${esc(f.target_host)}:${f.target_port}${esc(f.allowed_sources || "alle")}${f.status === "aktiv" ? 'aktiv' : f.status === "deaktiviert" ? 'deaktiviert' : `${esc(f.status)}`}
Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820
`; + $("#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) => ``; + main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen", + admin ? `` : "") + + `
+ ${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""} + + ${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))} + ${admin ? sel("proto", Object.entries(PROTO)) : ""} + + + + Zurücksetzen +
+
${admin ? "" : ""} + ${data.items.map((r) => ` + + ${admin ? `` : ""} + + + + + + `).join("") || ``} +
ZeitBenutzerClientProtokollZielStatus↑↓Dauer
${fmtTime(r.ts)}${r.username ? `${esc(r.username)}` : '–'}${esc(r.client_ip)}${protoBadge(r.proto)}${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}${r.detail ? `
${esc(r.detail)}
` : ""}
${statusBadge(r.status)}${fmtBytes(r.bytes_up)}${fmtBytes(r.bytes_down)}${fmtDur(r.duration)}
Keine Einträge
+
${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))} + +
`; + 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") + `
`; + const draw = async () => { + const list = await api("/api/connections"); + const el = $("#conns"); + if (!el) return; + el.innerHTML = `
+ ${list.map((c) => ` + + + `).join("") || ``} +
SeitBenutzerClientProtokollZiel↑↓
${fmtDur(Date.now() / 1000 - c.started)}${esc(c.username || "–")}${esc(c.client_ip)}${protoBadge(c.proto)}${esc(c.url || `${c.host}:${c.port}`)}${fmtBytes(c.bytes_up)}${fmtBytes(c.bytes_down)}
Keine aktiven Verbindungen
`; + }; + 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 `

Clients einrichten

+
+
HTTP/HTTPS-Proxy
${esc(host)}:${s.ports.http}
+
SOCKS5-Proxy
${esc(host)}:${s.ports.socks}
+
Auto-Konfiguration (PAC)
http://${esc(host)}:${s.ports.web}/proxy.pac
+
SOCKS5 UDP-Ports
${esc(s.ports.socks_udp)}
+
Weiterleitungs-Ports
${esc(s.ports.forwards)}
+
+

Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen. + Thunderbird & Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy); + für SMTP/IMAP in Outlook oder WireGuard eine Weiterleitung anlegen. + Kommandozeile: export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}

`; +} + +VIEWS.settings = async (main) => { + const s = await api("/api/settings"); + main.innerHTML = head("Einstellungen") + `
+
+ +
+ + + +
+ + + +
+
+ ${setupHelp(s)} +
`; + $("#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") + `
+
HTTP/HTTPS-Proxy
${esc(host)}:3128
+
SOCKS5
${esc(host)}:1080
+
PAC-Datei
http://${esc(host)}:${esc(location.port || 80)}/proxy.pac
+
Anmeldung
Dein Benutzername und Passwort
`; +}; + +// ---- 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. Interaktive API-Doku öffnen ↗`, + ``) + `
+
+ ${tokens.map((t) => ` + `).join("") || ``} +
NameBesitzerErstelltLäuft abZuletzt benutzt
${esc(t.name)}${esc(t.username)}${fmtTime(t.created_at)}${t.expires_at ? fmtTime(t.expires_at) : "nie"}${ago(t.last_used)}
Noch keine API-Tokens
+

Beispiele

# Benutzer anlegen
+curl -X POST ${esc(location.origin)}/api/users \\
+  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
+  -d '{"username":"max","password":"geheim123"}'
+
+# Domain global sperren
+curl -X POST ${esc(location.origin)}/api/rules \\
+  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
+  -d '{"action":"deny","host":"facebook.com"}'
+
+# Verlauf eines Benutzers
+curl "${esc(location.origin)}/api/history?username=max&limit=50" -H "Authorization: Bearer <TOKEN>"
`; + $("#add").onclick = async () => { + const out = await modalLoop("API-Token erstellen", ` + `, + (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", `

Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:

${esc(out.token)}
+

Verwendung: Header Authorization: Bearer <Token> oder X-API-Key: <Token>

`, { 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 ${esc(state.me.username)}`; + 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", ` + `, + (d) => api("/api/me/password", { method: "POST", body: d })); + if (ok) toast("Passwort geändert"); +}; +window.addEventListener("hashchange", () => state.me && route()); +start(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..2de1f93 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,42 @@ + + + + + +Proxy Manager + + + + + + + + + + + + + + + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..8da30a9 --- /dev/null +++ b/app/static/style.css @@ -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; } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4b8db2 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f8938d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn==0.34.0