mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-31 07:15:59 +00:00
v2.5.0: add local web admin dashboard
This commit is contained in:
19
DOCS_AI.md
19
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`.
|
||||
|
||||
@@ -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
533
admin-web/server.py
Normal 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
300
admin-web/static/app.js
Normal 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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[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();
|
||||
158
admin-web/static/index.html
Normal file
158
admin-web/static/index.html
Normal 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
410
admin-web/static/styles.css
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"menu_promo": "🎁 Промо",
|
||||
"menu_stats": "📊 Трафик",
|
||||
"menu_users": "🔑 Ключи",
|
||||
"menu_admin_web": "🖥 Web Admin",
|
||||
"menu_remove": "🗑️ Удалить",
|
||||
"menu_admins": "👤 Админы",
|
||||
"menu_credits": "ℹ️ О проекте",
|
||||
|
||||
78
install.sh
78
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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user