mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 17:56:07 +00:00
Compare commits
11 Commits
v2.4.1
...
release-ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10ea54ce9 | ||
|
|
2f3045bcc0 | ||
|
|
eb5175ccab | ||
|
|
3403975636 | ||
|
|
3919f201f5 | ||
|
|
4b63b79184 | ||
|
|
e9af6e969f | ||
|
|
724eeb92d9 | ||
|
|
fc28a1a099 | ||
|
|
7b53566dad | ||
|
|
6b206a1697 |
44
DOCS_AI.md
44
DOCS_AI.md
@@ -1,6 +1,6 @@
|
|||||||
# GoTelegram Pro — техническая документация для ИИ-агентов
|
# GoTelegram Pro — техническая документация для ИИ-агентов
|
||||||
|
|
||||||
**Версия:** 2.4.1
|
**Версия:** 2.4.3
|
||||||
**Репозиторий:** `anten-ka/gotelegram_pro`
|
**Репозиторий:** `anten-ka/gotelegram_pro`
|
||||||
**Активная ветка:** `alfa-test` (ветка `test` заморожена и содержит stable для конечных пользователей)
|
**Активная ветка:** `alfa-test` (ветка `test` заморожена и содержит stable для конечных пользователей)
|
||||||
**Целевая ОС:** Ubuntu 20.04+, Debian 11+
|
**Целевая ОС:** Ubuntu 20.04+, Debian 11+
|
||||||
@@ -349,6 +349,11 @@ Fallback: если по известным правилам index.html не на
|
|||||||
21. **КРИТИЧЕСКИЙ StartBootstrap dist/** — sb_* шаблоны хранят production в `dist/`. Фикс: клонируем в `$sb_tmp`, проверяем `dist/index.html`, копируем `dist/*` в `$clone_dir`, + universal fallback через `find`.
|
21. **КРИТИЧЕСКИЙ StartBootstrap dist/** — sb_* шаблоны хранят production в `dist/`. Фикс: клонируем в `$sb_tmp`, проверяем `dist/index.html`, копируем `dist/*` в `$clone_dir`, + universal fallback через `find`.
|
||||||
22. **templates_catalog.sh ls stdout leak** — `ls -la` в блоке ошибки `download_template` шёл в stdout. Фикс: `>&2`.
|
22. **templates_catalog.sh ls stdout leak** — `ls -la` в блоке ошибки `download_template` шёл в stdout. Фикс: `>&2`.
|
||||||
23. **КРИТИЧЕСКИЙ start_telemt stale config (v2.4.1)** — `systemctl start` это no-op для уже активного сервиса. После переустановки Lite поверх Pro конфиг на диске менялся, но telemt держал в памяти старый `tls_domain=anten-ka.com` и дропал клиентов с SNI=google.com. Фикс: `start_telemt` теперь делает `restart` если сервис уже активен. См. `lib/telemt.sh:189-213`.
|
23. **КРИТИЧЕСКИЙ start_telemt stale config (v2.4.1)** — `systemctl start` это no-op для уже активного сервиса. После переустановки Lite поверх Pro конфиг на диске менялся, но telemt держал в памяти старый `tls_domain=anten-ka.com` и дропал клиентов с SNI=google.com. Фикс: `start_telemt` теперь делает `restart` если сервис уже активен. См. `lib/telemt.sh:189-213`.
|
||||||
|
24. **КРИТИЧЕСКИЙ safe_edit_message TypeError (v2.4.2)** — в iter2 аудите субагент нашёл: `cb_pro_confirm` в success-пути вызывал `safe_edit_message(query, text, ..., disable_web_page_preview=True)`, но сигнатура обёртки этот kwarg не принимала. Runtime TypeError прямо в хэппи-пути смены шаблона из бота. Фикс: добавлен `disable_web_page_preview: Optional[bool] = None` + условный форвард через `**kwargs`.
|
||||||
|
25. **template_id field name mismatch (v2.4.2)** — `save_gotelegram_config` всегда писал ключ `template_id`, но `bot.py` читал `config.get('template')` и писал `config['template']` в `handle_text_message`. Результат: статус шаблона в боте никогда не отображался, а поле в JSON появлялось фантомно и не использовалось. Фикс: везде только `template_id`. Исторические чтения совместимы через `config.get("template_id") or config.get("template")` для legacy конфигов.
|
||||||
|
26. **jq 1.5 compat в bot_update_config_field (v2.4.2)** — использовалось `jq '... | .updated_at = (now | todate)'`, но фильтр `now|todate` только с jq 1.6. На Debian 10 jq 1.5 падал с syntax error, config не обновлялся. Фикс: генерим timestamp через `date -Iseconds` в shell и передаём через `jq --arg t`.
|
||||||
|
27. **Отсутствующая валидация tpl_id/domain (v2.4.2)** — `tpl_id` из `callback_data` и `domain` из текстового ввода передавались напрямую в subprocess как `--template=$x`. Defense-in-depth: в bot.py добавлены `_TPL_ID_RE = ^[A-Za-z0-9_-]{1,64}$` и `_DOMAIN_RE`, в install.sh — `validate_domain` перед `generate_telemt_toml`. Малформный ввод отвергается early.
|
||||||
|
28. **КРИТИЧЕСКИЙ race в bot_action_dispatch (v2.4.3)** — iter3-тест с 3 параллельными `change-lite-domain` дал `{"status":"error","code":"no_secret"}` на одном из вызовов. Причина: `bot_update_config_field` делает `jq ... > tmp && mv tmp config.json`; когда параллельный процесс заходил в `get_config_value secret` в момент между `>` и `mv`, он видел пустой/частичный файл. `asyncio.Lock` в боте ловил только внутри-процессные гонки, но не CLI-level. Фикс: `flock -w 30 9 < /var/lock/gotelegram-bot-action.lock` вокруг всего диспатчера. Новый error code: `lock_timeout` (`EX_TEMPFAIL`=75).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -358,13 +363,42 @@ Fallback: если по известным правилам index.html не на
|
|||||||
|
|
||||||
Ключевые моменты:
|
Ключевые моменты:
|
||||||
- **Admin ID** — бот отвечает только пользователю с `ADMIN_TG_ID` из `.env`. Всё остальное игнорируется.
|
- **Admin ID** — бот отвечает только пользователю с `ADMIN_TG_ID` из `.env`. Всё остальное игнорируется.
|
||||||
- **safe_edit_message** — обёртка над `query.edit_message_text` + `query.edit_message_caption`, глотает `BadRequest: message is not modified`. Используй её всегда вместо прямого вызова.
|
- **safe_edit_message** — обёртка над `query.edit_message_text` + `query.edit_message_caption`, глотает `BadRequest: message is not modified`. Начиная с v2.4.2 принимает опциональный `disable_web_page_preview` — не забудь прокинуть его если показываешь ссылку-превью (раньше вызов с этим kwarg ловил TypeError в runtime). Используй её всегда вместо прямого вызова.
|
||||||
- **Language per-user** — файл `locales/users.json` хранит `{user_id: "ru"/"en"}`. При каждом сообщении бот читает язык пользователя и подставляет строку через `t(key, lang)`.
|
- **Language per-user** — файл `locales/users.json` хранит `{user_id: "ru"/"en"}`. При каждом сообщении бот читает язык пользователя и подставляет строку через `t(key, lang)`.
|
||||||
- **QR-коды** — генерятся в `/tmp/gotelegram_qr_*.png`, отправляются как `InputFile`, удаляются в `finally`.
|
- **QR-коды** — генерятся в `/tmp/gotelegram_qr_*.png`, отправляются как `InputFile`, удаляются в `finally`.
|
||||||
- **Шаблон preview в HTML** — URL экранируется через `html.escape(url, quote=True)` (баг #11).
|
- **Шаблон preview в HTML** — URL экранируется через `html.escape(url, quote=True)` (баг #11).
|
||||||
- **Системные действия** — бот вызывает тот же `install.sh` через `subprocess.run(["bash", "/opt/gotelegram/install.sh", "--action=...", "--json"])` (флаги add как задача на будущее).
|
- **Системные действия (v2.4.2+)** — бот реально умеет менять шаблон и домен маскировки. Хелпер `run_bot_action(action, **kwargs)` вызывает `subprocess.run(["/opt/gotelegram/install.sh", "--action=X", "--json", ...])` и парсит JSON. Два коллбэка: `cb_pro_confirm` (change-template) и `cb_lite_domain` (change-lite-domain). Оба обёрнуты в `async with _BOT_ACTION_LOCK` (глобальный `asyncio.Lock()`) — сериализуют параллельные callback'и внутри процесса бота. Входы валидируются ДО subprocess: `_TPL_ID_RE = r"^[A-Za-z0-9_-]{1,64}$"`, `_DOMAIN_RE` (RFC-like). Малформный ввод отвергается с понятным сообщением, не доходя до shell.
|
||||||
|
- **Поле `template_id` в config.json** — канонический ключ для текущего шаблона. Раньше (до v2.4.2) бот читал `config['template']` и писал в него же в `handle_text_message`, но `save_gotelegram_config` всегда писал `template_id` → поле статуса никогда не отображалось. Используй только `template_id`.
|
||||||
- **Устанавливается** из меню `install.sh → 12) Telegram-бот → Установить`. Пользователь вводит BotFather token + свой Telegram ID, `.env` пишется в `/opt/gotelegram-bot/.env`.
|
- **Устанавливается** из меню `install.sh → 12) Telegram-бот → Установить`. Пользователь вводит BotFather token + свой Telegram ID, `.env` пишется в `/opt/gotelegram-bot/.env`.
|
||||||
|
|
||||||
|
### 11.1 Non-interactive action bridge (install.sh ↔ bot)
|
||||||
|
|
||||||
|
Бот общается с CLI через жёсткий JSON-протокол. Единая точка входа — `bot_action_dispatch` в `install.sh`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/gotelegram/install.sh --action=<name> [--template=ID|--domain=HOST] --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Доступные action'ы:**
|
||||||
|
- `change-template --template=<tpl_id>` — только в Pro режиме. Скачивает шаблон, деплоит в nginx, обновляет `config.json.template_id`.
|
||||||
|
- `change-lite-domain --domain=<host>` — только в Lite режиме. Регенерит telemt TOML с новым `tls_domain`, валидирует, рестартит telemt, обновляет `config.json.{domain,mask_host}`.
|
||||||
|
|
||||||
|
**Формат ответа (stdout, последняя строка):**
|
||||||
|
```json
|
||||||
|
{"status":"success","message":"...","<extra>":"..."}
|
||||||
|
{"status":"error","message":"...","code":"<machine_code>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Коды ошибок: `missing_arg`, `invalid_domain`, `wrong_mode`, `unknown_template`, `download_failed`, `deploy_failed`, `no_secret`, `gen_failed`, `validate_failed`, `restart_failed`, `unknown_action`, `lock_timeout`.
|
||||||
|
|
||||||
|
**Сериализация (v2.4.3):** `bot_action_dispatch` оборачивает вызов в `flock -w 30 9 < /var/lock/gotelegram-bot-action.lock`. Это защищает от гонок при:
|
||||||
|
1. Одновременных callback'ах внутри бота (asyncio.Lock уже ловит это, но flock — defense-in-depth).
|
||||||
|
2. Параллельных CLI-вызовах (бот + ручной SSH, или два бот-процесса — теоретически).
|
||||||
|
|
||||||
|
Если таймаут лока (30 с), диспетчер возвращает `exit 75` (`EX_TEMPFAIL`) и JSON `{"status":"error","code":"lock_timeout"}`. `flock` идёт из `util-linux` — добавлен в `critical` зависимости в `ensure_deps`.
|
||||||
|
|
||||||
|
**История:** до v2.4.2 коллбэки были stub'ами («сделай через CLI»). В v2.4.2 подключили реальные action'ы. В iter3-тестировании на VPS обнаружилась гонка: `bot_update_config_field` делает `jq ... > tmp && mv tmp config.json`, и параллельный процесс мог прочитать `config.json` в промежутке, увидев пустоту → ошибка `no_secret`. v2.4.3 починил flock'ом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. i18n (bash CLI)
|
## 12. i18n (bash CLI)
|
||||||
@@ -556,12 +590,14 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s:
|
|||||||
0) exit
|
0) exit
|
||||||
```
|
```
|
||||||
|
|
||||||
Диспатчер в `install.sh` принимает `--action=` для автоматизации из бота (будущая работа).
|
Диспатчер в `install.sh` (`bot_action_dispatch`) принимает `--action=` для автоматизации из бота. Полный контракт описан в разделе 11.1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Changelog
|
## 17. Changelog
|
||||||
|
|
||||||
|
- **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.1 (2026-04-10)** — баг #23: `start_telemt` делает `restart` если сервис активен (иначе stale in-memory config после переустановки Lite поверх Pro). Полная документация проекта — `DOCS_HUMAN.md` и `DOCS_AI.md` (этот файл).
|
- **2.4.1 (2026-04-10)** — баг #23: `start_telemt` делает `restart` если сервис активен (иначе stale in-memory config после переустановки Lite поверх Pro). Полная документация проекта — `DOCS_HUMAN.md` и `DOCS_AI.md` (этот файл).
|
||||||
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репозитория (валидация URL, таймауты, лимит размера клона).
|
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репозитория (валидация URL, таймауты, лимит размера клона).
|
||||||
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
|
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GoTelegram Pro — руководство пользователя
|
# GoTelegram Pro — руководство пользователя
|
||||||
|
|
||||||
**Версия:** 2.4.1
|
**Версия:** 2.4.3
|
||||||
**Репозиторий:** `anten-ka/gotelegram_pro`
|
**Репозиторий:** `anten-ka/gotelegram_pro`
|
||||||
**Для кого:** владельцы VPS, которым нужен надёжный MTProxy для Telegram с маскировкой под обычный HTTPS-сайт.
|
**Для кого:** владельцы VPS, которым нужен надёжный MTProxy для Telegram с маскировкой под обычный HTTPS-сайт.
|
||||||
|
|
||||||
@@ -214,6 +214,8 @@ A: Сам MTProxy — да, это публичная технология из
|
|||||||
|
|
||||||
## Changelog (коротко)
|
## Changelog (коротко)
|
||||||
|
|
||||||
|
- **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.1** — фикс: `start_telemt` теперь делает `restart` если сервис уже запущен. Раньше переустановка Lite поверх Pro оставляла в памяти старый конфиг, и клиенты получали «Unknown TLS SNI drop». Плюс полная документация проекта (этот файл и `DOCS_AI.md`).
|
- **2.4.1** — фикс: `start_telemt` теперь делает `restart` если сервис уже запущен. Раньше переустановка Lite поверх Pro оставляла в памяти старый конфиг, и клиенты получали «Unknown TLS SNI drop». Плюс полная документация проекта (этот файл и `DOCS_AI.md`).
|
||||||
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репо (с валидацией URL, таймаутами, лимитом размера клона).
|
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репо (с валидацией URL, таймаутами, лимитом размера клона).
|
||||||
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
|
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="anten-ka/gotelegram_pro"
|
REPO="anten-ka/gotelegram_pro"
|
||||||
BRANCH="${GOTELEGRAM_BRANCH:-test}"
|
BRANCH="${GOTELEGRAM_BRANCH:-test}"
|
||||||
PAT="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"
|
PAT="${GOTELEGRAM_PAT:-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}"
|
||||||
INSTALL_DIR="/opt/gotelegram"
|
INSTALL_DIR="/opt/gotelegram"
|
||||||
# Use raw.githubusercontent.com (CDN) — faster and avoids Contents API caching
|
# Use raw.githubusercontent.com (CDN) — faster and avoids Contents API caching
|
||||||
# issues that occasionally return 404 for recently added files on non-default branches.
|
# issues that occasionally return 404 for recently added files on non-default branches.
|
||||||
@@ -76,6 +76,7 @@ FILES=(
|
|||||||
"lib/backup.sh"
|
"lib/backup.sh"
|
||||||
"lib/website.sh"
|
"lib/website.sh"
|
||||||
"lib/templates_catalog.sh"
|
"lib/templates_catalog.sh"
|
||||||
|
"lib/stats.sh"
|
||||||
"lib/i18n.sh"
|
"lib/i18n.sh"
|
||||||
"lib/lang/en.sh"
|
"lib/lang/en.sh"
|
||||||
"lib/lang/ru.sh"
|
"lib/lang/ru.sh"
|
||||||
|
|||||||
@@ -100,13 +100,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
GOTELEGRAM_VERSION = "2.4.0"
|
GOTELEGRAM_VERSION = "2.4.6"
|
||||||
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
|
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
|
||||||
TELEMT_CONFIG = "/etc/telemt/config.toml"
|
TELEMT_CONFIG = "/etc/telemt/config.toml"
|
||||||
TELEMT_SERVICE = "telemt"
|
TELEMT_SERVICE = "telemt"
|
||||||
WEBSITE_ROOT = "/var/www/gotelegram-site"
|
WEBSITE_ROOT = "/var/www/gotelegram-site"
|
||||||
BACKUP_DIR = "/opt/gotelegram/backups"
|
BACKUP_DIR = "/opt/gotelegram/backups"
|
||||||
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
|
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
|
||||||
|
INSTALL_SH = "/opt/gotelegram/install.sh"
|
||||||
|
|
||||||
PROMO_LINK_1 = "https://vk.cc/ct29NQ"
|
PROMO_LINK_1 = "https://vk.cc/ct29NQ"
|
||||||
PROMO_LINK_2 = "https://vk.cc/cUxAhj"
|
PROMO_LINK_2 = "https://vk.cc/cUxAhj"
|
||||||
@@ -239,6 +240,76 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
|
|||||||
return (-1, "", str(e))
|
return (-1, "", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Per-host mutex preventing concurrent install.sh --action invocations. Two
|
||||||
|
# admins hitting "change template" at the same second could race each other
|
||||||
|
# and corrupt /var/www/gotelegram-site. One global lock is fine — these are
|
||||||
|
# rare operations and should serialize cleanly.
|
||||||
|
_BOT_ACTION_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
# Allowed template-id shape: catalog ids are [a-zA-Z0-9_-], never longer than 64.
|
||||||
|
# This is a defense-in-depth check before we hand the value to subprocess.
|
||||||
|
_TPL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
# Allowed Lite mask domain shape — simple DNS hostname, up to 253 chars total.
|
||||||
|
# Each label 1–63 chars, labels separated by dots, alphanumerics + hyphens.
|
||||||
|
_DOMAIN_RE = re.compile(
|
||||||
|
r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+"
|
||||||
|
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
|
||||||
|
"""Invoke install.sh --action=X --json and parse the JSON result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: action name (e.g. "change-template", "change-lite-domain")
|
||||||
|
timeout: seconds to wait for completion (long ops: template download can take time)
|
||||||
|
**params: arbitrary key→value pairs, each passed as --key=value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with at least {"status": "success|error", "message": "..."}.
|
||||||
|
Transport errors are mapped to {"status":"error","message":..., "code":"transport"}
|
||||||
|
"""
|
||||||
|
cmd = ["bash", INSTALL_SH, f"--action={action}", "--json"]
|
||||||
|
for k, v in params.items():
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
cmd.append(f"--{k.replace('_', '-')}={v}")
|
||||||
|
|
||||||
|
code, stdout, stderr = await sh(*cmd, timeout=timeout)
|
||||||
|
stdout = (stdout or "").strip()
|
||||||
|
|
||||||
|
# install.sh may print multiple log lines to stderr; the JSON is on stdout.
|
||||||
|
# Pick the last non-empty line that looks like JSON (robust to any stray output).
|
||||||
|
json_line = None
|
||||||
|
for line in reversed(stdout.splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("{") and line.endswith("}"):
|
||||||
|
json_line = line
|
||||||
|
break
|
||||||
|
|
||||||
|
if json_line:
|
||||||
|
try:
|
||||||
|
data = json.loads(json_line)
|
||||||
|
if isinstance(data, dict) and "status" in data:
|
||||||
|
return data
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"run_bot_action: JSON parse failed: {e} | line={json_line!r}")
|
||||||
|
|
||||||
|
# No JSON from install.sh — synthesize an error result
|
||||||
|
tail = (stderr or "")[-300:] if stderr else ""
|
||||||
|
logger.error(
|
||||||
|
f"run_bot_action({action}): no JSON output, rc={code}, "
|
||||||
|
f"stdout={stdout[-300:]!r}, stderr={tail!r}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "install.sh did not return a JSON result",
|
||||||
|
"code": "transport",
|
||||||
|
"rc": str(code),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: str) -> Optional[Dict]:
|
def load_json(path: str) -> Optional[Dict]:
|
||||||
"""Load JSON file."""
|
"""Load JSON file."""
|
||||||
try:
|
try:
|
||||||
@@ -271,12 +342,23 @@ def save_json(path: str, data: Dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool:
|
async def safe_edit_message(
|
||||||
"""Safely edit message, handling cases where message was deleted or not modified."""
|
query,
|
||||||
|
text: str,
|
||||||
|
reply_markup=None,
|
||||||
|
parse_mode=None,
|
||||||
|
disable_web_page_preview: Optional[bool] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Safely edit message, handling cases where message was deleted or not modified.
|
||||||
|
|
||||||
|
`disable_web_page_preview` is forwarded to edit_message_text when set; omitting
|
||||||
|
it keeps Telegram's default (enabled).
|
||||||
|
"""
|
||||||
|
kwargs = {"reply_markup": reply_markup, "parse_mode": parse_mode}
|
||||||
|
if disable_web_page_preview is not None:
|
||||||
|
kwargs["disable_web_page_preview"] = disable_web_page_preview
|
||||||
try:
|
try:
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(text, **kwargs)
|
||||||
text, reply_markup=reply_markup, parse_mode=parse_mode
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
except BadRequest as e:
|
except BadRequest as e:
|
||||||
err_msg = str(e).lower()
|
err_msg = str(e).lower()
|
||||||
@@ -288,6 +370,17 @@ async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None
|
|||||||
raise # Re-raise unexpected BadRequest
|
raise # Re-raise unexpected BadRequest
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_message_after(message, delay: int = 30) -> None:
|
||||||
|
"""Delete a Telegram message after `delay` seconds. Errors are swallowed
|
||||||
|
(message may already be deleted by the user). Used for ephemeral content
|
||||||
|
like promo blocks that should auto-cleanup."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"_delete_message_after: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def check_service_status(service: str) -> bool:
|
async def check_service_status(service: str) -> bool:
|
||||||
"""Check if systemd service is running."""
|
"""Check if systemd service is running."""
|
||||||
code, _, _ = await sh("systemctl", "is-active", service)
|
code, _, _ = await sh("systemctl", "is-active", service)
|
||||||
@@ -465,12 +558,13 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML"
|
welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Промо раз в сутки
|
# Промо раз в сутки — сообщение само удаляется через 30 секунд
|
||||||
if should_show_promo_bot():
|
if should_show_promo_bot():
|
||||||
mark_promo_shown_bot()
|
mark_promo_shown_bot()
|
||||||
await update.message.reply_text(
|
promo_msg = await update.message.reply_text(
|
||||||
get_promo_text(), parse_mode="HTML"
|
get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
|
||||||
)
|
)
|
||||||
|
asyncio.create_task(_delete_message_after(promo_msg, 30))
|
||||||
|
|
||||||
|
|
||||||
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
@@ -556,11 +650,13 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
|
|||||||
config = load_json(GOTELEGRAM_CONFIG)
|
config = load_json(GOTELEGRAM_CONFIG)
|
||||||
if config:
|
if config:
|
||||||
lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}")
|
lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}")
|
||||||
if "template" in config:
|
# install.sh/save_gotelegram_config uses "template_id" (not "template")
|
||||||
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(config['template']))}")
|
tpl = config.get("template_id") or config.get("template")
|
||||||
if "domain" in config:
|
if tpl:
|
||||||
|
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(tpl))}")
|
||||||
|
if config.get("domain"):
|
||||||
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
|
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
|
||||||
if "port" in config:
|
if config.get("port"):
|
||||||
lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {html.escape(str(config['port']))}")
|
lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {html.escape(str(config['port']))}")
|
||||||
|
|
||||||
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...)
|
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...)
|
||||||
@@ -796,7 +892,14 @@ async def cb_install_mode_lite(update: Update, context: ContextTypes.DEFAULT_TYP
|
|||||||
|
|
||||||
|
|
||||||
async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Lite domain selection callback."""
|
"""Lite domain selection callback — real implementation (v2.4.2+).
|
||||||
|
|
||||||
|
Branches on current mode:
|
||||||
|
* lite mode (active): invoke `install.sh --action=change-lite-domain`
|
||||||
|
which regenerates the telemt TOML with a new fake-TLS mask domain and
|
||||||
|
restarts the service. Preserves secret/port.
|
||||||
|
* any other mode: route to CLI. Fresh Lite install is interactive.
|
||||||
|
"""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
data = query.data
|
data = query.data
|
||||||
try:
|
try:
|
||||||
@@ -806,38 +909,79 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
await query.answer("Invalid domain selection")
|
await query.answer("Invalid domain selection")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Defense-in-depth: LITE_DOMAINS is trusted, but validate the shape anyway
|
||||||
|
# in case someone extends the list with garbage later.
|
||||||
|
if not _DOMAIN_RE.match(domain):
|
||||||
|
logger.warning(f"cb_lite_domain: rejecting malformed domain {domain!r}")
|
||||||
|
await query.answer("Invalid domain")
|
||||||
|
return
|
||||||
|
|
||||||
await query.answer()
|
await query.answer()
|
||||||
await safe_edit_message(query,f"⏳ Installing with domain: {domain}...")
|
|
||||||
|
|
||||||
# Simulate installation (in real scenario, call install script)
|
config = load_json(GOTELEGRAM_CONFIG) or {}
|
||||||
config = {
|
current_mode = config.get("mode", "")
|
||||||
"mode": "lite",
|
|
||||||
"domain": domain,
|
|
||||||
"port": 443,
|
|
||||||
"installed_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if save_json(GOTELEGRAM_CONFIG, config):
|
if current_mode != "lite":
|
||||||
text = (
|
text = (
|
||||||
f"✅ <b>Lite mode installed!</b>\n\n"
|
"<b>⚠️ Установка Lite из бота пока не поддерживается</b>\n\n"
|
||||||
f"<b>Domain:</b> {domain}\n"
|
f"Выбранный домен: <code>{html.escape(domain)}</code>\n\n"
|
||||||
f"<b>Mode:</b> Lite\n\n"
|
"Чтобы установить Lite, запустите на сервере:\n"
|
||||||
f"Service starting... Check status in 10 seconds."
|
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Lite</b>\n\n"
|
||||||
|
"Существующая конфигурация <b>не была изменена</b>."
|
||||||
)
|
)
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
)
|
)
|
||||||
await safe_edit_message(query,
|
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
text, reply_markup=keyboard, parse_mode="HTML"
|
return
|
||||||
|
|
||||||
|
# Lite active — switch fake-TLS mask domain in place
|
||||||
|
if _BOT_ACTION_LOCK.locked():
|
||||||
|
await safe_edit_message(
|
||||||
|
query,
|
||||||
|
"<b>⏳ Другая операция уже выполняется</b>\n\n"
|
||||||
|
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
progress_text = (
|
||||||
|
"<b>⏳ Меняю маскировочный домен...</b>\n\n"
|
||||||
|
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
|
||||||
|
"Перегенерирую конфиг telemt и перезапускаю сервис."
|
||||||
|
)
|
||||||
|
await safe_edit_message(query, progress_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
async with _BOT_ACTION_LOCK:
|
||||||
|
result = await run_bot_action("change-lite-domain", timeout=30, domain=domain)
|
||||||
|
|
||||||
|
if result.get("status") == "success":
|
||||||
|
text = (
|
||||||
|
"<b>✅ Маскировочный домен обновлён</b>\n\n"
|
||||||
|
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
|
||||||
|
"telemt перезапущен. <b>Важно:</b> старые ссылки подключения больше "
|
||||||
|
"не будут работать — нужно заново раздать новые."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await safe_edit_message(query,
|
err_msg = result.get("message", "unknown error")
|
||||||
"❌ Failed to save configuration",
|
err_code = result.get("code", "")
|
||||||
reply_markup=InlineKeyboardMarkup(
|
text = (
|
||||||
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
|
"<b>❌ Не удалось сменить домен</b>\n\n"
|
||||||
),
|
f"Домен: <code>{html.escape(domain)}</code>\n"
|
||||||
|
f"Причина: <code>{html.escape(err_msg)}</code>"
|
||||||
|
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
|
||||||
|
+ "\n\n"
|
||||||
|
"Существующая конфигурация <b>не была изменена</b>."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
|
)
|
||||||
|
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Pro mode - show template categories."""
|
"""Pro mode - show template categories."""
|
||||||
@@ -1106,42 +1250,113 @@ async def cb_pro_template(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
|
|
||||||
|
|
||||||
async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Confirm and install pro template."""
|
"""Confirm Pro template selection — real implementation (v2.4.2+).
|
||||||
|
|
||||||
|
Branches on current mode:
|
||||||
|
* pro mode (active deployment): invoke `install.sh --action=change-template`
|
||||||
|
which downloads the new template and redeploys it to nginx. Reuses the
|
||||||
|
existing domain + SSL cert.
|
||||||
|
* any other mode (or no install at all): route to CLI. Fresh Pro install
|
||||||
|
still requires interactive flow (domain, email, DNS check) — not safe
|
||||||
|
to run headless from the bot.
|
||||||
|
|
||||||
|
Historic context: v2.4.1 stub used to overwrite config.json with a fake
|
||||||
|
blob; that was replaced with a safe message in v2.4.1 hotfix; now in
|
||||||
|
v2.4.2 we wire the real change-template path through install.sh.
|
||||||
|
"""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
data = query.data
|
data = query.data
|
||||||
tpl_id = data.removeprefix("pro_confirm_")
|
tpl_id = data.removeprefix("pro_confirm_")
|
||||||
|
|
||||||
await query.answer()
|
await query.answer()
|
||||||
await safe_edit_message(query,"⏳ Installing template...")
|
|
||||||
|
|
||||||
config = {
|
# Defense-in-depth: even though subprocess.exec uses list args (no shell),
|
||||||
"mode": "pro",
|
# we still enforce the catalog id shape before handing it to install.sh.
|
||||||
"template": tpl_id,
|
if not _TPL_ID_RE.match(tpl_id):
|
||||||
"port": 443,
|
logger.warning(f"cb_pro_confirm: rejecting malformed tpl_id {tpl_id!r}")
|
||||||
"installed_at": datetime.now().isoformat(),
|
await safe_edit_message(
|
||||||
}
|
query,
|
||||||
|
"<b>❌ Некорректный идентификатор шаблона</b>\n\n"
|
||||||
|
"Выбран неподдерживаемый шаблон. Вернитесь в меню и попробуйте снова.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if save_json(GOTELEGRAM_CONFIG, config):
|
# Read current config to decide: in-place change-template vs fresh install
|
||||||
|
config = load_json(GOTELEGRAM_CONFIG) or {}
|
||||||
|
current_mode = config.get("mode", "")
|
||||||
|
|
||||||
|
if current_mode != "pro":
|
||||||
|
# Fresh install / mode switch — still routes to CLI (needs domain, SSL)
|
||||||
text = (
|
text = (
|
||||||
f"✅ <b>Pro mode installed!</b>\n\n"
|
"<b>⚠️ Установка Pro из бота пока не поддерживается</b>\n\n"
|
||||||
f"<b>Template:</b> {html.escape(tpl_id)}\n"
|
f"Выбранный шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
|
||||||
f"<b>Mode:</b> Pro\n\n"
|
"Pro-режим требует ввода домена, email и проверки DNS. "
|
||||||
f"Service starting... Check status in 10 seconds."
|
"Чтобы установить Pro, запустите на сервере:\n"
|
||||||
|
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Pro</b>\n\n"
|
||||||
|
"Существующая конфигурация <b>не была изменена</b>."
|
||||||
)
|
)
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
)
|
)
|
||||||
await safe_edit_message(query,
|
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
text, reply_markup=keyboard, parse_mode="HTML"
|
return
|
||||||
|
|
||||||
|
# Pro mode is active — perform change-template in place
|
||||||
|
if _BOT_ACTION_LOCK.locked():
|
||||||
|
await safe_edit_message(
|
||||||
|
query,
|
||||||
|
"<b>⏳ Другая операция уже выполняется</b>\n\n"
|
||||||
|
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
progress_text = (
|
||||||
|
"<b>⏳ Меняю шаблон сайта...</b>\n\n"
|
||||||
|
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
|
||||||
|
"Скачиваю репозиторий и разворачиваю в nginx. "
|
||||||
|
"Это может занять 30–90 секунд."
|
||||||
|
)
|
||||||
|
await safe_edit_message(query, progress_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
# Template download + git clone can be slow — generous timeout.
|
||||||
|
# Mutex serializes with any concurrent change-lite-domain/change-template.
|
||||||
|
async with _BOT_ACTION_LOCK:
|
||||||
|
result = await run_bot_action("change-template", timeout=180, template=tpl_id)
|
||||||
|
|
||||||
|
if result.get("status") == "success":
|
||||||
|
domain = result.get("domain", config.get("domain", ""))
|
||||||
|
text = (
|
||||||
|
"<b>✅ Шаблон обновлён</b>\n\n"
|
||||||
|
f"Новый шаблон: <code>{html.escape(tpl_id)}</code>\n"
|
||||||
|
f"Сайт: <a href=\"https://{html.escape(domain, quote=True)}\">https://{html.escape(domain)}</a>\n\n"
|
||||||
|
"Прокси продолжает работать без перерыва."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await safe_edit_message(query,
|
err_msg = result.get("message", "unknown error")
|
||||||
"❌ Failed to save configuration",
|
err_code = result.get("code", "")
|
||||||
reply_markup=InlineKeyboardMarkup(
|
text = (
|
||||||
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
|
"<b>❌ Не удалось сменить шаблон</b>\n\n"
|
||||||
),
|
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n"
|
||||||
|
f"Причина: <code>{html.escape(err_msg)}</code>"
|
||||||
|
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
|
||||||
|
+ "\n\n"
|
||||||
|
"Существующая конфигурация <b>не была изменена</b>. "
|
||||||
|
"Попробуйте другой шаблон или запустите <code>gotelegram</code> из консоли."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
||||||
|
)
|
||||||
|
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY LINK & SHARE
|
# PROXY LINK & SHARE
|
||||||
@@ -1799,14 +2014,16 @@ def mark_promo_shown_bot() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Promo information — always shown from menu."""
|
"""Promo information — shown as a separate ephemeral message that
|
||||||
|
auto-deletes after 30s so it does not clutter the chat. The main menu
|
||||||
|
message stays intact (we don't edit it in place)."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
promo_msg = await query.message.reply_text(
|
||||||
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
|
||||||
)
|
)
|
||||||
await safe_edit_message(query, get_promo_text(), reply_markup=keyboard, parse_mode="HTML")
|
asyncio.create_task(_delete_message_after(promo_msg, 30))
|
||||||
|
|
||||||
|
|
||||||
async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
@@ -2072,9 +2289,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
|
|||||||
if not ok:
|
if not ok:
|
||||||
await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
|
await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
|
||||||
return
|
return
|
||||||
# Success — record in GoTelegram config
|
# Success — record in GoTelegram config. Use "template_id" (canonical
|
||||||
|
# field name written by install.sh/save_gotelegram_config).
|
||||||
config = load_json(GOTELEGRAM_CONFIG) or {}
|
config = load_json(GOTELEGRAM_CONFIG) or {}
|
||||||
config["template"] = tpl_id
|
config["template_id"] = tpl_id
|
||||||
config["template_source"] = url
|
config["template_source"] = url
|
||||||
save_json(GOTELEGRAM_CONFIG, config)
|
save_json(GOTELEGRAM_CONFIG, config)
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
|
|||||||
408
install.sh
408
install.sh
@@ -751,11 +751,36 @@ menu_bot() {
|
|||||||
bot_install() {
|
bot_install() {
|
||||||
log_step "$(t bot_install_step)"
|
log_step "$(t bot_install_step)"
|
||||||
|
|
||||||
# Python
|
# Python + venv + pip (always ensure — python3 can be present without venv/pip)
|
||||||
if ! command -v python3 &>/dev/null; then
|
local need_py=0
|
||||||
|
command -v python3 &>/dev/null || need_py=1
|
||||||
|
# python3-venv not having its own command; probe by trying 'python3 -m venv --help'
|
||||||
|
if ! python3 -m venv --help &>/dev/null; then need_py=1; fi
|
||||||
|
# pip check
|
||||||
|
if ! python3 -m pip --version &>/dev/null; then need_py=1; fi
|
||||||
|
|
||||||
|
if [ "$need_py" = "1" ]; then
|
||||||
log_info "$(t bot_install_python)"
|
log_info "$(t bot_install_python)"
|
||||||
if command -v apt-get &>/dev/null; then
|
if command -v apt-get &>/dev/null; then
|
||||||
apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv
|
# Detect Python version for versioned venv package (Debian 12 / Ubuntu 24.04 need python3.12-venv)
|
||||||
|
local py_ver=""
|
||||||
|
if command -v python3 &>/dev/null; then
|
||||||
|
py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt_update
|
||||||
|
|
||||||
|
# Build package list with versioned venv fallback
|
||||||
|
local pkg_list=(python3 python3-venv python3-pip)
|
||||||
|
[ -n "$py_ver" ] && pkg_list+=("python${py_ver}-venv")
|
||||||
|
# python3-full optional
|
||||||
|
if ! apt_install "${pkg_list[@]}" python3-full; then
|
||||||
|
log_warning "python3-full unavailable, installing core packages only..."
|
||||||
|
apt_install "${pkg_list[@]}" || {
|
||||||
|
log_error "Failed to install Python packages. Run manually: apt install ${pkg_list[*]}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
elif command -v dnf &>/dev/null; then
|
elif command -v dnf &>/dev/null; then
|
||||||
dnf install -y -q python3 python3-pip
|
dnf install -y -q python3 python3-pip
|
||||||
elif command -v yum &>/dev/null; then
|
elif command -v yum &>/dev/null; then
|
||||||
@@ -782,17 +807,60 @@ bot_install() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Templates catalog
|
# Templates catalog — skip if source and dest are the same file (symlink install case)
|
||||||
[ -f "$SCRIPT_DIR/templates_catalog.json" ] && \
|
if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then
|
||||||
cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/"
|
local src_tc="$SCRIPT_DIR/templates_catalog.json"
|
||||||
|
local dst_tc="$GOTELEGRAM_DIR/templates_catalog.json"
|
||||||
# Venv
|
if [ "$(readlink -f "$src_tc" 2>/dev/null)" != "$(readlink -f "$dst_tc" 2>/dev/null)" ]; then
|
||||||
if [ ! -d "$BOT_DIR/venv" ]; then
|
cp "$src_tc" "$dst_tc"
|
||||||
log_info "$(t bot_create_venv)"
|
fi
|
||||||
python3 -m venv "$BOT_DIR/venv"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Venv — create, and verify pip exists (python3-venv can silently create broken venv)
|
||||||
|
if [ ! -d "$BOT_DIR/venv" ] || [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
|
||||||
|
log_info "$(t bot_create_venv)"
|
||||||
|
rm -rf "$BOT_DIR/venv"
|
||||||
|
if ! python3 -m venv "$BOT_DIR/venv" 2>/tmp/venv_err; then
|
||||||
|
log_error "venv creation failed:"
|
||||||
|
cat /tmp/venv_err >&2 2>/dev/null
|
||||||
|
# Try to fix by installing versioned python3.X-venv package
|
||||||
|
if command -v apt-get &>/dev/null; then
|
||||||
|
local py_ver
|
||||||
|
py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
|
||||||
|
log_info "reinstalling python${py_ver}-venv..."
|
||||||
|
apt_install python3-venv python3-pip "python${py_ver}-venv" python3-full || \
|
||||||
|
apt_install python3-venv python3-pip "python${py_ver}-venv" || true
|
||||||
|
rm -rf "$BOT_DIR/venv"
|
||||||
|
python3 -m venv "$BOT_DIR/venv" || { log_error "venv still broken, aborting. Manual fix: apt install python${py_ver}-venv python3-pip"; return 1; }
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
|
||||||
|
log_info "bootstrapping pip via ensurepip..."
|
||||||
|
"$BOT_DIR/venv/bin/python" -m ensurepip --upgrade 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
|
||||||
|
log_error "pip missing in venv — install python3-venv manually: apt install python3-venv python3-pip"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
log_info "$(t bot_install_deps)"
|
log_info "$(t bot_install_deps)"
|
||||||
"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
|
if ! "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q 2>/tmp/pip_err; then
|
||||||
|
log_error "pip install failed:"
|
||||||
|
tail -n 5 /tmp/pip_err >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sanity check: verify critical imports succeed
|
||||||
|
if ! "$BOT_DIR/venv/bin/python" -c "import telegram, toml, dotenv" 2>/tmp/imp_err; then
|
||||||
|
log_error "dependency import check failed:"
|
||||||
|
cat /tmp/imp_err >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
if [ ! -f "$BOT_DIR/.env" ]; then
|
if [ ! -f "$BOT_DIR/.env" ]; then
|
||||||
@@ -862,14 +930,13 @@ SVCEOF
|
|||||||
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
|
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
|
||||||
if [ -z "$has_ids" ]; then
|
if [ -z "$has_ids" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
|
# Simple bullet-style block (no box — printf %-Ns breaks on UTF-8 multibyte chars)
|
||||||
printf " ${YELLOW}║${NC} ${BOLD}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_title)"
|
echo -e " ${YELLOW}▸${NC} ${BOLD}$(t bot_wait_admin_title)${NC}"
|
||||||
echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
|
echo ""
|
||||||
printf " ${YELLOW}║${NC} %s ${CYAN}/start${NC}%*s${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg1)" 0 ""
|
echo -e " $(t bot_wait_admin_msg1) ${CYAN}/start${NC}"
|
||||||
printf " ${YELLOW}║${NC} %-52s ${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg2)"
|
echo -e " $(t bot_wait_admin_msg2)"
|
||||||
echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
|
echo ""
|
||||||
printf " ${YELLOW}║${NC} ${DIM}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_skip)"
|
echo -e " ${DIM}$(t bot_wait_admin_skip)${NC}"
|
||||||
echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||||
@@ -1071,21 +1138,14 @@ mark_promo_shown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── Promo with QR + delay (on install + once per day) ───────────────────
|
# ── Promo with QR + delay (on install + once per day) ───────────────────
|
||||||
|
# QR показываем ТОЛЬКО для чаевых/донатов. Для хостеров оставлены только
|
||||||
|
# текстовые ссылки и промокоды (см. _promo_block) — QR-коды хостеров
|
||||||
|
# визуально конкурировали с чаевыми и перегружали экран.
|
||||||
show_promo_with_qr() {
|
show_promo_with_qr() {
|
||||||
_promo_block
|
_promo_block
|
||||||
|
|
||||||
# QR codes
|
# QR только для чаевых
|
||||||
if command -v qrencode &>/dev/null; then
|
if command -v qrencode &>/dev/null; then
|
||||||
echo -e " ${DIM}$(t promo_qr_host1)${NC}"
|
|
||||||
qrencode -t UTF8 -m 1 "https://vk.cc/ct29NQ" 2>/dev/null | while IFS= read -r qr_line; do
|
|
||||||
echo " $qr_line"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo -e " ${DIM}$(t promo_qr_host2)${NC}"
|
|
||||||
qrencode -t UTF8 -m 1 "https://vk.cc/cUxAhj" 2>/dev/null | while IFS= read -r qr_line; do
|
|
||||||
echo " $qr_line"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo -e " ${DIM}$(t promo_qr_tips)${NC}"
|
echo -e " ${DIM}$(t promo_qr_tips)${NC}"
|
||||||
qrencode -t UTF8 -m 1 "https://pay.cloudtips.ru/p/7410814f" 2>/dev/null | while IFS= read -r qr_line; do
|
qrencode -t UTF8 -m 1 "https://pay.cloudtips.ru/p/7410814f" 2>/dev/null | while IFS= read -r qr_line; do
|
||||||
echo " $qr_line"
|
echo " $qr_line"
|
||||||
@@ -1136,11 +1196,297 @@ menu_language() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Non-interactive action dispatcher (bot / CI / scripting interface)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Usage examples:
|
||||||
|
# gotelegram --action=change-template --template=th_ariclaw --json
|
||||||
|
# gotelegram --action=change-lite-domain --domain=google.com --json
|
||||||
|
#
|
||||||
|
# Rules for action handlers:
|
||||||
|
# - Only JSON may be written to stdout (the caller parses it).
|
||||||
|
# - All human-oriented logging must go to stderr (log_* already do that).
|
||||||
|
# - Exit code 0 on success, non-zero on failure (caller still parses JSON).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
bot_emit_json() {
|
||||||
|
# bot_emit_json <status> <message> [key=value ...]
|
||||||
|
local status="$1"; shift
|
||||||
|
local message="$1"; shift
|
||||||
|
local extra="" kv k v
|
||||||
|
for kv in "$@"; do
|
||||||
|
k="${kv%%=*}"
|
||||||
|
v="${kv#*=}"
|
||||||
|
# escape backslashes and double quotes in value
|
||||||
|
v="${v//\\/\\\\}"
|
||||||
|
v="${v//\"/\\\"}"
|
||||||
|
extra="${extra},\"${k}\":\"${v}\""
|
||||||
|
done
|
||||||
|
# escape message
|
||||||
|
local msg_esc="${message//\\/\\\\}"
|
||||||
|
msg_esc="${msg_esc//\"/\\\"}"
|
||||||
|
printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update a single key in config.json without rewriting the whole file.
|
||||||
|
# Uses `date -Iseconds` rather than jq's `now | todate` — the latter requires
|
||||||
|
# jq 1.6+ which is not available on Debian 10 or older CentOS.
|
||||||
|
bot_update_config_field() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if [ ! -f "$GOTELEGRAM_CONFIG" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local tmp now
|
||||||
|
tmp=$(mktemp) || return 1
|
||||||
|
now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z)
|
||||||
|
if jq --arg k "$key" --arg v "$value" --arg t "$now" \
|
||||||
|
'.[$k] = $v | .updated_at = $t' \
|
||||||
|
"$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then
|
||||||
|
mv "$tmp" "$GOTELEGRAM_CONFIG"
|
||||||
|
chmod 600 "$GOTELEGRAM_CONFIG"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Action: change-template (pro mode only) ──────────────────────────────────
|
||||||
|
bot_action_change_template() {
|
||||||
|
local tpl_id="$1"
|
||||||
|
local json_out="${2:-0}"
|
||||||
|
|
||||||
|
if [ -z "$tpl_id" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "template id is required" "code=missing_arg"
|
||||||
|
log_error "change-template: --template is required"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must be in pro mode
|
||||||
|
local mode
|
||||||
|
mode=$(config_get mode 2>/dev/null || echo "")
|
||||||
|
if [ "$mode" != "pro" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "change-template requires pro mode (current: ${mode:-none})" "code=wrong_mode"
|
||||||
|
log_error "change-template: current mode is '${mode:-none}', requires 'pro'"
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate template id exists in catalog
|
||||||
|
if ! get_template_info "$tpl_id" >/dev/null 2>&1; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "unknown template: $tpl_id" "code=unknown_template"
|
||||||
|
log_error "change-template: template not found in catalog: $tpl_id"
|
||||||
|
return 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure git (and other deps) are present. download_template uses git
|
||||||
|
# clone under the hood — on a minimal host (bootstrap-only install) git may
|
||||||
|
# not be installed yet, and the clone would fail silently.
|
||||||
|
ensure_deps >&2
|
||||||
|
|
||||||
|
log_info "change-template: downloading $tpl_id..."
|
||||||
|
local template_dir
|
||||||
|
template_dir=$(download_template "$tpl_id")
|
||||||
|
if [ $? -ne 0 ] || [ -z "$template_dir" ] || [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "download failed for $tpl_id" "code=download_failed"
|
||||||
|
log_error "change-template: download_template failed for $tpl_id"
|
||||||
|
return 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "change-template: deploying to nginx..."
|
||||||
|
if ! deploy_template_to_nginx "$template_dir" >&2; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "deploy failed" "code=deploy_failed"
|
||||||
|
return 6
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reload nginx (no full restart needed for static files — but be safe)
|
||||||
|
systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null
|
||||||
|
|
||||||
|
# Update config.json template_id field
|
||||||
|
bot_update_config_field "template_id" "$tpl_id" || \
|
||||||
|
log_warning "change-template: could not update config.json template_id"
|
||||||
|
|
||||||
|
local domain
|
||||||
|
domain=$(config_get domain 2>/dev/null || echo "")
|
||||||
|
log_success "change-template: $tpl_id deployed"
|
||||||
|
|
||||||
|
if [ "$json_out" = "1" ]; then
|
||||||
|
bot_emit_json "success" "template changed to $tpl_id" \
|
||||||
|
"template=$tpl_id" "domain=$domain" "mode=pro"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Action: change-lite-domain ───────────────────────────────────────────────
|
||||||
|
# Regenerates telemt TOML with a new fake-TLS mask domain. Lite mode only.
|
||||||
|
bot_action_change_lite_domain() {
|
||||||
|
local new_domain="$1"
|
||||||
|
local json_out="${2:-0}"
|
||||||
|
|
||||||
|
if [ -z "$new_domain" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "domain is required" "code=missing_arg"
|
||||||
|
log_error "change-lite-domain: --domain is required"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! validate_domain "$new_domain" 2>/dev/null; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "invalid domain: $new_domain" "code=invalid_domain"
|
||||||
|
log_error "change-lite-domain: invalid domain: $new_domain"
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
local mode
|
||||||
|
mode=$(config_get mode 2>/dev/null || echo "")
|
||||||
|
if [ "$mode" != "lite" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "change-lite-domain requires lite mode (current: ${mode:-none})" "code=wrong_mode"
|
||||||
|
log_error "change-lite-domain: current mode is '${mode:-none}', requires 'lite'"
|
||||||
|
return 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
local secret port
|
||||||
|
secret=$(get_config_value secret 2>/dev/null || echo "")
|
||||||
|
port=$(get_config_value port 2>/dev/null || echo "443")
|
||||||
|
|
||||||
|
if [ -z "$secret" ]; then
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "no secret in config" "code=no_secret"
|
||||||
|
log_error "change-lite-domain: no secret in config.json"
|
||||||
|
return 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "change-lite-domain: regenerating telemt TOML..."
|
||||||
|
generate_telemt_toml "$secret" "$port" "lite" "$new_domain" "443" >&2 || {
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "config generation failed" "code=gen_failed"
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_telemt_config >&2 || {
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "config validation failed" "code=validate_failed"
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_telemt >&2 || {
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "telemt restart failed" "code=restart_failed"
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update both domain and mask_host fields in config.json
|
||||||
|
bot_update_config_field "mask_host" "$new_domain" || \
|
||||||
|
log_warning "change-lite-domain: could not update mask_host"
|
||||||
|
bot_update_config_field "domain" "$new_domain" || \
|
||||||
|
log_warning "change-lite-domain: could not update domain"
|
||||||
|
|
||||||
|
log_success "change-lite-domain: switched to $new_domain"
|
||||||
|
|
||||||
|
if [ "$json_out" = "1" ]; then
|
||||||
|
bot_emit_json "success" "lite mask domain changed to $new_domain" \
|
||||||
|
"domain=$new_domain" "mode=lite" "port=$port"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main dispatcher — called from main() when --action=X is present.
|
||||||
|
# Uses a file lock (flock) so concurrent CLI invocations (from multiple bot
|
||||||
|
# users, or from bot + manual CLI) serialize cleanly. Without this, two
|
||||||
|
# parallel `change-lite-domain` calls raced on the jq-rewrite of config.json
|
||||||
|
# and one process would see a truncated file ("no secret in config").
|
||||||
|
bot_action_dispatch() {
|
||||||
|
local lock_file="/var/lock/gotelegram-bot-action.lock"
|
||||||
|
# Make sure /var/lock exists (it does on Debian/Ubuntu; be defensive for minimal images)
|
||||||
|
[ -d /var/lock ] || mkdir -p /var/lock 2>/dev/null || true
|
||||||
|
|
||||||
|
if command -v flock >/dev/null 2>&1; then
|
||||||
|
# Wait up to 30 seconds for the lock — bot actions are fast (<5s
|
||||||
|
# typical), so 30s is plenty for legitimate serialization but short
|
||||||
|
# enough to surface a stuck process.
|
||||||
|
(
|
||||||
|
flock -w 30 9 || {
|
||||||
|
# If we time out, emit JSON error for the bot parent.
|
||||||
|
local json_out=0 a
|
||||||
|
for a in "$@"; do
|
||||||
|
[ "$a" = "--json" ] && json_out=1
|
||||||
|
done
|
||||||
|
if [ "$json_out" = "1" ]; then
|
||||||
|
bot_emit_json "error" "another action in progress (lock timeout)" "code=lock_timeout"
|
||||||
|
fi
|
||||||
|
exit 75 # EX_TEMPFAIL
|
||||||
|
}
|
||||||
|
_bot_action_dispatch_locked "$@"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
return $?
|
||||||
|
else
|
||||||
|
# No flock installed — run unlocked with a warning. ensure_deps/check_deps
|
||||||
|
# normally ensures util-linux is present, so this branch is defensive.
|
||||||
|
log_warning "flock not available — bot actions not serialized"
|
||||||
|
_bot_action_dispatch_locked "$@"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_bot_action_dispatch_locked() {
|
||||||
|
local action="" tpl_id="" domain="" json_out=0 arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--action=*) action="${arg#--action=}" ;;
|
||||||
|
--template=*) tpl_id="${arg#--template=}" ;;
|
||||||
|
--domain=*) domain="${arg#--domain=}" ;;
|
||||||
|
--json) json_out=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
change-template)
|
||||||
|
bot_action_change_template "$tpl_id" "$json_out"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
change-lite-domain)
|
||||||
|
bot_action_change_lite_domain "$domain" "$json_out"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
log_error "no --action specified"
|
||||||
|
return 64
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
[ "$json_out" = "1" ] && bot_emit_json "error" "unknown action: $action" "code=unknown_action"
|
||||||
|
log_error "unknown action: $action"
|
||||||
|
return 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# ── Точка входа / Entry point ───────────────────────────────────────────────
|
# ── Точка входа / Entry point ───────────────────────────────────────────────
|
||||||
main() {
|
main() {
|
||||||
|
# Non-interactive action mode: if --action=X is in args, dispatch and exit.
|
||||||
|
# Must run BEFORE interactive banner/menus so the bot gets clean JSON.
|
||||||
|
local a has_action=0
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in --action=*) has_action=1; break ;; esac
|
||||||
|
done
|
||||||
|
if [ "$has_action" = "1" ]; then
|
||||||
|
check_root
|
||||||
|
init_dirs
|
||||||
|
# Для bot-экшенов тоже нужны зависимости (git для change-template), но
|
||||||
|
# без шумного apt-get update если всё уже на месте.
|
||||||
|
if ! check_deps_present; then
|
||||||
|
ensure_deps >&2 || exit 1
|
||||||
|
fi
|
||||||
|
bot_action_dispatch "$@"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
check_root
|
check_root
|
||||||
init_dirs
|
init_dirs
|
||||||
|
|
||||||
|
# Первый запуск: если критические зависимости отсутствуют — ставим их ДО
|
||||||
|
# того как пользователь дойдёт до меню. На последующих запусках это просто
|
||||||
|
# дёшево проверяет command -v по всем командам и ничего не делает.
|
||||||
|
if ! check_deps_present; then
|
||||||
|
log_step "Первый запуск: проверяю зависимости..."
|
||||||
|
ensure_deps || {
|
||||||
|
log_error "Не удалось установить зависимости. См. сообщения выше."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
# First-run language picker (before banner so banner appears in chosen lang)
|
# First-run language picker (before banner so banner appears in chosen lang)
|
||||||
first_run_language_picker
|
first_run_language_picker
|
||||||
|
|
||||||
|
|||||||
219
lib/common.sh
Normal file → Executable file
219
lib/common.sh
Normal file → Executable file
@@ -3,7 +3,7 @@
|
|||||||
# Colors, logging, spinner, system helpers, v1 compat, i18n-aware
|
# Colors, logging, spinner, system helpers, v1 compat, i18n-aware
|
||||||
|
|
||||||
# ── Version ───────────────────────────────────────────────────────────────────
|
# ── Version ───────────────────────────────────────────────────────────────────
|
||||||
GOTELEGRAM_VERSION="2.4.1"
|
GOTELEGRAM_VERSION="2.4.6"
|
||||||
GOTELEGRAM_NAME="GoTelegram"
|
GOTELEGRAM_NAME="GoTelegram"
|
||||||
|
|
||||||
# ── Пути ──────────────────────────────────────────────────────────────────────
|
# ── Пути ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -240,32 +240,215 @@ get_pkg_manager() {
|
|||||||
install_pkg() {
|
install_pkg() {
|
||||||
local pkg="$1"
|
local pkg="$1"
|
||||||
case "$(get_pkg_manager)" in
|
case "$(get_pkg_manager)" in
|
||||||
apt) apt-get install -y -qq "$pkg" ;;
|
apt) apt_install "$pkg" ;;
|
||||||
dnf) dnf install -y -q "$pkg" ;;
|
dnf) dnf install -y -q "$pkg" ;;
|
||||||
yum) yum install -y -q "$pkg" ;;
|
yum) yum install -y -q "$pkg" ;;
|
||||||
*) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;;
|
*) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_deps() {
|
# ── apt lock wait + install ─────────────────────────────────────────────────
|
||||||
local missing=()
|
# На свежих Ubuntu/Debian unattended-upgrades часто держит dpkg lock на старте
|
||||||
for cmd in curl jq openssl git qrencode; do
|
# → любой apt-get install падает с "Could not get lock /var/lib/dpkg/lock-frontend".
|
||||||
if ! command -v "$cmd" &>/dev/null; then
|
# Эти функции ждут освобождения лока до 300с, потом запускают apt с нативным
|
||||||
missing+=("$cmd")
|
# таймаутом DPkg::Lock::Timeout. Использовать везде, где раньше был
|
||||||
|
# "apt-get install ...".
|
||||||
|
apt_lock_wait() {
|
||||||
|
local max_wait="${1:-300}"
|
||||||
|
local waited=0
|
||||||
|
local warned=0
|
||||||
|
while fuser /var/lib/dpkg/lock-frontend &>/dev/null \
|
||||||
|
|| fuser /var/lib/dpkg/lock &>/dev/null \
|
||||||
|
|| fuser /var/lib/apt/lists/lock &>/dev/null \
|
||||||
|
|| pgrep -f '^/usr/bin/unattended-upgrade' &>/dev/null; do
|
||||||
|
if [ "$warned" = "0" ]; then
|
||||||
|
log_warning "apt/dpkg locked by unattended-upgrades, waiting up to ${max_wait}s..."
|
||||||
|
warned=1
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
waited=$((waited + 3))
|
||||||
|
if [ "$waited" -ge "$max_wait" ]; then
|
||||||
|
log_error "apt lock not released after ${max_wait}s"
|
||||||
|
log_dim "Manual fix: systemctl stop unattended-upgrades && killall -9 unattended-upgr 2>/dev/null; dpkg --configure -a"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
[ "$warned" = "1" ] && log_success "apt lock released (waited ${waited}s)"
|
||||||
if type tf &>/dev/null; then
|
return 0
|
||||||
log_step "$(tf deps_installing "${missing[*]}")"
|
}
|
||||||
else
|
|
||||||
log_step "Installing dependencies: ${missing[*]}"
|
# apt_install <pkg> [pkg2 ...] — ждёт lock + ставит пакеты + показывает ошибку
|
||||||
fi
|
apt_install() {
|
||||||
case "$(get_pkg_manager)" in
|
[ $# -eq 0 ] && return 0
|
||||||
apt) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;;
|
apt_lock_wait || return 1
|
||||||
dnf) dnf install -y -q "${missing[@]}" ;;
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
yum) yum install -y -q "${missing[@]}" ;;
|
local opts="-o DPkg::Lock::Timeout=120"
|
||||||
esac
|
local err_file; err_file=$(mktemp 2>/dev/null || echo /tmp/apt_err.$$)
|
||||||
|
if ! apt-get $opts install -y -qq "$@" 2>"$err_file"; then
|
||||||
|
log_error "apt-get install failed: $*"
|
||||||
|
[ -s "$err_file" ] && tail -n 5 "$err_file" | sed 's/^/ /' >&2
|
||||||
|
rm -f "$err_file"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$err_file"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# apt_update — тихий update с ожиданием лока
|
||||||
|
apt_update() {
|
||||||
|
apt_lock_wait || return 1
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get -o DPkg::Lock::Timeout=120 update -qq 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Зависимости GoTelegram ──────────────────────────────────────────────────
|
||||||
|
# Полный список внешних команд, которые скрипт использует. Для каждой команды
|
||||||
|
# указан пакет на apt и dnf/yum (имена различаются: например dig = dnsutils на
|
||||||
|
# Debian, bind-utils на RHEL).
|
||||||
|
#
|
||||||
|
# КРИТИЧЕСКИЕ (без них скрипт просто не работает):
|
||||||
|
# jq — парсинг config.json, templates_catalog.json
|
||||||
|
# curl — скачивание telemt и проверки HTTPS
|
||||||
|
# openssl — генерация секретов, шифрование бекапов, SSL проверка
|
||||||
|
# git — клонирование шаблонов через download_template
|
||||||
|
# xxd — hex-encode домена для fake-TLS секрета (ee-prefix)
|
||||||
|
# tar — распаковка telemt архива и бекапы
|
||||||
|
# dig — DNS-проверка домена в Pro-режиме
|
||||||
|
#
|
||||||
|
# ЖЕЛАТЕЛЬНЫЕ (есть fallback, но с ними лучше):
|
||||||
|
# qrencode — QR-коды для прокси-ссылок
|
||||||
|
# bc — красивое форматирование чисел в статистике
|
||||||
|
#
|
||||||
|
# Pro-режим доустанавливает nginx/certbot через install_nginx/install_certbot
|
||||||
|
# (они большие и нужны только если пользователь выбрал Pro).
|
||||||
|
|
||||||
|
# Маппинг команды -> (apt_pkg, dnf_pkg). apt_pkg_for_cmd <cmd>
|
||||||
|
apt_pkg_for_cmd() {
|
||||||
|
case "$1" in
|
||||||
|
dig) echo "dnsutils" ;;
|
||||||
|
xxd) echo "xxd" ;; # Ubuntu 22+: отдельный пакет, fallback ниже
|
||||||
|
nslookup) echo "dnsutils" ;;
|
||||||
|
host) echo "dnsutils" ;;
|
||||||
|
ss) echo "iproute2" ;;
|
||||||
|
netstat) echo "net-tools" ;;
|
||||||
|
flock) echo "util-linux" ;;
|
||||||
|
*) echo "$1" ;; # команда == имя пакета
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
dnf_pkg_for_cmd() {
|
||||||
|
case "$1" in
|
||||||
|
dig|nslookup|host) echo "bind-utils" ;;
|
||||||
|
xxd) echo "vim-common" ;;
|
||||||
|
ss) echo "iproute" ;;
|
||||||
|
netstat) echo "net-tools" ;;
|
||||||
|
flock) echo "util-linux" ;;
|
||||||
|
*) echo "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_deps() {
|
||||||
|
# Критические зависимости — без них скрипт не работает.
|
||||||
|
# flock используется bot_action_dispatch для сериализации параллельных
|
||||||
|
# вызовов (иначе гонка на config.json при одновременных change-template /
|
||||||
|
# change-lite-domain из бота).
|
||||||
|
local critical=(curl jq openssl git xxd tar dig flock)
|
||||||
|
# Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли
|
||||||
|
local optional=(qrencode bc)
|
||||||
|
|
||||||
|
local missing_critical=() missing_optional=() cmd
|
||||||
|
for cmd in "${critical[@]}"; do
|
||||||
|
command -v "$cmd" &>/dev/null || missing_critical+=("$cmd")
|
||||||
|
done
|
||||||
|
for cmd in "${optional[@]}"; do
|
||||||
|
command -v "$cmd" &>/dev/null || missing_optional+=("$cmd")
|
||||||
|
done
|
||||||
|
|
||||||
|
local all_missing=("${missing_critical[@]}" "${missing_optional[@]}")
|
||||||
|
[ ${#all_missing[@]} -eq 0 ] && return 0
|
||||||
|
|
||||||
|
# Собираем список пакетов для выбранного менеджера
|
||||||
|
local pkg_mgr pkg pkgs=()
|
||||||
|
pkg_mgr=$(get_pkg_manager)
|
||||||
|
|
||||||
|
for cmd in "${all_missing[@]}"; do
|
||||||
|
case "$pkg_mgr" in
|
||||||
|
apt) pkg=$(apt_pkg_for_cmd "$cmd") ;;
|
||||||
|
dnf|yum) pkg=$(dnf_pkg_for_cmd "$cmd") ;;
|
||||||
|
*) pkg="$cmd" ;;
|
||||||
|
esac
|
||||||
|
pkgs+=("$pkg")
|
||||||
|
done
|
||||||
|
|
||||||
|
# Убираем дубликаты (например dig+nslookup оба = dnsutils)
|
||||||
|
local uniq_pkgs=()
|
||||||
|
for pkg in "${pkgs[@]}"; do
|
||||||
|
local found=0 p
|
||||||
|
for p in "${uniq_pkgs[@]}"; do
|
||||||
|
[ "$p" = "$pkg" ] && { found=1; break; }
|
||||||
|
done
|
||||||
|
[ "$found" = "0" ] && uniq_pkgs+=("$pkg")
|
||||||
|
done
|
||||||
|
|
||||||
|
if type tf &>/dev/null; then
|
||||||
|
log_step "$(tf deps_installing "${all_missing[*]}")"
|
||||||
|
else
|
||||||
|
log_step "Installing dependencies: ${all_missing[*]} (packages: ${uniq_pkgs[*]})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$pkg_mgr" in
|
||||||
|
apt)
|
||||||
|
apt_update
|
||||||
|
apt_install "${uniq_pkgs[@]}" || true
|
||||||
|
;;
|
||||||
|
dnf) dnf install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;;
|
||||||
|
yum) yum install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown package manager — install manually: ${uniq_pkgs[*]}"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Фолбэки для xxd: на некоторых системах нужен vim-common вместо xxd
|
||||||
|
if ! command -v xxd &>/dev/null && [ "$pkg_mgr" = "apt" ]; then
|
||||||
|
apt_install vim-common || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Повторная проверка критических команд
|
||||||
|
local still_missing=()
|
||||||
|
for cmd in "${critical[@]}"; do
|
||||||
|
command -v "$cmd" &>/dev/null || still_missing+=("$cmd")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#still_missing[@]} -gt 0 ]; then
|
||||||
|
log_error "Critical dependencies still missing: ${still_missing[*]}"
|
||||||
|
log_error "Install manually and re-run gotelegram"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Опциональные — только предупреждение
|
||||||
|
local still_missing_opt=()
|
||||||
|
for cmd in "${optional[@]}"; do
|
||||||
|
command -v "$cmd" &>/dev/null || still_missing_opt+=("$cmd")
|
||||||
|
done
|
||||||
|
if [ ${#still_missing_opt[@]} -gt 0 ]; then
|
||||||
|
log_warning "Optional deps missing (features degraded): ${still_missing_opt[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Dependencies ready"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Быстрая проверка — только смотрит что критические установлены, ничего не ставит.
|
||||||
|
# Возвращает 0 если всё ок, 1 если что-то отсутствует. Используется на старте
|
||||||
|
# main() чтобы не дёргать apt-get update при каждом запуске меню.
|
||||||
|
check_deps_present() {
|
||||||
|
local cmd
|
||||||
|
for cmd in curl jq openssl git xxd tar dig flock; do
|
||||||
|
command -v "$cmd" &>/dev/null || return 1
|
||||||
|
done
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
check_port() {
|
check_port() {
|
||||||
|
|||||||
413
lib/stats.sh
Normal file
413
lib/stats.sh
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# stats.sh — Traffic statistics module for GoTelegram
|
||||||
|
# Tracks proxy (telemt port 443) and site (nginx port 8443) traffic
|
||||||
|
# Uses iptables counters + real-time snapshots + historical CSV
|
||||||
|
|
||||||
|
# Color codes (from common.sh)
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
STATS_DIR="/run/gotelegram"
|
||||||
|
HISTORY_FILE="/opt/gotelegram/stats_history.csv"
|
||||||
|
SNAPSHOTS_DIR="$STATS_DIR/snapshots"
|
||||||
|
CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json"
|
||||||
|
CONFIG_FILE="/opt/gotelegram/config.json"
|
||||||
|
|
||||||
|
# Initialize stats infrastructure
|
||||||
|
stats_init() {
|
||||||
|
# Create runtime directory
|
||||||
|
mkdir -p "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
|
||||||
|
chmod 755 "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
|
||||||
|
|
||||||
|
# Create iptables chain if not exists
|
||||||
|
if ! iptables -L GOTELEGRAM_STATS -n >/dev/null 2>&1; then
|
||||||
|
iptables -N GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add chain to INPUT if not already present
|
||||||
|
if ! iptables -C INPUT -j GOTELEGRAM_STATS 2>/dev/null; then
|
||||||
|
iptables -I INPUT -j GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add rule for proxy traffic (port 443, TCP)
|
||||||
|
if ! iptables -C GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null; then
|
||||||
|
iptables -A GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add rule for site traffic (loopback, port 8443, TCP)
|
||||||
|
if ! iptables -C GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null; then
|
||||||
|
iptables -A GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize CSV header if file doesn't exist
|
||||||
|
if [[ ! -f "$HISTORY_FILE" ]]; then
|
||||||
|
echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write initial snapshot
|
||||||
|
stats_collect
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect current traffic statistics from iptables
|
||||||
|
stats_collect() {
|
||||||
|
local proxy_bytes=0 proxy_pkts=0 site_bytes=0 site_pkts=0
|
||||||
|
local ts=$(date +%s)
|
||||||
|
local temp_file=$(mktemp)
|
||||||
|
|
||||||
|
# Parse iptables output: format is "pkts bytes target"
|
||||||
|
# We need to extract bytes (2nd column) for each rule
|
||||||
|
local iptables_output=$(iptables -L GOTELEGRAM_STATS -v -n -x 2>/dev/null)
|
||||||
|
|
||||||
|
# Extract counters for port 443 (proxy)
|
||||||
|
proxy_bytes=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $2}')
|
||||||
|
proxy_pkts=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $1}')
|
||||||
|
|
||||||
|
# Extract counters for port 8443 on loopback (site)
|
||||||
|
site_bytes=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $2}')
|
||||||
|
site_pkts=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $1}')
|
||||||
|
|
||||||
|
# Default to 0 if not found
|
||||||
|
proxy_bytes=${proxy_bytes:-0}
|
||||||
|
proxy_pkts=${proxy_pkts:-0}
|
||||||
|
site_bytes=${site_bytes:-0}
|
||||||
|
site_pkts=${site_pkts:-0}
|
||||||
|
|
||||||
|
# Write current snapshot as JSON
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
echo "{\"ts\":$ts,\"proxy_bytes\":$proxy_bytes,\"proxy_pkts\":$proxy_pkts,\"site_bytes\":$site_bytes,\"site_pkts\":$site_pkts}" > "$CURRENT_SNAPSHOT" 2>/dev/null
|
||||||
|
else
|
||||||
|
cat > "$CURRENT_SNAPSHOT" 2>/dev/null <<EOF
|
||||||
|
{"ts":$ts,"proxy_bytes":$proxy_bytes,"proxy_pkts":$proxy_pkts,"site_bytes":$site_bytes,"site_pkts":$site_pkts}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save snapshot for rate calculation (one per minute)
|
||||||
|
local minute_key
|
||||||
|
minute_key=$(date +%Y%m%d%H%M 2>/dev/null)
|
||||||
|
local snapshot_file="$SNAPSHOTS_DIR/snap_${minute_key}.json"
|
||||||
|
cp "$CURRENT_SNAPSHOT" "$snapshot_file" 2>/dev/null
|
||||||
|
|
||||||
|
# Append to history CSV (once per minute, check if last entry is fresh)
|
||||||
|
# Auto-recreate the file with header if it was deleted — otherwise the
|
||||||
|
# collector would silently stop writing history after any wipe (v2.4.1 fix).
|
||||||
|
if [[ ! -f "$HISTORY_FILE" ]]; then
|
||||||
|
mkdir -p "$(dirname "$HISTORY_FILE")" 2>/dev/null
|
||||||
|
echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$HISTORY_FILE" ]]; then
|
||||||
|
local last_ts
|
||||||
|
last_ts=$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f1)
|
||||||
|
last_ts="${last_ts:-0}"
|
||||||
|
local current_minute=$((ts - (ts % 60)))
|
||||||
|
|
||||||
|
if [[ "$last_ts" -eq 0 ]] || [[ $((current_minute - last_ts)) -ge 60 ]]; then
|
||||||
|
echo "$current_minute,$proxy_bytes,$site_bytes" >> "$HISTORY_FILE" 2>/dev/null
|
||||||
|
|
||||||
|
# Cleanup old entries (keep only 365 days)
|
||||||
|
stats_cleanup_history
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$temp_file" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read current snapshot as JSON
|
||||||
|
stats_read_current() {
|
||||||
|
if [[ -f "$CURRENT_SNAPSHOT" ]]; then
|
||||||
|
cat "$CURRENT_SNAPSHOT"
|
||||||
|
else
|
||||||
|
echo "{}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract value from JSON (fallback if jq not available)
|
||||||
|
json_get() {
|
||||||
|
local json="$1"
|
||||||
|
local key="$2"
|
||||||
|
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
echo "$json" | jq -r ".${key}" 2>/dev/null || echo "0"
|
||||||
|
else
|
||||||
|
echo "$json" | grep -o "\"$key\":[^,}]*" | cut -d: -f2 | tr -d ' "' || echo "0"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert bytes to human-readable format
|
||||||
|
format_bytes() {
|
||||||
|
local bytes=$1
|
||||||
|
|
||||||
|
if (( bytes < 1024 )); then
|
||||||
|
printf "%.0f B" "$bytes"
|
||||||
|
elif (( bytes < 1024 * 1024 )); then
|
||||||
|
printf "%.1f KB" "$(echo "scale=1; $bytes / 1024" | bc 2>/dev/null || echo "$((bytes / 1024))")"
|
||||||
|
elif (( bytes < 1024 * 1024 * 1024 )); then
|
||||||
|
printf "%.1f MB" "$(echo "scale=1; $bytes / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024))")"
|
||||||
|
elif (( bytes < 1024 * 1024 * 1024 * 1024 )); then
|
||||||
|
printf "%.1f GB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024))")"
|
||||||
|
else
|
||||||
|
printf "%.1f TB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024 / 1024))")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert bytes/sec to human-readable rate
|
||||||
|
format_rate() {
|
||||||
|
local bytes_per_sec=$1
|
||||||
|
|
||||||
|
if (( bytes_per_sec < 1024 )); then
|
||||||
|
printf "%.0f B/s" "$bytes_per_sec"
|
||||||
|
elif (( bytes_per_sec < 1024 * 1024 )); then
|
||||||
|
printf "%.1f KB/s" "$(echo "scale=1; $bytes_per_sec / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024))")"
|
||||||
|
elif (( bytes_per_sec < 1024 * 1024 * 1024 )); then
|
||||||
|
printf "%.1f MB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024))")"
|
||||||
|
else
|
||||||
|
printf "%.1f GB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024 / 1024))")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Safely convert value to integer (returns 0 for empty/non-numeric)
|
||||||
|
_to_int() {
|
||||||
|
local val="${1:-0}"
|
||||||
|
# Strip non-numeric chars, default to 0
|
||||||
|
val="${val//[^0-9]/}"
|
||||||
|
echo "${val:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate diff safely (never negative, never crashes on empty)
|
||||||
|
_safe_diff() {
|
||||||
|
local a=$(_to_int "$1")
|
||||||
|
local b=$(_to_int "$2")
|
||||||
|
local d=$((a - b))
|
||||||
|
(( d < 0 )) && d=0
|
||||||
|
echo "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate traffic rates and totals from history
|
||||||
|
stats_calculate_rates() {
|
||||||
|
local traffic_type="$1" # "proxy" or "site"
|
||||||
|
local col_idx=2 # proxy_bytes is column 2
|
||||||
|
[[ "$traffic_type" == "site" ]] && col_idx=3
|
||||||
|
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
# Get latest data line (skip header with grep -E '^[0-9]')
|
||||||
|
local bytes_now
|
||||||
|
bytes_now=$(_to_int "$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
|
||||||
|
|
||||||
|
local periods="60 300 3600 86400 604800 2592000 31536000"
|
||||||
|
local results=""
|
||||||
|
|
||||||
|
for secs in $periods; do
|
||||||
|
local target_ts=$((now - secs))
|
||||||
|
# Find closest entry at or after target timestamp (skip header)
|
||||||
|
local old_val
|
||||||
|
old_val=$(_to_int "$(awk -F, -v ts="$target_ts" '$1 ~ /^[0-9]/ && $1 <= ts' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
|
||||||
|
|
||||||
|
local diff
|
||||||
|
diff=$(_safe_diff "$bytes_now" "$old_val")
|
||||||
|
local rate=$(( secs > 0 ? diff / secs : 0 ))
|
||||||
|
|
||||||
|
local bytes_fmt rate_fmt
|
||||||
|
bytes_fmt=$(format_bytes "$diff")
|
||||||
|
rate_fmt=$(format_rate "$rate")
|
||||||
|
|
||||||
|
if [ -z "$results" ]; then
|
||||||
|
results="${bytes_fmt}|${rate_fmt}"
|
||||||
|
else
|
||||||
|
results="${results}|${bytes_fmt}|${rate_fmt}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$results"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main display function for traffic statistics
|
||||||
|
show_traffic_stats() {
|
||||||
|
# Ensure stats are collected
|
||||||
|
stats_collect
|
||||||
|
|
||||||
|
# Get current counters
|
||||||
|
local current_json=$(stats_read_current)
|
||||||
|
local proxy_pkts=$(json_get "$current_json" "proxy_pkts")
|
||||||
|
local site_pkts=$(json_get "$current_json" "site_pkts")
|
||||||
|
|
||||||
|
# Calculate rates for proxy
|
||||||
|
local proxy_rates=$(stats_calculate_rates "proxy")
|
||||||
|
IFS='|' read -r p1m p1mr p5m p5mr p60m p60mr p1d p1dr p7d p7dr p30d p30dr p365d p365dr <<< "$proxy_rates"
|
||||||
|
|
||||||
|
# Calculate rates for site
|
||||||
|
local site_rates=$(stats_calculate_rates "site")
|
||||||
|
IFS='|' read -r s1m s1mr s5m s5mr s60m s60mr s1d s1dr s7d s7dr s30d s30dr s365d s365dr <<< "$site_rates"
|
||||||
|
|
||||||
|
# Display proxy stats
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE} Proxy (telemt, порт 443):${NC}"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
echo -e "${BLUE} Период │ Входящий │ Скорость${NC}"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "1 мин" "$p1m" "$p1mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "5 мин" "$p5m" "$p5mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "60 мин" "$p60m" "$p60mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "1 день" "$p1d" "$p1dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "7 дней" "$p7d" "$p7dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "30 дней" "$p30d" "$p30dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "365 дней" "$p365d" "$p365dr"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
printf " Пакетов: %d\n\n" "$proxy_pkts"
|
||||||
|
|
||||||
|
echo -e "${BLUE} Сайт (nginx, порт 8443):${NC}"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
echo -e "${BLUE} Период │ Входящий │ Скорость${NC}"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "1 мин" "$s1m" "$s1mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "5 мин" "$s5m" "$s5mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "60 мин" "$s60m" "$s60mr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "1 день" "$s1d" "$s1dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "7 дней" "$s7d" "$s7dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "30 дней" "$s30d" "$s30dr"
|
||||||
|
printf " %-9s │ %14s │ %s\n" "365 дней" "$s365d" "$s365dr"
|
||||||
|
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
|
||||||
|
printf " Пакетов: %d\n" "$site_pkts"
|
||||||
|
echo ""
|
||||||
|
} >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up history older than 365 days
|
||||||
|
stats_cleanup_history() {
|
||||||
|
if [[ ! -f "$HISTORY_FILE" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local now=$(date +%s)
|
||||||
|
local ts_365d=$((now - 31536000))
|
||||||
|
local temp_file=$(mktemp)
|
||||||
|
|
||||||
|
# Keep header + entries from last 365 days
|
||||||
|
{
|
||||||
|
head -1 "$HISTORY_FILE"
|
||||||
|
awk -F, -v ts="$ts_365d" '$1 >= ts' "$HISTORY_FILE" | tail -n +2
|
||||||
|
} > "$temp_file" 2>/dev/null
|
||||||
|
|
||||||
|
mv "$temp_file" "$HISTORY_FILE" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Toggle stats collection on/off
|
||||||
|
toggle_stats() {
|
||||||
|
local current_state="false"
|
||||||
|
|
||||||
|
# Read current state from config
|
||||||
|
if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
|
||||||
|
current_state=$(jq -r '.stats_enabled // false' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Toggle
|
||||||
|
if [[ "$current_state" == "true" ]]; then
|
||||||
|
# Disable stats
|
||||||
|
if [[ -f "$CONFIG_FILE" ]]; then
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
jq '.stats_enabled = false' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
|
||||||
|
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove iptables rules
|
||||||
|
iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
iptables -F GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
iptables -X GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
|
||||||
|
# Clean up directories
|
||||||
|
rm -rf "$STATS_DIR" 2>/dev/null
|
||||||
|
|
||||||
|
echo "Сбор статистики ОТКЛЮЧЕН" >&2
|
||||||
|
else
|
||||||
|
# Enable stats
|
||||||
|
if [[ -f "$CONFIG_FILE" ]]; then
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
jq '.stats_enabled = true' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
|
||||||
|
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize stats collection
|
||||||
|
stats_init
|
||||||
|
|
||||||
|
echo "Сбор статистики ВКЛЮЧЕН" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install systemd service for stats collection
|
||||||
|
install_stats_collector() {
|
||||||
|
local service_file="/etc/systemd/system/gotelegram-stats.service"
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Требуется root для установки сервиса" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get script directory (resolve symlinks)
|
||||||
|
local script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
|
||||||
|
local lib_dir=$(dirname "$script_dir")
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
cat > "$service_file" <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=GoTelegram Traffic Stats Collector
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
ExecStart=/bin/bash -c 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/stats.sh; stats_init; while true; do stats_collect; sleep 1; done'
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$service_file"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable gotelegram-stats.service
|
||||||
|
systemctl start gotelegram-stats.service
|
||||||
|
|
||||||
|
echo "Сервис gotelegram-stats установлен и запущен" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove stats collector service
|
||||||
|
remove_stats_collector() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Требуется root для удаления сервиса" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl stop gotelegram-stats.service 2>/dev/null
|
||||||
|
systemctl disable gotelegram-stats.service 2>/dev/null
|
||||||
|
rm -f /etc/systemd/system/gotelegram-stats.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Remove iptables rules
|
||||||
|
iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
iptables -F GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
iptables -X GOTELEGRAM_STATS 2>/dev/null
|
||||||
|
|
||||||
|
# Clean up directories and files
|
||||||
|
rm -rf "$STATS_DIR" 2>/dev/null
|
||||||
|
rm -f "$HISTORY_FILE" 2>/dev/null
|
||||||
|
|
||||||
|
echo "Сервис статистики удалён" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export functions for external use
|
||||||
|
export -f stats_init stats_collect stats_read_current stats_calculate_rates
|
||||||
|
export -f show_traffic_stats format_bytes format_rate toggle_stats
|
||||||
|
export -f stats_cleanup_history install_stats_collector remove_stats_collector
|
||||||
|
export -f json_get
|
||||||
@@ -9,9 +9,9 @@ install_nginx() {
|
|||||||
fi
|
fi
|
||||||
log_info "Установка nginx..."
|
log_info "Установка nginx..."
|
||||||
case "$(get_pkg_manager)" in
|
case "$(get_pkg_manager)" in
|
||||||
apt) apt-get update -qq && apt-get install -y -qq nginx ;;
|
apt) apt_update && apt_install nginx || return 1 ;;
|
||||||
dnf) dnf install -y -q nginx ;;
|
dnf) dnf install -y -q nginx || return 1 ;;
|
||||||
yum) yum install -y -q nginx ;;
|
yum) yum install -y -q nginx || return 1 ;;
|
||||||
esac
|
esac
|
||||||
systemctl enable nginx 2>/dev/null
|
systemctl enable nginx 2>/dev/null
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,9 @@ install_certbot() {
|
|||||||
fi
|
fi
|
||||||
log_info "Установка certbot..."
|
log_info "Установка certbot..."
|
||||||
case "$(get_pkg_manager)" in
|
case "$(get_pkg_manager)" in
|
||||||
apt) apt-get install -y -qq certbot python3-certbot-nginx ;;
|
apt) apt_install certbot python3-certbot-nginx || return 1 ;;
|
||||||
dnf) dnf install -y -q certbot python3-certbot-nginx ;;
|
dnf) dnf install -y -q certbot python3-certbot-nginx || return 1 ;;
|
||||||
yum) yum install -y -q certbot python3-certbot-nginx ;;
|
yum) yum install -y -q certbot python3-certbot-nginx || return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user