v2.5.0: remove local web admin token gate

This commit is contained in:
Виталий Литвинов
2026-04-24 22:00:08 +03:00
parent 8804319e19
commit 008143a617
10 changed files with 15 additions and 136 deletions

View File

@@ -445,12 +445,12 @@ switch_language ru|en
- frontend: `admin-web/static/`, vanilla JS/CSS, canvas-график без CDN; - frontend: `admin-web/static/`, vanilla JS/CSS, canvas-график без CDN;
- systemd service: `gotelegram-admin`; - systemd service: `gotelegram-admin`;
- bind: `127.0.0.1:1984`, доступ только через SSH tunnel; - bind: `127.0.0.1:1984`, доступ только через SSH tunnel;
- token: `/opt/gotelegram-admin/token`, HTTP cookie `gtauth` выставляется при открытии `/?token=...`; - токена нет: после SSH tunnel открывается `http://127.0.0.1:1984/`;
- API требует cookie/Bearer token, а write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`. - 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. Функции: 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`. `install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. Backup v1.3 сохраняет `admin_web/server.py` и `admin_web/static/`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`.
### 13.2 Upgrade migration (v2.5.0) ### 13.2 Upgrade migration (v2.5.0)
@@ -626,7 +626,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s:
## 17. Changelog ## 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); добавлена локальная 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.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 инструкцией в боте без отдельного web-admin токена; исправлено чтение 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 server/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.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.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`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`.

View File

@@ -140,7 +140,7 @@ CLI и бот переведены на русский и английский.
- systemd service: `gotelegram-admin`; - systemd service: `gotelegram-admin`;
- слушает только `127.0.0.1:1984`; - слушает только `127.0.0.1:1984`;
- наружу не публикуется и рассчитана на доступ через SSH tunnel; - наружу не публикуется и рассчитана на доступ через SSH tunnel;
- токен доступа хранится в `/opt/gotelegram-admin/token`; - после туннеля открывается обычным URL `http://127.0.0.1:1984/`;
- Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`.
В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, график трафика, список бекапов и просмотр логов. В админке есть dashboard, статус сервисов, управление `[access.users]`, генерация ссылок, график трафика, список бекапов и просмотр логов.
@@ -236,7 +236,7 @@ A: Сам MTProxy — да, это публичная технология из
## Changelog (коротко) ## 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-информация; добавлена локальная 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.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 server/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.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.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. - **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.

View File

@@ -10,7 +10,6 @@ from __future__ import annotations
import csv import csv
import hashlib import hashlib
import http.cookies
import json import json
import mimetypes import mimetypes
import os import os
@@ -30,7 +29,6 @@ from typing import Any
ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin")) ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin"))
STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static"))) 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")) GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json"))
TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml")) TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml"))
@@ -63,21 +61,6 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
return 125, "", str(exc) 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: def load_json(path: Path, fallback: Any = None) -> Any:
try: try:
with path.open("r", encoding="utf-8") as fh: with path.open("r", encoding="utf-8") as fh:
@@ -353,27 +336,6 @@ class AdminHandler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None: def log_message(self, fmt: str, *args: Any) -> None:
print("%s - %s" % (self.address_string(), fmt % args)) print("%s - %s" % (self.address_string(), fmt % args))
def 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: def send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
@@ -394,12 +356,6 @@ class AdminHandler(BaseHTTPRequestHandler):
return {} return {}
return json.loads(self.rfile.read(length).decode("utf-8")) 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: def require_write_guard(self) -> bool:
if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1": if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1":
self.send_error_json(403, "missing write guard") self.send_error_json(403, "missing write guard")
@@ -407,8 +363,6 @@ class AdminHandler(BaseHTTPRequestHandler):
return True return True
def route_get_api(self, parsed: urllib.parse.ParseResult) -> None: def route_get_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth():
return
path = parsed.path path = parsed.path
if path == "/api/overview": if path == "/api/overview":
self.send_json({"ok": True, "data": overview_payload()}) self.send_json({"ok": True, "data": overview_payload()})
@@ -437,7 +391,7 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(404, "not found") self.send_error_json(404, "not found")
def route_post_api(self, parsed: urllib.parse.ParseResult) -> None: def route_post_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth() or not self.require_write_guard(): if not self.require_write_guard():
return return
path = parsed.path path = parsed.path
try: try:
@@ -480,7 +434,7 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(404, "not found") self.send_error_json(404, "not found")
def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None: def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None:
if not self.require_auth() or not self.require_write_guard(): if not self.require_write_guard():
return return
path = parsed.path path = parsed.path
if not path.startswith("/api/users/"): if not path.startswith("/api/users/"):
@@ -504,15 +458,6 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_json({"ok": True, "restarted": restarted}) self.send_json({"ok": True, "restarted": restarted})
def send_static(self, parsed: urllib.parse.ParseResult) -> None: 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" rel = parsed.path.lstrip("/") or "index.html"
if rel.startswith("api/") or ".." in rel.split("/"): if rel.startswith("api/") or ".." in rel.split("/"):
self.send_error(404) self.send_error(404)
@@ -558,7 +503,6 @@ class AdminHandler(BaseHTTPRequestHandler):
def main() -> None: def main() -> None:
ensure_token()
if not STATIC_DIR.exists(): if not STATIC_DIR.exists():
raise SystemExit(f"static dir not found: {STATIC_DIR}") raise SystemExit(f"static dir not found: {STATIC_DIR}")
httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler) httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler)

View File

@@ -36,10 +36,6 @@ async function api(path, options = {}) {
}; };
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers, credentials: "same-origin" }); 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(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`);
return data.data ?? data; return data.data ?? data;

