feat: add admin web basic auth

This commit is contained in:
Андрей Бобырев
2026-06-06 12:36:37 +03:00
parent f6eb56b88d
commit a01e0ddfba
2 changed files with 69 additions and 1 deletions

View File

@@ -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_BASE` | `https://raw.githubusercontent.com/andrey271192/gotelegram/main` | база загрузки файлов |
| `GOTELEGRAM_INSTALL_DIR` | `/opt/gotelegram` | путь установки | | `GOTELEGRAM_INSTALL_DIR` | `/opt/gotelegram` | путь установки |
| `GITHUB_TOKEN` / `GH_TOKEN` | пусто | token для private GitHub raw downloads | | `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.
## Проверки ## Проверки

View File

@@ -3,14 +3,17 @@
goTelegram Pro local web admin. goTelegram Pro local web admin.
The service is intentionally bound to 127.0.0.1:1984. Operators reach it 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 from __future__ import annotations
import base64
import csv import csv
import fcntl import fcntl
import hashlib import hashlib
import hmac
import json import json
import mimetypes import mimetypes
import os 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") HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) 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" VERSION = "2.5.0"
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
LANG_RE = re.compile(r"^(en|ru)$") LANG_RE = re.compile(r"^(en|ru)$")
@@ -1281,6 +1287,35 @@ class AdminHandler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None: def log_message(self, fmt: str, *args: Any) -> None:
print("%s - %s" % (self.address_string(), fmt % args)) 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: def send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
@@ -1632,6 +1667,8 @@ class AdminHandler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self) -> None: def do_GET(self) -> None:
if not self.require_auth():
return
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"): if parsed.path.startswith("/api/"):
self.route_get_api(parsed) self.route_get_api(parsed)
@@ -1639,6 +1676,8 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_static(parsed) self.send_static(parsed)
def do_POST(self) -> None: def do_POST(self) -> None:
if not self.require_auth():
return
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"): if parsed.path.startswith("/api/"):
self.route_post_api(parsed) self.route_post_api(parsed)
@@ -1646,6 +1685,8 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error(404) self.send_error(404)
def do_DELETE(self) -> None: def do_DELETE(self) -> None:
if not self.require_auth():
return
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/api/"): if parsed.path.startswith("/api/"):
self.route_delete_api(parsed) self.route_delete_api(parsed)