From a01e0ddfba3a14bba4e11b1fe2c45d422e754777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 12:36:37 +0300 Subject: [PATCH] feat: add admin web basic auth --- README.md | 27 +++++++++++++++++++++++++++ admin-web/server.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fa53464..794e8d7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,33 @@ ssh root@SERVER 'chmod +x /opt/gotelegram/install.sh /opt/gotelegram/install_got | `GOTELEGRAM_BASE` | `https://raw.githubusercontent.com/andrey271192/gotelegram/main` | база загрузки файлов | | `GOTELEGRAM_INSTALL_DIR` | `/opt/gotelegram` | путь установки | | `GITHUB_TOKEN` / `GH_TOKEN` | пусто | token для private GitHub raw downloads | +| `GOTELEGRAM_ADMIN_HOST` | `127.0.0.1` | bind web-admin | +| `GOTELEGRAM_ADMIN_PORT` | `1984` | port web-admin | +| `GOTELEGRAM_ADMIN_USER` | `admin` | Basic Auth login | +| `GOTELEGRAM_ADMIN_PASSWORD` | пусто | Basic Auth password; обязателен при public bind | + +## Web-admin + +После первого запуска `gotelegram` локальная web-admin панель ставится как `gotelegram-admin.service`. + +Безопасный локальный доступ через SSH tunnel: + +```bash +ssh -L 1984:127.0.0.1:1984 root@SERVER +open http://127.0.0.1:1984/ +``` + +Для public bind сначала задайте пароль через systemd override или reverse proxy: + +```ini +[Service] +Environment=GOTELEGRAM_ADMIN_HOST=0.0.0.0 +Environment=GOTELEGRAM_ADMIN_PORT=1984 +Environment=GOTELEGRAM_ADMIN_USER=admin +Environment=GOTELEGRAM_ADMIN_PASSWORD=change-me +``` + +Без HTTPS public Basic Auth гонит пароль открытым текстом. Для постоянного доступа лучше reverse proxy с TLS. ## Проверки diff --git a/admin-web/server.py b/admin-web/server.py index 43ffb3b..3b71444 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -3,14 +3,17 @@ goTelegram Pro local web admin. The service is intentionally bound to 127.0.0.1:1984. Operators reach it -through an SSH tunnel; it must never be exposed directly on the public network. +through an SSH tunnel by default. If exposed through a reverse proxy or public +bind, enable GOTELEGRAM_ADMIN_PASSWORD. """ from __future__ import annotations +import base64 import csv import fcntl import hashlib +import hmac import json import mimetypes import os @@ -48,6 +51,9 @@ BACKUP_RESTORE_LOG = Path(os.getenv("GOTELEGRAM_BACKUP_RESTORE_LOG", "/var/log/g HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) +ADMIN_USER = os.getenv("GOTELEGRAM_ADMIN_USER", "admin") +ADMIN_PASSWORD = os.getenv("GOTELEGRAM_ADMIN_PASSWORD", "") +ADMIN_REALM = os.getenv("GOTELEGRAM_ADMIN_REALM", "GoTelegram") VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") @@ -1281,6 +1287,35 @@ class AdminHandler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args: Any) -> None: print("%s - %s" % (self.address_string(), fmt % args)) + def auth_enabled(self) -> bool: + return bool(ADMIN_PASSWORD) + + def is_authorized(self) -> bool: + if not self.auth_enabled(): + return True + header = self.headers.get("Authorization", "") + if not header.startswith("Basic "): + return False + try: + decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8") + username, password = decoded.split(":", 1) + except Exception: + return False + return hmac.compare_digest(username, ADMIN_USER) and hmac.compare_digest(password, ADMIN_PASSWORD) + + def require_auth(self) -> bool: + if self.is_authorized(): + return True + body = b"Authentication required\n" + self.send_response(401) + self.send_header("WWW-Authenticate", f'Basic realm="{ADMIN_REALM}"') + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return False + def send_json(self, payload: Any, status: int = 200) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) @@ -1632,6 +1667,8 @@ class AdminHandler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self) -> None: + if not self.require_auth(): + return parsed = urllib.parse.urlparse(self.path) if parsed.path.startswith("/api/"): self.route_get_api(parsed) @@ -1639,6 +1676,8 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_static(parsed) def do_POST(self) -> None: + if not self.require_auth(): + return parsed = urllib.parse.urlparse(self.path) if parsed.path.startswith("/api/"): self.route_post_api(parsed) @@ -1646,6 +1685,8 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error(404) def do_DELETE(self) -> None: + if not self.require_auth(): + return parsed = urllib.parse.urlparse(self.path) if parsed.path.startswith("/api/"): self.route_delete_api(parsed)