View File

@@ -7,14 +7,6 @@
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <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"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">

View File

@@ -352,34 +352,6 @@ td code {
transform: translateY(0); 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 { .hidden {
display: none; display: none;
} }

View File

@@ -118,7 +118,6 @@ PROMO_STAMP_FILE = "/opt/gotelegram/.promo_bot_last_shown"
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
ENV_FILE = "/opt/gotelegram-bot/.env" ENV_FILE = "/opt/gotelegram-bot/.env"
ADMIN_WEB_SERVICE = "gotelegram-admin" ADMIN_WEB_SERVICE = "gotelegram-admin"
ADMIN_WEB_TOKEN_FILE = "/opt/gotelegram-admin/token"
ADMIN_WEB_PORT = 1984 ADMIN_WEB_PORT = 1984
# ── Загрузка ALLOWED_IDS ──────────────────────────────────────────────────── # ── Загрузка ALLOWED_IDS ────────────────────────────────────────────────────
@@ -2161,14 +2160,6 @@ async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") 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: async def admin_web_host_hint() -> str:
config = load_json(GOTELEGRAM_CONFIG) or {} config = load_json(GOTELEGRAM_CONFIG) or {}
domain = str(config.get("domain") or "") domain = str(config.get("domain") or "")
@@ -2183,18 +2174,15 @@ async def cb_menu_admin_web(update: Update, context: ContextTypes.DEFAULT_TYPE)
await query.answer() await query.answer()
user_id = _uid(update) user_id = _uid(update)
running = await check_service_status(ADMIN_WEB_SERVICE) running = await check_service_status(ADMIN_WEB_SERVICE)
token = read_admin_web_token()
host = await admin_web_host_hint() 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}/" local_url = 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}" ssh_cmd = f"ssh -L {ADMIN_WEB_PORT}:127.0.0.1:{ADMIN_WEB_PORT} root@{host}"
if get_user_lang(user_id) == "ru": if get_user_lang(user_id) == "ru":
status = "запущена" if running else "не запущена" status = "запущена" if running else "не запущена"
token_note = "Токен найден." if token else "Токен не найден: переустановите/обновите web-admin через CLI."
text = ( text = (
f"<b>🖥 Web Admin</b>\n\n" f"<b>🖥 Web Admin</b>\n\n"
f"Статус: <code>{status}</code>\n" f"Статус: <code>{status}</code>\n\n"
f"{html.escape(token_note)}\n\n"
"<b>Termius</b>\n" "<b>Termius</b>\n"
"1. Откройте сервер → Port Forwarding.\n" "1. Откройте сервер → Port Forwarding.\n"
f"2. Добавьте Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → " f"2. Добавьте Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → "
@@ -2207,11 +2195,9 @@ async def cb_menu_admin_web(update: Update, context: ContextTypes.DEFAULT_TYPE)
) )
else: else:
status = "running" if running else "not running" 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 = ( text = (
f"<b>🖥 Web Admin</b>\n\n" f"<b>🖥 Web Admin</b>\n\n"
f"Status: <code>{status}</code>\n" f"Status: <code>{status}</code>\n\n"
f"{html.escape(token_note)}\n\n"
"<b>Termius</b>\n" "<b>Termius</b>\n"
"1. Open the server → Port Forwarding.\n" "1. Open the server → Port Forwarding.\n"
f"2. Add a Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → " f"2. Add a Local tunnel: <code>127.0.0.1:{ADMIN_WEB_PORT}</code> → "

View File

@@ -899,12 +899,7 @@ install_admin_web() {
cp -a "$src_dir/static/." "$ADMIN_WEB_DIR/static/" cp -a "$src_dir/static/." "$ADMIN_WEB_DIR/static/"
chmod 700 "$ADMIN_WEB_DIR" chmod 700 "$ADMIN_WEB_DIR"
chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static"
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
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 local python_bin
python_bin=$(command -v python3) python_bin=$(command -v python3)

View File

@@ -123,12 +123,7 @@ if [ -f "$SCRIPT_DIR/admin-web/server.py" ]; then
cp -a "$SCRIPT_DIR/admin-web/static/." "$ADMIN_WEB_DIR/static/" cp -a "$SCRIPT_DIR/admin-web/static/." "$ADMIN_WEB_DIR/static/"
chmod 700 "$ADMIN_WEB_DIR" chmod 700 "$ADMIN_WEB_DIR"
chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static"
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
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) PYTHON_BIN=$(command -v python3)
cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF

View File

@@ -72,10 +72,9 @@ create_backup() {
[ -d "$BOT_DIR/lang" ] && cp -a "$BOT_DIR/lang" "$tmp_dir/bot/" 2>/dev/null [ -d "$BOT_DIR/lang" ] && cp -a "$BOT_DIR/lang" "$tmp_dir/bot/" 2>/dev/null
fi fi
# Local web admin state (token is needed so restored bots can show the same tunnel URL) # Local web admin state
if [ -d "$ADMIN_WEB_DIR" ]; then if [ -d "$ADMIN_WEB_DIR" ]; then
mkdir -p "$tmp_dir/admin_web" 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 [ -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 [ -d "$ADMIN_WEB_DIR/static" ] && cp -a "$ADMIN_WEB_DIR/static" "$tmp_dir/admin_web/" 2>/dev/null
fi fi
@@ -316,9 +315,9 @@ restore_backup() {
# Восстанавливаем состояние локальной web-админки # Восстанавливаем состояние локальной web-админки
if [ -d "$backup_dir/admin_web" ]; then if [ -d "$backup_dir/admin_web" ]; then
mkdir -p "$ADMIN_WEB_DIR" 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 [ -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 [ -d "$backup_dir/admin_web/static" ] && cp -a "$backup_dir/admin_web/static" "$ADMIN_WEB_DIR/" 2>/dev/null
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
log_success "Конфигурация web-админки восстановлена" log_success "Конфигурация web-админки восстановлена"
fi fi