From 20103ccac84aad0756cbe52ac8cac03c5909a8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B8?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 24 Apr 2026 19:19:12 +0300 Subject: [PATCH] v2.5.0: add local web admin dashboard --- DOCS_AI.md | 19 +- DOCS_HUMAN.md | 26 +- admin-web/server.py | 533 ++++++++++++++++++++++++++++++++++++ admin-web/static/app.js | 300 ++++++++++++++++++++ admin-web/static/index.html | 158 +++++++++++ admin-web/static/styles.css | 410 +++++++++++++++++++++++++++ bootstrap.sh | 8 +- gotelegram-bot/README.md | 1 + gotelegram-bot/bot.py | 80 +++++- gotelegram-bot/lang/en.json | 1 + gotelegram-bot/lang/ru.json | 1 + install.sh | 78 ++++++ install_gotelegram_bot.sh | 41 +++ lib/backup.sh | 20 +- lib/common.sh | 4 + 15 files changed, 1668 insertions(+), 12 deletions(-) create mode 100644 admin-web/server.py create mode 100644 admin-web/static/app.js create mode 100644 admin-web/static/index.html create mode 100644 admin-web/static/styles.css diff --git a/DOCS_AI.md b/DOCS_AI.md index 15cb5b1..8e8d3f3 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -437,7 +437,22 @@ switch_language ru|en `restore_backup` разворачивает архив обратно, перезапускает telemt и nginx. -### 13.1 Upgrade migration (v2.5.0) +### 13.1 Local Web Admin (v2.5.0) + +Локальная web-админка находится в `admin-web/` и устанавливается в `/opt/gotelegram-admin`: + +- backend: `admin-web/server.py`, Python stdlib only, без pip/npm dependencies; +- frontend: `admin-web/static/`, vanilla JS/CSS, canvas-график без CDN; +- systemd service: `gotelegram-admin`; +- bind: `127.0.0.1:1984`, доступ только через SSH tunnel; +- token: `/opt/gotelegram-admin/token`, HTTP cookie `gtauth` выставляется при открытии `/?token=...`; +- API требует cookie/Bearer token, а write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`. + +Функции: overview, service status/restart, чтение/запись `[access.users]`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv`, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, journal logs. + +`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. Backup v1.3 сохраняет `admin_web/token`, `server.py` и `static/`, restore возвращает их и пробует перезапустить `gotelegram-admin`. + +### 13.2 Upgrade migration (v2.5.0) `install.sh` вызывает `auto_migrate_legacy_state` перед интерактивным меню и перед non-interactive `--action=...` для бота. Цель — комфортно обновляться даже с кривой старой логики без переустановки прокси: @@ -611,7 +626,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s: ## 17. Changelog -- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/удалить/показать ссылку/runtime info); исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.2` сохраняет bot `.env`, bot lang files, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений. +- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/удалить/показать ссылку/runtime info); добавлена локальная web-админка `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.3` сохраняет bot `.env`, bot lang files, web-admin token/static, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений. - **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS. - **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index 0928378..83892f5 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -125,7 +125,7 @@ CLI и бот переведены на русский и английский. ## 7. Бекап и восстановление -Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, данные nginx-сайта и сертификаты. По умолчанию в `/opt/gotelegram/backups/`. +Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, данные nginx-сайта, сертификаты, состояние Telegram-бота и локальной web-админки. По умолчанию в `/opt/gotelegram/backups/`. Пункт 9 принимает такой архив и восстанавливает всё обратно (конфиг, сервис, ссылка — всё то же, что было). @@ -133,7 +133,21 @@ CLI и бот переведены на русский и английский. --- -## 8. Обновление +## 8. Локальная web-админка + +Начиная с **2.5.0** вместе с ботом ставится локальная web-админка: + +- systemd service: `gotelegram-admin`; +- слушает только `127.0.0.1:1984`; +- наружу не публикуется и рассчитана на доступ через SSH tunnel; +- токен доступа хранится в `/opt/gotelegram-admin/token`; +- Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. + +В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, график трафика, список бекапов и просмотр логов. + +--- + +## 9. Обновление Два типа обновлений: @@ -146,7 +160,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст --- -## 9. Удаление +## 10. Удаление Пункт **13) Удалить всё** даёт выбор: @@ -158,7 +172,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст --- -## 10. Требования к VPS +## 11. Требования к VPS - **ОС:** Ubuntu 20.04+ или Debian 11+ (протестировано на Ubuntu 22.04). - **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот). @@ -169,7 +183,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст --- -## 11. Частые вопросы +## 12. Частые вопросы **Q: Ключ перестал работать, telemt живой.** A: 95% случаев — это когда после переустановки telemt не перечитал свежий конфиг (было исправлено в 2.4.1, см. changelog). Перезапусти вручную: `systemctl restart telemt`. Если не помогло — смотри логи (пункт 6 меню) и проверь `/etc/telemt/config.toml` на предмет правильного `tls_domain`. @@ -222,7 +236,7 @@ A: Сам MTProxy — да, это публичная технология из ## Changelog (коротко) -- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, удаление, ссылка и runtime/API-информация; backup/restore сохраняет bot `.env`, языки бота, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443. +- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, удаление, ссылка и runtime/API-информация; добавлена локальная web-админка на `127.0.0.1:1984` под SSH tunnel; backup/restore сохраняет bot `.env`, языки бота, web-admin token/static, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443. - **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades. - **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. diff --git a/admin-web/server.py b/admin-web/server.py new file mode 100644 index 0000000..b1a9972 --- /dev/null +++ b/admin-web/server.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +GoTelegram 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. +""" + +from __future__ import annotations + +import csv +import hashlib +import http.cookies +import json +import mimetypes +import os +import re +import secrets +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin")) +STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static"))) +TOKEN_FILE = Path(os.getenv("GOTELEGRAM_ADMIN_TOKEN_FILE", str(ADMIN_DIR / "token"))) + +GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json")) +TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml")) +HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv")) +CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json")) +BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups")) +INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram")) + +HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") +PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) +VERSION = "2.5.0" +USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]: + try: + proc = subprocess.run( + cmd, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr + except Exception as exc: # pragma: no cover - system dependent + return 125, "", str(exc) + + +def ensure_token() -> str: + ADMIN_DIR.mkdir(parents=True, exist_ok=True) + if not TOKEN_FILE.exists() or TOKEN_FILE.stat().st_size < 24: + TOKEN_FILE.write_text(secrets.token_urlsafe(36) + "\n", encoding="utf-8") + os.chmod(TOKEN_FILE, 0o600) + return TOKEN_FILE.read_text(encoding="utf-8").strip() + + +def current_token() -> str: + try: + return ensure_token() + except OSError: + return "" + + +def load_json(path: Path, fallback: Any = None) -> Any: + try: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return fallback + + +def read_telemt_users() -> dict[str, str]: + if not TELEMT_CONFIG.exists(): + return {} + users: dict[str, str] = {} + in_users = False + for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if line == "[access.users]": + in_users = True + continue + if in_users and line.startswith("["): + break + if not in_users or not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + name = name.strip() + value = value.strip().split("#", 1)[0].strip() + if value.startswith('"') and '"' in value[1:]: + value = value[1:].split('"', 1)[0] + elif value.startswith("'") and "'" in value[1:]: + value = value[1:].split("'", 1)[0] + if USER_RE.match(name) and value: + users[name] = value + return users + + +def _ordered_user_lines(users: dict[str, str]) -> list[str]: + names = [] + if "main" in users: + names.append("main") + names.extend(sorted(n for n in users if n != "main")) + return [f'{name} = "{users[name]}"' for name in names] + + +def write_telemt_users(users: dict[str, str]) -> None: + TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True) + lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else [] + rendered = _ordered_user_lines(users) + out: list[str] = [] + in_users = False + found = False + + for raw in lines: + if raw.strip() == "[access.users]": + found = True + in_users = True + out.append(raw) + out.extend(rendered) + continue + if in_users and raw.strip().startswith("["): + in_users = False + if in_users: + continue + out.append(raw) + + if not found: + if out and out[-1].strip(): + out.append("") + out.append("[access.users]") + out.extend(rendered) + + TELEMT_CONFIG.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(TELEMT_CONFIG, 0o600) + + +def restart_service(name: str) -> bool: + code, _, _ = run(["systemctl", "restart", name], timeout=25) + return code == 0 + + +def service_status(name: str) -> str: + code, stdout, _ = run(["systemctl", "is-active", name], timeout=3) + value = stdout.strip() + if code == 0 and value == "active": + return "running" + code, stdout, _ = run(["systemctl", "list-unit-files", f"{name}.service", "--no-legend"], timeout=3) + if code != 0 or not stdout.strip(): + return "not_installed" + if value in {"failed", "inactive", "activating", "deactivating"}: + return value + return "stopped" + + +def public_ip() -> str: + code, stdout, _ = run(["curl", "-s", "-4", "--max-time", "3", "https://api.ipify.org"], timeout=5) + ip = stdout.strip() + if code == 0 and re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip): + return ip + code, stdout, _ = run(["hostname", "-I"], timeout=3) + return stdout.split()[0] if code == 0 and stdout.split() else "0.0.0.0" + + +def proxy_link(secret: str) -> str: + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + mode = str(config.get("mode", "lite")) + port = int(config.get("port", 443) or 443) + domain = str(config.get("domain", "") or "") + mask_host = str(config.get("mask_host", "") or "") + + if mode == "pro" and domain: + host_hex = domain.encode().hex() + return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{host_hex}" + + server = public_ip() + if mask_host: + host_hex = mask_host.encode().hex() + return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{host_hex}" + return f"tg://proxy?server={server}&port={port}&secret={secret}" + + +def telemt_api(path: str) -> Any: + try: + with urllib.request.urlopen(f"http://127.0.0.1:9091{path}", timeout=1.8) as resp: + payload = resp.read(256 * 1024) + return json.loads(payload.decode("utf-8")) + except Exception: + return None + + +def load_stats_history(limit: int = 240) -> list[dict[str, int]]: + if not HISTORY_FILE.exists(): + return [] + rows: list[dict[str, int]] = [] + try: + with HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh: + for row in csv.DictReader(fh): + try: + rows.append({ + "epoch": int(row.get("epoch") or 0), + "proxy_bytes": int(row.get("proxy_bytes") or 0), + "site_bytes": int(row.get("site_bytes") or 0), + }) + except ValueError: + continue + except OSError: + return [] + rows = rows[-limit:] + previous = None + enriched: list[dict[str, int]] = [] + for row in rows: + item = dict(row) + if previous: + item["proxy_delta"] = max(0, row["proxy_bytes"] - previous["proxy_bytes"]) + item["site_delta"] = max(0, row["site_bytes"] - previous["site_bytes"]) + else: + item["proxy_delta"] = 0 + item["site_delta"] = 0 + enriched.append(item) + previous = row + return enriched + + +def list_backups() -> list[dict[str, Any]]: + if not BACKUP_DIR.exists(): + return [] + items = [] + for path in sorted(BACKUP_DIR.glob("*.tar.gz*"), key=lambda p: p.stat().st_mtime, reverse=True): + if path.name.endswith(".sha256"): + continue + try: + st = path.stat() + except OSError: + continue + items.append({ + "name": path.name, + "path": str(path), + "size": st.st_size, + "mtime": int(st.st_mtime), + "encrypted": path.name.endswith(".enc"), + }) + return items[:30] + + +def create_backup() -> tuple[bool, str]: + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\"" + ) + code, stdout, stderr = run(["bash", "-lc", script], timeout=180) + text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, text + + +def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[str, Any]: + item: dict[str, Any] = { + "name": name, + "secret": secret, + "link": proxy_link(secret), + "main": name == "main", + } + if include_runtime: + item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}") + return item + + +def overview_payload() -> dict[str, Any]: + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + users = read_telemt_users() + current = load_json(CURRENT_STATS, {}) or {} + summary = telemt_api("/v1/stats/summary") + services = { + "telemt": service_status("telemt"), + "nginx": service_status("nginx"), + "bot": service_status("gotelegram-bot"), + "stats": service_status("gotelegram-stats"), + "admin": service_status("gotelegram-admin"), + } + return { + "version": VERSION, + "time": utc_now(), + "config": config, + "users_count": len(users), + "services": services, + "stats_current": current, + "stats_history": load_stats_history(), + "runtime_summary": summary, + "backups": list_backups(), + } + + +class AdminHandler(BaseHTTPRequestHandler): + server_version = "GoTelegramAdmin/2.5.0" + + def log_message(self, fmt: str, *args: Any) -> None: + print("%s - %s" % (self.address_string(), fmt % args)) + + def token_from_request(self) -> str: + parsed = urllib.parse.urlparse(self.path) + qs = urllib.parse.parse_qs(parsed.query) + if qs.get("token", [""])[0]: + return qs["token"][0] + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[len("Bearer "):].strip() + cookie_header = self.headers.get("Cookie", "") + if cookie_header: + cookie = http.cookies.SimpleCookie() + cookie.load(cookie_header) + if "gtauth" in cookie: + return cookie["gtauth"].value + return "" + + def is_authorized(self) -> bool: + token = current_token() + candidate = self.token_from_request() + return bool(token and candidate and secrets.compare_digest(token, candidate)) + + def send_json(self, payload: Any, status: int = 200) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; 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) + + def send_error_json(self, status: int, message: str) -> None: + self.send_json({"ok": False, "error": message}, status) + + def read_json_body(self) -> Any: + length = int(self.headers.get("Content-Length", "0") or 0) + if length > 1024 * 1024: + raise ValueError("request body too large") + if length <= 0: + return {} + return json.loads(self.rfile.read(length).decode("utf-8")) + + def require_auth(self) -> bool: + if self.is_authorized(): + return True + self.send_error_json(401, "unauthorized") + return False + + def require_write_guard(self) -> bool: + if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1": + self.send_error_json(403, "missing write guard") + return False + return True + + def route_get_api(self, parsed: urllib.parse.ParseResult) -> None: + if not self.require_auth(): + return + path = parsed.path + if path == "/api/overview": + self.send_json({"ok": True, "data": overview_payload()}) + elif path == "/api/users": + users = read_telemt_users() + self.send_json({"ok": True, "data": [user_payload(k, v) for k, v in sorted(users.items())]}) + elif path.startswith("/api/users/"): + name = urllib.parse.unquote(path[len("/api/users/"):]) + users = read_telemt_users() + if name not in users: + self.send_error_json(404, "user not found") + return + self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)}) + elif path == "/api/backups": + self.send_json({"ok": True, "data": list_backups()}) + elif path == "/api/logs": + qs = urllib.parse.parse_qs(parsed.query) + service = qs.get("service", ["telemt"])[0] + allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"} + if service not in allowed: + self.send_error_json(400, "unsupported service") + return + code, stdout, stderr = run(["journalctl", "-u", service, "-n", "120", "--no-pager"], timeout=8) + self.send_json({"ok": code == 0, "data": stdout if code == 0 else stderr}) + else: + self.send_error_json(404, "not found") + + def route_post_api(self, parsed: urllib.parse.ParseResult) -> None: + if not self.require_auth() or not self.require_write_guard(): + return + path = parsed.path + try: + body = self.read_json_body() + except Exception as exc: + self.send_error_json(400, str(exc)) + return + + if path == "/api/users": + name = str(body.get("name", "")).strip() + if not USER_RE.match(name): + self.send_error_json(400, "invalid user name") + return + users = read_telemt_users() + if name in users: + self.send_error_json(409, "user already exists") + return + seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode() + secret = hashlib.sha256(seed).hexdigest()[:32] + users[name] = secret + try: + write_telemt_users(users) + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restarted = restart_service("telemt") + self.send_json({"ok": True, "data": user_payload(name, secret), "restarted": restarted}) + elif path == "/api/backups": + ok, result = create_backup() + self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) + elif path.startswith("/api/services/") and path.endswith("/restart"): + service = path[len("/api/services/"):-len("/restart")] + allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"} + if service not in allowed: + self.send_error_json(400, "unsupported service") + return + ok = restart_service(service) + self.send_json({"ok": ok, "status": service_status(service)}, 200 if ok else 500) + else: + self.send_error_json(404, "not found") + + def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None: + if not self.require_auth() or not self.require_write_guard(): + return + path = parsed.path + if not path.startswith("/api/users/"): + self.send_error_json(404, "not found") + return + name = urllib.parse.unquote(path[len("/api/users/"):]) + if name == "main": + self.send_error_json(400, "main user cannot be deleted") + return + users = read_telemt_users() + if name not in users: + self.send_error_json(404, "user not found") + return + users.pop(name, None) + try: + write_telemt_users(users) + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restarted = restart_service("telemt") + self.send_json({"ok": True, "restarted": restarted}) + + def send_static(self, parsed: urllib.parse.ParseResult) -> None: + qs = urllib.parse.parse_qs(parsed.query) + if qs.get("token", [""])[0] and self.is_authorized(): + self.send_response(302) + self.send_header("Location", "/") + self.send_header("Set-Cookie", "gtauth=%s; Path=/; HttpOnly; SameSite=Strict" % qs["token"][0]) + self.send_header("Cache-Control", "no-store") + self.end_headers() + return + + rel = parsed.path.lstrip("/") or "index.html" + if rel.startswith("api/") or ".." in rel.split("/"): + self.send_error(404) + return + path = STATIC_DIR / rel + if path.is_dir(): + path = path / "index.html" + if not path.exists(): + path = STATIC_DIR / "index.html" + try: + body = path.read_bytes() + except OSError: + self.send_error(404) + return + mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Cache-Control", "no-store" if path.name == "index.html" else "public, max-age=3600") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_get_api(parsed) + else: + self.send_static(parsed) + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_post_api(parsed) + else: + self.send_error(404) + + def do_DELETE(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_delete_api(parsed) + else: + self.send_error(404) + + +def main() -> None: + ensure_token() + if not STATIC_DIR.exists(): + raise SystemExit(f"static dir not found: {STATIC_DIR}") + httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler) + print(f"GoTelegram admin listening on http://{HOST}:{PORT}") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/admin-web/static/app.js b/admin-web/static/app.js new file mode 100644 index 0000000..0865b40 --- /dev/null +++ b/admin-web/static/app.js @@ -0,0 +1,300 @@ +const $ = (sel) => document.querySelector(sel); +const state = { overview: null, users: [], events: [] }; + +const fmtBytes = (value = 0) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let n = Number(value) || 0; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i += 1; + } + return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +}; + +const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString(); + +const toast = (message) => { + const el = $("#toast"); + el.textContent = message; + el.classList.add("show"); + clearTimeout(toast._timer); + toast._timer = setTimeout(() => el.classList.remove("show"), 2600); +}; + +const event = (title, detail = "") => { + state.events.unshift({ title, detail, time: new Date() }); + state.events = state.events.slice(0, 8); + renderEvents(); +}; + +async function api(path, options = {}) { + const headers = { + "Accept": "application/json", + "X-GoTelegram-Admin": "1", + ...(options.headers || {}), + }; + if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { ...options, headers, credentials: "same-origin" }); + if (res.status === 401) { + $("#authLock").classList.remove("hidden"); + throw new Error("Unauthorized"); + } + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`); + return data.data ?? data; +} + +function renderServices(services = {}) { + const items = [ + { key: "telemt", label: "telemt", api: "telemt" }, + { key: "nginx", label: "nginx", api: "nginx" }, + { key: "bot", label: "bot", api: "gotelegram-bot" }, + { key: "stats", label: "stats", api: "gotelegram-stats" }, + { key: "admin", label: "admin", api: "gotelegram-admin" }, + ]; + $("#services").innerHTML = items.map((item) => { + const status = services[item.key] || "unknown"; + return `
+ ${item.label} +
${status}
+ +
`; + }).join(""); +} + +function renderOverview() { + const data = state.overview; + if (!data) return; + const cfg = data.config || {}; + const stats = data.stats_current || {}; + $("#metricMode").textContent = cfg.mode || "--"; + $("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--"; + $("#metricUsers").textContent = data.users_count ?? 0; + $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); + $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} packets`; + $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); + $("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`; + $("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2); + renderServices(data.services || {}); + renderBackups(data.backups || []); + drawTrafficChart(data.stats_history || []); +} + +function renderUsers() { + const tbody = $("#usersTable"); + if (!state.users.length) { + tbody.innerHTML = `No keys yet`; + return; + } + tbody.innerHTML = state.users.map((user) => ` + + ${escapeHtml(user.name)}${user.main ? " main" : ""} + ${escapeHtml(user.secret)} + + + + + + + `).join(""); +} + +function renderBackups(backups) { + const box = $("#backupsList"); + if (!backups.length) { + box.innerHTML = `
No backups
`; + return; + } + box.innerHTML = backups.map((item) => ` +
+
+ ${escapeHtml(item.name)} + ${escapeHtml(item.path)} · ${fmtDate(item.mtime)} +
+
${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}
+
+ `).join(""); +} + +function renderEvents() { + $("#events").innerHTML = state.events.map((item) => ` +
+ ${escapeHtml(item.title)} + ${escapeHtml(item.detail || item.time.toLocaleTimeString())} +
+ `).join(""); +} + +function drawTrafficChart(rows) { + const canvas = $("#trafficChart"); + const ctx = canvas.getContext("2d"); + const ratio = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = Math.max(320, rect.width) * ratio; + canvas.height = 260 * ratio; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + const w = canvas.width / ratio; + const h = canvas.height / ratio; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + + const pad = { l: 48, r: 18, t: 20, b: 34 }; + const points = rows.length ? rows : [{ proxy_delta: 0, site_delta: 0 }, { proxy_delta: 0, site_delta: 0 }]; + const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0))); + const plotW = w - pad.l - pad.r; + const plotH = h - pad.t - pad.b; + + ctx.strokeStyle = "#dfe6f1"; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i <= 4; i += 1) { + const y = pad.t + (plotH / 4) * i; + ctx.moveTo(pad.l, y); + ctx.lineTo(w - pad.r, y); + } + ctx.stroke(); + + const line = (key, color) => { + ctx.strokeStyle = color; + ctx.lineWidth = 2.4; + ctx.beginPath(); + points.forEach((p, i) => { + const x = pad.l + (plotW * i) / Math.max(1, points.length - 1); + const y = pad.t + plotH - ((p[key] || 0) / max) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + }; + line("proxy_delta", "#2563eb"); + line("site_delta", "#0f9f6e"); + + ctx.fillStyle = "#647087"; + ctx.font = "12px system-ui"; + ctx.fillText(`max ${fmtBytes(max)}/min`, pad.l, 14); + ctx.fillStyle = "#2563eb"; + ctx.fillText("proxy", pad.l, h - 10); + ctx.fillStyle = "#0f9f6e"; + ctx.fillText("site", pad.l + 58, h - 10); +} + +async function refreshAll() { + const btn = $("#refreshBtn"); + btn.disabled = true; + try { + state.overview = await api("/api/overview"); + state.users = await api("/api/users"); + renderOverview(); + renderUsers(); + } catch (err) { + if (err.message !== "Unauthorized") toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function addUser(name) { + const data = await api("/api/users", { + method: "POST", + body: JSON.stringify({ name }), + }); + event("Key created", data.name); + toast("Key created"); + await refreshAll(); +} + +async function deleteUser(name) { + await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" }); + event("Key deleted", name); + toast("Key deleted"); + await refreshAll(); +} + +async function createBackup() { + const btn = $("#createBackupBtn"); + btn.disabled = true; + try { + const data = await api("/api/backups", { method: "POST", body: "{}" }); + event("Backup created", data.path || ""); + toast("Backup created"); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function loadLogs() { + const service = $("#logService").value; + $("#logsBox").textContent = "Loading..."; + try { + $("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`); + } catch (err) { + $("#logsBox").textContent = err.message; + } +} + +async function restartService(name) { + await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" }); + event("Service restarted", name); + toast(`${name} restarted`); + await refreshAll(); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[ch]); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, "`"); +} + +document.addEventListener("click", async (eventObj) => { + const target = eventObj.target.closest("button"); + if (!target) return; + + if (target.dataset.copy) { + await navigator.clipboard.writeText(target.dataset.copy); + toast("Copied"); + } + if (target.dataset.delete) { + const name = target.dataset.delete; + if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); + } + if (target.dataset.restart) { + const name = target.dataset.restart; + if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message)); + } +}); + +$("#addUserForm").addEventListener("submit", (eventObj) => { + eventObj.preventDefault(); + const input = $("#userName"); + const name = input.value.trim(); + if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) { + toast("Use latin letters, digits, _, . or -"); + return; + } + input.value = ""; + addUser(name).catch((err) => toast(err.message)); +}); + +$("#refreshBtn").addEventListener("click", refreshAll); +$("#createBackupBtn").addEventListener("click", createBackup); +$("#loadLogsBtn").addEventListener("click", loadLogs); +window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || [])); + +document.querySelectorAll("nav a").forEach((link) => { + link.addEventListener("click", () => { + document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active")); + link.classList.add("active"); + }); +}); + +refreshAll(); +loadLogs(); diff --git a/admin-web/static/index.html b/admin-web/static/index.html new file mode 100644 index 0000000..2b0970c --- /dev/null +++ b/admin-web/static/index.html @@ -0,0 +1,158 @@ + + + + + + GoTelegram Admin + + + + + +
+ + +
+
+
+

127.0.0.1:1984

+

GoTelegram Admin

+
+ +
+ +
+
+
+ Mode + -- + -- +
+
+ Keys + 0 + configured users +
+
+ Proxy Traffic + 0 B + 0 packets +
+
+ Site Traffic + 0 B + 0 packets +
+
+ +
+
+ +
+
+
+
+

Traffic

+

History

+
+
+
+ +
+
+ +
+ +
+
+
+

Access

+

User keys

+
+
+ + +
+
+
+ + + + + + + + + + +
UserSecretLink
+
+
+ +
+
+
+
+

Snapshots

+

Backups

+
+ +
+
+
+ +
+ +
+
+
+

Journal

+

Logs

+
+
+ + +
+
+

+      
+
+
+ +
+ + + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css new file mode 100644 index 0000000..78b95e6 --- /dev/null +++ b/admin-web/static/styles.css @@ -0,0 +1,410 @@ +:root { + color-scheme: light; + --bg: #f5f7fb; + --panel: #ffffff; + --panel-2: #f9fbff; + --text: #172033; + --muted: #647087; + --line: #dfe6f1; + --blue: #2563eb; + --green: #0f9f6e; + --amber: #c77700; + --red: #d92d20; + --ink: #0d172a; + --shadow: 0 18px 55px rgba(16, 24, 40, 0.08); +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, input, select { + font: inherit; +} + +button { + border: 0; + border-radius: 8px; + padding: 10px 14px; + background: var(--ink); + color: #fff; + cursor: pointer; + transition: transform .18s ease, box-shadow .18s ease, background .18s ease; +} + +button:hover { transform: translateY(-1px); box-shadow: 0 12px 25px rgba(16, 24, 40, .16); } +button:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; } +button.ghost { background: #e8eef8; color: var(--ink); } +button.danger { background: #fee4e2; color: #b42318; } +button.soft { background: #eef4ff; color: #1d4ed8; } + +input, select { + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--text); + padding: 0 12px; + outline: none; +} + +input:focus, select:focus { + border-color: #8bb4ff; + box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); +} + +.shell { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 18px; + background: #0f172a; + color: #e5edf8; +} + +.brand { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 30px; +} + +.brand-mark, .mark { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 8px; + background: #29b57f; + color: white; + font-weight: 800; +} + +.brand span { + display: block; + color: #93a4bc; + font-size: 12px; +} + +nav { + display: grid; + gap: 6px; +} + +nav a { + color: #b8c5d8; + text-decoration: none; + border-radius: 8px; + padding: 10px 12px; + transition: background .18s ease, color .18s ease; +} + +nav a:hover, nav a.active { + background: rgba(255,255,255,.08); + color: #fff; +} + +main { + width: min(1400px, 100%); + padding: 26px; +} + +.topbar, .section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.topbar { + margin-bottom: 24px; +} + +h1, h2, p { + margin: 0; +} + +h1 { + font-size: 30px; + line-height: 1.15; +} + +h2 { + font-size: 20px; +} + +.eyebrow { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + font-weight: 700; +} + +.section { + margin-bottom: 24px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.metric-card, .chart-wrap, .table-wrap, .activity, .backup-list, .logs { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.metric-card { + min-height: 124px; + padding: 18px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.metric-card span, .metric-card small { + color: var(--muted); +} + +.metric-card strong { + font-size: 28px; + line-height: 1.1; +} + +.service-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.service { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + padding: 14px; +} + +.status { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); +} + +.running .dot { background: var(--green); } +.failed .dot, .not_installed .dot { background: var(--red); } +.inactive .dot, .stopped .dot { background: var(--amber); } + +.split { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 14px; +} + +.chart-wrap { + margin-top: 14px; + padding: 16px; +} + +.activity { + padding: 16px; + overflow: hidden; +} + +pre { + white-space: pre-wrap; + word-break: break-word; +} + +#runtimeBox, .logs { + max-height: 340px; + overflow: auto; + color: #344054; + background: #f7f9fc; + border-radius: 8px; + padding: 12px; +} + +.inline-form { + display: flex; + align-items: center; + gap: 8px; +} + +.table-wrap { + margin-top: 14px; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 14px 16px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +td code { + display: inline-block; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.backup-list { + margin-top: 14px; + padding: 8px; +} + +.backup-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 12px; + border-radius: 8px; +} + +.backup-item + .backup-item { + border-top: 1px solid var(--line); +} + +.backup-item span { + display: block; + color: var(--muted); + font-size: 12px; +} + +#events { + display: grid; + gap: 10px; +} + +.event { + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.event small { + display: block; + color: var(--muted); +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + min-width: 240px; + max-width: 420px; + padding: 13px 14px; + border-radius: 8px; + background: #0f172a; + color: white; + opacity: 0; + transform: translateY(10px); + pointer-events: none; + transition: opacity .18s ease, transform .18s ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.auth-lock { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + background: rgba(245, 247, 251, .92); + backdrop-filter: blur(8px); +} + +.auth-panel { + width: min(420px, calc(100vw - 32px)); + border: 1px solid var(--line); + border-radius: 8px; + background: white; + padding: 26px; + box-shadow: var(--shadow); +} + +.auth-panel h1 { + margin-top: 16px; +} + +.auth-panel p { + margin-top: 8px; + color: var(--muted); +} + +.hidden { + display: none; +} + +@media (max-width: 1040px) { + .shell { grid-template-columns: 1fr; } + .sidebar { + position: static; + height: auto; + display: flex; + align-items: center; + justify-content: space-between; + } + nav { display: flex; flex-wrap: wrap; } + .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + main { padding: 18px; } + .sidebar { padding: 18px; align-items: flex-start; flex-direction: column; } + .metric-grid, .service-grid { grid-template-columns: 1fr; } + .topbar, .section-head { align-items: flex-start; flex-direction: column; } + .inline-form { width: 100%; flex-wrap: wrap; } + .inline-form input, .inline-form select { flex: 1 1 180px; } + .inline-form button { flex: 0 0 auto; } +} diff --git a/bootstrap.sh b/bootstrap.sh index 604f120..e5ae3c7 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -93,10 +93,14 @@ FILES=( "gotelegram-bot/config.example.env" "gotelegram-bot/requirements.txt" "gotelegram-bot/README.md" + "admin-web/server.py" + "admin-web/static/index.html" + "admin-web/static/styles.css" + "admin-web/static/app.js" ) echo -e " ${CYAN}↻${NC} Загрузка файлов в ${INSTALL_DIR}..." -mkdir -p "${INSTALL_DIR}/lib/lang" "${INSTALL_DIR}/gotelegram-bot/lang" +mkdir -p "${INSTALL_DIR}/lib/lang" "${INSTALL_DIR}/gotelegram-bot/lang" "${INSTALL_DIR}/admin-web/static" failed=0 for f in "${FILES[@]}"; do @@ -118,8 +122,10 @@ fi echo -e " ${CYAN}↻${NC} Настройка прав..." chmod +x "${INSTALL_DIR}/install.sh" "${INSTALL_DIR}/install_gotelegram_bot.sh" chmod +x "${INSTALL_DIR}"/lib/*.sh +chmod +x "${INSTALL_DIR}/admin-web/server.py" 2>/dev/null || true sed -i 's/\r$//' "${INSTALL_DIR}/install.sh" "${INSTALL_DIR}/install_gotelegram_bot.sh" "${INSTALL_DIR}"/lib/*.sh "${INSTALL_DIR}"/lib/lang/*.sh 2>/dev/null || true sed -i 's/\r$//' "${INSTALL_DIR}"/gotelegram-bot/*.py "${INSTALL_DIR}"/gotelegram-bot/lang/*.json 2>/dev/null || true +sed -i 's/\r$//' "${INSTALL_DIR}"/admin-web/*.py "${INSTALL_DIR}"/admin-web/static/* 2>/dev/null || true # Create symlink ln -sf "${INSTALL_DIR}/install.sh" /usr/local/bin/gotelegram diff --git a/gotelegram-bot/README.md b/gotelegram-bot/README.md index 4c9d190..2395916 100644 --- a/gotelegram-bot/README.md +++ b/gotelegram-bot/README.md @@ -20,6 +20,7 @@ Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux se - **Template Browsing** - Browse categories → templates → preview → install - **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus +- **Local Web Admin** - Shows SSH tunnel instructions for the 127.0.0.1:1984 dashboard - **V1 Migration** - Detects old mtg Docker container and offers migration - **Access Control** - ALLOWED_IDS from .env - **Async/Await** - Full async support via python-telegram-bot v21+ diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index fe578a5..b8dc243 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -117,6 +117,9 @@ PROMO_STAMP_FILE = "/opt/gotelegram/.promo_bot_last_shown" BOT_TOKEN = os.getenv("BOT_TOKEN") ENV_FILE = "/opt/gotelegram-bot/.env" +ADMIN_WEB_SERVICE = "gotelegram-admin" +ADMIN_WEB_TOKEN_FILE = "/opt/gotelegram-admin/token" +ADMIN_WEB_PORT = 1984 # ── Загрузка ALLOWED_IDS ──────────────────────────────────────────────────── # Поддерживает запятую, пробел, или их комбинацию как разделитель @@ -521,14 +524,15 @@ def get_main_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup: InlineKeyboardButton(_t(user_id, "menu_users"), callback_data="menu_users"), ], [ - InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"), + InlineKeyboardButton(_t(user_id, "menu_admin_web"), callback_data="menu_admin_web"), InlineKeyboardButton(_t(user_id, "menu_admins"), callback_data="menu_admins"), ], [ + InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"), InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"), - InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"), ], [ + InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"), InlineKeyboardButton(_t(user_id, "menu_close"), callback_data="close_menu"), ], ] @@ -2157,6 +2161,77 @@ async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") +def read_admin_web_token() -> str: + try: + token = Path(ADMIN_WEB_TOKEN_FILE).read_text(encoding="utf-8").strip() + return token if len(token) >= 24 else "" + except OSError: + return "" + + +async def admin_web_host_hint() -> str: + config = load_json(GOTELEGRAM_CONFIG) or {} + domain = str(config.get("domain") or "") + if domain: + return domain + code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org", timeout=7) + return stdout.strip() if code == 0 and stdout.strip() else "SERVER_IP" + + +async def cb_menu_admin_web(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + running = await check_service_status(ADMIN_WEB_SERVICE) + token = read_admin_web_token() + host = await admin_web_host_hint() + local_url = f"http://127.0.0.1:{ADMIN_WEB_PORT}/?token={token}" if token else f"http://127.0.0.1:{ADMIN_WEB_PORT}/" + ssh_cmd = f"ssh -L {ADMIN_WEB_PORT}:127.0.0.1:{ADMIN_WEB_PORT} root@{host}" + + if get_user_lang(user_id) == "ru": + status = "запущена" if running else "не запущена" + token_note = "Токен найден." if token else "Токен не найден: переустановите/обновите web-admin через CLI." + text = ( + f"🖥 Web Admin\n\n" + f"Статус: {status}\n" + f"{html.escape(token_note)}\n\n" + "Termius\n" + "1. Откройте сервер → Port Forwarding.\n" + f"2. Добавьте Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → " + f"127.0.0.1:{ADMIN_WEB_PORT}.\n" + "3. Запустите tunnel и откройте в браузере:\n" + f"{html.escape(local_url)}\n\n" + "Обычный SSH\n" + f"{html.escape(ssh_cmd)}\n\n" + "Админка слушает только localhost на сервере и не публикуется наружу." + ) + else: + status = "running" if running else "not running" + token_note = "Token is ready." if token else "Token is missing: reinstall/update web-admin from CLI." + text = ( + f"🖥 Web Admin\n\n" + f"Status: {status}\n" + f"{html.escape(token_note)}\n\n" + "Termius\n" + "1. Open the server → Port Forwarding.\n" + f"2. Add a Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → " + f"127.0.0.1:{ADMIN_WEB_PORT}.\n" + "3. Start the tunnel and open:\n" + f"{html.escape(local_url)}\n\n" + "Regular SSH\n" + f"{html.escape(ssh_cmd)}\n\n" + "The admin listens only on server localhost and is not exposed publicly." + ) + + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + # ============================================================================ # ADMIN MANAGEMENT # ============================================================================ @@ -2516,6 +2591,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "menu_website": cb_menu_website, "menu_promo": cb_menu_promo, "menu_credits": cb_menu_credits, + "menu_admin_web": cb_menu_admin_web, "menu_admins": cb_menu_admins, "menu_users": cb_menu_users, "menu_remove": cb_menu_remove, diff --git a/gotelegram-bot/lang/en.json b/gotelegram-bot/lang/en.json index 647ae6a..8c4def9 100644 --- a/gotelegram-bot/lang/en.json +++ b/gotelegram-bot/lang/en.json @@ -34,6 +34,7 @@ "menu_promo": "🎁 Promo", "menu_stats": "📊 Traffic Stats", "menu_users": "🔑 Keys", + "menu_admin_web": "🖥 Web Admin", "menu_remove": "🗑️ Remove", "menu_admins": "👤 Admins", "menu_credits": "ℹ️ Credits", diff --git a/gotelegram-bot/lang/ru.json b/gotelegram-bot/lang/ru.json index ed32566..3208c56 100644 --- a/gotelegram-bot/lang/ru.json +++ b/gotelegram-bot/lang/ru.json @@ -34,6 +34,7 @@ "menu_promo": "🎁 Промо", "menu_stats": "📊 Трафик", "menu_users": "🔑 Ключи", + "menu_admin_web": "🖥 Web Admin", "menu_remove": "🗑️ Удалить", "menu_admins": "👤 Админы", "menu_credits": "ℹ️ О проекте", diff --git a/install.sh b/install.sh index ed81f34..f9d76f7 100755 --- a/install.sh +++ b/install.sh @@ -869,6 +869,7 @@ menu_remove() { systemctl daemon-reload rm -rf "$BOT_DIR" fi + remove_admin_web log_success "$(t remove_all_done)" ;; esac @@ -878,6 +879,80 @@ menu_remove() { BOT_DIR="/opt/gotelegram-bot" BOT_SERVICE="gotelegram-bot" +admin_web_service_status() { + if ! systemctl list-unit-files "$ADMIN_WEB_SERVICE.service" &>/dev/null 2>&1; then + echo "not_installed" + elif systemctl is-active "$ADMIN_WEB_SERVICE" &>/dev/null 2>&1; then + echo "running" + else + echo "stopped" + fi +} + +install_admin_web() { + local src_dir="$SCRIPT_DIR/admin-web" + [ -d "$src_dir" ] || { log_warning "admin-web files not found: $src_dir"; return 1; } + command -v python3 &>/dev/null || { log_warning "python3 not found; web admin skipped"; return 1; } + + mkdir -p "$ADMIN_WEB_DIR/static" + cp "$src_dir/server.py" "$ADMIN_WEB_DIR/server.py" + cp -a "$src_dir/static/." "$ADMIN_WEB_DIR/static/" + chmod 700 "$ADMIN_WEB_DIR" + chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" + + if [ ! -f "$ADMIN_WEB_DIR/token" ]; then + openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-48 > "$ADMIN_WEB_DIR/token" + echo "" >> "$ADMIN_WEB_DIR/token" + fi + chmod 600 "$ADMIN_WEB_DIR/token" + + local python_bin + python_bin=$(command -v python3) + cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << SVCEOF +[Unit] +Description=GoTelegram v${GOTELEGRAM_VERSION} Local Web Admin +After=network.target + +[Service] +Type=simple +WorkingDirectory=$ADMIN_WEB_DIR +ExecStart=$python_bin $ADMIN_WEB_DIR/server.py +Restart=always +RestartSec=5 +Environment=GOTELEGRAM_ADMIN_HOST=$ADMIN_WEB_HOST +Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT + +[Install] +WantedBy=multi-user.target +SVCEOF + + systemctl daemon-reload + systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null + systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" + log_success "Web admin installed: ${ADMIN_WEB_HOST}:${ADMIN_WEB_PORT}" +} + +auto_install_admin_web_if_possible() { + [ -d "$SCRIPT_DIR/admin-web" ] || return 0 + command -v python3 &>/dev/null || return 0 + if [ "$(admin_web_service_status)" != "not_installed" ] && \ + [ -f "$ADMIN_WEB_DIR/server.py" ] && \ + cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/app.js" "$ADMIN_WEB_DIR/static/app.js" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then + return 0 + fi + install_admin_web >/dev/null 2>&1 || true +} + +remove_admin_web() { + systemctl stop "$ADMIN_WEB_SERVICE" 2>/dev/null + systemctl disable "$ADMIN_WEB_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" + systemctl daemon-reload 2>/dev/null + rm -rf "$ADMIN_WEB_DIR" +} + bot_service_status() { if ! systemctl list-unit-files "$BOT_SERVICE.service" &>/dev/null 2>&1; then echo "not_installed" @@ -1136,6 +1211,8 @@ SVCEOF systemctl enable "$BOT_SERVICE" &>/dev/null systemctl restart "$BOT_SERVICE" 2>/dev/null || systemctl start "$BOT_SERVICE" + install_admin_web || log_warning "Web admin could not be installed" + # If auto mode — wait until bot captures first admin local has_ids has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2) @@ -1700,6 +1777,7 @@ main() { fi auto_migrate_legacy_state || true + auto_install_admin_web_if_possible || true # First-run language picker (before banner so banner appears in chosen lang) first_run_language_picker diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh index eb73c5a..6a51ffa 100755 --- a/install_gotelegram_bot.sh +++ b/install_gotelegram_bot.sh @@ -12,6 +12,9 @@ NC='\033[0m' BOT_DIR="/opt/gotelegram-bot" SERVICE_NAME="gotelegram-bot" GOTELEGRAM_DIR="/opt/gotelegram" +ADMIN_WEB_DIR="/opt/gotelegram-admin" +ADMIN_WEB_SERVICE="gotelegram-admin" +ADMIN_WEB_PORT="1984" if [ "$EUID" -ne 0 ]; then echo -e "${RED}Запустите с sudo.${NC}" @@ -112,6 +115,44 @@ systemctl daemon-reload systemctl enable "$SERVICE_NAME" systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" +# ── Local Web Admin ────────────────────────────────────────────────────────── +if [ -f "$SCRIPT_DIR/admin-web/server.py" ]; then + echo -e "${GREEN}[*] Установка локальной web-админки...${NC}" + mkdir -p "$ADMIN_WEB_DIR/static" + cp "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" + cp -a "$SCRIPT_DIR/admin-web/static/." "$ADMIN_WEB_DIR/static/" + chmod 700 "$ADMIN_WEB_DIR" + chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" + + if [ ! -f "$ADMIN_WEB_DIR/token" ]; then + openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-48 > "$ADMIN_WEB_DIR/token" + echo "" >> "$ADMIN_WEB_DIR/token" + fi + chmod 600 "$ADMIN_WEB_DIR/token" + + PYTHON_BIN=$(command -v python3) + cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF +[Unit] +Description=GoTelegram v2.5.0 Local Web Admin +After=network.target + +[Service] +Type=simple +WorkingDirectory=$ADMIN_WEB_DIR +ExecStart=$PYTHON_BIN $ADMIN_WEB_DIR/server.py +Restart=always +RestartSec=5 +Environment=GOTELEGRAM_ADMIN_HOST=127.0.0.1 +Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable "$ADMIN_WEB_SERVICE" + systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" +fi + echo "" echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ ✅ Бот установлен и запущен! ║${NC}" diff --git a/lib/backup.sh b/lib/backup.sh index abb4dc6..9d5a21d 100755 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -72,6 +72,14 @@ create_backup() { [ -d "$BOT_DIR/lang" ] && cp -a "$BOT_DIR/lang" "$tmp_dir/bot/" 2>/dev/null fi + # Local web admin state (token is needed so restored bots can show the same tunnel URL) + if [ -d "$ADMIN_WEB_DIR" ]; then + mkdir -p "$tmp_dir/admin_web" + [ -f "$ADMIN_WEB_DIR/token" ] && cp "$ADMIN_WEB_DIR/token" "$tmp_dir/admin_web/token" 2>/dev/null + [ -f "$ADMIN_WEB_DIR/server.py" ] && cp "$ADMIN_WEB_DIR/server.py" "$tmp_dir/admin_web/server.py" 2>/dev/null + [ -d "$ADMIN_WEB_DIR/static" ] && cp -a "$ADMIN_WEB_DIR/static" "$tmp_dir/admin_web/" 2>/dev/null + fi + # Traffic history if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null @@ -90,7 +98,7 @@ create_backup() { cat > "$tmp_dir/metadata.json" << EOMETA { - "backup_version": "1.2", + "backup_version": "1.3", "gotelegram_version": "$GOTELEGRAM_VERSION", "created_at": "$(date -Iseconds)", "hostname": "$(hostname)", @@ -305,6 +313,15 @@ restore_backup() { log_success "Конфигурация Telegram-бота восстановлена" fi + # Восстанавливаем состояние локальной web-админки + if [ -d "$backup_dir/admin_web" ]; then + mkdir -p "$ADMIN_WEB_DIR" + [ -f "$backup_dir/admin_web/token" ] && cp "$backup_dir/admin_web/token" "$ADMIN_WEB_DIR/token" 2>/dev/null && chmod 600 "$ADMIN_WEB_DIR/token" + [ -f "$backup_dir/admin_web/server.py" ] && cp "$backup_dir/admin_web/server.py" "$ADMIN_WEB_DIR/server.py" 2>/dev/null + [ -d "$backup_dir/admin_web/static" ] && cp -a "$backup_dir/admin_web/static" "$ADMIN_WEB_DIR/" 2>/dev/null + log_success "Конфигурация web-админки восстановлена" + fi + # Запускаем сервисы if is_telemt_installed && [ ! -f "/etc/systemd/system/${TELEMT_SERVICE}.service" ]; then install_telemt_service @@ -314,6 +331,7 @@ restore_backup() { fi command -v nginx &>/dev/null && systemctl start nginx 2>/dev/null systemctl restart gotelegram-bot 2>/dev/null || true + systemctl restart gotelegram-admin 2>/dev/null || true # Очистка rm -rf "$tmp_dir" diff --git a/lib/common.sh b/lib/common.sh index 0a02eef..1d7c9ec 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -18,6 +18,10 @@ WEBSITE_ROOT="/var/www/gotelegram-site" BACKUP_DIR="$GOTELEGRAM_DIR/backups" LOG_FILE="/var/log/gotelegram.log" BOT_DIR="/opt/gotelegram-bot" +ADMIN_WEB_DIR="/opt/gotelegram-admin" +ADMIN_WEB_SERVICE="gotelegram-admin" +ADMIN_WEB_HOST="127.0.0.1" +ADMIN_WEB_PORT="1984" # ── V1 совместимость ───────────────────────────────────────────────────────── V1_CONTAINER_NAME="mtproto-proxy"