first commit
This commit is contained in:
+16
@@ -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"]
|
||||
@@ -0,0 +1,101 @@
|
||||
# Proxy Manager
|
||||
|
||||
Ein Proxy-Server im Docker-Container, ähnlich wie Squid, aber mit WebUI und REST-API.
|
||||
|
||||
- **HTTP/HTTPS-Proxy** (Port 3128) für Browser, Systemproxy, Outlook/Exchange (EWS, MAPI over HTTP, OWA, ActiveSync)
|
||||
- **SOCKS5-Proxy** (Port 1080) mit TCP **und UDP**. Darüber läuft jedes TCP-Protokoll, z. B. SMTP (25/465/587), IMAP (143/993), POP3 (110/995), SSH, RDP oder FTP-Steuerverbindungen.
|
||||
- **Portweiterleitungen TCP/UDP** für Programme ohne Proxy-Unterstützung, z. B. WireGuard, Outlook-Mailkonten oder Geräte
|
||||
- Beide Proxy-Ports verstehen **HTTP und SOCKS5**, das Protokoll wird automatisch erkannt.
|
||||
- **Benutzerverwaltung** mit Login am Proxy (Basic-Auth bzw. SOCKS5-Login)
|
||||
- **Black-/Whitelist** für Domains, Wildcards, Regex, IPs/Netze und Ports, global oder pro Benutzer
|
||||
- **Verlauf:** Wer hat wann was aufgerufen, mit Datenmenge, Dauer und Status, als CSV exportierbar
|
||||
- **Live-Verbindungen** ansehen und trennen
|
||||
- **Dashboard** mit Statistik
|
||||
- **REST-API** mit API-Tokens und Swagger-Doku unter `/api/docs`
|
||||
- **PAC-Datei** für automatische Browser-Konfiguration unter `/proxy.pac`
|
||||
- Daten liegen in einer SQLite-Datei im **Bind Mount** `./data`, es gibt keine Docker-Volumes.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env # ADMIN_PASSWORD setzen (sonst wird eins generiert, siehe Log)
|
||||
docker-compose up -d --build
|
||||
docker-compose logs proxy # zeigt ggf. das generierte Admin-Passwort
|
||||
```
|
||||
|
||||
WebUI: `http://<host>:8080` (Host-Ports in `.env` änderbar: `HTTP_PORT_HOST`, `SOCKS_PORT_HOST`, `WEB_PORT_HOST`)
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Zweck |
|
||||
|---|---|
|
||||
| 3128/tcp | HTTP/HTTPS-Proxy (+ SOCKS5) |
|
||||
| 1080/tcp | SOCKS5 (+ HTTP) |
|
||||
| 8080/tcp | WebUI + API |
|
||||
| 10800-10819/udp | SOCKS5 UDP ASSOCIATE (ein Port pro aktiver UDP-Sitzung) |
|
||||
| 40000-40019/tcp+udp | Frei für Portweiterleitungen |
|
||||
|
||||
Die Bereiche sind in `docker-compose.yml` einstellbar (`SOCKS_UDP_PORTS`, `FORWARD_PORTS` plus das Port-Mapping).
|
||||
Mit `network_mode: host` entfällt das Mapping. Dann ist jeder Port für Weiterleitungen nutzbar.
|
||||
|
||||
Für SOCKS5-UDP muss `SOCKS_UDP_ADVERTISE` in `.env` die IP des Docker-Hosts enthalten, wie sie die Clients erreichen.
|
||||
Im Host-Netzwerk-Modus ist das nicht nötig.
|
||||
|
||||
## Clients einrichten
|
||||
|
||||
| Client | Einstellung |
|
||||
|---|---|
|
||||
| Browser | HTTP-Proxy `host:3128` oder PAC-URL `http://host:8080/proxy.pac` |
|
||||
| Windows-Systemproxy / Outlook (Exchange) | HTTP-Proxy `host:3128` |
|
||||
| Thunderbird (SMTP/IMAP/POP3) | SOCKS5 `host:1080`, „DNS über SOCKS“ aktivieren |
|
||||
| Kommandozeile | `export https_proxy=http://user:pass@host:3128` |
|
||||
| WireGuard | Weiterleitung UDP `40000 → vpn.example.com:51820`, im Client `Endpoint = host:40000` |
|
||||
| Outlook IMAP/SMTP-Konto | Weiterleitungen, z. B. TCP `40001 → imap.example.com:993` und `40002 → smtp.example.com:587`. Achtung: Das TLS-Zertifikat lautet auf den echten Servernamen, ggf. per hosts-Eintrag auf den Proxy zeigen lassen. |
|
||||
| Programme ohne Proxy-Support | Proxifier/proxychains mit SOCKS5 oder eine Weiterleitung |
|
||||
|
||||
## Regeln
|
||||
|
||||
Auswertung in dieser Reihenfolge:
|
||||
|
||||
1. Loopback-Schutz (127.0.0.0/8, ::1), abschaltbar
|
||||
2. Sperr-Regeln des Benutzers
|
||||
3. Erlaubt-Regeln des Benutzers
|
||||
4. Globale Sperr-Regeln
|
||||
5. Globale Erlaubt-Regeln
|
||||
6. Standardrichtlinie des Benutzers bzw. die globale Standardrichtlinie
|
||||
|
||||
| Host-Muster | Bedeutung |
|
||||
|---|---|
|
||||
| leer / `*` | alle Ziele |
|
||||
| `example.com` | Domain inkl. aller Subdomains |
|
||||
| `*.example.com` | Platzhalter |
|
||||
| `10.0.0.0/8`, `1.2.3.4` | IP/Netz, wird gegen die aufgelöste Ziel-IP geprüft |
|
||||
| `re:^ads?\.` | regulärer Ausdruck |
|
||||
|
||||
Ports: leer = alle, sonst z. B. `25`, `8000-9000` oder `25,465,587`.
|
||||
|
||||
**Whitelist für einen Benutzer:** Standardrichtlinie des Benutzers auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.
|
||||
**Blocklisten importieren:** „Liste importieren“ versteht eine Domain pro Zeile und das Hosts-Datei-Format (`0.0.0.0 ads.example.com`).
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Bei HTTPS sieht der Proxy nur Host und Port, nicht die komplette URL, weil es kein SSL-Aufbrechen gibt. Bei unverschlüsseltem HTTP wird die volle URL protokolliert.
|
||||
- Bei SOCKS5 mit lokaler DNS-Auflösung (`socks5://` statt `socks5h://`) kennt der Proxy nur die IP. Dann greifen nur IP-Regeln, Domain-Regeln nicht. In den Clients deshalb „DNS über Proxy“ aktivieren.
|
||||
- Der Verlauf wird nach `log_retention_days` (Standard 90 Tage) automatisch bereinigt. Rechtliche Vorgaben (DSGVO, Betriebsrat) beachten.
|
||||
- Backup: Ordner `./data` sichern.
|
||||
|
||||
## API
|
||||
|
||||
Interaktive Doku: `http://host:8080/api/docs`. Token in der WebUI unter „API“ erzeugen.
|
||||
|
||||
```bash
|
||||
TOKEN=...
|
||||
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-X POST http://host:8080/api/users -d '{"username":"max","password":"geheim123"}'
|
||||
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-X POST http://host:8080/api/rules -d '{"action":"deny","host":"tiktok.com"}'
|
||||
curl -H "Authorization: Bearer $TOKEN" "http://host:8080/api/history?username=max&limit=50"
|
||||
```
|
||||
|
||||
Wichtige Endpunkte: `/api/users`, `/api/rules` (+ `/bulk`, `/test`), `/api/forwards`, `/api/history` (+ `/export.csv`),
|
||||
`/api/stats`, `/api/connections`, `/api/settings`, `/api/tokens`.
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
"""REST-API und WebUI."""
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import config
|
||||
from .auth import hash_password, new_token, token_hash, users, verify_password
|
||||
from .core import registry
|
||||
from .db import db
|
||||
from .forwarder import forwards
|
||||
from .policy import parse_networks, policy, validate_rule
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
|
||||
app = FastAPI(title="Proxy Manager API", version="1.0", docs_url="/api/docs", redoc_url=None,
|
||||
openapi_url="/api/openapi.json")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Auth
|
||||
def current_user(request: Request) -> dict:
|
||||
header = request.headers.get("authorization", "")
|
||||
token = header[7:].strip() if header.lower().startswith("bearer ") else request.headers.get("x-api-key", "")
|
||||
if not token:
|
||||
raise HTTPException(401, "Nicht angemeldet")
|
||||
row = db.one("SELECT t.id tid, t.expires_at, u.* FROM tokens t JOIN users u ON u.id=t.user_id "
|
||||
"WHERE t.token_hash=?", (token_hash(token),))
|
||||
if not row or not row["enabled"] or (row["expires_at"] and row["expires_at"] < time.time()):
|
||||
raise HTTPException(401, "Sitzung ungültig oder abgelaufen")
|
||||
db.execute("UPDATE tokens SET last_used=? WHERE id=?", (time.time(), row["tid"]))
|
||||
return row
|
||||
|
||||
|
||||
def admin_user(user: dict = Depends(current_user)) -> dict:
|
||||
if not user["is_admin"]:
|
||||
raise HTTPException(403, "Nur für Administratoren")
|
||||
return user
|
||||
|
||||
|
||||
def public_user(u: dict) -> dict:
|
||||
return {k: u[k] for k in ("id", "username", "is_admin", "enabled", "policy", "comment", "created_at",
|
||||
"last_seen")}
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/api/auth/login", tags=["Auth"])
|
||||
def login(data: LoginIn, request: Request):
|
||||
ip = request.client.host if request.client else "?"
|
||||
if users.is_locked(ip):
|
||||
raise HTTPException(429, "Zu viele Fehlversuche, bitte später erneut versuchen")
|
||||
u = db.one("SELECT * FROM users WHERE username=?", (data.username,))
|
||||
if not u or not u["enabled"] or not verify_password(data.password, u["password_hash"]):
|
||||
users._fail(ip)
|
||||
raise HTTPException(401, "Benutzername oder Passwort falsch")
|
||||
token = new_token()
|
||||
db.execute("INSERT INTO tokens(token_hash,user_id,name,kind,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
||||
(token_hash(token), u["id"], "WebUI", "session", time.time(),
|
||||
time.time() + config.SESSION_HOURS * 3600))
|
||||
db.execute("DELETE FROM tokens WHERE kind='session' AND expires_at<?", (time.time(),))
|
||||
return {"token": token, "user": public_user(u)}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout", tags=["Auth"])
|
||||
def logout(request: Request, user: dict = Depends(current_user)):
|
||||
header = request.headers.get("authorization", "")
|
||||
db.execute("DELETE FROM tokens WHERE token_hash=? AND kind='session'", (token_hash(header[7:].strip()),))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/me", tags=["Auth"])
|
||||
def me(user: dict = Depends(current_user)):
|
||||
return public_user(user)
|
||||
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
old_password: str
|
||||
new_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
@app.post("/api/me/password", tags=["Auth"])
|
||||
def change_own_password(data: PasswordIn, user: dict = Depends(current_user)):
|
||||
if not verify_password(data.old_password, user["password_hash"]):
|
||||
raise HTTPException(400, "Altes Passwort ist falsch")
|
||||
db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(data.new_password), user["id"]))
|
||||
users.reload()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Benutzer
|
||||
class UserIn(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
||||
password: str | None = Field(default=None, min_length=6)
|
||||
is_admin: bool = False
|
||||
enabled: bool = True
|
||||
policy: Literal["inherit", "allow", "deny"] = "inherit"
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
||||
password: str | None = Field(default=None, min_length=6)
|
||||
is_admin: bool | None = None
|
||||
enabled: bool | None = None
|
||||
policy: Literal["inherit", "allow", "deny"] | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _reload_all():
|
||||
users.reload()
|
||||
policy.reload()
|
||||
|
||||
|
||||
@app.get("/api/users", tags=["Benutzer"])
|
||||
def list_users(_: dict = Depends(admin_user)):
|
||||
rows = db.query("SELECT u.*, (SELECT COUNT(*) FROM rules r WHERE r.user_id=u.id) rule_count FROM users u "
|
||||
"ORDER BY username")
|
||||
out = []
|
||||
for r in rows:
|
||||
d = public_user(r)
|
||||
d["rule_count"] = r["rule_count"]
|
||||
d["last_seen"] = max(r["last_seen"] or 0, users.last_seen.get(r["id"], 0)) or None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/users", tags=["Benutzer"], status_code=201)
|
||||
def create_user(data: UserIn, _: dict = Depends(admin_user)):
|
||||
if not data.password:
|
||||
raise HTTPException(400, "Passwort erforderlich")
|
||||
if db.one("SELECT id FROM users WHERE username=?", (data.username,)):
|
||||
raise HTTPException(409, "Benutzername existiert bereits")
|
||||
cur = db.execute("INSERT INTO users(username,password_hash,is_admin,enabled,policy,comment,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?)",
|
||||
(data.username, hash_password(data.password), int(data.is_admin), int(data.enabled),
|
||||
data.policy, data.comment, time.time()))
|
||||
_reload_all()
|
||||
return public_user(db.one("SELECT * FROM users WHERE id=?", (cur.lastrowid,)))
|
||||
|
||||
|
||||
@app.get("/api/users/{uid}", tags=["Benutzer"])
|
||||
def get_user(uid: int, _: dict = Depends(admin_user)):
|
||||
u = db.one("SELECT * FROM users WHERE id=?", (uid,))
|
||||
if not u:
|
||||
raise HTTPException(404, "Benutzer nicht gefunden")
|
||||
return public_user(u)
|
||||
|
||||
|
||||
@app.put("/api/users/{uid}", tags=["Benutzer"])
|
||||
def update_user(uid: int, data: UserUpdate, me_: dict = Depends(admin_user)):
|
||||
u = db.one("SELECT * FROM users WHERE id=?", (uid,))
|
||||
if not u:
|
||||
raise HTTPException(404, "Benutzer nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if uid == me_["id"] and (fields.get("is_admin") is False or fields.get("enabled") is False):
|
||||
raise HTTPException(400, "Du kannst dich nicht selbst sperren oder entmachten")
|
||||
if "username" in fields and db.one("SELECT id FROM users WHERE username=? AND id<>?", (fields["username"], uid)):
|
||||
raise HTTPException(409, "Benutzername existiert bereits")
|
||||
if "password" in fields:
|
||||
fields["password_hash"] = hash_password(fields.pop("password"))
|
||||
for k in ("is_admin", "enabled"):
|
||||
if k in fields:
|
||||
fields[k] = int(fields[k])
|
||||
if fields:
|
||||
db.execute(f"UPDATE users SET {', '.join(f'{k}=?' for k in fields)} WHERE id=?", (*fields.values(), uid))
|
||||
if fields.get("enabled") == 0 or "password_hash" in fields:
|
||||
db.execute("DELETE FROM tokens WHERE user_id=? AND kind='session'", (uid,))
|
||||
_reload_all()
|
||||
return public_user(db.one("SELECT * FROM users WHERE id=?", (uid,)))
|
||||
|
||||
|
||||
@app.delete("/api/users/{uid}", tags=["Benutzer"])
|
||||
def delete_user(uid: int, me_: dict = Depends(admin_user)):
|
||||
if uid == me_["id"]:
|
||||
raise HTTPException(400, "Du kannst dich nicht selbst löschen")
|
||||
db.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
_reload_all()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Regeln
|
||||
class RuleIn(BaseModel):
|
||||
user_id: int | None = None
|
||||
action: Literal["allow", "deny"]
|
||||
host: str = ""
|
||||
ports: str = ""
|
||||
comment: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class RuleBulkIn(BaseModel):
|
||||
user_id: int | None = None
|
||||
action: Literal["allow", "deny"]
|
||||
hosts: list[str]
|
||||
ports: str = ""
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class RuleTestIn(BaseModel):
|
||||
username: str | None = None
|
||||
host: str
|
||||
port: int = 443
|
||||
|
||||
|
||||
def _check_rule(host: str, ports: str, user_id):
|
||||
try:
|
||||
validate_rule(host, ports)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(400, f"Ungültige Regel: {e}")
|
||||
if user_id is not None and not db.one("SELECT id FROM users WHERE id=?", (user_id,)):
|
||||
raise HTTPException(400, "Benutzer existiert nicht")
|
||||
|
||||
|
||||
@app.get("/api/rules", tags=["Regeln"])
|
||||
def list_rules(user_id: int | None = None, scope: Literal["all", "global", "user"] = "all",
|
||||
_: dict = Depends(admin_user)):
|
||||
sql = "SELECT r.*, u.username FROM rules r LEFT JOIN users u ON u.id=r.user_id WHERE 1=1"
|
||||
params: list = []
|
||||
if user_id is not None:
|
||||
sql += " AND r.user_id=?"
|
||||
params.append(user_id)
|
||||
elif scope == "global":
|
||||
sql += " AND r.user_id IS NULL"
|
||||
elif scope == "user":
|
||||
sql += " AND r.user_id IS NOT NULL"
|
||||
return db.query(sql + " ORDER BY r.user_id IS NOT NULL, u.username, r.action, r.host", params)
|
||||
|
||||
|
||||
@app.post("/api/rules", tags=["Regeln"], status_code=201)
|
||||
def create_rule(data: RuleIn, _: dict = Depends(admin_user)):
|
||||
_check_rule(data.host, data.ports, data.user_id)
|
||||
cur = db.execute("INSERT INTO rules(user_id,action,host,ports,comment,enabled,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(data.user_id, data.action, data.host.strip(), data.ports.strip(), data.comment,
|
||||
int(data.enabled), time.time()))
|
||||
policy.reload()
|
||||
return db.one("SELECT * FROM rules WHERE id=?", (cur.lastrowid,))
|
||||
|
||||
|
||||
@app.post("/api/rules/bulk", tags=["Regeln"], status_code=201)
|
||||
def bulk_rules(data: RuleBulkIn, _: dict = Depends(admin_user)):
|
||||
hosts = []
|
||||
for h in data.hosts:
|
||||
h = h.split("#", 1)[0].strip()
|
||||
# Hosts-Datei-Format "0.0.0.0 ads.example.com" unterstützen
|
||||
parts = h.split()
|
||||
if len(parts) == 2 and parts[0] in ("0.0.0.0", "127.0.0.1", "::"):
|
||||
h = parts[1]
|
||||
if h:
|
||||
_check_rule(h, data.ports, data.user_id)
|
||||
hosts.append(h)
|
||||
existing = {(r["host"].lower(), r["ports"]) for r in
|
||||
db.query("SELECT host, ports FROM rules WHERE action=? AND user_id IS ?", (data.action, data.user_id))}
|
||||
rows = [(data.user_id, data.action, h, data.ports.strip(), data.comment, 1, time.time())
|
||||
for h in dict.fromkeys(hosts) if (h.lower(), data.ports.strip()) not in existing]
|
||||
if rows:
|
||||
db.executemany("INSERT INTO rules(user_id,action,host,ports,comment,enabled,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
rows)
|
||||
policy.reload()
|
||||
return {"added": len(rows), "skipped": len(hosts) - len(rows)}
|
||||
|
||||
|
||||
@app.put("/api/rules/{rid}", tags=["Regeln"])
|
||||
def update_rule(rid: int, data: RuleIn, _: dict = Depends(admin_user)):
|
||||
if not db.one("SELECT id FROM rules WHERE id=?", (rid,)):
|
||||
raise HTTPException(404, "Regel nicht gefunden")
|
||||
_check_rule(data.host, data.ports, data.user_id)
|
||||
db.execute("UPDATE rules SET user_id=?, action=?, host=?, ports=?, comment=?, enabled=? WHERE id=?",
|
||||
(data.user_id, data.action, data.host.strip(), data.ports.strip(), data.comment, int(data.enabled),
|
||||
rid))
|
||||
policy.reload()
|
||||
return db.one("SELECT * FROM rules WHERE id=?", (rid,))
|
||||
|
||||
|
||||
@app.delete("/api/rules/{rid}", tags=["Regeln"])
|
||||
def delete_rule(rid: int, _: dict = Depends(admin_user)):
|
||||
db.execute("DELETE FROM rules WHERE id=?", (rid,))
|
||||
policy.reload()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/rules/test", tags=["Regeln"])
|
||||
async def test_rule(data: RuleTestIn, _: dict = Depends(admin_user)):
|
||||
"""Prüft, ob ein Benutzer ein Ziel erreichen dürfte (inkl. DNS-Auflösung)."""
|
||||
import ipaddress
|
||||
from .core import resolve
|
||||
user = users.get(data.username) if data.username else None
|
||||
if data.username and not user:
|
||||
raise HTTPException(404, "Benutzer nicht gefunden")
|
||||
d = policy.decide(user, data.host, data.port)
|
||||
result = {"allowed": d.allowed, "reason": d.reason, "addresses": []}
|
||||
if d.allowed:
|
||||
try:
|
||||
for _, sockaddr in await resolve(data.host, data.port):
|
||||
dd = policy.decide(user, data.host, data.port, ipaddress.ip_address(sockaddr[0].split("%")[0]))
|
||||
result["addresses"].append({"ip": sockaddr[0], "allowed": dd.allowed, "reason": dd.reason})
|
||||
if result["addresses"] and not any(a["allowed"] for a in result["addresses"]):
|
||||
result["allowed"] = False
|
||||
result["reason"] = result["addresses"][0]["reason"]
|
||||
except OSError as e:
|
||||
result["dns_error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Weiterleitungen
|
||||
class ForwardIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
protocol: Literal["tcp", "udp"]
|
||||
listen_port: int = Field(ge=1, le=65535)
|
||||
target_host: str = Field(min_length=1)
|
||||
target_port: int = Field(ge=1, le=65535)
|
||||
allowed_sources: str = ""
|
||||
enabled: bool = True
|
||||
comment: str = ""
|
||||
|
||||
|
||||
def _check_forward(data: ForwardIn, fid: int | None = None):
|
||||
reserved = {config.HTTP_PORT, config.SOCKS_PORT, config.WEB_PORT}
|
||||
if data.listen_port in reserved or (data.protocol == "udp" and data.listen_port in config.SOCKS_UDP_PORTS):
|
||||
raise HTTPException(400, "Port ist bereits vom Proxy belegt")
|
||||
if db.one("SELECT id FROM forwards WHERE protocol=? AND listen_port=? AND id IS NOT ?",
|
||||
(data.protocol, data.listen_port, fid)):
|
||||
raise HTTPException(409, "Port wird bereits für eine andere Weiterleitung verwendet")
|
||||
for part in data.allowed_sources.replace(",", " ").split():
|
||||
if not parse_networks(part):
|
||||
raise HTTPException(400, f"Ungültiges Netz: {part}")
|
||||
|
||||
|
||||
def _forward_out(f):
|
||||
f = dict(f)
|
||||
f["status"] = forwards.status.get(f["id"], "unbekannt")
|
||||
return f
|
||||
|
||||
|
||||
@app.get("/api/forwards", tags=["Weiterleitungen"])
|
||||
def list_forwards(_: dict = Depends(admin_user)):
|
||||
return {"ports_hint": config.FORWARD_PORTS,
|
||||
"items": [_forward_out(f) for f in db.query("SELECT * FROM forwards ORDER BY listen_port")]}
|
||||
|
||||
|
||||
@app.post("/api/forwards", tags=["Weiterleitungen"], status_code=201)
|
||||
async def create_forward(data: ForwardIn, _: dict = Depends(admin_user)):
|
||||
_check_forward(data)
|
||||
cur = db.execute("INSERT INTO forwards(name,protocol,listen_port,target_host,target_port,allowed_sources,enabled,"
|
||||
"comment,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(data.name, data.protocol, data.listen_port, data.target_host.strip(), data.target_port,
|
||||
data.allowed_sources.strip(), int(data.enabled), data.comment, time.time()))
|
||||
await forwards.reload()
|
||||
return _forward_out(db.one("SELECT * FROM forwards WHERE id=?", (cur.lastrowid,)))
|
||||
|
||||
|
||||
@app.put("/api/forwards/{fid}", tags=["Weiterleitungen"])
|
||||
async def update_forward(fid: int, data: ForwardIn, _: dict = Depends(admin_user)):
|
||||
if not db.one("SELECT id FROM forwards WHERE id=?", (fid,)):
|
||||
raise HTTPException(404, "Weiterleitung nicht gefunden")
|
||||
_check_forward(data, fid)
|
||||
db.execute("UPDATE forwards SET name=?, protocol=?, listen_port=?, target_host=?, target_port=?, "
|
||||
"allowed_sources=?, enabled=?, comment=? WHERE id=?",
|
||||
(data.name, data.protocol, data.listen_port, data.target_host.strip(), data.target_port,
|
||||
data.allowed_sources.strip(), int(data.enabled), data.comment, fid))
|
||||
await forwards.reload()
|
||||
return _forward_out(db.one("SELECT * FROM forwards WHERE id=?", (fid,)))
|
||||
|
||||
|
||||
@app.delete("/api/forwards/{fid}", tags=["Weiterleitungen"])
|
||||
async def delete_forward(fid: int, _: dict = Depends(admin_user)):
|
||||
db.execute("DELETE FROM forwards WHERE id=?", (fid,))
|
||||
await forwards.reload()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Verlauf
|
||||
def _history_filter(username, q, status, proto, since, until):
|
||||
where, params = ["1=1"], []
|
||||
if username:
|
||||
where.append("username=?")
|
||||
params.append(username)
|
||||
if q:
|
||||
where.append("(host LIKE ? OR url LIKE ? OR client_ip LIKE ? OR detail LIKE ?)")
|
||||
params += [f"%{q}%"] * 4
|
||||
if status:
|
||||
where.append("status=?")
|
||||
params.append(status)
|
||||
if proto:
|
||||
where.append("proto=?")
|
||||
params.append(proto)
|
||||
if since:
|
||||
where.append("ts>=?")
|
||||
params.append(since)
|
||||
if until:
|
||||
where.append("ts<=?")
|
||||
params.append(until)
|
||||
return " AND ".join(where), params
|
||||
|
||||
|
||||
def _history(username, q, status, proto, since, until, limit, offset):
|
||||
where, params = _history_filter(username, q, status, proto, since, until)
|
||||
total = db.one(f"SELECT COUNT(*) n FROM history WHERE {where}", params)["n"]
|
||||
rows = db.query(f"SELECT * FROM history WHERE {where} ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?",
|
||||
(*params, limit, offset))
|
||||
return {"total": total, "items": rows}
|
||||
|
||||
|
||||
@app.get("/api/history", tags=["Verlauf"])
|
||||
def get_history(username: str | None = None, q: str | None = None, status: str | None = None,
|
||||
proto: str | None = None, since: float | None = None, until: float | None = None,
|
||||
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0),
|
||||
_: dict = Depends(admin_user)):
|
||||
return _history(username, q, status, proto, since, until, limit, offset)
|
||||
|
||||
|
||||
@app.get("/api/me/history", tags=["Verlauf"])
|
||||
def my_history(q: str | None = None, status: str | None = None, since: float | None = None,
|
||||
until: float | None = None, limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0),
|
||||
user: dict = Depends(current_user)):
|
||||
return _history(user["username"], q, status, None, since, until, limit, offset)
|
||||
|
||||
|
||||
@app.get("/api/history/export.csv", tags=["Verlauf"])
|
||||
def export_history(username: str | None = None, q: str | None = None, status: str | None = None,
|
||||
proto: str | None = None, since: float | None = None, until: float | None = None,
|
||||
_: dict = Depends(admin_user)):
|
||||
where, params = _history_filter(username, q, status, proto, since, until)
|
||||
|
||||
def gen():
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";")
|
||||
w.writerow(["Zeit", "Benutzer", "Client-IP", "Protokoll", "Methode", "Host", "Port", "URL", "Status",
|
||||
"Detail", "Bytes hoch", "Bytes runter", "Dauer (s)"])
|
||||
last_id = None
|
||||
while True:
|
||||
extra = " AND id<?" if last_id else ""
|
||||
rows = db.query(f"SELECT * FROM history WHERE {where}{extra} ORDER BY id DESC LIMIT 5000",
|
||||
(*params, last_id) if last_id else params)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
w.writerow([time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])), r["username"],
|
||||
r["client_ip"], r["proto"], r["method"], r["host"], r["port"], r["url"], r["status"],
|
||||
r["detail"], r["bytes_up"], r["bytes_down"], r["duration"]])
|
||||
last_id = rows[-1]["id"]
|
||||
yield buf.getvalue()
|
||||
buf.seek(0)
|
||||
buf.truncate()
|
||||
yield buf.getvalue()
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=proxy-verlauf.csv"})
|
||||
|
||||
|
||||
@app.delete("/api/history", tags=["Verlauf"])
|
||||
def clear_history(before: float | None = None, _: dict = Depends(admin_user)):
|
||||
if before:
|
||||
n = db.execute("DELETE FROM history WHERE ts<?", (before,)).rowcount
|
||||
else:
|
||||
n = db.execute("DELETE FROM history").rowcount
|
||||
return {"deleted": n}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Statistik & Verbindungen
|
||||
@app.get("/api/stats", tags=["Statistik"])
|
||||
def stats(hours: int = Query(24, ge=1, le=24 * 90), _: dict = Depends(admin_user)):
|
||||
since = time.time() - hours * 3600
|
||||
tot = db.one("SELECT COUNT(*) total, SUM(status='blocked') blocked, SUM(status='error') errors, "
|
||||
"SUM(status='auth_failed') auth_failed, COALESCE(SUM(bytes_up),0) up, "
|
||||
"COALESCE(SUM(bytes_down),0) down, COUNT(DISTINCT username) users FROM history WHERE ts>=?",
|
||||
(since,))
|
||||
bucket = 3600 if hours <= 72 else 86400
|
||||
timeline = db.query("SELECT CAST(ts/? AS INTEGER)*? b, COUNT(*) n, SUM(status='blocked') blocked "
|
||||
"FROM history WHERE ts>=? GROUP BY b ORDER BY b", (bucket, bucket, since))
|
||||
top_hosts = db.query("SELECT host, COUNT(*) n, SUM(bytes_up+bytes_down) bytes FROM history "
|
||||
"WHERE ts>=? AND host IS NOT NULL GROUP BY host ORDER BY n DESC LIMIT 10", (since,))
|
||||
top_blocked = db.query("SELECT host, COUNT(*) n FROM history WHERE ts>=? AND status='blocked' "
|
||||
"GROUP BY host ORDER BY n DESC LIMIT 10", (since,))
|
||||
top_users = db.query("SELECT username, COUNT(*) n, SUM(bytes_up+bytes_down) bytes FROM history "
|
||||
"WHERE ts>=? AND username IS NOT NULL GROUP BY username ORDER BY bytes DESC LIMIT 10",
|
||||
(since,))
|
||||
return {"hours": hours, "bucket": bucket, "totals": {k: v or 0 for k, v in tot.items()},
|
||||
"timeline": timeline, "top_hosts": top_hosts, "top_blocked": top_blocked, "top_users": top_users,
|
||||
"active_connections": len(registry),
|
||||
"user_count": db.one("SELECT COUNT(*) n FROM users")["n"]}
|
||||
|
||||
|
||||
@app.get("/api/connections", tags=["Verbindungen"])
|
||||
def connections(_: dict = Depends(admin_user)):
|
||||
return sorted((c.to_dict() for c in registry.values()), key=lambda c: c["started"], reverse=True)
|
||||
|
||||
|
||||
@app.delete("/api/connections/{cid}", tags=["Verbindungen"])
|
||||
def kill_connection(cid: int, _: dict = Depends(admin_user)):
|
||||
c = registry.get(cid)
|
||||
if not c:
|
||||
raise HTTPException(404, "Verbindung nicht gefunden")
|
||||
c.kill()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Einstellungen
|
||||
class SettingsIn(BaseModel):
|
||||
default_policy: Literal["allow", "deny"] | None = None
|
||||
log_enabled: bool | None = None
|
||||
log_retention_days: int | None = Field(default=None, ge=0, le=3650)
|
||||
anonymous_networks: str | None = None
|
||||
block_loopback: bool | None = None
|
||||
auth_realm: str | None = Field(default=None, max_length=64)
|
||||
public_host: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
def _settings_out():
|
||||
s = db.settings()
|
||||
return {"default_policy": s["default_policy"], "log_enabled": s["log_enabled"] == "1",
|
||||
"log_retention_days": int(s["log_retention_days"]), "anonymous_networks": s["anonymous_networks"],
|
||||
"block_loopback": s["block_loopback"] == "1", "auth_realm": s["auth_realm"],
|
||||
"public_host": s["public_host"],
|
||||
"ports": {"http": config.HTTP_PORT, "socks": config.SOCKS_PORT, "web": config.WEB_PORT,
|
||||
"socks_udp": f"{min(config.SOCKS_UDP_PORTS)}-{max(config.SOCKS_UDP_PORTS)}"
|
||||
if config.SOCKS_UDP_PORTS else "", "forwards": config.FORWARD_PORTS}}
|
||||
|
||||
|
||||
@app.get("/api/settings", tags=["Einstellungen"])
|
||||
def get_settings(_: dict = Depends(admin_user)):
|
||||
return _settings_out()
|
||||
|
||||
|
||||
@app.put("/api/settings", tags=["Einstellungen"])
|
||||
def put_settings(data: SettingsIn, _: dict = Depends(admin_user)):
|
||||
for k, v in data.model_dump(exclude_none=True).items():
|
||||
if isinstance(v, bool):
|
||||
v = "1" if v else "0"
|
||||
if k == "anonymous_networks":
|
||||
for part in str(v).replace(",", " ").split():
|
||||
if not parse_networks(part):
|
||||
raise HTTPException(400, f"Ungültiges Netz: {part}")
|
||||
db.set_setting(k, str(v))
|
||||
policy.reload()
|
||||
return _settings_out()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- API-Tokens
|
||||
class TokenIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
|
||||
@app.get("/api/tokens", tags=["API-Tokens"])
|
||||
def list_tokens(_: dict = Depends(admin_user)):
|
||||
return db.query("SELECT t.id, t.name, t.created_at, t.expires_at, t.last_used, u.username FROM tokens t "
|
||||
"JOIN users u ON u.id=t.user_id WHERE t.kind='api' ORDER BY t.created_at DESC")
|
||||
|
||||
|
||||
@app.post("/api/tokens", tags=["API-Tokens"], status_code=201)
|
||||
def create_token(data: TokenIn, user: dict = Depends(admin_user)):
|
||||
token = new_token()
|
||||
exp = time.time() + data.days * 86400 if data.days else None
|
||||
db.execute("INSERT INTO tokens(token_hash,user_id,name,kind,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
||||
(token_hash(token), user["id"], data.name, "api", time.time(), exp))
|
||||
return {"token": token, "name": data.name, "expires_at": exp}
|
||||
|
||||
|
||||
@app.delete("/api/tokens/{tid}", tags=["API-Tokens"])
|
||||
def delete_token(tid: int, _: dict = Depends(admin_user)):
|
||||
db.execute("DELETE FROM tokens WHERE id=? AND kind='api'", (tid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- PAC & WebUI
|
||||
@app.get("/proxy.pac", include_in_schema=False)
|
||||
@app.get("/wpad.dat", include_in_schema=False)
|
||||
def pac(request: Request):
|
||||
host = policy.settings.get("public_host") or (request.url.hostname or "proxy")
|
||||
js = f"""function FindProxyForURL(url, host) {{
|
||||
if (isPlainHostName(host) || shExpMatch(host, "localhost") ||
|
||||
isInNet(dnsResolve(host), "10.0.0.0", "255.0.0.0") ||
|
||||
isInNet(dnsResolve(host), "172.16.0.0", "255.240.0.0") ||
|
||||
isInNet(dnsResolve(host), "192.168.0.0", "255.255.0.0") ||
|
||||
isInNet(dnsResolve(host), "127.0.0.0", "255.0.0.0"))
|
||||
return "DIRECT";
|
||||
return "PROXY {host}:{config.HTTP_PORT}; SOCKS5 {host}:{config.SOCKS_PORT}";
|
||||
}}
|
||||
"""
|
||||
return PlainTextResponse(js, media_type="application/x-ns-proxy-autoconfig")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index():
|
||||
return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
+104
@@ -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()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Konfiguration aus Umgebungsvariablen."""
|
||||
import os
|
||||
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _range(value: str) -> list[int]:
|
||||
ports: list[int] = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
a, b = part.split("-", 1)
|
||||
ports.extend(range(int(a), int(b) + 1))
|
||||
else:
|
||||
ports.append(int(part))
|
||||
return ports
|
||||
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
DB_PATH = os.path.join(DATA_DIR, "proxy.db")
|
||||
|
||||
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
|
||||
HTTP_PORT = _int("HTTP_PROXY_PORT", 3128)
|
||||
SOCKS_PORT = _int("SOCKS_PORT", 1080)
|
||||
WEB_PORT = _int("WEB_PORT", 8080)
|
||||
|
||||
# UDP-Ports für SOCKS5 UDP ASSOCIATE (müssen im Container veröffentlicht sein)
|
||||
SOCKS_UDP_PORTS = _range(os.environ.get("SOCKS_UDP_PORTS", "10800-10819"))
|
||||
# Adresse, die SOCKS-Clients für UDP mitgeteilt wird (Host-IP). Leer = lokale Adresse der Verbindung
|
||||
SOCKS_UDP_ADVERTISE = os.environ.get("SOCKS_UDP_ADVERTISE", "").strip()
|
||||
|
||||
# Ports, die für Portweiterleitungen freigegeben sind (nur Hinweis in der UI)
|
||||
FORWARD_PORTS = os.environ.get("FORWARD_PORTS", "40000-40019")
|
||||
|
||||
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||
|
||||
CONNECT_TIMEOUT = _int("CONNECT_TIMEOUT", 15)
|
||||
SESSION_HOURS = _int("SESSION_HOURS", 12)
|
||||
+197
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Portweiterleitungen (TCP/UDP) für Clients ohne Proxy-Unterstützung,
|
||||
z. B. WireGuard, Outlook-SMTP/IMAP, alte Geräte.
|
||||
|
||||
Jeder Listener leitet an genau ein festes Ziel weiter. Zugriff wird über
|
||||
erlaubte Quell-Netze gesteuert; die Regeln (Black-/Whitelist) gelten zusätzlich."""
|
||||
import asyncio
|
||||
import socket
|
||||
import time
|
||||
|
||||
from . import config
|
||||
from .core import Blocked, Conn, check_and_resolve, history, log, open_upstream, peer_ip, register, relay
|
||||
from .db import db
|
||||
from .policy import ip_in, parse_networks
|
||||
|
||||
UDP_IDLE = 180
|
||||
|
||||
|
||||
class UdpSession(asyncio.DatagramProtocol):
|
||||
def __init__(self, fwd, client_addr, listener):
|
||||
self.fwd = fwd
|
||||
self.client_addr = client_addr
|
||||
self.listener = listener
|
||||
self.transport = None
|
||||
self.last = time.time()
|
||||
self.start = time.time()
|
||||
self.up = 0
|
||||
self.down = 0
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
self.last = time.time()
|
||||
self.down += len(data)
|
||||
self.listener.transport.sendto(data, self.client_addr)
|
||||
|
||||
def send(self, data: bytes):
|
||||
self.last = time.time()
|
||||
self.up += len(data)
|
||||
self.transport.sendto(data)
|
||||
|
||||
def close(self):
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
cip = self.client_addr[0]
|
||||
history.add(ts=self.start, username=None, client_ip=cip[7:] if cip.startswith("::ffff:") else cip,
|
||||
proto="fwd-udp", method="UDP", host=self.fwd["target_host"], port=self.fwd["target_port"],
|
||||
url=f'{self.fwd["target_host"]}:{self.fwd["target_port"]}', status="ok",
|
||||
detail=f'Weiterleitung "{self.fwd["name"]}" (:{self.fwd["listen_port"]})',
|
||||
bytes_up=self.up, bytes_down=self.down, duration=round(time.time() - self.start, 3))
|
||||
|
||||
|
||||
class UdpListener(asyncio.DatagramProtocol):
|
||||
def __init__(self, fwd, nets):
|
||||
self.fwd = fwd
|
||||
self.nets = nets
|
||||
self.transport = None
|
||||
self.sessions: dict[tuple, UdpSession] = {}
|
||||
self.pending: dict[tuple, list] = {}
|
||||
self.denied_logged: set = set()
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
key = (addr[0], addr[1])
|
||||
s = self.sessions.get(key)
|
||||
if s:
|
||||
s.send(data)
|
||||
return
|
||||
if key in self.pending:
|
||||
if len(self.pending[key]) < 32:
|
||||
self.pending[key].append(data)
|
||||
return
|
||||
ip = addr[0][7:] if addr[0].startswith("::ffff:") else addr[0]
|
||||
if self.nets and not ip_in(ip, self.nets):
|
||||
if ip not in self.denied_logged:
|
||||
self.denied_logged.add(ip)
|
||||
history.add(client_ip=ip, proto="fwd-udp", method="UDP", host=self.fwd["target_host"],
|
||||
port=self.fwd["target_port"], status="blocked",
|
||||
detail=f'Quelle nicht erlaubt für "{self.fwd["name"]}"')
|
||||
return
|
||||
self.pending[key] = [data]
|
||||
asyncio.ensure_future(self._open(key, addr))
|
||||
|
||||
async def _open(self, key, addr):
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
addrs = await check_and_resolve(None, self.fwd["target_host"], self.fwd["target_port"], socket.SOCK_DGRAM)
|
||||
family, sockaddr = addrs[0]
|
||||
_, sess = await loop.create_datagram_endpoint(lambda: UdpSession(self.fwd, addr, self),
|
||||
remote_addr=(sockaddr[0], self.fwd["target_port"]),
|
||||
family=family)
|
||||
except (Blocked, OSError) as e:
|
||||
self.pending.pop(key, None)
|
||||
reason = e.decision.reason if isinstance(e, Blocked) else str(e)
|
||||
history.add(client_ip=addr[0], proto="fwd-udp", method="UDP", host=self.fwd["target_host"],
|
||||
port=self.fwd["target_port"], status="blocked" if isinstance(e, Blocked) else "error",
|
||||
detail=reason)
|
||||
return
|
||||
self.sessions[key] = sess
|
||||
for d in self.pending.pop(key, []):
|
||||
sess.send(d)
|
||||
|
||||
def cleanup(self, force=False):
|
||||
now = time.time()
|
||||
for key, s in list(self.sessions.items()):
|
||||
if force or now - s.last > UDP_IDLE:
|
||||
s.close()
|
||||
del self.sessions[key]
|
||||
|
||||
|
||||
class ForwardManager:
|
||||
def __init__(self):
|
||||
self.running: dict[int, dict] = {} # id -> {"sig", "server"/"transport", "proto"}
|
||||
self.status: dict[int, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _sig(f):
|
||||
return (f["protocol"], f["listen_port"], f["target_host"], f["target_port"], f["allowed_sources"],
|
||||
f["enabled"], f["name"])
|
||||
|
||||
async def reload(self):
|
||||
rows = {f["id"]: f for f in db.query("SELECT * FROM forwards")}
|
||||
for fid in list(self.running):
|
||||
if fid not in rows or self._sig(rows[fid]) != self.running[fid]["sig"]:
|
||||
await self._stop(fid)
|
||||
for fid, f in rows.items():
|
||||
if fid in self.running:
|
||||
continue
|
||||
if not f["enabled"]:
|
||||
self.status[fid] = "deaktiviert"
|
||||
continue
|
||||
try:
|
||||
await self._start(f)
|
||||
self.status[fid] = "aktiv"
|
||||
except OSError as e:
|
||||
self.status[fid] = f"Fehler: {e.strerror or e}"
|
||||
log.warning("Weiterleitung %s konnte nicht starten: %s", f["name"], e)
|
||||
for fid in list(self.status):
|
||||
if fid not in rows:
|
||||
del self.status[fid]
|
||||
|
||||
async def _start(self, f):
|
||||
nets = parse_networks(f["allowed_sources"])
|
||||
loop = asyncio.get_running_loop()
|
||||
entry = {"sig": self._sig(f)}
|
||||
if f["protocol"] == "tcp":
|
||||
async def handler(reader, writer, f=f, nets=nets):
|
||||
await self._tcp_client(f, nets, reader, writer)
|
||||
entry["server"] = await asyncio.start_server(handler, host=config.LISTEN_HOST, port=f["listen_port"])
|
||||
else:
|
||||
transport, proto = await loop.create_datagram_endpoint(lambda: UdpListener(f, nets),
|
||||
local_addr=(config.LISTEN_HOST, f["listen_port"]))
|
||||
entry["transport"], entry["proto"] = transport, proto
|
||||
self.running[f["id"]] = entry
|
||||
|
||||
async def _stop(self, fid):
|
||||
entry = self.running.pop(fid, None)
|
||||
if not entry:
|
||||
return
|
||||
if "server" in entry:
|
||||
entry["server"].close()
|
||||
if "transport" in entry:
|
||||
entry["proto"].cleanup(force=True)
|
||||
entry["transport"].close()
|
||||
|
||||
async def _tcp_client(self, f, nets, reader, writer):
|
||||
ip = peer_ip(writer)
|
||||
conn = Conn(None, ip, "fwd-tcp", f["target_host"], f["target_port"], "FORWARD",
|
||||
f'{f["target_host"]}:{f["target_port"]}')
|
||||
label = f'Weiterleitung "{f["name"]}" (:{f["listen_port"]})'
|
||||
try:
|
||||
if nets and not ip_in(ip, nets):
|
||||
conn.finish("blocked", f"Quelle nicht erlaubt – {label}")
|
||||
return
|
||||
try:
|
||||
r_reader, r_writer = await open_upstream(None, f["target_host"], f["target_port"])
|
||||
except Blocked as b:
|
||||
conn.finish("blocked", b.decision.reason)
|
||||
return
|
||||
except OSError as e:
|
||||
conn.finish("error", str(e))
|
||||
return
|
||||
register(conn)
|
||||
try:
|
||||
await relay(conn, reader, writer, r_reader, r_writer)
|
||||
finally:
|
||||
conn.finish("ok", label)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
async def housekeeping(self):
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
for entry in self.running.values():
|
||||
if "proto" in entry:
|
||||
entry["proto"].cleanup()
|
||||
|
||||
|
||||
forwards = ForwardManager()
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""Einstiegspunkt: startet HTTP-/SOCKS-Proxy, Weiterleitungen und WebUI in einer Event-Loop."""
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import time
|
||||
|
||||
import uvicorn
|
||||
|
||||
from . import config
|
||||
from .api import app
|
||||
from .auth import hash_password, users
|
||||
from .core import history, log, peer_ip
|
||||
from .db import db
|
||||
from .forwarder import forwards
|
||||
from .policy import policy
|
||||
from .proxy_http import handle_http
|
||||
from .proxy_socks import handle_socks5
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
if db.one("SELECT COUNT(*) n FROM users")["n"] == 0:
|
||||
password = config.ADMIN_PASSWORD or secrets.token_urlsafe(12)
|
||||
db.execute("INSERT INTO users(username,password_hash,is_admin,enabled,policy,comment,created_at) "
|
||||
"VALUES(?,?,1,1,'inherit','Initialer Administrator',?)",
|
||||
(config.ADMIN_USER, hash_password(password), time.time()))
|
||||
log.warning("=" * 60)
|
||||
log.warning("Admin-Benutzer angelegt: %s", config.ADMIN_USER)
|
||||
if not config.ADMIN_PASSWORD:
|
||||
log.warning("Generiertes Passwort: %s", password)
|
||||
log.warning("=" * 60)
|
||||
users.reload()
|
||||
policy.reload()
|
||||
|
||||
|
||||
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Erkennt anhand des ersten Bytes, ob SOCKS5 oder HTTP gesprochen wird –
|
||||
beide Proxy-Ports verstehen daher beide Protokolle."""
|
||||
client_ip = peer_ip(writer)
|
||||
sock = writer.get_extra_info("socket")
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
first = await asyncio.wait_for(reader.readexactly(1), timeout=60)
|
||||
if first == b"\x05":
|
||||
await handle_socks5(reader, writer, client_ip)
|
||||
elif first == b"\x04":
|
||||
pass # SOCKS4 wird nicht unterstützt
|
||||
else:
|
||||
await handle_http(reader, writer, first, client_ip)
|
||||
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError, OSError):
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("Fehler bei Verbindung von %s", client_ip)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
async def maintenance() -> None:
|
||||
last_cleanup = 0.0
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
await asyncio.to_thread(history.flush)
|
||||
if time.time() - last_cleanup > 3600:
|
||||
last_cleanup = time.time()
|
||||
users.flush_last_seen()
|
||||
days = int(policy.settings.get("log_retention_days", "90") or 0)
|
||||
if days > 0:
|
||||
n = db.execute("DELETE FROM history WHERE ts<?", (time.time() - days * 86400,)).rowcount
|
||||
if n:
|
||||
log.info("Verlauf bereinigt: %d Einträge älter als %d Tage gelöscht", n, days)
|
||||
db.execute("DELETE FROM tokens WHERE expires_at IS NOT NULL AND expires_at<?", (time.time(),))
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("Wartungsfehler")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
bootstrap()
|
||||
servers = []
|
||||
ports = dict.fromkeys([config.HTTP_PORT, config.SOCKS_PORT])
|
||||
for port in ports:
|
||||
srv = await asyncio.start_server(handle_client, host=config.LISTEN_HOST, port=port, limit=256 * 1024,
|
||||
backlog=1024, reuse_address=True)
|
||||
servers.append(srv)
|
||||
log.info("Proxy lauscht auf %s:%d (HTTP + SOCKS5)", config.LISTEN_HOST, port)
|
||||
await forwards.reload()
|
||||
|
||||
web = uvicorn.Server(uvicorn.Config(app, host=config.LISTEN_HOST, port=config.WEB_PORT, log_level="warning",
|
||||
proxy_headers=True, forwarded_allow_ips="*"))
|
||||
log.info("WebUI/API auf http://%s:%d (API-Doku: /api/docs)", config.LISTEN_HOST, config.WEB_PORT)
|
||||
background = [asyncio.create_task(c) for c in (maintenance(), forwards.housekeeping(),
|
||||
*(s.serve_forever() for s in servers))]
|
||||
try:
|
||||
await web.serve() # beendet sich bei SIGTERM/SIGINT
|
||||
finally:
|
||||
for t in background:
|
||||
t.cancel()
|
||||
history.flush()
|
||||
users.flush_last_seen()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+187
@@ -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()
|
||||
@@ -0,0 +1,333 @@
|
||||
"""HTTP-Proxy (HTTP/1.1, CONNECT-Tunnel für HTTPS und beliebige TCP-Protokolle)."""
|
||||
import asyncio
|
||||
import base64
|
||||
import html
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .auth import users
|
||||
from .core import Blocked, Conn, history, open_upstream, register, relay
|
||||
|
||||
MAX_HEAD = 64 * 1024
|
||||
HOP_BY_HOP = {"connection", "keep-alive", "proxy-authorization", "proxy-connection", "proxy-authenticate",
|
||||
"te", "trailer", "upgrade", "expect"}
|
||||
|
||||
|
||||
class BadRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_head(head: bytes) -> tuple[str, list[tuple[str, str]]]:
|
||||
lines = head.decode("latin-1").split("\r\n")
|
||||
first = lines[0]
|
||||
headers = []
|
||||
for line in lines[1:]:
|
||||
if not line:
|
||||
continue
|
||||
if ":" not in line:
|
||||
raise BadRequest("Ungültige Kopfzeile")
|
||||
k, v = line.split(":", 1)
|
||||
headers.append((k.strip(), v.strip()))
|
||||
return first, headers
|
||||
|
||||
|
||||
def get_header(headers, name: str) -> str | None:
|
||||
name = name.lower()
|
||||
for k, v in headers:
|
||||
if k.lower() == name:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def split_hostport(value: str, default_port: int) -> tuple[str, int]:
|
||||
value = value.strip()
|
||||
if value.startswith("["):
|
||||
end = value.index("]")
|
||||
host, rest = value[1:end], value[end + 1:]
|
||||
port = int(rest[1:]) if rest.startswith(":") else default_port
|
||||
return host, port
|
||||
if value.count(":") == 1:
|
||||
host, port = value.rsplit(":", 1)
|
||||
return host, int(port)
|
||||
return value, default_port
|
||||
|
||||
|
||||
def response(code: int, reason: str, body: str = "", extra: list[str] | None = None, close: bool = False) -> bytes:
|
||||
data = body.encode()
|
||||
lines = [f"HTTP/1.1 {code} {reason}", "Content-Type: text/html; charset=utf-8",
|
||||
f"Content-Length: {len(data)}", "Connection: close" if close else "Connection: keep-alive",
|
||||
*(extra or [])]
|
||||
return ("\r\n".join(lines) + "\r\n\r\n").encode() + data
|
||||
|
||||
|
||||
def error_page(title: str, text: str) -> str:
|
||||
return (f"<!doctype html><html lang=de><meta charset=utf-8><title>{html.escape(title)}</title>"
|
||||
"<body style='font-family:system-ui,sans-serif;background:#f4f5f7;color:#1d2330;display:flex;"
|
||||
"align-items:center;justify-content:center;min-height:90vh'>"
|
||||
"<div style='background:#fff;border-radius:12px;padding:32px 40px;max-width:560px;"
|
||||
"box-shadow:0 4px 24px rgba(0,0,0,.08)'>"
|
||||
f"<h1 style='margin:0 0 12px;font-size:22px'>{html.escape(title)}</h1>"
|
||||
f"<p style='margin:0;color:#4a5263;line-height:1.5'>{html.escape(text)}</p></div></body></html>")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Body-Übertragung
|
||||
async def copy_exact(reader, writer, n: int, counter) -> None:
|
||||
while n > 0:
|
||||
data = await reader.read(min(n, 65536))
|
||||
if not data:
|
||||
raise ConnectionError("Verbindung vorzeitig beendet")
|
||||
n -= len(data)
|
||||
if writer:
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
counter(len(data))
|
||||
|
||||
|
||||
async def copy_chunked(reader, writer, counter) -> None:
|
||||
while True:
|
||||
line = await reader.readuntil(b"\r\n")
|
||||
size = int(line.split(b";", 1)[0].strip() or b"0", 16)
|
||||
if writer:
|
||||
writer.write(line)
|
||||
counter(len(line))
|
||||
if size == 0:
|
||||
# Trailer bis zur Leerzeile
|
||||
while True:
|
||||
t = await reader.readuntil(b"\r\n")
|
||||
if writer:
|
||||
writer.write(t)
|
||||
counter(len(t))
|
||||
if t == b"\r\n":
|
||||
break
|
||||
if writer:
|
||||
await writer.drain()
|
||||
return
|
||||
await copy_exact(reader, writer, size + 2, counter)
|
||||
|
||||
|
||||
async def copy_until_eof(reader, writer, counter) -> None:
|
||||
while True:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
return
|
||||
writer.write(data)
|
||||
counter(len(data))
|
||||
await writer.drain()
|
||||
|
||||
|
||||
async def transfer_body(headers, reader, writer, counter) -> None:
|
||||
te = (get_header(headers, "transfer-encoding") or "").lower()
|
||||
if "chunked" in te:
|
||||
await copy_chunked(reader, writer, counter)
|
||||
return
|
||||
cl = get_header(headers, "content-length")
|
||||
if cl:
|
||||
await copy_exact(reader, writer, int(cl), counter)
|
||||
|
||||
|
||||
def body_is_discardable(headers) -> bool:
|
||||
if "chunked" in (get_header(headers, "transfer-encoding") or "").lower():
|
||||
return False
|
||||
cl = get_header(headers, "content-length")
|
||||
return not cl or int(cl) <= 1_000_000
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Handler
|
||||
async def authenticate(headers, client_ip: str):
|
||||
"""(user, anonym erlaubt?, Zugangsdaten angegeben?)"""
|
||||
from .policy import policy
|
||||
value = get_header(headers, "proxy-authorization")
|
||||
if value and value.lower().startswith("basic "):
|
||||
try:
|
||||
raw = base64.b64decode(value[6:].strip()).decode("utf-8", "replace")
|
||||
username, _, password = raw.partition(":")
|
||||
except (ValueError, UnicodeError):
|
||||
return None, False, True
|
||||
user = await users.authenticate(username, password, client_ip)
|
||||
return user, False, True
|
||||
if policy.is_anonymous_allowed(client_ip):
|
||||
return None, True, False
|
||||
return None, False, False
|
||||
|
||||
|
||||
async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, prefix: bytes, client_ip: str):
|
||||
from .policy import policy
|
||||
realm = policy.settings.get("auth_realm", "Proxy")
|
||||
while True:
|
||||
try:
|
||||
head = prefix + await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=300)
|
||||
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
|
||||
return
|
||||
except asyncio.LimitOverrunError:
|
||||
writer.write(response(431, "Request Header Fields Too Large", close=True))
|
||||
return
|
||||
prefix = b""
|
||||
try:
|
||||
first, headers = parse_head(head[:-4])
|
||||
method, target, version = first.split(" ", 2)
|
||||
except (BadRequest, ValueError):
|
||||
writer.write(response(400, "Bad Request", error_page("Ungültige Anfrage", "Anfrage nicht lesbar."),
|
||||
close=True))
|
||||
return
|
||||
client_close = (get_header(headers, "proxy-connection") or get_header(headers, "connection") or "").lower() \
|
||||
== "close" or version == "HTTP/1.0"
|
||||
|
||||
user, anonymous, gave_credentials = await authenticate(headers, client_ip)
|
||||
if not user and not anonymous:
|
||||
if gave_credentials:
|
||||
history.add(username=None, client_ip=client_ip, proto="http", method=method, url=target[:2000],
|
||||
status="auth_failed", detail="Anmeldung fehlgeschlagen")
|
||||
if not body_is_discardable(headers):
|
||||
client_close = True
|
||||
else:
|
||||
await transfer_body(headers, reader, None, lambda n: None)
|
||||
writer.write(response(407, "Proxy Authentication Required",
|
||||
error_page("Anmeldung erforderlich", "Bitte am Proxy anmelden."),
|
||||
[f'Proxy-Authenticate: Basic realm="{realm}"'], close=client_close))
|
||||
await writer.drain()
|
||||
if client_close:
|
||||
return
|
||||
continue
|
||||
|
||||
username = user["username"] if user else "anonym"
|
||||
if method.upper() == "CONNECT":
|
||||
keep = await handle_connect(reader, writer, user, username, client_ip, target)
|
||||
else:
|
||||
keep = await handle_plain(reader, writer, user, username, client_ip, method, target, version, headers)
|
||||
if not keep or client_close:
|
||||
return
|
||||
|
||||
|
||||
async def handle_connect(reader, writer, user, username, client_ip, target) -> bool:
|
||||
try:
|
||||
host, port = split_hostport(target, 443)
|
||||
except (ValueError, IndexError):
|
||||
writer.write(response(400, "Bad Request", close=True))
|
||||
return False
|
||||
conn = Conn(username, client_ip, "https" if port == 443 else "connect", host, port, "CONNECT", f"{host}:{port}")
|
||||
try:
|
||||
r_reader, r_writer = await open_upstream(user, host, port)
|
||||
except Blocked as b:
|
||||
conn.finish("blocked", b.decision.reason)
|
||||
writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt",
|
||||
f"{host}:{port} ist durch die Proxy-Richtlinie gesperrt.")))
|
||||
await writer.drain()
|
||||
return True
|
||||
except OSError as e:
|
||||
conn.finish("error", str(e))
|
||||
writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"),
|
||||
close=True))
|
||||
await writer.drain()
|
||||
return False
|
||||
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||
register(conn)
|
||||
try:
|
||||
await writer.drain()
|
||||
await relay(conn, reader, writer, r_reader, r_writer)
|
||||
finally:
|
||||
conn.finish("ok")
|
||||
return False
|
||||
|
||||
|
||||
async def handle_plain(reader, writer, user, username, client_ip, method, target, version, headers) -> bool:
|
||||
url = urlsplit(target)
|
||||
if not url.scheme:
|
||||
body = error_page("Proxy-Server", "Dies ist ein Proxy-Server. Bitte im Browser als HTTP-Proxy eintragen.")
|
||||
writer.write(response(400, "Bad Request", body, close=True))
|
||||
return False
|
||||
if url.scheme.lower() != "http":
|
||||
writer.write(response(501, "Not Implemented",
|
||||
error_page("Nicht unterstützt", f"Protokoll {url.scheme} wird nicht unterstützt."),
|
||||
close=True))
|
||||
return False
|
||||
host = url.hostname or ""
|
||||
port = url.port or 80
|
||||
conn = Conn(username, client_ip, "http", host, port, method, target[:2000])
|
||||
try:
|
||||
r_reader, r_writer = await open_upstream(user, host, port)
|
||||
except Blocked as b:
|
||||
conn.finish("blocked", b.decision.reason)
|
||||
keep = body_is_discardable(headers)
|
||||
if keep:
|
||||
await transfer_body(headers, reader, None, lambda n: None)
|
||||
writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt",
|
||||
f"{host} ist durch die Proxy-Richtlinie gesperrt."),
|
||||
close=not keep))
|
||||
await writer.drain()
|
||||
return keep
|
||||
except OSError as e:
|
||||
conn.finish("error", str(e))
|
||||
writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"),
|
||||
close=True))
|
||||
await writer.drain()
|
||||
return False
|
||||
|
||||
register(conn)
|
||||
status = "ok"
|
||||
detail = ""
|
||||
|
||||
def up(n):
|
||||
conn.bytes_up += n
|
||||
|
||||
def down(n):
|
||||
conn.bytes_down += n
|
||||
|
||||
conn.closers += [writer.close, r_writer.close]
|
||||
try:
|
||||
# Hop-by-Hop-Header entfernen (inkl. der in "Connection" genannten)
|
||||
conn_tokens = {t.strip().lower() for t in (get_header(headers, "connection") or "").split(",")}
|
||||
out_headers = [(k, v) for k, v in headers if k.lower() not in HOP_BY_HOP and k.lower() not in conn_tokens]
|
||||
if not get_header(out_headers, "host"):
|
||||
out_headers.insert(0, ("Host", url.netloc.rsplit("@", 1)[-1]))
|
||||
path = url.path or "/"
|
||||
if url.query:
|
||||
path += "?" + url.query
|
||||
head = f"{method} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in out_headers) + \
|
||||
"Connection: close\r\n\r\n"
|
||||
r_writer.write(head.encode("latin-1"))
|
||||
if (get_header(headers, "expect") or "").lower() == "100-continue":
|
||||
writer.write(b"HTTP/1.1 100 Continue\r\n\r\n")
|
||||
await transfer_body(headers, reader, r_writer, up)
|
||||
await r_writer.drain()
|
||||
|
||||
# Antwort lesen (1xx durchreichen)
|
||||
while True:
|
||||
rhead = await asyncio.wait_for(r_reader.readuntil(b"\r\n\r\n"), timeout=120)
|
||||
rfirst, rheaders = parse_head(rhead[:-4])
|
||||
parts = rfirst.split(" ", 2)
|
||||
code = int(parts[1])
|
||||
if 100 <= code < 200 and code != 101:
|
||||
writer.write(rhead)
|
||||
continue
|
||||
break
|
||||
detail = str(code)
|
||||
kept = [(k, v) for k, v in rheaders if k.lower() not in ("connection", "keep-alive", "proxy-connection")]
|
||||
writer.write((rfirst + "\r\n" + "".join(f"{k}: {v}\r\n" for k, v in kept) + "Connection: close\r\n\r\n")
|
||||
.encode("latin-1"))
|
||||
down(len(rhead))
|
||||
no_body = method.upper() == "HEAD" or code in (204, 304)
|
||||
if not no_body:
|
||||
te = (get_header(rheaders, "transfer-encoding") or "").lower()
|
||||
if "chunked" in te or get_header(rheaders, "content-length"):
|
||||
await transfer_body(rheaders, r_reader, writer, down)
|
||||
else:
|
||||
await copy_until_eof(r_reader, writer, down)
|
||||
await writer.drain()
|
||||
except asyncio.CancelledError:
|
||||
status, detail = "error", "Verbindung beendet"
|
||||
raise
|
||||
except (OSError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
|
||||
ValueError, BadRequest) as e:
|
||||
status, detail = "error", (str(e) or e.__class__.__name__)[:300]
|
||||
if conn.bytes_down == 0:
|
||||
try:
|
||||
writer.write(response(502, "Bad Gateway", error_page("Fehler", detail), close=True))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
r_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn.finish(status, detail)
|
||||
return False
|
||||
@@ -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)
|
||||
@@ -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 `<span class="badge ${c}">${esc(l)}</span>`; };
|
||||
const PROTO = { http: "HTTP", https: "HTTPS", connect: "CONNECT", socks: "SOCKS5", "socks-udp": "SOCKS5 UDP", "fwd-tcp": "Weiterl. TCP", "fwd-udp": "Weiterl. UDP" };
|
||||
const protoBadge = (p) => `<span class="badge info">${esc(PROTO[p] || p || "–")}</span>`;
|
||||
const policyLabel = { inherit: "Wie global", allow: "Alles erlauben (Blacklist)", deny: "Alles sperren (Whitelist)" };
|
||||
|
||||
// ------------------------------------------------------------------ Modal
|
||||
const KEEP_OPEN = Symbol("keep");
|
||||
|
||||
// onSubmit(data) darf werfen (Fehler wird angezeigt) oder KEEP_OPEN liefern (Dialog bleibt offen).
|
||||
function modal(title, fieldsHtml, { submit = "Speichern", danger = false, onSubmit = null } = {}) {
|
||||
const dlg = $("#modal");
|
||||
const form = $("#modal-form");
|
||||
form.innerHTML = `<h2>${esc(title)}</h2>${fieldsHtml}
|
||||
<p class="error" id="modal-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" value="cancel" formnovalidate>${submit ? "Abbrechen" : "Schließen"}</button>
|
||||
${submit ? `<button class="btn ${danger ? "danger" : "primary"}" value="ok">${esc(submit)}</button>` : ""}
|
||||
</div>`;
|
||||
dlg.showModal();
|
||||
return new Promise((resolve) => {
|
||||
let busy = false;
|
||||
form.onsubmit = async (e) => {
|
||||
if (e.submitter?.value !== "ok") return; // Abbrechen schließt per method=dialog
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const data = {};
|
||||
for (const el of form.elements) if (el.name) data[el.name] = el.type === "checkbox" ? el.checked : el.value;
|
||||
if (!onSubmit) { resolve(data); dlg.close(); return; }
|
||||
busy = true;
|
||||
$("#modal-error").textContent = "";
|
||||
try {
|
||||
const out = await onSubmit(data);
|
||||
if (out !== KEEP_OPEN) { resolve(out ?? true); dlg.close(); }
|
||||
} catch (err) { $("#modal-error").textContent = err.message; }
|
||||
busy = false;
|
||||
};
|
||||
dlg.onclose = () => resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
const modalLoop = (title, html, action, opts = {}) => modal(title, html, { ...opts, onSubmit: action });
|
||||
|
||||
const confirmBox = (text, submit = "Löschen") => modal("Bitte bestätigen", `<p>${esc(text)}</p>`, { submit, danger: true });
|
||||
|
||||
// ------------------------------------------------------------------ Navigation
|
||||
const PAGES_ADMIN = [
|
||||
["dashboard", "◧", "Übersicht"], ["users", "☺", "Benutzer"], ["rules", "⛨", "Regeln"],
|
||||
["forwards", "⇆", "Weiterleitungen"], ["history", "☰", "Verlauf"], ["connections", "⚡", "Live-Verbindungen"],
|
||||
["settings", "⚙", "Einstellungen"], ["api", "⌘", "API"],
|
||||
];
|
||||
const PAGES_USER = [["myhistory", "☰", "Mein Verlauf"], ["help", "?", "Einrichtung"]];
|
||||
|
||||
function renderNav() {
|
||||
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
|
||||
const current = location.hash.slice(1).split("?")[0] || pages[0][0];
|
||||
$("#nav").innerHTML = pages.map(([id, ico, label]) =>
|
||||
`<a href="#${id}" class="${id === current ? "active" : ""}"><span class="ico">${ico}</span>${label}</a>`).join("");
|
||||
}
|
||||
|
||||
async function route() {
|
||||
clearInterval(state.timer);
|
||||
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
|
||||
const [page, query] = location.hash.slice(1).split("?");
|
||||
const id = pages.some((p) => p[0] === page) ? page : pages[0][0];
|
||||
renderNav();
|
||||
const params = new URLSearchParams(query || "");
|
||||
try { await VIEWS[id]($("#main"), params); }
|
||||
catch (e) { $("#main").innerHTML = `<div class="card error">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
const head = (title, sub = "", actions = "") =>
|
||||
`<div class="page-head"><div><h1>${esc(title)}</h1>${sub ? `<p>${sub}</p>` : ""}</div><div class="toolbar" style="margin:0">${actions}</div></div>`;
|
||||
|
||||
async function loadUsers() { state.users = await api("/api/users"); return state.users; }
|
||||
const userOptions = (selected, withGlobal = true) =>
|
||||
(withGlobal ? `<option value="">Global (alle Benutzer)</option>` : "") +
|
||||
state.users.map((u) => `<option value="${u.id}" ${String(selected) === String(u.id) ? "selected" : ""}>${esc(u.username)}</option>`).join("");
|
||||
|
||||
// ------------------------------------------------------------------ Diagramm
|
||||
function barChart(el, points, bucket) {
|
||||
if (!points.length) { el.innerHTML = `<div class="empty">Noch keine Daten im Zeitraum</div>`; return; }
|
||||
const W = Math.max(el.clientWidth, 300), H = 180, padL = 40, padB = 22, padT = 8;
|
||||
const max = Math.max(...points.map((p) => p.n), 1);
|
||||
const step = (W - padL) / points.length;
|
||||
const bw = Math.max(Math.min(step - 2, 28), 2);
|
||||
const y = (v) => padT + (H - padT - padB) * (1 - v / max);
|
||||
const ticks = [0, Math.round(max / 2), max];
|
||||
const fmtLabel = (ts) => bucket >= 86400
|
||||
? new Date(ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
|
||||
: new Date(ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
const every = Math.ceil(points.length / Math.floor((W - padL) / 60));
|
||||
let svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Anfragen pro ${bucket >= 86400 ? "Tag" : "Stunde"}">`;
|
||||
for (const t of ticks) svg += `<line class="grid-line" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${fmtNum(t)}</text>`;
|
||||
points.forEach((p, i) => {
|
||||
const x = padL + i * step + (step - bw) / 2;
|
||||
const top = y(p.n), h = Math.max(H - padB - top, p.n ? 1 : 0);
|
||||
const r = Math.min(4, bw / 2, h);
|
||||
// oben abgerundet, unten an der Grundlinie gerade
|
||||
const path = h > 0 ? `M${x},${top + h} V${top + r} Q${x},${top} ${x + r},${top} H${x + bw - r} Q${x + bw},${top} ${x + bw},${top + r} V${top + h} Z` : "";
|
||||
svg += `<path class="bar" data-i="${i}" d="${path}"/>`;
|
||||
svg += `<rect data-i="${i}" x="${padL + i * step}" y="${padT}" width="${step}" height="${H - padT - padB}" fill="transparent"/>`;
|
||||
if (i % every === 0) svg += `<text class="axis" x="${x + bw / 2}" y="${H - 6}" text-anchor="middle">${fmtLabel(p.b)}</text>`;
|
||||
});
|
||||
svg += "</svg>";
|
||||
el.innerHTML = svg + `<div class="chart-tip" hidden></div>`;
|
||||
const tip = $(".chart-tip", el);
|
||||
el.querySelector("svg").addEventListener("mousemove", (e) => {
|
||||
const i = e.target.dataset?.i;
|
||||
el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover"));
|
||||
if (i === undefined) { tip.hidden = true; return; }
|
||||
const p = points[i];
|
||||
el.querySelector(`.bar[data-i="${i}"]`)?.classList.add("hover");
|
||||
const rect = el.getBoundingClientRect();
|
||||
tip.hidden = false;
|
||||
tip.style.left = (e.clientX - rect.left) + "px";
|
||||
tip.style.top = (e.clientY - rect.top) + "px";
|
||||
tip.innerHTML = `<b>${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}</b><br>${fmtNum(p.n)} Anfragen · ${fmtNum(p.blocked)} gesperrt`;
|
||||
});
|
||||
el.querySelector("svg").addEventListener("mouseleave", () => { tip.hidden = true; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); });
|
||||
}
|
||||
|
||||
function fillBuckets(timeline, hours, bucket) {
|
||||
const map = new Map(timeline.map((t) => [t.b, t]));
|
||||
const end = Math.floor(Date.now() / 1000 / bucket) * bucket;
|
||||
const n = Math.ceil(hours * 3600 / bucket);
|
||||
const out = [];
|
||||
for (let i = n - 1; i >= 0; i--) { const b = end - i * bucket; out.push(map.get(b) || { b, n: 0, blocked: 0 }); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Seiten
|
||||
const VIEWS = {};
|
||||
|
||||
VIEWS.dashboard = async (main, params) => {
|
||||
const hours = Number(params.get("h") || 24);
|
||||
const s = await api(`/api/stats?hours=${hours}`);
|
||||
const t = s.totals;
|
||||
const rangeSel = `<select id="range">${[[24, "Letzte 24 Stunden"], [168, "Letzte 7 Tage"], [720, "Letzte 30 Tage"]].map(([v, l]) => `<option value="${v}" ${v === hours ? "selected" : ""}>${l}</option>`).join("")}</select>`;
|
||||
const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0";
|
||||
const list = (rows, cols) => rows.length ? `<div class="table-wrap"><table class="mini"><tbody>${rows.map(cols).join("")}</tbody></table></div>` : `<div class="empty">Keine Daten</div>`;
|
||||
main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
|
||||
<div class="stack">
|
||||
<div class="grid tiles">
|
||||
<div class="card tile"><div class="label">Anfragen</div><div class="value">${fmtNum(t.total)}</div><div class="sub">${fmtNum(t.users)} aktive Benutzer</div></div>
|
||||
<div class="card tile"><div class="label">Gesperrt</div><div class="value">${fmtNum(t.blocked)}</div><div class="sub">${blockedPct} % aller Anfragen</div></div>
|
||||
<div class="card tile"><div class="label">Datenvolumen</div><div class="value">${fmtBytes(t.up + t.down)}</div><div class="sub">↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}</div></div>
|
||||
<div class="card tile"><div class="label">Live-Verbindungen</div><div class="value">${fmtNum(s.active_connections)}</div><div class="sub">${fmtNum(s.user_count)} Benutzer angelegt</div></div>
|
||||
<div class="card tile"><div class="label">Fehler / Fehllogins</div><div class="value">${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}</div><div class="sub">Verbindungsfehler / Anmeldung</div></div>
|
||||
</div>
|
||||
<div class="card"><h2>Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}</h2><div class="chart" id="chart"></div></div>
|
||||
<div class="grid two">
|
||||
<div class="card"><h2>Meistbesuchte Ziele</h2>${list(s.top_hosts, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
|
||||
<div class="card"><h2>Meistgesperrte Ziele</h2>${list(s.top_blocked, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td></tr>`)}</div>
|
||||
<div class="card"><h2>Benutzer nach Datenvolumen</h2>${list(s.top_users, (r) => `<tr><td><a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a></td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
$("#range").onchange = (e) => (location.hash = `dashboard?h=${e.target.value}`);
|
||||
barChart($("#chart"), fillBuckets(s.timeline, hours, s.bucket), s.bucket);
|
||||
};
|
||||
|
||||
// ---- Benutzer
|
||||
function userForm(u = {}) {
|
||||
const pol = u.policy || "inherit";
|
||||
return `<div class="form-grid">
|
||||
<label>Benutzername<input name="username" value="${esc(u.username)}" required pattern="[A-Za-z0-9._@\\-]+"></label>
|
||||
<label>Passwort${u.id ? ' <span class="hint">(leer = unverändert)</span>' : ""}<input name="password" type="password" minlength="6" ${u.id ? "" : "required"} autocomplete="new-password"></label>
|
||||
</div>
|
||||
<label>Standardrichtlinie
|
||||
<select name="policy">${Object.entries(policyLabel).map(([k, l]) => `<option value="${k}" ${k === pol ? "selected" : ""}>${l}</option>`).join("")}</select>
|
||||
<span class="hint">Greift, wenn keine Regel passt. „Alles sperren“ + Erlaubt-Regeln = Whitelist für diesen Benutzer.</span>
|
||||
</label>
|
||||
<label>Kommentar<input name="comment" value="${esc(u.comment)}"></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" ${u.enabled !== 0 && u.enabled !== false ? "checked" : ""}> Aktiv (darf den Proxy benutzen)</label>
|
||||
<label class="check"><input type="checkbox" name="is_admin" ${u.is_admin ? "checked" : ""}> Administrator (Zugriff auf diese Oberfläche)</label>`;
|
||||
}
|
||||
|
||||
VIEWS.users = async (main) => {
|
||||
const users = await loadUsers();
|
||||
main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", `<button class="btn primary" id="add">+ Benutzer anlegen</button>`) +
|
||||
`<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Status</th><th>Richtlinie</th><th class="num">Eigene Regeln</th><th>Zuletzt aktiv</th><th>Kommentar</th><th></th></tr></thead><tbody>
|
||||
${users.map((u) => `<tr>
|
||||
<td><b>${esc(u.username)}</b> ${u.is_admin ? '<span class="badge info">Admin</span>' : ""}</td>
|
||||
<td>${u.enabled ? '<span class="badge ok">Aktiv</span>' : '<span class="badge bad">Gesperrt</span>'}</td>
|
||||
<td>${esc(policyLabel[u.policy])}</td>
|
||||
<td class="num"><a href="#rules?user=${u.id}">${u.rule_count}</a></td>
|
||||
<td title="${esc(fmtTime(u.last_seen))}">${ago(u.last_seen)}</td>
|
||||
<td class="wrap">${esc(u.comment)}</td>
|
||||
<td class="actions">
|
||||
<a class="btn small ghost" href="#history?username=${encodeURIComponent(u.username)}">Verlauf</a>
|
||||
<button class="btn small" data-edit="${u.id}">Bearbeiten</button>
|
||||
<button class="btn small danger" data-del="${u.id}">Löschen</button>
|
||||
</td></tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Benutzer</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
$("#add").onclick = async () => {
|
||||
const ok = await modalLoop("Benutzer anlegen", userForm(), (d) => api("/api/users", { method: "POST", body: d }));
|
||||
if (ok) { toast("Benutzer angelegt"); route(); }
|
||||
};
|
||||
main.onclick = async (e) => {
|
||||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||||
if (edit) {
|
||||
const u = users.find((x) => x.id == edit);
|
||||
const ok = await modalLoop(`Benutzer „${u.username}“ bearbeiten`, userForm(u), (d) => {
|
||||
if (!d.password) delete d.password;
|
||||
return api(`/api/users/${edit}`, { method: "PUT", body: d });
|
||||
});
|
||||
if (ok) { toast("Gespeichert"); route(); }
|
||||
}
|
||||
if (del) {
|
||||
const u = users.find((x) => x.id == del);
|
||||
if (await confirmBox(`Benutzer „${u.username}“ inkl. seiner Regeln löschen?`)) {
|
||||
try { await api(`/api/users/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } catch (err) { toast(err.message, true); }
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Regeln
|
||||
function ruleForm(r = {}) {
|
||||
return `<label>Gilt für<select name="user_id">${userOptions(r.user_id ?? "")}</select></label>
|
||||
<label>Aktion<select name="action">
|
||||
<option value="deny" ${r.action !== "allow" ? "selected" : ""}>Sperren (Blacklist)</option>
|
||||
<option value="allow" ${r.action === "allow" ? "selected" : ""}>Erlauben (Whitelist)</option></select></label>
|
||||
<div class="form-grid">
|
||||
<label>Host / Domain / Netz<input name="host" value="${esc(r.host)}" placeholder="z. B. facebook.com">
|
||||
<span class="hint">leer = alle · example.com (inkl. Subdomains) · *.example.com · 10.0.0.0/8 · re:regex</span></label>
|
||||
<label>Ports<input name="ports" value="${esc(r.ports)}" placeholder="z. B. 25 oder 6000-7000,8080">
|
||||
<span class="hint">leer = alle Ports</span></label>
|
||||
</div>
|
||||
<label>Kommentar<input name="comment" value="${esc(r.comment)}"></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" ${r.enabled !== 0 ? "checked" : ""}> Regel aktiv</label>`;
|
||||
}
|
||||
const ruleBody = (d) => ({ ...d, user_id: d.user_id ? Number(d.user_id) : null });
|
||||
|
||||
VIEWS.rules = async (main, params) => {
|
||||
await loadUsers();
|
||||
const userFilter = params.get("user") || "";
|
||||
const rules = await api(userFilter ? `/api/rules?user_id=${userFilter}` : "/api/rules");
|
||||
const filterSel = `<select id="rule-filter"><option value="">Alle Regeln</option>${state.users.map((u) => `<option value="${u.id}" ${userFilter == u.id ? "selected" : ""}>nur ${esc(u.username)}</option>`).join("")}</select>`;
|
||||
main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports",
|
||||
`${filterSel}<button class="btn" id="test">Regel testen</button><button class="btn" id="bulk">Liste importieren</button><button class="btn primary" id="add">+ Regel</button>`) +
|
||||
`<div class="card" style="margin-bottom:16px"><b>So wird ausgewertet:</b> Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie.
|
||||
Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine <b>Whitelist</b> die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Gilt für</th><th>Aktion</th><th>Host / Netz</th><th>Ports</th><th>Kommentar</th><th>Aktiv</th><th></th></tr></thead><tbody>
|
||||
${rules.map((r) => `<tr>
|
||||
<td>${r.user_id ? `<span class="badge info">${esc(r.username)}</span>` : '<span class="badge">Global</span>'}</td>
|
||||
<td>${r.action === "deny" ? '<span class="badge bad">Sperren</span>' : '<span class="badge ok">Erlauben</span>'}</td>
|
||||
<td class="wrap mono">${esc(r.host || "* (alle)")}</td>
|
||||
<td class="mono">${esc(r.ports || "alle")}</td>
|
||||
<td class="wrap">${esc(r.comment)}</td>
|
||||
<td>${r.enabled ? "✓" : "–"}</td>
|
||||
<td class="actions"><button class="btn small" data-edit="${r.id}">Bearbeiten</button> <button class="btn small danger" data-del="${r.id}">Löschen</button></td>
|
||||
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Regeln – alles wird nach der Standardrichtlinie behandelt.</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
$("#rule-filter").onchange = (e) => (location.hash = e.target.value ? `rules?user=${e.target.value}` : "rules");
|
||||
$("#add").onclick = async () => {
|
||||
const ok = await modalLoop("Regel anlegen", ruleForm({ user_id: userFilter || null }), (d) => api("/api/rules", { method: "POST", body: ruleBody(d) }));
|
||||
if (ok) { toast("Regel angelegt"); route(); }
|
||||
};
|
||||
$("#bulk").onclick = async () => {
|
||||
const html = `<label>Gilt für<select name="user_id">${userOptions(userFilter)}</select></label>
|
||||
<label>Aktion<select name="action"><option value="deny">Sperren (Blacklist)</option><option value="allow">Erlauben (Whitelist)</option></select></label>
|
||||
<label>Hosts (einer pro Zeile)<textarea name="hosts" placeholder="facebook.com *.tiktok.com 0.0.0.0 ads.example.com" required></textarea>
|
||||
<span class="hint">Auch Hosts-Datei-Format und #-Kommentare werden verstanden.</span></label>
|
||||
<label>Ports<input name="ports" placeholder="leer = alle"></label>
|
||||
<label>Kommentar<input name="comment" placeholder="z. B. Social Media"></label>`;
|
||||
const out = await modalLoop("Liste importieren", html, (d) => api("/api/rules/bulk", { method: "POST", body: { ...ruleBody(d), hosts: d.hosts.split(/\r?\n/) } }), { submit: "Importieren" });
|
||||
if (out) { toast(`${out.added} Regeln importiert, ${out.skipped} übersprungen`); route(); }
|
||||
};
|
||||
$("#test").onclick = async () => {
|
||||
const html = `<label>Benutzer<select name="username"><option value="">(anonym / nur globale Regeln)</option>${state.users.map((u) => `<option>${esc(u.username)}</option>`).join("")}</select></label>
|
||||
<div class="form-grid"><label>Host<input name="host" required placeholder="www.example.com"></label><label>Port<input name="port" type="number" value="443" min="1" max="65535"></label></div>
|
||||
<div id="test-result"></div>`;
|
||||
await modal("Regel testen", html, { submit: "Prüfen", onSubmit: async (d) => {
|
||||
const res = await api("/api/rules/test", { method: "POST", body: { username: d.username || null, host: d.host, port: Number(d.port) } });
|
||||
$("#test-result").innerHTML = `<div class="card">${res.allowed ? '<span class="badge ok">Erlaubt</span>' : '<span class="badge bad">Gesperrt</span>'} ${esc(res.reason)}
|
||||
${res.addresses.length ? `<div class="hint" style="margin-top:8px">${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("<br>")}</div>` : ""}
|
||||
${res.dns_error ? `<div class="hint">DNS: ${esc(res.dns_error)}</div>` : ""}</div>`;
|
||||
return KEEP_OPEN;
|
||||
} });
|
||||
};
|
||||
main.onclick = async (e) => {
|
||||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||||
if (edit) {
|
||||
const rule = rules.find((x) => x.id == edit);
|
||||
const ok = await modalLoop("Regel bearbeiten", ruleForm(rule), (d) => api(`/api/rules/${edit}`, { method: "PUT", body: ruleBody(d) }));
|
||||
if (ok) { toast("Gespeichert"); route(); }
|
||||
}
|
||||
if (del && await confirmBox("Regel löschen?")) {
|
||||
await api(`/api/rules/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Weiterleitungen
|
||||
function forwardForm(f = {}) {
|
||||
return `<label>Name<input name="name" value="${esc(f.name)}" required placeholder="z. B. WireGuard Büro"></label>
|
||||
<div class="form-grid">
|
||||
<label>Protokoll<select name="protocol"><option value="tcp" ${f.protocol !== "udp" ? "selected" : ""}>TCP</option><option value="udp" ${f.protocol === "udp" ? "selected" : ""}>UDP</option></select></label>
|
||||
<label>Lokaler Port<input name="listen_port" type="number" min="1" max="65535" value="${esc(f.listen_port)}" required></label>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>Ziel-Host<input name="target_host" value="${esc(f.target_host)}" required placeholder="vpn.example.com"></label>
|
||||
<label>Ziel-Port<input name="target_port" type="number" min="1" max="65535" value="${esc(f.target_port)}" required></label>
|
||||
</div>
|
||||
<label>Erlaubte Quell-Netze<input name="allowed_sources" value="${esc(f.allowed_sources)}" placeholder="z. B. 192.168.1.0/24 10.8.0.5">
|
||||
<span class="hint">Leer = jeder darf (das Ziel ist fest, es ist also kein offener Proxy). Globale Regeln gelten zusätzlich.</span></label>
|
||||
<label>Kommentar<input name="comment" value="${esc(f.comment)}"></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" ${f.enabled !== 0 ? "checked" : ""}> Aktiv</label>`;
|
||||
}
|
||||
const fwdBody = (d) => ({ ...d, listen_port: Number(d.listen_port), target_port: Number(d.target_port) });
|
||||
|
||||
VIEWS.forwards = async (main) => {
|
||||
const { items, ports_hint } = await api("/api/forwards");
|
||||
main.innerHTML = head("Weiterleitungen", `Für Programme ohne Proxy-Unterstützung (WireGuard, Outlook-Mailkonten, Geräte …): Port am Proxy → festes Ziel. Im Docker-Setup freigegebene Ports: <b>${esc(ports_hint)}</b> (TCP & UDP).`,
|
||||
`<button class="btn primary" id="add">+ Weiterleitung</button>`) +
|
||||
`<div class="table-wrap"><table><thead><tr><th>Name</th><th>Proto</th><th>Proxy-Port</th><th>Ziel</th><th>Erlaubte Quellen</th><th>Status</th><th></th></tr></thead><tbody>
|
||||
${items.map((f) => `<tr>
|
||||
<td><b>${esc(f.name)}</b>${f.comment ? `<div class="hint">${esc(f.comment)}</div>` : ""}</td>
|
||||
<td><span class="badge">${f.protocol.toUpperCase()}</span></td>
|
||||
<td class="mono">${f.listen_port}</td>
|
||||
<td class="mono">${esc(f.target_host)}:${f.target_port}</td>
|
||||
<td class="mono wrap">${esc(f.allowed_sources || "alle")}</td>
|
||||
<td>${f.status === "aktiv" ? '<span class="badge ok">aktiv</span>' : f.status === "deaktiviert" ? '<span class="badge">deaktiviert</span>' : `<span class="badge bad">${esc(f.status)}</span>`}</td>
|
||||
<td class="actions"><button class="btn small" data-edit="${f.id}">Bearbeiten</button> <button class="btn small danger" data-del="${f.id}">Löschen</button></td>
|
||||
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
$("#add").onclick = async () => {
|
||||
const ok = await modalLoop("Weiterleitung anlegen", forwardForm(), (d) => api("/api/forwards", { method: "POST", body: fwdBody(d) }));
|
||||
if (ok) { toast("Weiterleitung angelegt"); route(); }
|
||||
};
|
||||
main.onclick = async (e) => {
|
||||
const edit = e.target.dataset.edit, del = e.target.dataset.del;
|
||||
if (edit) {
|
||||
const f = items.find((x) => x.id == edit);
|
||||
const ok = await modalLoop("Weiterleitung bearbeiten", forwardForm(f), (d) => api(`/api/forwards/${edit}`, { method: "PUT", body: fwdBody(d) }));
|
||||
if (ok) { toast("Gespeichert"); route(); }
|
||||
}
|
||||
if (del && await confirmBox("Weiterleitung löschen?")) {
|
||||
await api(`/api/forwards/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Verlauf
|
||||
function historyView(admin) {
|
||||
return async (main, params) => {
|
||||
if (admin) await loadUsers();
|
||||
const f = Object.fromEntries(params.entries());
|
||||
const limit = 100, offset = Number(f.offset || 0);
|
||||
const q = new URLSearchParams();
|
||||
for (const k of ["username", "q", "status", "proto"]) if (f[k]) q.set(k, f[k]);
|
||||
if (f.from) q.set("since", new Date(f.from).getTime() / 1000);
|
||||
if (f.to) q.set("until", new Date(f.to).getTime() / 1000);
|
||||
const base = admin ? "/api/history" : "/api/me/history";
|
||||
const data = await api(`${base}?${q}&limit=${limit}&offset=${offset}`);
|
||||
const sel = (name, opts) => `<select name="${name}"><option value="">${name === "username" ? "Alle Benutzer" : name === "status" ? "Alle Status" : "Alle Protokolle"}</option>${opts.map(([v, l]) => `<option value="${esc(v)}" ${f[name] === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
|
||||
main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen",
|
||||
admin ? `<button class="btn" id="csv">CSV-Export</button><button class="btn danger" id="clear">Verlauf leeren</button>` : "") +
|
||||
`<form class="toolbar" id="filter">
|
||||
${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""}
|
||||
<input name="q" placeholder="Suche Host, URL, IP …" value="${esc(f.q)}">
|
||||
${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))}
|
||||
${admin ? sel("proto", Object.entries(PROTO)) : ""}
|
||||
<label>Von<input type="datetime-local" name="from" value="${esc(f.from)}"></label>
|
||||
<label>Bis<input type="datetime-local" name="to" value="${esc(f.to)}"></label>
|
||||
<button class="btn primary">Filtern</button>
|
||||
<a class="btn ghost" href="#${admin ? "history" : "myhistory"}">Zurücksetzen</a>
|
||||
</form>
|
||||
<div class="table-wrap"><table><thead><tr><th>Zeit</th>${admin ? "<th>Benutzer</th>" : ""}<th>Client</th><th>Protokoll</th><th>Ziel</th><th>Status</th><th class="num">↑</th><th class="num">↓</th><th class="num">Dauer</th></tr></thead><tbody>
|
||||
${data.items.map((r) => `<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.ts)}</td>
|
||||
${admin ? `<td>${r.username ? `<a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a>` : '<span class="hint">–</span>'}</td>` : ""}
|
||||
<td class="mono">${esc(r.client_ip)}</td>
|
||||
<td>${protoBadge(r.proto)}</td>
|
||||
<td class="wrap"><span class="mono">${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}</span>${r.detail ? `<div class="hint">${esc(r.detail)}</div>` : ""}</td>
|
||||
<td>${statusBadge(r.status)}</td>
|
||||
<td class="num">${fmtBytes(r.bytes_up)}</td><td class="num">${fmtBytes(r.bytes_down)}</td><td class="num">${fmtDur(r.duration)}</td>
|
||||
</tr>`).join("") || `<tr><td colspan="9" class="empty">Keine Einträge</td></tr>`}
|
||||
</tbody></table></div>
|
||||
<div class="pager">${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))}
|
||||
<button class="btn small" id="prev" ${offset <= 0 ? "disabled" : ""}>‹ Zurück</button>
|
||||
<button class="btn small" id="next" ${offset + limit >= data.total ? "disabled" : ""}>Weiter ›</button></div>`;
|
||||
const go = (extra = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
const form = new FormData($("#filter"));
|
||||
for (const [k, v] of form.entries()) if (v) p.set(k, v);
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
location.hash = `${admin ? "history" : "myhistory"}?${p}`;
|
||||
};
|
||||
$("#filter").onsubmit = (e) => { e.preventDefault(); go(); };
|
||||
$("#prev").onclick = () => go({ offset: Math.max(0, offset - limit) });
|
||||
$("#next").onclick = () => go({ offset: offset + limit });
|
||||
if (admin) {
|
||||
$("#csv").onclick = async () => {
|
||||
try {
|
||||
const res = await api(`/api/history/export.csv?${q}`, { raw: true });
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
const a = Object.assign(document.createElement("a"), { href: url, download: "proxy-verlauf.csv" });
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
};
|
||||
$("#clear").onclick = async () => {
|
||||
if (await confirmBox("Den kompletten Verlauf unwiderruflich löschen?", "Alles löschen")) {
|
||||
const r = await api("/api/history", { method: "DELETE" }); toast(`${fmtNum(r.deleted)} Einträge gelöscht`); route();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
VIEWS.history = historyView(true);
|
||||
VIEWS.myhistory = historyView(false);
|
||||
|
||||
// ---- Live-Verbindungen
|
||||
VIEWS.connections = async (main) => {
|
||||
main.innerHTML = head("Live-Verbindungen", "Aktualisiert sich alle 3 Sekunden") + `<div id="conns"></div>`;
|
||||
const draw = async () => {
|
||||
const list = await api("/api/connections");
|
||||
const el = $("#conns");
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="table-wrap"><table><thead><tr><th>Seit</th><th>Benutzer</th><th>Client</th><th>Protokoll</th><th>Ziel</th><th class="num">↑</th><th class="num">↓</th><th></th></tr></thead><tbody>
|
||||
${list.map((c) => `<tr><td>${fmtDur(Date.now() / 1000 - c.started)}</td><td>${esc(c.username || "–")}</td><td class="mono">${esc(c.client_ip)}</td>
|
||||
<td>${protoBadge(c.proto)}</td><td class="wrap mono">${esc(c.url || `${c.host}:${c.port}`)}</td>
|
||||
<td class="num">${fmtBytes(c.bytes_up)}</td><td class="num">${fmtBytes(c.bytes_down)}</td>
|
||||
<td class="actions"><button class="btn small danger" data-kill="${c.id}">Trennen</button></td></tr>`).join("") || `<tr><td colspan="8" class="empty">Keine aktiven Verbindungen</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
};
|
||||
await draw();
|
||||
state.timer = setInterval(() => draw().catch(() => {}), 3000);
|
||||
main.onclick = async (e) => {
|
||||
if (e.target.dataset.kill) { await api(`/api/connections/${e.target.dataset.kill}`, { method: "DELETE" }).catch((x) => toast(x.message, true)); draw(); }
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Einstellungen
|
||||
function setupHelp(s) {
|
||||
const host = s.public_host || location.hostname;
|
||||
return `<div class="card"><h2>Clients einrichten</h2>
|
||||
<dl class="kv">
|
||||
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.http}</dd>
|
||||
<dt>SOCKS5-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.socks}</dd>
|
||||
<dt>Auto-Konfiguration (PAC)</dt><dd class="mono">http://${esc(host)}:${s.ports.web}/proxy.pac</dd>
|
||||
<dt>SOCKS5 UDP-Ports</dt><dd class="mono">${esc(s.ports.socks_udp)}</dd>
|
||||
<dt>Weiterleitungs-Ports</dt><dd class="mono">${esc(s.ports.forwards)}</dd>
|
||||
</dl>
|
||||
<p class="hint" style="margin-top:12px">Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen.
|
||||
Thunderbird & Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy);
|
||||
für SMTP/IMAP in Outlook oder WireGuard eine <a href="#forwards">Weiterleitung</a> anlegen.
|
||||
Kommandozeile: <code>export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}</code></p></div>`;
|
||||
}
|
||||
|
||||
VIEWS.settings = async (main) => {
|
||||
const s = await api("/api/settings");
|
||||
main.innerHTML = head("Einstellungen") + `<div class="stack">
|
||||
<form class="card stack" id="settings">
|
||||
<label>Globale Standardrichtlinie
|
||||
<select name="default_policy">
|
||||
<option value="allow" ${s.default_policy === "allow" ? "selected" : ""}>Alles erlauben, Sperrliste anwenden (Blacklist-Modus)</option>
|
||||
<option value="deny" ${s.default_policy === "deny" ? "selected" : ""}>Alles sperren, nur Freigaben erlauben (Whitelist-Modus)</option>
|
||||
</select></label>
|
||||
<div class="form-grid">
|
||||
<label>Verlauf aufbewahren (Tage)<input type="number" name="log_retention_days" min="0" value="${s.log_retention_days}"><span class="hint">0 = unbegrenzt</span></label>
|
||||
<label>Öffentlicher Hostname<input name="public_host" value="${esc(s.public_host)}" placeholder="proxy.firma.local"><span class="hint">Für PAC-Datei und Anleitung</span></label>
|
||||
<label>Anmelde-Bereich (Realm)<input name="auth_realm" value="${esc(s.auth_realm)}"></label>
|
||||
</div>
|
||||
<label>Netze ohne Anmeldung<input name="anonymous_networks" value="${esc(s.anonymous_networks)}" placeholder="z. B. 192.168.10.0/24">
|
||||
<span class="hint">Clients aus diesen Netzen dürfen ohne Login surfen (erscheinen als „anonym“, nur globale Regeln). Leer = Anmeldung immer Pflicht.</span></label>
|
||||
<label class="check"><input type="checkbox" name="log_enabled" ${s.log_enabled ? "checked" : ""}> Verlauf protokollieren</label>
|
||||
<label class="check"><input type="checkbox" name="block_loopback" ${s.block_loopback ? "checked" : ""}> Zugriff auf Loopback (127.0.0.1, ::1) sperren – schützt diese Oberfläche</label>
|
||||
<div><button class="btn primary">Speichern</button></div>
|
||||
</form>
|
||||
${setupHelp(s)}
|
||||
</div>`;
|
||||
$("#settings").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const el = e.target.elements;
|
||||
try {
|
||||
await api("/api/settings", { method: "PUT", body: {
|
||||
default_policy: el.default_policy.value, log_retention_days: Number(el.log_retention_days.value),
|
||||
public_host: el.public_host.value, auth_realm: el.auth_realm.value, anonymous_networks: el.anonymous_networks.value,
|
||||
log_enabled: el.log_enabled.checked, block_loopback: el.block_loopback.checked } });
|
||||
toast("Einstellungen gespeichert");
|
||||
} catch (err) { toast(err.message, true); }
|
||||
};
|
||||
};
|
||||
|
||||
VIEWS.help = async (main) => {
|
||||
const host = location.hostname;
|
||||
main.innerHTML = head("Einrichtung") + `<div class="card"><dl class="kv">
|
||||
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:3128</dd>
|
||||
<dt>SOCKS5</dt><dd class="mono">${esc(host)}:1080</dd>
|
||||
<dt>PAC-Datei</dt><dd class="mono">http://${esc(host)}:${esc(location.port || 80)}/proxy.pac</dd>
|
||||
<dt>Anmeldung</dt><dd>Dein Benutzername und Passwort</dd></dl></div>`;
|
||||
};
|
||||
|
||||
// ---- API
|
||||
VIEWS.api = async (main) => {
|
||||
const tokens = await api("/api/tokens");
|
||||
main.innerHTML = head("API", `Alle Funktionen der Oberfläche sind per REST-API nutzbar. <a href="/api/docs" target="_blank">Interaktive API-Doku öffnen ↗</a>`,
|
||||
`<button class="btn primary" id="add">+ API-Token</button>`) + `<div class="stack">
|
||||
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Besitzer</th><th>Erstellt</th><th>Läuft ab</th><th>Zuletzt benutzt</th><th></th></tr></thead><tbody>
|
||||
${tokens.map((t) => `<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.username)}</td><td>${fmtTime(t.created_at)}</td><td>${t.expires_at ? fmtTime(t.expires_at) : "nie"}</td><td>${ago(t.last_used)}</td>
|
||||
<td class="actions"><button class="btn small danger" data-del="${t.id}">Widerrufen</button></td></tr>`).join("") || `<tr><td colspan="6" class="empty">Noch keine API-Tokens</td></tr>`}
|
||||
</tbody></table></div>
|
||||
<div class="card"><h2>Beispiele</h2><pre class="mono" style="white-space:pre-wrap;margin:0"># Benutzer anlegen
|
||||
curl -X POST ${esc(location.origin)}/api/users \\
|
||||
-H "Authorization: Bearer <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>"</pre></div></div>`;
|
||||
$("#add").onclick = async () => {
|
||||
const out = await modalLoop("API-Token erstellen", `<label>Name<input name="name" required placeholder="z. B. Monitoring"></label>
|
||||
<label>Gültigkeit (Tage)<input name="days" type="number" min="1" placeholder="leer = unbegrenzt"></label>`,
|
||||
(d) => api("/api/tokens", { method: "POST", body: { name: d.name, days: d.days ? Number(d.days) : null } }), { submit: "Erstellen" });
|
||||
if (out) {
|
||||
await modal("Token erstellt", `<p>Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:</p><div class="secret mono">${esc(out.token)}</div>
|
||||
<p class="hint">Verwendung: Header <code>Authorization: Bearer <Token></code> oder <code>X-API-Key: <Token></code></p>`, { submit: "" });
|
||||
route();
|
||||
}
|
||||
};
|
||||
main.onclick = async (e) => {
|
||||
if (e.target.dataset.del && await confirmBox("Token widerrufen?", "Widerrufen")) {
|
||||
await api(`/api/tokens/${e.target.dataset.del}`, { method: "DELETE" }); toast("Widerrufen"); route();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Start / Login
|
||||
function logout(silent) {
|
||||
if (!silent && state.token) api("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
state.token = null;
|
||||
state.me = null;
|
||||
try { localStorage.removeItem("proxy_token"); } catch (_) { /* ignore */ }
|
||||
clearInterval(state.timer);
|
||||
$("#app").hidden = true;
|
||||
$("#login").hidden = false;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!state.token) { logout(true); return; }
|
||||
try { state.me = await api("/api/me"); } catch (_) { return; }
|
||||
$("#login").hidden = true;
|
||||
$("#app").hidden = false;
|
||||
$("#whoami").innerHTML = `Angemeldet als <b>${esc(state.me.username)}</b>`;
|
||||
route();
|
||||
}
|
||||
|
||||
$("#login-form").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const f = e.target.elements;
|
||||
$("#login-error").textContent = "";
|
||||
try {
|
||||
const r = await api("/api/auth/login", { method: "POST", body: { username: f.username.value, password: f.password.value } });
|
||||
state.token = r.token;
|
||||
try { localStorage.setItem("proxy_token", r.token); } catch (_) { /* ignore */ }
|
||||
f.password.value = "";
|
||||
start();
|
||||
} catch (err) { $("#login-error").textContent = err.message; }
|
||||
};
|
||||
$("#btn-logout").onclick = () => logout(false);
|
||||
$("#btn-password").onclick = async () => {
|
||||
const ok = await modalLoop("Passwort ändern", `<label>Aktuelles Passwort<input type="password" name="old_password" required autocomplete="current-password"></label>
|
||||
<label>Neues Passwort<input type="password" name="new_password" required minlength="6" autocomplete="new-password"></label>`,
|
||||
(d) => api("/api/me/password", { method: "POST", body: d }));
|
||||
if (ok) toast("Passwort geändert");
|
||||
};
|
||||
window.addEventListener("hashchange", () => state.me && route());
|
||||
start();
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Proxy Manager</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 rx=%227%22 fill=%22%232a78d6%22/%3E%3Ctext x=%2216%22 y=%2223%22 font-size=%2219%22 text-anchor=%22middle%22 fill=%22white%22%3E%E2%87%84%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<section id="login" class="login" hidden>
|
||||
<form id="login-form" class="login-card">
|
||||
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
|
||||
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary" type="submit">Anmelden</button>
|
||||
<p class="error" id="login-error"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- App -->
|
||||
<div id="app" class="app" hidden>
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
|
||||
<nav id="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="whoami" id="whoami"></div>
|
||||
<button class="btn ghost small" id="btn-password">Passwort ändern</button>
|
||||
<button class="btn ghost small" id="btn-logout">Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<dialog id="modal"><form method="dialog" id="modal-form"></form></dialog>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
Reference in New Issue
Block a user