127.0.0.1:1984
+GoTelegram Admin
+Traffic
+History
+Access
+User keys
+| User | +Secret | +Link | ++ |
|---|
Snapshots
+Backups
+Journal
+diff --git a/DOCS_AI.md b/DOCS_AI.md
index 15cb5b1..8e8d3f3 100644
--- a/DOCS_AI.md
+++ b/DOCS_AI.md
@@ -437,7 +437,22 @@ switch_language ru|en
`restore_backup` разворачивает архив обратно, перезапускает telemt и nginx.
-### 13.1 Upgrade migration (v2.5.0)
+### 13.1 Local Web Admin (v2.5.0)
+
+Локальная web-админка находится в `admin-web/` и устанавливается в `/opt/gotelegram-admin`:
+
+- backend: `admin-web/server.py`, Python stdlib only, без pip/npm dependencies;
+- frontend: `admin-web/static/`, vanilla JS/CSS, canvas-график без CDN;
+- systemd service: `gotelegram-admin`;
+- bind: `127.0.0.1:1984`, доступ только через SSH tunnel;
+- token: `/opt/gotelegram-admin/token`, HTTP cookie `gtauth` выставляется при открытии `/?token=...`;
+- API требует cookie/Bearer token, а write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`.
+
+Функции: overview, service status/restart, чтение/запись `[access.users]`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv`, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, journal logs.
+
+`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. Backup v1.3 сохраняет `admin_web/token`, `server.py` и `static/`, restore возвращает их и пробует перезапустить `gotelegram-admin`.
+
+### 13.2 Upgrade migration (v2.5.0)
`install.sh` вызывает `auto_migrate_legacy_state` перед интерактивным меню и перед non-interactive `--action=...` для бота. Цель — комфортно обновляться даже с кривой старой логики без переустановки прокси:
@@ -611,7 +626,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s:
## 17. Changelog
-- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/удалить/показать ссылку/runtime info); исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.2` сохраняет bot `.env`, bot lang files, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений.
+- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/удалить/показать ссылку/runtime info); добавлена локальная web-админка `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.3` сохраняет bot `.env`, bot lang files, web-admin token/static, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений.
- **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS.
- **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`.
- **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`.
diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md
index 0928378..83892f5 100644
--- a/DOCS_HUMAN.md
+++ b/DOCS_HUMAN.md
@@ -125,7 +125,7 @@ CLI и бот переведены на русский и английский.
## 7. Бекап и восстановление
-Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, данные nginx-сайта и сертификаты. По умолчанию в `/opt/gotelegram/backups/`.
+Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, данные nginx-сайта, сертификаты, состояние Telegram-бота и локальной web-админки. По умолчанию в `/opt/gotelegram/backups/`.
Пункт 9 принимает такой архив и восстанавливает всё обратно (конфиг, сервис, ссылка — всё то же, что было).
@@ -133,7 +133,21 @@ CLI и бот переведены на русский и английский.
---
-## 8. Обновление
+## 8. Локальная web-админка
+
+Начиная с **2.5.0** вместе с ботом ставится локальная web-админка:
+
+- systemd service: `gotelegram-admin`;
+- слушает только `127.0.0.1:1984`;
+- наружу не публикуется и рассчитана на доступ через SSH tunnel;
+- токен доступа хранится в `/opt/gotelegram-admin/token`;
+- Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`.
+
+В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, график трафика, список бекапов и просмотр логов.
+
+---
+
+## 9. Обновление
Два типа обновлений:
@@ -146,7 +160,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст
---
-## 9. Удаление
+## 10. Удаление
Пункт **13) Удалить всё** даёт выбор:
@@ -158,7 +172,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст
---
-## 10. Требования к VPS
+## 11. Требования к VPS
- **ОС:** Ubuntu 20.04+ или Debian 11+ (протестировано на Ubuntu 22.04).
- **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот).
@@ -169,7 +183,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст
---
-## 11. Частые вопросы
+## 12. Частые вопросы
**Q: Ключ перестал работать, telemt живой.**
A: 95% случаев — это когда после переустановки telemt не перечитал свежий конфиг (было исправлено в 2.4.1, см. changelog). Перезапусти вручную: `systemctl restart telemt`. Если не помогло — смотри логи (пункт 6 меню) и проверь `/etc/telemt/config.toml` на предмет правильного `tls_domain`.
@@ -222,7 +236,7 @@ A: Сам MTProxy — да, это публичная технология из
## Changelog (коротко)
-- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, удаление, ссылка и runtime/API-информация; backup/restore сохраняет bot `.env`, языки бота, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443.
+- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, удаление, ссылка и runtime/API-информация; добавлена локальная web-админка на `127.0.0.1:1984` под SSH tunnel; backup/restore сохраняет bot `.env`, языки бота, web-admin token/static, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443.
- **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades.
- **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости.
- **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess.
diff --git a/admin-web/server.py b/admin-web/server.py
new file mode 100644
index 0000000..b1a9972
--- /dev/null
+++ b/admin-web/server.py
@@ -0,0 +1,533 @@
+#!/usr/bin/env python3
+"""
+GoTelegram local web admin.
+
+The service is intentionally bound to 127.0.0.1:1984. Operators reach it
+through an SSH tunnel; it must never be exposed directly on the public network.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import http.cookies
+import json
+import mimetypes
+import os
+import re
+import secrets
+import subprocess
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+
+ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin"))
+STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static")))
+TOKEN_FILE = Path(os.getenv("GOTELEGRAM_ADMIN_TOKEN_FILE", str(ADMIN_DIR / "token")))
+
+GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json"))
+TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml"))
+HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv"))
+CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json"))
+BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
+INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram"))
+
+HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
+PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
+VERSION = "2.5.0"
+USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
+ try:
+ proc = subprocess.run(
+ cmd,
+ text=True,
+ capture_output=True,
+ timeout=timeout,
+ check=False,
+ )
+ return proc.returncode, proc.stdout, proc.stderr
+ except Exception as exc: # pragma: no cover - system dependent
+ return 125, "", str(exc)
+
+
+def ensure_token() -> str:
+ ADMIN_DIR.mkdir(parents=True, exist_ok=True)
+ if not TOKEN_FILE.exists() or TOKEN_FILE.stat().st_size < 24:
+ TOKEN_FILE.write_text(secrets.token_urlsafe(36) + "\n", encoding="utf-8")
+ os.chmod(TOKEN_FILE, 0o600)
+ return TOKEN_FILE.read_text(encoding="utf-8").strip()
+
+
+def current_token() -> str:
+ try:
+ return ensure_token()
+ except OSError:
+ return ""
+
+
+def load_json(path: Path, fallback: Any = None) -> Any:
+ try:
+ with path.open("r", encoding="utf-8") as fh:
+ return json.load(fh)
+ except Exception:
+ return fallback
+
+
+def read_telemt_users() -> dict[str, str]:
+ if not TELEMT_CONFIG.exists():
+ return {}
+ users: dict[str, str] = {}
+ in_users = False
+ for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines():
+ line = raw.strip()
+ if line == "[access.users]":
+ in_users = True
+ continue
+ if in_users and line.startswith("["):
+ break
+ if not in_users or not line or line.startswith("#") or "=" not in line:
+ continue
+ name, value = line.split("=", 1)
+ name = name.strip()
+ value = value.strip().split("#", 1)[0].strip()
+ if value.startswith('"') and '"' in value[1:]:
+ value = value[1:].split('"', 1)[0]
+ elif value.startswith("'") and "'" in value[1:]:
+ value = value[1:].split("'", 1)[0]
+ if USER_RE.match(name) and value:
+ users[name] = value
+ return users
+
+
+def _ordered_user_lines(users: dict[str, str]) -> list[str]:
+ names = []
+ if "main" in users:
+ names.append("main")
+ names.extend(sorted(n for n in users if n != "main"))
+ return [f'{name} = "{users[name]}"' for name in names]
+
+
+def write_telemt_users(users: dict[str, str]) -> None:
+ TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
+ lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else []
+ rendered = _ordered_user_lines(users)
+ out: list[str] = []
+ in_users = False
+ found = False
+
+ for raw in lines:
+ if raw.strip() == "[access.users]":
+ found = True
+ in_users = True
+ out.append(raw)
+ out.extend(rendered)
+ continue
+ if in_users and raw.strip().startswith("["):
+ in_users = False
+ if in_users:
+ continue
+ out.append(raw)
+
+ if not found:
+ if out and out[-1].strip():
+ out.append("")
+ out.append("[access.users]")
+ out.extend(rendered)
+
+ TELEMT_CONFIG.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
+ os.chmod(TELEMT_CONFIG, 0o600)
+
+
+def restart_service(name: str) -> bool:
+ code, _, _ = run(["systemctl", "restart", name], timeout=25)
+ return code == 0
+
+
+def service_status(name: str) -> str:
+ code, stdout, _ = run(["systemctl", "is-active", name], timeout=3)
+ value = stdout.strip()
+ if code == 0 and value == "active":
+ return "running"
+ code, stdout, _ = run(["systemctl", "list-unit-files", f"{name}.service", "--no-legend"], timeout=3)
+ if code != 0 or not stdout.strip():
+ return "not_installed"
+ if value in {"failed", "inactive", "activating", "deactivating"}:
+ return value
+ return "stopped"
+
+
+def public_ip() -> str:
+ code, stdout, _ = run(["curl", "-s", "-4", "--max-time", "3", "https://api.ipify.org"], timeout=5)
+ ip = stdout.strip()
+ if code == 0 and re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
+ return ip
+ code, stdout, _ = run(["hostname", "-I"], timeout=3)
+ return stdout.split()[0] if code == 0 and stdout.split() else "0.0.0.0"
+
+
+def proxy_link(secret: str) -> str:
+ config = load_json(GOTELEGRAM_CONFIG, {}) or {}
+ mode = str(config.get("mode", "lite"))
+ port = int(config.get("port", 443) or 443)
+ domain = str(config.get("domain", "") or "")
+ mask_host = str(config.get("mask_host", "") or "")
+
+ if mode == "pro" and domain:
+ host_hex = domain.encode().hex()
+ return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{host_hex}"
+
+ server = public_ip()
+ if mask_host:
+ host_hex = mask_host.encode().hex()
+ return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{host_hex}"
+ return f"tg://proxy?server={server}&port={port}&secret={secret}"
+
+
+def telemt_api(path: str) -> Any:
+ try:
+ with urllib.request.urlopen(f"http://127.0.0.1:9091{path}", timeout=1.8) as resp:
+ payload = resp.read(256 * 1024)
+ return json.loads(payload.decode("utf-8"))
+ except Exception:
+ return None
+
+
+def load_stats_history(limit: int = 240) -> list[dict[str, int]]:
+ if not HISTORY_FILE.exists():
+ return []
+ rows: list[dict[str, int]] = []
+ try:
+ with HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh:
+ for row in csv.DictReader(fh):
+ try:
+ rows.append({
+ "epoch": int(row.get("epoch") or 0),
+ "proxy_bytes": int(row.get("proxy_bytes") or 0),
+ "site_bytes": int(row.get("site_bytes") or 0),
+ })
+ except ValueError:
+ continue
+ except OSError:
+ return []
+ rows = rows[-limit:]
+ previous = None
+ enriched: list[dict[str, int]] = []
+ for row in rows:
+ item = dict(row)
+ if previous:
+ item["proxy_delta"] = max(0, row["proxy_bytes"] - previous["proxy_bytes"])
+ item["site_delta"] = max(0, row["site_bytes"] - previous["site_bytes"])
+ else:
+ item["proxy_delta"] = 0
+ item["site_delta"] = 0
+ enriched.append(item)
+ previous = row
+ return enriched
+
+
+def list_backups() -> list[dict[str, Any]]:
+ if not BACKUP_DIR.exists():
+ return []
+ items = []
+ for path in sorted(BACKUP_DIR.glob("*.tar.gz*"), key=lambda p: p.stat().st_mtime, reverse=True):
+ if path.name.endswith(".sha256"):
+ continue
+ try:
+ st = path.stat()
+ except OSError:
+ continue
+ items.append({
+ "name": path.name,
+ "path": str(path),
+ "size": st.st_size,
+ "mtime": int(st.st_mtime),
+ "encrypted": path.name.endswith(".enc"),
+ })
+ return items[:30]
+
+
+def create_backup() -> tuple[bool, str]:
+ script = (
+ "source /opt/gotelegram/lib/common.sh; "
+ "source /opt/gotelegram/lib/i18n.sh; "
+ "source /opt/gotelegram/lib/telemt.sh; "
+ "source /opt/gotelegram/lib/website.sh; "
+ "source /opt/gotelegram/lib/backup.sh; "
+ "load_language \"$(detect_language 2>/dev/null || echo en)\"; "
+ "create_backup \"\""
+ )
+ code, stdout, stderr = run(["bash", "-lc", script], timeout=180)
+ text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
+ return code == 0, text
+
+
+def user_payload(name: str, secret: str, include_runtime: bool = False) -> dict[str, Any]:
+ item: dict[str, Any] = {
+ "name": name,
+ "secret": secret,
+ "link": proxy_link(secret),
+ "main": name == "main",
+ }
+ if include_runtime:
+ item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
+ return item
+
+
+def overview_payload() -> dict[str, Any]:
+ config = load_json(GOTELEGRAM_CONFIG, {}) or {}
+ users = read_telemt_users()
+ current = load_json(CURRENT_STATS, {}) or {}
+ summary = telemt_api("/v1/stats/summary")
+ services = {
+ "telemt": service_status("telemt"),
+ "nginx": service_status("nginx"),
+ "bot": service_status("gotelegram-bot"),
+ "stats": service_status("gotelegram-stats"),
+ "admin": service_status("gotelegram-admin"),
+ }
+ return {
+ "version": VERSION,
+ "time": utc_now(),
+ "config": config,
+ "users_count": len(users),
+ "services": services,
+ "stats_current": current,
+ "stats_history": load_stats_history(),
+ "runtime_summary": summary,
+ "backups": list_backups(),
+ }
+
+
+class AdminHandler(BaseHTTPRequestHandler):
+ server_version = "GoTelegramAdmin/2.5.0"
+
+ def log_message(self, fmt: str, *args: Any) -> None:
+ print("%s - %s" % (self.address_string(), fmt % args))
+
+ def token_from_request(self) -> str:
+ parsed = urllib.parse.urlparse(self.path)
+ qs = urllib.parse.parse_qs(parsed.query)
+ if qs.get("token", [""])[0]:
+ return qs["token"][0]
+ auth = self.headers.get("Authorization", "")
+ if auth.startswith("Bearer "):
+ return auth[len("Bearer "):].strip()
+ cookie_header = self.headers.get("Cookie", "")
+ if cookie_header:
+ cookie = http.cookies.SimpleCookie()
+ cookie.load(cookie_header)
+ if "gtauth" in cookie:
+ return cookie["gtauth"].value
+ return ""
+
+ def is_authorized(self) -> bool:
+ token = current_token()
+ candidate = self.token_from_request()
+ return bool(token and candidate and secrets.compare_digest(token, candidate))
+
+ def send_json(self, payload: Any, status: int = 200) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def send_error_json(self, status: int, message: str) -> None:
+ self.send_json({"ok": False, "error": message}, status)
+
+ def read_json_body(self) -> Any:
+ length = int(self.headers.get("Content-Length", "0") or 0)
+ if length > 1024 * 1024:
+ raise ValueError("request body too large")
+ if length <= 0:
+ return {}
+ return json.loads(self.rfile.read(length).decode("utf-8"))
+
+ def require_auth(self) -> bool:
+ if self.is_authorized():
+ return True
+ self.send_error_json(401, "unauthorized")
+ return False
+
+ def require_write_guard(self) -> bool:
+ if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1":
+ self.send_error_json(403, "missing write guard")
+ return False
+ return True
+
+ def route_get_api(self, parsed: urllib.parse.ParseResult) -> None:
+ if not self.require_auth():
+ return
+ path = parsed.path
+ if path == "/api/overview":
+ self.send_json({"ok": True, "data": overview_payload()})
+ elif path == "/api/users":
+ users = read_telemt_users()
+ self.send_json({"ok": True, "data": [user_payload(k, v) for k, v in sorted(users.items())]})
+ elif path.startswith("/api/users/"):
+ name = urllib.parse.unquote(path[len("/api/users/"):])
+ users = read_telemt_users()
+ if name not in users:
+ self.send_error_json(404, "user not found")
+ return
+ self.send_json({"ok": True, "data": user_payload(name, users[name], include_runtime=True)})
+ elif path == "/api/backups":
+ self.send_json({"ok": True, "data": list_backups()})
+ elif path == "/api/logs":
+ qs = urllib.parse.parse_qs(parsed.query)
+ service = qs.get("service", ["telemt"])[0]
+ allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
+ if service not in allowed:
+ self.send_error_json(400, "unsupported service")
+ return
+ code, stdout, stderr = run(["journalctl", "-u", service, "-n", "120", "--no-pager"], timeout=8)
+ self.send_json({"ok": code == 0, "data": stdout if code == 0 else stderr})
+ else:
+ self.send_error_json(404, "not found")
+
+ def route_post_api(self, parsed: urllib.parse.ParseResult) -> None:
+ if not self.require_auth() or not self.require_write_guard():
+ return
+ path = parsed.path
+ try:
+ body = self.read_json_body()
+ except Exception as exc:
+ self.send_error_json(400, str(exc))
+ return
+
+ if path == "/api/users":
+ name = str(body.get("name", "")).strip()
+ if not USER_RE.match(name):
+ self.send_error_json(400, "invalid user name")
+ return
+ users = read_telemt_users()
+ if name in users:
+ self.send_error_json(409, "user already exists")
+ return
+ seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode()
+ secret = hashlib.sha256(seed).hexdigest()[:32]
+ users[name] = secret
+ try:
+ write_telemt_users(users)
+ except Exception as exc:
+ self.send_error_json(500, f"failed to save config: {exc}")
+ return
+ restarted = restart_service("telemt")
+ self.send_json({"ok": True, "data": user_payload(name, secret), "restarted": restarted})
+ elif path == "/api/backups":
+ ok, result = create_backup()
+ self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
+ elif path.startswith("/api/services/") and path.endswith("/restart"):
+ service = path[len("/api/services/"):-len("/restart")]
+ allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"}
+ if service not in allowed:
+ self.send_error_json(400, "unsupported service")
+ return
+ ok = restart_service(service)
+ self.send_json({"ok": ok, "status": service_status(service)}, 200 if ok else 500)
+ else:
+ self.send_error_json(404, "not found")
+
+ def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None:
+ if not self.require_auth() or not self.require_write_guard():
+ return
+ path = parsed.path
+ if not path.startswith("/api/users/"):
+ self.send_error_json(404, "not found")
+ return
+ name = urllib.parse.unquote(path[len("/api/users/"):])
+ if name == "main":
+ self.send_error_json(400, "main user cannot be deleted")
+ return
+ users = read_telemt_users()
+ if name not in users:
+ self.send_error_json(404, "user not found")
+ return
+ users.pop(name, None)
+ try:
+ write_telemt_users(users)
+ except Exception as exc:
+ self.send_error_json(500, f"failed to save config: {exc}")
+ return
+ restarted = restart_service("telemt")
+ self.send_json({"ok": True, "restarted": restarted})
+
+ def send_static(self, parsed: urllib.parse.ParseResult) -> None:
+ qs = urllib.parse.parse_qs(parsed.query)
+ if qs.get("token", [""])[0] and self.is_authorized():
+ self.send_response(302)
+ self.send_header("Location", "/")
+ self.send_header("Set-Cookie", "gtauth=%s; Path=/; HttpOnly; SameSite=Strict" % qs["token"][0])
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+
+ rel = parsed.path.lstrip("/") or "index.html"
+ if rel.startswith("api/") or ".." in rel.split("/"):
+ self.send_error(404)
+ return
+ path = STATIC_DIR / rel
+ if path.is_dir():
+ path = path / "index.html"
+ if not path.exists():
+ path = STATIC_DIR / "index.html"
+ try:
+ body = path.read_bytes()
+ except OSError:
+ self.send_error(404)
+ return
+ mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
+ self.send_response(200)
+ self.send_header("Content-Type", mime)
+ self.send_header("Cache-Control", "no-store" if path.name == "index.html" else "public, max-age=3600")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ parsed = urllib.parse.urlparse(self.path)
+ if parsed.path.startswith("/api/"):
+ self.route_get_api(parsed)
+ else:
+ self.send_static(parsed)
+
+ def do_POST(self) -> None:
+ parsed = urllib.parse.urlparse(self.path)
+ if parsed.path.startswith("/api/"):
+ self.route_post_api(parsed)
+ else:
+ self.send_error(404)
+
+ def do_DELETE(self) -> None:
+ parsed = urllib.parse.urlparse(self.path)
+ if parsed.path.startswith("/api/"):
+ self.route_delete_api(parsed)
+ else:
+ self.send_error(404)
+
+
+def main() -> None:
+ ensure_token()
+ if not STATIC_DIR.exists():
+ raise SystemExit(f"static dir not found: {STATIC_DIR}")
+ httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler)
+ print(f"GoTelegram admin listening on http://{HOST}:{PORT}")
+ httpd.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/admin-web/static/app.js b/admin-web/static/app.js
new file mode 100644
index 0000000..0865b40
--- /dev/null
+++ b/admin-web/static/app.js
@@ -0,0 +1,300 @@
+const $ = (sel) => document.querySelector(sel);
+const state = { overview: null, users: [], events: [] };
+
+const fmtBytes = (value = 0) => {
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let n = Number(value) || 0;
+ let i = 0;
+ while (n >= 1024 && i < units.length - 1) {
+ n /= 1024;
+ i += 1;
+ }
+ return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
+};
+
+const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString();
+
+const toast = (message) => {
+ const el = $("#toast");
+ el.textContent = message;
+ el.classList.add("show");
+ clearTimeout(toast._timer);
+ toast._timer = setTimeout(() => el.classList.remove("show"), 2600);
+};
+
+const event = (title, detail = "") => {
+ state.events.unshift({ title, detail, time: new Date() });
+ state.events = state.events.slice(0, 8);
+ renderEvents();
+};
+
+async function api(path, options = {}) {
+ const headers = {
+ "Accept": "application/json",
+ "X-GoTelegram-Admin": "1",
+ ...(options.headers || {}),
+ };
+ if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
+ const res = await fetch(path, { ...options, headers, credentials: "same-origin" });
+ if (res.status === 401) {
+ $("#authLock").classList.remove("hidden");
+ throw new Error("Unauthorized");
+ }
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`);
+ return data.data ?? data;
+}
+
+function renderServices(services = {}) {
+ const items = [
+ { key: "telemt", label: "telemt", api: "telemt" },
+ { key: "nginx", label: "nginx", api: "nginx" },
+ { key: "bot", label: "bot", api: "gotelegram-bot" },
+ { key: "stats", label: "stats", api: "gotelegram-stats" },
+ { key: "admin", label: "admin", api: "gotelegram-admin" },
+ ];
+ $("#services").innerHTML = items.map((item) => {
+ const status = services[item.key] || "unknown";
+ return `
${escapeHtml(user.secret)}127.0.0.1:1984
+Traffic
+Access
+| User | +Secret | +Link | ++ |
|---|
Snapshots
+Journal
+{status}\n"
+ f"{html.escape(token_note)}\n\n"
+ "Termius\n"
+ "1. Откройте сервер → Port Forwarding.\n"
+ f"2. Добавьте Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → "
+ f"127.0.0.1:{ADMIN_WEB_PORT}.\n"
+ "3. Запустите tunnel и откройте в браузере:\n"
+ f"{html.escape(local_url)}\n\n"
+ "Обычный SSH\n"
+ f"{html.escape(ssh_cmd)}\n\n"
+ "Админка слушает только localhost на сервере и не публикуется наружу."
+ )
+ else:
+ status = "running" if running else "not running"
+ token_note = "Token is ready." if token else "Token is missing: reinstall/update web-admin from CLI."
+ text = (
+ f"🖥 Web Admin\n\n"
+ f"Status: {status}\n"
+ f"{html.escape(token_note)}\n\n"
+ "Termius\n"
+ "1. Open the server → Port Forwarding.\n"
+ f"2. Add a Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → "
+ f"127.0.0.1:{ADMIN_WEB_PORT}.\n"
+ "3. Start the tunnel and open:\n"
+ f"{html.escape(local_url)}\n\n"
+ "Regular SSH\n"
+ f"{html.escape(ssh_cmd)}\n\n"
+ "The admin listens only on server localhost and is not exposed publicly."
+ )
+
+ await safe_edit_message(
+ query,
+ text,
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+
+
# ============================================================================
# ADMIN MANAGEMENT
# ============================================================================
@@ -2516,6 +2591,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"menu_website": cb_menu_website,
"menu_promo": cb_menu_promo,
"menu_credits": cb_menu_credits,
+ "menu_admin_web": cb_menu_admin_web,
"menu_admins": cb_menu_admins,
"menu_users": cb_menu_users,
"menu_remove": cb_menu_remove,
diff --git a/gotelegram-bot/lang/en.json b/gotelegram-bot/lang/en.json
index 647ae6a..8c4def9 100644
--- a/gotelegram-bot/lang/en.json
+++ b/gotelegram-bot/lang/en.json
@@ -34,6 +34,7 @@
"menu_promo": "🎁 Promo",
"menu_stats": "📊 Traffic Stats",
"menu_users": "🔑 Keys",
+ "menu_admin_web": "🖥 Web Admin",
"menu_remove": "🗑️ Remove",
"menu_admins": "👤 Admins",
"menu_credits": "ℹ️ Credits",
diff --git a/gotelegram-bot/lang/ru.json b/gotelegram-bot/lang/ru.json
index ed32566..3208c56 100644
--- a/gotelegram-bot/lang/ru.json
+++ b/gotelegram-bot/lang/ru.json
@@ -34,6 +34,7 @@
"menu_promo": "🎁 Промо",
"menu_stats": "📊 Трафик",
"menu_users": "🔑 Ключи",
+ "menu_admin_web": "🖥 Web Admin",
"menu_remove": "🗑️ Удалить",
"menu_admins": "👤 Админы",
"menu_credits": "ℹ️ О проекте",
diff --git a/install.sh b/install.sh
index ed81f34..f9d76f7 100755
--- a/install.sh
+++ b/install.sh
@@ -869,6 +869,7 @@ menu_remove() {
systemctl daemon-reload
rm -rf "$BOT_DIR"
fi
+ remove_admin_web
log_success "$(t remove_all_done)"
;;
esac
@@ -878,6 +879,80 @@ menu_remove() {
BOT_DIR="/opt/gotelegram-bot"
BOT_SERVICE="gotelegram-bot"
+admin_web_service_status() {
+ if ! systemctl list-unit-files "$ADMIN_WEB_SERVICE.service" &>/dev/null 2>&1; then
+ echo "not_installed"
+ elif systemctl is-active "$ADMIN_WEB_SERVICE" &>/dev/null 2>&1; then
+ echo "running"
+ else
+ echo "stopped"
+ fi
+}
+
+install_admin_web() {
+ local src_dir="$SCRIPT_DIR/admin-web"
+ [ -d "$src_dir" ] || { log_warning "admin-web files not found: $src_dir"; return 1; }
+ command -v python3 &>/dev/null || { log_warning "python3 not found; web admin skipped"; return 1; }
+
+ mkdir -p "$ADMIN_WEB_DIR/static"
+ cp "$src_dir/server.py" "$ADMIN_WEB_DIR/server.py"
+ cp -a "$src_dir/static/." "$ADMIN_WEB_DIR/static/"
+ chmod 700 "$ADMIN_WEB_DIR"
+ chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static"
+
+ if [ ! -f "$ADMIN_WEB_DIR/token" ]; then
+ openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-48 > "$ADMIN_WEB_DIR/token"
+ echo "" >> "$ADMIN_WEB_DIR/token"
+ fi
+ chmod 600 "$ADMIN_WEB_DIR/token"
+
+ local python_bin
+ python_bin=$(command -v python3)
+ cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << SVCEOF
+[Unit]
+Description=GoTelegram v${GOTELEGRAM_VERSION} Local Web Admin
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=$ADMIN_WEB_DIR
+ExecStart=$python_bin $ADMIN_WEB_DIR/server.py
+Restart=always
+RestartSec=5
+Environment=GOTELEGRAM_ADMIN_HOST=$ADMIN_WEB_HOST
+Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT
+
+[Install]
+WantedBy=multi-user.target
+SVCEOF
+
+ systemctl daemon-reload
+ systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null
+ systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE"
+ log_success "Web admin installed: ${ADMIN_WEB_HOST}:${ADMIN_WEB_PORT}"
+}
+
+auto_install_admin_web_if_possible() {
+ [ -d "$SCRIPT_DIR/admin-web" ] || return 0
+ command -v python3 &>/dev/null || return 0
+ if [ "$(admin_web_service_status)" != "not_installed" ] && \
+ [ -f "$ADMIN_WEB_DIR/server.py" ] && \
+ cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \
+ cmp -s "$SCRIPT_DIR/admin-web/static/app.js" "$ADMIN_WEB_DIR/static/app.js" && \
+ cmp -s "$SCRIPT_DIR/admin-web/static/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then
+ return 0
+ fi
+ install_admin_web >/dev/null 2>&1 || true
+}
+
+remove_admin_web() {
+ systemctl stop "$ADMIN_WEB_SERVICE" 2>/dev/null
+ systemctl disable "$ADMIN_WEB_SERVICE" 2>/dev/null
+ rm -f "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service"
+ systemctl daemon-reload 2>/dev/null
+ rm -rf "$ADMIN_WEB_DIR"
+}
+
bot_service_status() {
if ! systemctl list-unit-files "$BOT_SERVICE.service" &>/dev/null 2>&1; then
echo "not_installed"
@@ -1136,6 +1211,8 @@ SVCEOF
systemctl enable "$BOT_SERVICE" &>/dev/null
systemctl restart "$BOT_SERVICE" 2>/dev/null || systemctl start "$BOT_SERVICE"
+ install_admin_web || log_warning "Web admin could not be installed"
+
# If auto mode — wait until bot captures first admin
local has_ids
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
@@ -1700,6 +1777,7 @@ main() {
fi
auto_migrate_legacy_state || true
+ auto_install_admin_web_if_possible || true
# First-run language picker (before banner so banner appears in chosen lang)
first_run_language_picker
diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh
index eb73c5a..6a51ffa 100755
--- a/install_gotelegram_bot.sh
+++ b/install_gotelegram_bot.sh
@@ -12,6 +12,9 @@ NC='\033[0m'
BOT_DIR="/opt/gotelegram-bot"
SERVICE_NAME="gotelegram-bot"
GOTELEGRAM_DIR="/opt/gotelegram"
+ADMIN_WEB_DIR="/opt/gotelegram-admin"
+ADMIN_WEB_SERVICE="gotelegram-admin"
+ADMIN_WEB_PORT="1984"
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Запустите с sudo.${NC}"
@@ -112,6 +115,44 @@ systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME"
+# ── Local Web Admin ──────────────────────────────────────────────────────────
+if [ -f "$SCRIPT_DIR/admin-web/server.py" ]; then
+ echo -e "${GREEN}[*] Установка локальной web-админки...${NC}"
+ mkdir -p "$ADMIN_WEB_DIR/static"
+ cp "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py"
+ cp -a "$SCRIPT_DIR/admin-web/static/." "$ADMIN_WEB_DIR/static/"
+ chmod 700 "$ADMIN_WEB_DIR"
+ chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static"
+
+ if [ ! -f "$ADMIN_WEB_DIR/token" ]; then
+ openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-48 > "$ADMIN_WEB_DIR/token"
+ echo "" >> "$ADMIN_WEB_DIR/token"
+ fi
+ chmod 600 "$ADMIN_WEB_DIR/token"
+
+ PYTHON_BIN=$(command -v python3)
+ cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF
+[Unit]
+Description=GoTelegram v2.5.0 Local Web Admin
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=$ADMIN_WEB_DIR
+ExecStart=$PYTHON_BIN $ADMIN_WEB_DIR/server.py
+Restart=always
+RestartSec=5
+Environment=GOTELEGRAM_ADMIN_HOST=127.0.0.1
+Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT
+
+[Install]
+WantedBy=multi-user.target
+EOF
+ systemctl daemon-reload
+ systemctl enable "$ADMIN_WEB_SERVICE"
+ systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE"
+fi
+
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ Бот установлен и запущен! ║${NC}"
diff --git a/lib/backup.sh b/lib/backup.sh
index abb4dc6..9d5a21d 100755
--- a/lib/backup.sh
+++ b/lib/backup.sh
@@ -72,6 +72,14 @@ create_backup() {
[ -d "$BOT_DIR/lang" ] && cp -a "$BOT_DIR/lang" "$tmp_dir/bot/" 2>/dev/null
fi
+ # Local web admin state (token is needed so restored bots can show the same tunnel URL)
+ if [ -d "$ADMIN_WEB_DIR" ]; then
+ mkdir -p "$tmp_dir/admin_web"
+ [ -f "$ADMIN_WEB_DIR/token" ] && cp "$ADMIN_WEB_DIR/token" "$tmp_dir/admin_web/token" 2>/dev/null
+ [ -f "$ADMIN_WEB_DIR/server.py" ] && cp "$ADMIN_WEB_DIR/server.py" "$tmp_dir/admin_web/server.py" 2>/dev/null
+ [ -d "$ADMIN_WEB_DIR/static" ] && cp -a "$ADMIN_WEB_DIR/static" "$tmp_dir/admin_web/" 2>/dev/null
+ fi
+
# Traffic history
if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then
cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null
@@ -90,7 +98,7 @@ create_backup() {
cat > "$tmp_dir/metadata.json" << EOMETA
{
- "backup_version": "1.2",
+ "backup_version": "1.3",
"gotelegram_version": "$GOTELEGRAM_VERSION",
"created_at": "$(date -Iseconds)",
"hostname": "$(hostname)",
@@ -305,6 +313,15 @@ restore_backup() {
log_success "Конфигурация Telegram-бота восстановлена"
fi
+ # Восстанавливаем состояние локальной web-админки
+ if [ -d "$backup_dir/admin_web" ]; then
+ mkdir -p "$ADMIN_WEB_DIR"
+ [ -f "$backup_dir/admin_web/token" ] && cp "$backup_dir/admin_web/token" "$ADMIN_WEB_DIR/token" 2>/dev/null && chmod 600 "$ADMIN_WEB_DIR/token"
+ [ -f "$backup_dir/admin_web/server.py" ] && cp "$backup_dir/admin_web/server.py" "$ADMIN_WEB_DIR/server.py" 2>/dev/null
+ [ -d "$backup_dir/admin_web/static" ] && cp -a "$backup_dir/admin_web/static" "$ADMIN_WEB_DIR/" 2>/dev/null
+ log_success "Конфигурация web-админки восстановлена"
+ fi
+
# Запускаем сервисы
if is_telemt_installed && [ ! -f "/etc/systemd/system/${TELEMT_SERVICE}.service" ]; then
install_telemt_service
@@ -314,6 +331,7 @@ restore_backup() {
fi
command -v nginx &>/dev/null && systemctl start nginx 2>/dev/null
systemctl restart gotelegram-bot 2>/dev/null || true
+ systemctl restart gotelegram-admin 2>/dev/null || true
# Очистка
rm -rf "$tmp_dir"
diff --git a/lib/common.sh b/lib/common.sh
index 0a02eef..1d7c9ec 100755
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -18,6 +18,10 @@ WEBSITE_ROOT="/var/www/gotelegram-site"
BACKUP_DIR="$GOTELEGRAM_DIR/backups"
LOG_FILE="/var/log/gotelegram.log"
BOT_DIR="/opt/gotelegram-bot"
+ADMIN_WEB_DIR="/opt/gotelegram-admin"
+ADMIN_WEB_SERVICE="gotelegram-admin"
+ADMIN_WEB_HOST="127.0.0.1"
+ADMIN_WEB_PORT="1984"
# ── V1 совместимость ─────────────────────────────────────────────────────────
V1_CONTAINER_NAME="mtproto-proxy"