v2.5.0: add local web admin dashboard

This commit is contained in:
Виталий Литвинов
2026-04-24 19:19:12 +03:00
parent ed9073f28f
commit 20103ccac8
15 changed files with 1668 additions and 12 deletions

View File

@@ -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`.

View File

@@ -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.

533
admin-web/server.py Normal file
View File

@@ -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()

300
admin-web/static/app.js Normal file
View File

@@ -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 `<article class="service ${status}">
<strong>${item.label}</strong>
<div class="status"><span class="dot"></span><span>${status}</span></div>
<button class="soft" data-restart="${item.api}" ${item.key === "admin" ? "disabled" : ""}>Restart</button>
</article>`;
}).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 = `<tr><td colspan="4">No keys yet</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => `
<tr>
<td><strong>${escapeHtml(user.name)}</strong>${user.main ? " <small>main</small>" : ""}</td>
<td><code title="${escapeHtml(user.secret)}">${escapeHtml(user.secret)}</code></td>
<td><button class="soft" data-copy="${escapeAttr(user.link)}">Copy link</button></td>
<td class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">Copy secret</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>Delete</button>
</td>
</tr>
`).join("");
}
function renderBackups(backups) {
const box = $("#backupsList");
if (!backups.length) {
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></div>`;
return;
}
box.innerHTML = backups.map((item) => `
<div class="backup-item">
<div>
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.path)} · ${fmtDate(item.mtime)}</span>
</div>
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div>
</div>
`).join("");
}
function renderEvents() {
$("#events").innerHTML = state.events.map((item) => `
<div class="event">
<strong>${escapeHtml(item.title)}</strong>
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
</div>
`).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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[ch]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
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();

158
admin-web/static/index.html Normal file
View File

@@ -0,0 +1,158 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoTelegram Admin</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="authLock" class="auth-lock hidden">
<div class="auth-panel">
<div class="mark">GT</div>
<h1>GoTelegram Admin</h1>
<p>Откройте ссылку из Telegram-бота после запуска SSH-туннеля.</p>
</div>
</div>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">GT</div>
<div>
<strong>GoTelegram</strong>
<span>Local Admin</span>
</div>
</div>
<nav>
<a href="#dashboard" class="active">Dashboard</a>
<a href="#keys">Keys</a>
<a href="#traffic">Traffic</a>
<a href="#backups">Backups</a>
<a href="#logs">Logs</a>
</nav>
</aside>
<main>
<header class="topbar">
<div>
<p class="eyebrow">127.0.0.1:1984</p>
<h1>GoTelegram Admin</h1>
</div>
<button id="refreshBtn" class="ghost">Refresh</button>
</header>
<section id="dashboard" class="section">
<div class="metric-grid">
<article class="metric-card">
<span>Mode</span>
<strong id="metricMode">--</strong>
<small id="metricDomain">--</small>
</article>
<article class="metric-card">
<span>Keys</span>
<strong id="metricUsers">0</strong>
<small>configured users</small>
</article>
<article class="metric-card">
<span>Proxy Traffic</span>
<strong id="metricProxyTraffic">0 B</strong>
<small id="metricProxyPackets">0 packets</small>
</article>
<article class="metric-card">
<span>Site Traffic</span>
<strong id="metricSiteTraffic">0 B</strong>
<small id="metricSitePackets">0 packets</small>
</article>
</div>
<div class="service-grid" id="services"></div>
</section>
<section id="traffic" class="section split">
<div>
<div class="section-head">
<div>
<p class="eyebrow">Traffic</p>
<h2>History</h2>
</div>
</div>
<div class="chart-wrap">
<canvas id="trafficChart" height="260"></canvas>
</div>
</div>
<aside class="activity">
<p class="eyebrow">Runtime</p>
<pre id="runtimeBox">{}</pre>
</aside>
</section>
<section id="keys" class="section">
<div class="section-head">
<div>
<p class="eyebrow">Access</p>
<h2>User keys</h2>
</div>
<form id="addUserForm" class="inline-form">
<input id="userName" autocomplete="off" placeholder="client-name">
<button type="submit">Add key</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Secret</th>
<th>Link</th>
<th></th>
</tr>
</thead>
<tbody id="usersTable"></tbody>
</table>
</div>
</section>
<section id="backups" class="section split">
<div>
<div class="section-head">
<div>
<p class="eyebrow">Snapshots</p>
<h2>Backups</h2>
</div>
<button id="createBackupBtn">Create backup</button>
</div>
<div id="backupsList" class="backup-list"></div>
</div>
<aside class="activity">
<p class="eyebrow">Events</p>
<div id="events"></div>
</aside>
</section>
<section id="logs" class="section">
<div class="section-head">
<div>
<p class="eyebrow">Journal</p>
<h2>Logs</h2>
</div>
<div class="inline-form">
<select id="logService">
<option value="telemt">telemt</option>
<option value="nginx">nginx</option>
<option value="gotelegram-bot">bot</option>
<option value="gotelegram-stats">stats</option>
<option value="gotelegram-admin">admin</option>
</select>
<button id="loadLogsBtn" type="button">Load</button>
</div>
</div>
<pre id="logsBox" class="logs"></pre>
</section>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/app.js" type="module"></script>
</body>
</html>

410
admin-web/static/styles.css Normal file
View File

@@ -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; }
}

View File

@@ -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

View File

@@ -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+

View File

@@ -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"<b>🖥 Web Admin</b>\n\n"
f"Статус: <code>{status}</code>\n"
f"{html.escape(token_note)}\n\n"
"<b>Termius</b>\n"
"1. Откройте сервер → Port Forwarding.\n"
f"2. Добавьте Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → "
f"<code>127.0.0.1:{ADMIN_WEB_PORT}</code>.\n"
"3. Запустите tunnel и откройте в браузере:\n"
f"<code>{html.escape(local_url)}</code>\n\n"
"<b>Обычный SSH</b>\n"
f"<code>{html.escape(ssh_cmd)}</code>\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"<b>🖥 Web Admin</b>\n\n"
f"Status: <code>{status}</code>\n"
f"{html.escape(token_note)}\n\n"
"<b>Termius</b>\n"
"1. Open the server → Port Forwarding.\n"
f"2. Add a Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → "
f"<code>127.0.0.1:{ADMIN_WEB_PORT}</code>.\n"
"3. Start the tunnel and open:\n"
f"<code>{html.escape(local_url)}</code>\n\n"
"<b>Regular SSH</b>\n"
f"<code>{html.escape(ssh_cmd)}</code>\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,

View File

@@ -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",

View File

@@ -34,6 +34,7 @@
"menu_promo": "🎁 Промо",
"menu_stats": "📊 Трафик",
"menu_users": "🔑 Ключи",
"menu_admin_web": "🖥 Web Admin",
"menu_remove": "🗑️ Удалить",
"menu_admins": "👤 Админы",
"menu_credits": " О проекте",

View File

@@ -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

View File

@@ -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}"

View File

@@ -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"

View File

@@ -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"