v2.5.0: add shared 443 and per-user traffic

This commit is contained in:
Виталий Литвинов
2026-04-25 14:07:47 +03:00
parent c1b5ffc5a7
commit 63b564f70f
12 changed files with 990 additions and 34 deletions

View File

@@ -449,13 +449,24 @@ switch_language ru|en
- write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. - write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически.
- язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `POST /api/settings/language` сохраняет RU/EN в общий конфиг, marker file и bot `.env`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override; - язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `POST /api/settings/language` сохраняет RU/EN в общий конфиг, marker file и bot `.env`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override;
- UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, полезный overview-блок по реальным TCP/UDP-слушателям 443, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`; - UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, полезный overview-блок по реальным TCP/UDP-слушателям 443, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`;
- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status` и `port_443`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. - `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status` и `port_443`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/users/<name>/traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`.
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/enabled`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/enabled`, генерация proxy links, общий traffic history из `/opt/gotelegram/stats_history.csv`, per-user traffic history из `/opt/gotelegram/user_stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`).
### 13.1.1 Shared TCP/443 with 3x-ui/Xray
`lib/shared443.sh` добавляет управляемую схему shared-443 через nginx stream `ssl_preread`:
- публичный вход: `0.0.0.0:443` принадлежит nginx stream dispatcher;
- default backend: `127.0.0.1:7443` (`telemt`), а `general.links.public_port` остаётся `443`;
- сайт остаётся за telemt через `dns_overrides` на `127.0.0.1:8443`;
- Xray/3x-ui должен быть перенесён в панели на внутренний target, например `127.0.0.1:9443`, после чего `shared443_enable <domain> <xray-sni> 127.0.0.1:9443` пишет `/opt/gotelegram/shared-443.json` и `/etc/nginx/stream-conf.d/gotelegram-shared443.conf`.
Автоматически переписывать SQLite/JSON 3x-ui нельзя: панель может перегенерировать Xray config и потерять ручные правки. Поэтому goTelegram Pro показывает конфликт прямого bind на `443`, даёт маршрут и включает dispatcher только на уровне собственных конфигов/nginx.
Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами в web-admin и Telegram-боте берут общий file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace и quoted keys (`"a.b"`), а telemt restart для add/delete/enable/disable ставится через `systemctl --no-block restart`, чтобы switch в UI не зависал на `wait_tcp_port`. Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами в web-admin и Telegram-боте берут общий file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace и quoted keys (`"a.b"`), а telemt restart для add/delete/enable/disable ставится через `systemctl --no-block restart`, чтобы switch в UI не зависал на `wait_tcp_port`.
`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.4 сохраняет `admin_web/server.py`, `admin_web/static/` и `disabled_users.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. `install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.5 сохраняет `admin_web/server.py`, `admin_web/static/`, `disabled_users.json`, `stats_history.csv`, `user_stats_history.csv` и `shared-443.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`.
### 13.2 Upgrade migration (v2.5.0) ### 13.2 Upgrade migration (v2.5.0)
@@ -631,7 +642,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s:
## 17. Changelog ## 17. Changelog
- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, блоком реальных TCP/UDP-слушателей 443, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом, быстрыми switch-переключателями ключей, traffic history 15m/1h/24h/month с переключением график/строки и stats collector restart endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.4` сохраняет bot `.env`, bot lang files, disabled user keys, web-admin server/static, custom templates, templates catalog, stats history и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и генерируется `/opt/gotelegram/shared-443-3xui.md` с объяснением shared-443 ограничений. - **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, блоком реальных TCP/UDP-слушателей 443, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом, быстрыми switch-переключателями ключей, traffic history 15m/1h/24h/month с переключением график/строки, per-user traffic history из `telemt total_octets` и stats collector restart endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.5` сохраняет bot `.env`, bot lang files, disabled user keys, web-admin server/static, custom templates, templates catalog, stats history, user stats history, shared-443 config и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher.
- **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS. - **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS.
- **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`.
- **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`.

View File

@@ -145,7 +145,20 @@ CLI и бот переведены на русский и английский.
- есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile;
- Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`.
В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, кнопка разового обновления статистики, кнопка перезапуска сборщика, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список бекапов и просмотр логов с количеством строк и статусом `journalctl`.
## 8.1 3x-ui / VLESS на том же 443
Один порт `443` не могут одновременно слушать `telemt` и Xray напрямую. Для совместной работы используется схема shared-443: публичный `443` занимает nginx stream-диспетчер, goTelegram `telemt` переносится на `127.0.0.1:7443`, сайт остаётся на `127.0.0.1:8443`, а inbound 3x-ui/Xray нужно в панели перенести на внутренний адрес, например `127.0.0.1:9443`.
После переноса Xray-входа можно включить маршрут:
```bash
source /opt/gotelegram/lib/shared443.sh
shared443_enable my-domain.com xray-domain.com 127.0.0.1:9443
```
goTelegram Pro не переписывает базу 3x-ui автоматически, потому что панель может перегенерировать Xray-конфиг. Админка показывает карту `443`: публичный edge, telemt, сайт и Xray-маршруты.
Отключённые ключи убираются из активного telemt-конфига и сохраняются в `/opt/gotelegram/disabled_users.json`, поэтому их можно включить обратно без потери secret. Основной ключ `main` защищён от удаления и отключения. Отключённые ключи убираются из активного telemt-конфига и сохраняются в `/opt/gotelegram/disabled_users.json`, поэтому их можно включить обратно без потери secret. Основной ключ `main` защищён от удаления и отключения.
@@ -182,7 +195,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст
- **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот). - **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот).
- **Диск:** 2 ГБ (в основном под каталог шаблонов и бекапы). - **Диск:** 2 ГБ (в основном под каталог шаблонов и бекапы).
- **Права:** root или sudo. - **Права:** root или sudo.
- **Порты:** 443 должен быть свободен (ни apache, ни nginx, ни ничего другого не должно на нём висеть). Если занят — скрипт предупредит. - **Порты:** в обычной схеме 443 должен быть свободен. Для совместной работы с 3x-ui/Xray используйте shared-443 и переносите Xray inbound на внутренний порт, например `127.0.0.1:9443`.
- **Для Pro-режима:** домен с настроенным A-record на IP VPS. DNS должен отвечать ДО установки, иначе Let's Encrypt не выдаст сертификат. - **Для Pro-режима:** домен с настроенным A-record на IP VPS. DNS должен отвечать ДО установки, иначе Let's Encrypt не выдаст сертификат.
--- ---
@@ -202,7 +215,7 @@ A: Пункт 7 → сменить режим/шаблон. Можно такж
A: Посмотри логи бота в пункте 12 → «Логи бота». Чаще всего — неверный токен или неверный admin ID в `.env`. A: Посмотри логи бота в пункте 12 → «Логи бота». Чаще всего — неверный токен или неверный admin ID в `.env`.
**Q: Могу ли я поставить несколько прокси на одном VPS?** **Q: Могу ли я поставить несколько прокси на одном VPS?**
A: На одном IP на порту 443 — нет, telemt один. На разных портах — можно, но скрипт этого не поддерживает из коробки, нужно руками. A: Да, через shared-443: nginx stream слушает публичный `443`, goTelegram `telemt` работает на `127.0.0.1:7443`, а Xray/3x-ui — на внутреннем порту вроде `127.0.0.1:9443`. Напрямую два процесса на `0.0.0.0:443` работать не будут.
**Q: Это легально?** **Q: Это легально?**
A: Сам MTProxy — да, это публичная технология из исходников Telegram. Запуск прокси, чтобы твои друзья могли пользоваться Telegram там, где он заблокирован — в большинстве юрисдикций легально. Проверь локальные законы. A: Сам MTProxy — да, это публичная технология из исходников Telegram. Запуск прокси, чтобы твои друзья могли пользоваться Telegram там, где он заблокирован — в большинстве юрисдикций легально. Проверь локальные законы.
@@ -240,7 +253,7 @@ A: Сам MTProxy — да, это публичная технология из
## Changelog (коротко) ## Changelog (коротко)
- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка и информация из API telemt; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, блоком реальных TCP/UDP-слушателей 443, подсказками к техническим терминам и traffic history по периодам 15 минут / 1 час / 24 часа / месяц; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 с предупреждением и заметкой по shared-443. - **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка, текущий runtime и история трафика по ключу; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, блоком реальных TCP/UDP-слушателей 443, подсказками к техническим терминам, traffic history по периодам 15 минут / 1 час / 24 часа / месяц и per-user traffic history; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history, user stats history, shared-443 config и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher.
- **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades. - **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades.
- **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости.
- **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess.

View File

@@ -34,12 +34,14 @@ STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static")
GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json")) GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json"))
TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml")) TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml"))
HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv")) HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv"))
USER_HISTORY_FILE = Path(os.getenv("GOTELEGRAM_USER_STATS_HISTORY", "/opt/gotelegram/user_stats_history.csv"))
CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json")) CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json"))
BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups")) BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups"))
INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram")) INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram"))
BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot")) BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot"))
DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json")) DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json"))
USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock")) USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock"))
SHARED_443_CONFIG = Path(os.getenv("GOTELEGRAM_SHARED_443", "/opt/gotelegram/shared-443.json"))
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
@@ -421,14 +423,81 @@ def read_telemt_edge_settings() -> dict[str, Any]:
return settings return settings
def load_shared443_config() -> dict[str, Any]:
raw = load_json(SHARED_443_CONFIG, {}) or {}
if not isinstance(raw, dict):
return {}
routes = raw.get("xray_routes") if isinstance(raw.get("xray_routes"), list) else []
clean_routes = []
for item in routes:
if not isinstance(item, dict):
continue
public = str(item.get("public") or item.get("domain") or "").strip()
target = str(item.get("target") or "").strip()
if public and target:
clean_routes.append({"public": public, "target": target})
return {
"enabled": bool(raw.get("enabled")),
"dispatcher": str(raw.get("dispatcher") or "nginx-stream"),
"public_port": _int_value(raw.get("public_port") or 443) or 443,
"telemt_target": str(raw.get("telemt_target") or "127.0.0.1:7443"),
"site_target": str(raw.get("site_target") or ""),
"xray_routes": clean_routes,
"updated_at": str(raw.get("updated_at") or ""),
}
def listener_for_target(target: str) -> dict[str, Any] | None:
try:
port = int(target.rsplit(":", 1)[-1])
except ValueError:
return None
listeners, _ = collect_port_listeners(port)
return listeners[0] if listeners else None
def routed_behind_443() -> list[dict[str, Any]]: def routed_behind_443() -> list[dict[str, Any]]:
config = load_json(GOTELEGRAM_CONFIG, {}) or {} config = load_json(GOTELEGRAM_CONFIG, {}) or {}
mode = str(config.get("mode") or "") mode = str(config.get("mode") or "")
domain = str(config.get("domain") or "") domain = str(config.get("domain") or "")
settings = read_telemt_edge_settings() settings = read_telemt_edge_settings()
shared = load_shared443_config()
mask_port = int(settings.get("mask_port") or 0) mask_port = int(settings.get("mask_port") or 0)
tls_domain = str(settings.get("tls_domain") or domain) tls_domain = str(settings.get("tls_domain") or domain)
routes: list[dict[str, Any]] = [] routes: list[dict[str, Any]] = []
if shared.get("enabled"):
telemt_target = str(shared.get("telemt_target") or "127.0.0.1:7443")
telemt_listener = listener_for_target(telemt_target)
routes.append({
"role": "mtproxy",
"proto": "MTProxy",
"public": f"{domain or tls_domain or 'default'}:443",
"target": telemt_target,
"process": (telemt_listener or {}).get("process") or "telemt",
"pid": (telemt_listener or {}).get("pid") or "",
"status": service_status("telemt"),
"via": "nginx stream ssl_preread",
"tls_domain": tls_domain,
"details": ["default -> telemt"] if not shared.get("xray_routes") else [],
})
for item in shared.get("xray_routes", []):
target = item.get("target", "")
listener = listener_for_target(target)
public = item.get("public", "")
if public and ":" not in public:
public = f"{public}:443"
routes.append({
"role": "xray",
"proto": "VLESS",
"public": public or "xray:443",
"target": target,
"process": (listener or {}).get("process") or "xray",
"pid": (listener or {}).get("pid") or "",
"status": "running" if listener else "not_installed",
"via": "nginx stream ssl_preread",
"tls_domain": public.split(":", 1)[0] if public else "",
"details": [],
})
if mode == "pro" and domain and mask_port and mask_port != 443: if mode == "pro" and domain and mask_port and mask_port != 443:
internal, _ = collect_port_listeners(mask_port) internal, _ = collect_port_listeners(mask_port)
site_listener = next((item for item in internal if item.get("role") == "site"), None) site_listener = next((item for item in internal if item.get("role") == "site"), None)
@@ -449,11 +518,18 @@ def routed_behind_443() -> list[dict[str, Any]]:
def port_443_status() -> dict[str, Any]: def port_443_status() -> dict[str, Any]:
listeners, errors = collect_port_listeners(443) listeners, errors = collect_port_listeners(443)
shared = load_shared443_config()
if shared.get("enabled"):
for item in listeners:
if item.get("role") == "site" and "nginx" in str(item.get("process", "")).lower():
item["role"] = "edge"
item["details"] = "nginx stream ssl_preread"
return { return {
"checked_at": int(time.time()), "checked_at": int(time.time()),
"configured_port": read_telemt_port(), "configured_port": read_telemt_port(),
"listeners": listeners, "listeners": listeners,
"routes": routed_behind_443(), "routes": routed_behind_443(),
"shared_443": shared,
"ok": not errors, "ok": not errors,
"error": "; ".join(errors[:2]), "error": "; ".join(errors[:2]),
} }
@@ -567,6 +643,78 @@ def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]:
return enriched return enriched
def _int_value(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def load_user_stats_history(name: str | None = None, limit: int | None = 240) -> list[dict[str, Any]]:
if not USER_HISTORY_FILE.exists():
return []
rows: list[dict[str, Any]] = []
try:
with USER_HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh:
for row in csv.DictReader(fh):
user = str(row.get("user") or "").strip()
if name is not None and user != name:
continue
if not USER_RE.match(user):
continue
rows.append({
"epoch": _int_value(row.get("epoch")),
"user": user,
"total_octets": _int_value(row.get("total_octets")),
"current_connections": _int_value(row.get("current_connections")),
"active_unique_ips": _int_value(row.get("active_unique_ips")),
"recent_unique_ips": _int_value(row.get("recent_unique_ips")),
})
except OSError:
return []
rows.sort(key=lambda item: (item["user"], item["epoch"]))
if limit and name is not None:
rows = rows[-limit:]
previous_by_user: dict[str, dict[str, Any]] = {}
enriched: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
previous = previous_by_user.get(row["user"])
item["total_delta"] = max(0, row["total_octets"] - previous["total_octets"]) if previous else 0
enriched.append(item)
previous_by_user[row["user"]] = row
if limit and name is None:
enriched = enriched[-limit:]
return enriched
def latest_user_stats() -> dict[str, dict[str, Any]]:
latest: dict[str, dict[str, Any]] = {}
for row in load_user_stats_history(limit=None):
if row["epoch"] >= latest.get(row["user"], {}).get("epoch", 0):
latest[row["user"]] = row
return latest
def runtime_user_traffic(name: str, enabled: bool = True) -> dict[str, Any]:
if not enabled:
return {"ok": False, "enabled": False, "total_octets": 0, "current_connections": 0, "active_unique_ips": 0, "recent_unique_ips": 0}
payload = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
data = payload.get("data", payload) if isinstance(payload, dict) else {}
if not isinstance(data, dict):
data = {}
return {
"ok": bool(payload),
"enabled": True,
"total_octets": _int_value(data.get("total_octets")),
"current_connections": _int_value(data.get("current_connections")),
"active_unique_ips": _int_value(data.get("active_unique_ips")),
"recent_unique_ips": _int_value(data.get("recent_unique_ips")),
"in_runtime": bool(data.get("in_runtime")) if data else False,
}
def history_limit_for_range(range_key: str) -> int: def history_limit_for_range(range_key: str) -> int:
return { return {
"15m": 180, "15m": 180,
@@ -617,6 +765,32 @@ def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any
return summaries return summaries
def user_traffic_interval_summaries(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not rows:
return [
{"range": key, "points": 0, "from": 0, "to": 0, "total_delta": 0, "total_octets": 0}
for key in TRAFFIC_WINDOWS
]
latest = max(row.get("epoch", 0) for row in rows)
summaries = []
for key, seconds in TRAFFIC_WINDOWS.items():
window = [row for row in rows if row.get("epoch", 0) >= latest - seconds]
if not window:
summaries.append({"range": key, "points": 0, "from": 0, "to": latest, "total_delta": 0, "total_octets": 0})
continue
first = window[0]
last = window[-1]
summaries.append({
"range": key,
"points": len(window),
"from": first.get("epoch", 0),
"to": last.get("epoch", 0),
"total_delta": sum(max(0, int(item.get("total_delta", 0))) for item in window),
"total_octets": int(last.get("total_octets", 0)),
})
return summaries
def count_history_rows() -> int: def count_history_rows() -> int:
if not HISTORY_FILE.exists(): if not HISTORY_FILE.exists():
return 0 return 0
@@ -627,6 +801,18 @@ def count_history_rows() -> int:
return 0 return 0
def count_user_history_rows(name: str | None = None) -> int:
if not USER_HISTORY_FILE.exists():
return 0
try:
with USER_HISTORY_FILE.open("r", encoding="utf-8", errors="ignore") as fh:
if name is None:
return sum(1 for line in fh if line and line[0].isdigit())
return sum(1 for line in fh if line.startswith(tuple(str(d) for d in range(10))) and f",{name}," in line)
except OSError:
return 0
def stats_status(current: dict[str, Any] | None = None, history: list[dict[str, int]] | None = None) -> dict[str, Any]: def stats_status(current: dict[str, Any] | None = None, history: list[dict[str, int]] | None = None) -> dict[str, Any]:
current = current if current is not None else (load_json(CURRENT_STATS, {}) or {}) current = current if current is not None else (load_json(CURRENT_STATS, {}) or {})
history = history if history is not None else load_stats_history(limit=2) history = history if history is not None else load_stats_history(limit=2)
@@ -740,7 +926,13 @@ def read_log_payload(service: str) -> dict[str, Any]:
} }
def user_payload(name: str, secret: str, enabled: bool = True, include_runtime: bool = False) -> dict[str, Any]: def user_payload(
name: str,
secret: str,
enabled: bool = True,
include_runtime: bool = False,
traffic_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any]:
item: dict[str, Any] = { item: dict[str, Any] = {
"name": name, "name": name,
"secret": secret, "secret": secret,
@@ -748,6 +940,14 @@ def user_payload(name: str, secret: str, enabled: bool = True, include_runtime:
"main": name == "main", "main": name == "main",
"enabled": bool(enabled), "enabled": bool(enabled),
} }
if traffic_snapshot:
item["traffic"] = {
"epoch": traffic_snapshot.get("epoch", 0),
"total_octets": traffic_snapshot.get("total_octets", 0),
"current_connections": traffic_snapshot.get("current_connections", 0),
"active_unique_ips": traffic_snapshot.get("active_unique_ips", 0),
"recent_unique_ips": traffic_snapshot.get("recent_unique_ips", 0),
}
if include_runtime and enabled: if include_runtime and enabled:
item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}") item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}")
return item return item
@@ -823,11 +1023,40 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_json({"ok": True, "data": overview_payload()}) self.send_json({"ok": True, "data": overview_payload()})
elif path == "/api/users": elif path == "/api/users":
users = read_user_records() users = read_user_records()
latest = latest_user_stats()
items = [] items = []
for name in sorted(users, key=lambda item: (item != "main", item)): for name in sorted(users, key=lambda item: (item != "main", item)):
record = users[name] record = users[name]
items.append(user_payload(name, record["secret"], record["enabled"])) items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
self.send_json({"ok": True, "data": items}) self.send_json({"ok": True, "data": items})
elif path.startswith("/api/users/") and path.endswith("/traffic"):
name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")])
users = read_user_records()
if name not in users:
self.send_error_json(404, "user not found")
return
qs = urllib.parse.parse_qs(parsed.query)
range_key = normalize_range(qs.get("range", ["1h"])[0])
all_history = load_user_stats_history(name, limit=history_limit_for_range("month"))
history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key)
current = runtime_user_traffic(name, bool(users[name].get("enabled")))
self.send_json({
"ok": True,
"data": {
"name": name,
"range": range_key,
"current": current,
"history": history,
"summary_rows": user_traffic_interval_summaries(all_history),
"status": {
"history_exists": USER_HISTORY_FILE.exists(),
"history_rows": count_user_history_rows(name),
"history_points": len(history),
"last_ts": history[-1]["epoch"] if history else 0,
"runtime_ok": current.get("ok", False),
},
},
})
elif path.startswith("/api/users/"): elif path.startswith("/api/users/"):
name = urllib.parse.unquote(path[len("/api/users/"):]) name = urllib.parse.unquote(path[len("/api/users/"):])
users = read_user_records() users = read_user_records()
@@ -835,7 +1064,7 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(404, "user not found") self.send_error_json(404, "user not found")
return return
record = users[name] record = users[name]
self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True)}) self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True, traffic_snapshot=latest_user_stats().get(name))})
elif path == "/api/backups": elif path == "/api/backups":
self.send_json({"ok": True, "data": list_backups()}) self.send_json({"ok": True, "data": list_backups()})
elif path == "/api/stats": elif path == "/api/stats":

View File

@@ -56,6 +56,9 @@ const i18n = {
tableUser: "User", tableUser: "User",
tableSecret: "Secret", tableSecret: "Secret",
tableLink: "Link", tableLink: "Link",
tableTraffic: "Traffic",
tableTrafficDelta: "Traffic delta",
tableTrafficTotal: "Total",
tableActions: "Actions", tableActions: "Actions",
userPlaceholder: "client-name", userPlaceholder: "client-name",
addKey: "Add key", addKey: "Add key",
@@ -81,6 +84,15 @@ const i18n = {
noHistory: "No traffic history yet", noHistory: "No traffic history yet",
noTrafficForRange: "No data for this range yet", noTrafficForRange: "No data for this range yet",
noRuntime: "Runtime data is not available", noRuntime: "Runtime data is not available",
userTrafficEyebrow: "Per user",
userTrafficTitle: "User traffic",
selectUserTraffic: "Select a key to see its traffic history",
openStats: "Stats",
trafficTotal: "Total",
currentConnections: "Connections",
activeIps: "Active IPs",
recentIps: "Recent IPs",
trafficRuntimeUnavailable: "Runtime unavailable",
badConnections: "Bad connections", badConnections: "Bad connections",
connections: "Connections", connections: "Connections",
uptime: "Uptime", uptime: "Uptime",
@@ -150,6 +162,7 @@ const i18n = {
port443NoRoutes: "No routed services detected", port443NoRoutes: "No routed services detected",
port443Via: "via {value}", port443Via: "via {value}",
roleMtproxy: "MTProxy", roleMtproxy: "MTProxy",
roleEdge: "443 Edge",
roleSite: "Website", roleSite: "Website",
roleXray: "Xray / 3x-ui", roleXray: "Xray / 3x-ui",
roleAmneziawg: "AmneziaWG", roleAmneziawg: "AmneziaWG",
@@ -243,6 +256,9 @@ const i18n = {
tableUser: "Пользователь", tableUser: "Пользователь",
tableSecret: "Секрет", tableSecret: "Секрет",
tableLink: "Ссылка", tableLink: "Ссылка",
tableTraffic: "Трафик",
tableTrafficDelta: "Прирост трафика",
tableTrafficTotal: "Всего",
tableActions: "Действия", tableActions: "Действия",
userPlaceholder: "client-name", userPlaceholder: "client-name",
addKey: "Добавить ключ", addKey: "Добавить ключ",
@@ -268,6 +284,15 @@ const i18n = {
noHistory: "Истории трафика пока нет", noHistory: "Истории трафика пока нет",
noTrafficForRange: "За этот период данных пока нет", noTrafficForRange: "За этот период данных пока нет",
noRuntime: "Данные среды выполнения недоступны", noRuntime: "Данные среды выполнения недоступны",
userTrafficEyebrow: "По пользователю",
userTrafficTitle: "Трафик ключа",
selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика",
openStats: "Статистика",
trafficTotal: "Всего",
currentConnections: "Подключения",
activeIps: "Активные IP",
recentIps: "Недавние IP",
trafficRuntimeUnavailable: "Runtime недоступен",
badConnections: "Ошибочные подключения", badConnections: "Ошибочные подключения",
connections: "Подключения", connections: "Подключения",
uptime: "Аптайм", uptime: "Аптайм",
@@ -337,6 +362,7 @@ const i18n = {
port443NoRoutes: "Маршрутизируемых сервисов не найдено", port443NoRoutes: "Маршрутизируемых сервисов не найдено",
port443Via: "через {value}", port443Via: "через {value}",
roleMtproxy: "MTProxy", roleMtproxy: "MTProxy",
roleEdge: "443 Edge",
roleSite: "Сайт", roleSite: "Сайт",
roleXray: "Xray / 3x-ui", roleXray: "Xray / 3x-ui",
roleAmneziawg: "AmneziaWG", roleAmneziawg: "AmneziaWG",
@@ -389,6 +415,11 @@ const state = {
trafficRange: "1h", trafficRange: "1h",
trafficView: "chart", trafficView: "chart",
trafficLoading: false, trafficLoading: false,
userTrafficUser: "",
userTrafficRange: "1h",
userTrafficView: "chart",
userTraffic: null,
userTrafficLoading: false,
pendingUsers: new Set(), pendingUsers: new Set(),
}; };
@@ -482,6 +513,7 @@ function applyI18n() {
$("#visualTitle").textContent = t("visualTitle"); $("#visualTitle").textContent = t("visualTitle");
$("#visualText").textContent = t("visualText"); $("#visualText").textContent = t("visualText");
updateTrafficControls(); updateTrafficControls();
updateUserTrafficControls();
updatePageTitle(); updatePageTitle();
} }
@@ -491,6 +523,7 @@ function setTheme(theme) {
localStorage.setItem("gotelegram-theme", state.theme); localStorage.setItem("gotelegram-theme", state.theme);
applyI18n(); applyI18n();
if (state.overview) renderStats(); if (state.overview) renderStats();
if (state.userTraffic) renderUserTraffic();
} }
async function setLanguage(lang) { async function setLanguage(lang) {
@@ -525,6 +558,10 @@ function setPage(page, push = true) {
} }
if (next === "traffic") { if (next === "traffic") {
refreshStats().catch((err) => toast(err.message)); refreshStats().catch((err) => toast(err.message));
} else if (next === "keys") {
ensureUserTrafficSelection();
renderUserTraffic();
if (state.userTrafficUser) refreshUserTraffic().catch((err) => toast(err.message));
} }
} }
@@ -736,6 +773,15 @@ function updateTrafficControls() {
}); });
} }
function updateUserTrafficControls() {
$$("[data-user-traffic-range]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.userTrafficRange === state.userTrafficRange);
});
$$("[data-user-traffic-view]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.userTrafficView === state.userTrafficView);
});
}
function trafficRangeLabel(range) { function trafficRangeLabel(range) {
const labels = { const labels = {
"15m": t("range15m"), "15m": t("range15m"),
@@ -886,14 +932,150 @@ function renderHistoryTable(rows) {
`).join(""); `).join("");
} }
function ensureUserTrafficSelection() {
if (state.userTrafficUser && state.users.some((user) => user.name === state.userTrafficUser)) return;
state.userTrafficUser = state.users[0]?.name || "";
}
function userTrafficRows() {
return state.userTraffic?.history || [];
}
function bucketUserTrafficRows(rows) {
const filtered = filterTrafficRows(rows, state.userTrafficRange);
if (filtered.length <= 140) return filtered;
const chunk = Math.ceil(filtered.length / 120);
const buckets = [];
for (let i = 0; i < filtered.length; i += chunk) {
const slice = filtered.slice(i, i + chunk);
const last = slice[slice.length - 1];
buckets.push({
epoch: last.epoch,
total_delta: slice.reduce((sum, item) => sum + (Number(item.total_delta) || 0), 0),
total_octets: last.total_octets,
current_connections: last.current_connections,
active_unique_ips: last.active_unique_ips,
});
}
return buckets;
}
function fallbackUserTrafficSummaries(rows) {
return trafficRanges.map((range) => {
const windowRows = filterTrafficRows(rows, range);
if (!windowRows.length) {
return { range, points: 0, total_delta: 0, total_octets: 0 };
}
const last = windowRows[windowRows.length - 1];
return {
range,
points: windowRows.length,
total_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.total_delta) || 0), 0),
total_octets: Number(last.total_octets) || 0,
};
});
}
function renderUserTrafficLoading() {
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("loading"))}</strong></div>`;
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("loading"))}</td></tr>`;
}
function drawUserTrafficChart(rows) {
const el = $("#userTrafficChart");
const points = bucketUserTrafficRows(rows);
const color = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb";
if (points.length < 2) {
el.innerHTML = `<div class="empty-chart">
<strong>${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))}</strong>
<span>${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))}</span>
</div>`;
return;
}
const width = 900;
const height = 260;
const pad = { l: 54, r: 22, t: 24, b: 42 };
const max = Math.max(1, ...points.map((p) => Number(p.total_delta) || 0));
const plotW = width - pad.l - pad.r;
const plotH = height - pad.t - pad.b;
const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1);
const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH;
const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p.total_delta).toFixed(1)}`).join(" ");
const grid = Array.from({ length: 5 }, (_, i) => {
const y = pad.t + (plotH / 4) * i;
return `<line x1="${pad.l}" y1="${y}" x2="${width - pad.r}" y2="${y}"></line>`;
}).join("");
const axis = t("chartMax").replace("{value}", fmtBytes(max));
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(t("ariaTrafficHistory"))}">
<g class="grid">${grid}</g>
<path class="area proxy-area" d="${path} L${width - pad.r},${height - pad.b} L${pad.l},${height - pad.b} Z"></path>
<path class="line proxy-line" d="${path}"></path>
<text x="${pad.l}" y="17" class="axis">${escapeHtml(axis)}</text>
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${color}">${escapeHtml(state.userTrafficUser || t("users"))}</text>
</svg>`;
}
function renderUserTrafficTable(rows) {
if (!rows.length) {
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#userTrafficTable").innerHTML = rows.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tablePeriod"))}"><strong>${escapeHtml(trafficRangeLabel(row.range))}</strong><small>${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))}</small></td>
<td data-label="${escapeAttr(t("tableTrafficDelta"))}">${escapeHtml(fmtBytes(row.total_delta))}</td>
<td data-label="${escapeAttr(t("tableTrafficTotal"))}">${escapeHtml(fmtBytes(row.total_octets))}</td>
</tr>
`).join("");
}
function renderUserTraffic() {
updateUserTrafficControls();
if (!state.userTrafficUser) {
$("#userTrafficTitle").textContent = t("userTrafficTitle");
$("#userTrafficHealth").className = "status-pill health-unknown";
$("#userTrafficHealth").textContent = "--";
$("#userTrafficTotal").textContent = "--";
$("#userTrafficConnections").textContent = "--";
$("#userTrafficIps").textContent = "--";
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("selectUserTraffic"))}</strong></div>`;
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("selectUserTraffic"))}</td></tr>`;
return;
}
$("#userTrafficTitle").textContent = `${t("userTrafficTitle")}: ${state.userTrafficUser}`;
if (state.userTrafficLoading) {
renderUserTrafficLoading();
return;
}
const payload = state.userTraffic || {};
const current = payload.current || {};
const rows = userTrafficRows();
const last = rows[rows.length - 1] || {};
const total = Number(current.total_octets) || Number(last.total_octets) || 0;
$("#userTrafficHealth").className = `status-pill ${current.enabled === false ? "health-stopped" : (current.ok ? "health-ok" : "health-stale")}`;
$("#userTrafficHealth").textContent = current.enabled === false ? t("disabled") : (current.ok ? t("healthOk") : t("trafficRuntimeUnavailable"));
$("#userTrafficTotal").textContent = fmtBytes(total);
$("#userTrafficConnections").textContent = current.current_connections ?? last.current_connections ?? 0;
$("#userTrafficIps").textContent = current.active_unique_ips ?? last.active_unique_ips ?? 0;
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
drawUserTrafficChart(rows);
renderUserTrafficTable(payload.summary_rows?.length ? payload.summary_rows : fallbackUserTrafficSummaries(rows));
}
function renderUsers() { function renderUsers() {
const tbody = $("#usersTable"); const tbody = $("#usersTable");
if (!state.users.length) { if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`; tbody.innerHTML = `<tr><td colspan="6" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return; return;
} }
tbody.innerHTML = state.users.map((user) => { tbody.innerHTML = state.users.map((user) => {
const pending = state.pendingUsers.has(user.name); const pending = state.pendingUsers.has(user.name);
const traffic = user.traffic || {};
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
const activeIps = Number(traffic.active_unique_ips) || 0;
return ` return `
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}"> <tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}">
<td data-label="${escapeAttr(t("tableUser"))}"> <td data-label="${escapeAttr(t("tableUser"))}">
@@ -910,6 +1092,13 @@ function renderUsers() {
</td> </td>
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td> <td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td> <td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td>
<td data-label="${escapeAttr(t("tableTraffic"))}">
<div class="traffic-cell">
<strong>${escapeHtml(trafficTotal)}</strong>
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
</div>
</td>
<td data-label="${escapeAttr(t("tableActions"))}" class="actions"> <td data-label="${escapeAttr(t("tableActions"))}" class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button> <button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button> <button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
@@ -993,6 +1182,9 @@ async function refreshAll() {
renderUsers(); renderUsers();
if (state.page === "traffic") { if (state.page === "traffic") {
await refreshStats(); await refreshStats();
} else if (state.page === "keys") {
ensureUserTrafficSelection();
await refreshUserTraffic();
} }
} catch (err) { } catch (err) {
toast(err.message); toast(err.message);
@@ -1021,6 +1213,26 @@ async function refreshStats(options = {}) {
} }
} }
async function refreshUserTraffic(options = {}) {
ensureUserTrafficSelection();
if (!state.userTrafficUser) {
renderUserTraffic();
return null;
}
if (options.showLoading) {
state.userTrafficLoading = true;
renderUserTraffic();
}
try {
const data = await api(`/api/users/${encodeURIComponent(state.userTrafficUser)}/traffic?range=${encodeURIComponent(state.userTrafficRange)}`);
state.userTraffic = data;
return data;
} finally {
state.userTrafficLoading = false;
renderUserTraffic();
}
}
async function changeTrafficRange(range) { async function changeTrafficRange(range) {
const next = trafficRanges.includes(range) ? range : "1h"; const next = trafficRanges.includes(range) ? range : "1h";
if (next === state.trafficRange && state.stats?.range === next) return; if (next === state.trafficRange && state.stats?.range === next) return;
@@ -1036,6 +1248,21 @@ async function changeTrafficRange(range) {
} }
} }
async function changeUserTrafficRange(range) {
const next = trafficRanges.includes(range) ? range : "1h";
if (next === state.userTrafficRange && state.userTraffic?.range === next) return;
const previous = state.userTrafficRange;
state.userTrafficRange = next;
try {
await refreshUserTraffic({ showLoading: true });
} catch (err) {
state.userTrafficRange = previous;
state.userTrafficLoading = false;
renderUserTraffic();
toast(err.message);
}
}
async function addUser(name) { async function addUser(name) {
const data = await api("/api/users", { const data = await api("/api/users", {
method: "POST", method: "POST",
@@ -1206,6 +1433,14 @@ document.addEventListener("click", async (eventObj) => {
} else if (button.dataset.trafficView) { } else if (button.dataset.trafficView) {
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
renderStats(); renderStats();
} else if (button.dataset.userTraffic) {
state.userTrafficUser = button.dataset.userTraffic;
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
} else if (button.dataset.userTrafficRange) {
changeUserTrafficRange(button.dataset.userTrafficRange);
} else if (button.dataset.userTrafficView) {
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
renderUserTraffic();
} else if (button.dataset.copy) { } else if (button.dataset.copy) {
await copyText(button.dataset.copy); await copyText(button.dataset.copy);
} else if (button.dataset.delete) { } else if (button.dataset.delete) {

View File

@@ -11,7 +11,7 @@
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
}()); }());
</script> </script>
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin7"> <link rel="stylesheet" href="/styles.css?v=2.5.0-admin8">
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@@ -202,6 +202,7 @@
<th data-i18n="tableStatus">Status</th> <th data-i18n="tableStatus">Status</th>
<th data-i18n="tableSecret">Secret</th> <th data-i18n="tableSecret">Secret</th>
<th data-i18n="tableLink">Link</th> <th data-i18n="tableLink">Link</th>
<th data-i18n="tableTraffic">Traffic</th>
<th data-i18n="tableActions">Actions</th> <th data-i18n="tableActions">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -209,6 +210,56 @@
</table> </table>
</div> </div>
</div> </div>
<div class="panel user-traffic-panel" id="userTrafficPanel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="userTrafficEyebrow">Per user</p>
<h2 id="userTrafficTitle" data-i18n="userTrafficTitle">User traffic</h2>
</div>
<div class="panel-actions">
<span id="userTrafficHealth" class="status-pill">--</span>
</div>
</div>
<div class="traffic-summary compact">
<article>
<span data-i18n="trafficTotal">Total</span>
<strong id="userTrafficTotal">--</strong>
</article>
<article>
<span data-i18n="currentConnections">Connections</span>
<strong id="userTrafficConnections">--</strong>
</article>
<article>
<span data-i18n="activeIps">Active IPs</span>
<strong id="userTrafficIps">--</strong>
</article>
</div>
<div class="traffic-controls">
<div class="segmented" id="userTrafficRange" aria-label="User traffic range" data-i18n-aria-label="ariaTrafficRange">
<button type="button" data-user-traffic-range="15m" data-i18n="range15m">15 min</button>
<button type="button" data-user-traffic-range="1h" data-i18n="range1h">1 hour</button>
<button type="button" data-user-traffic-range="24h" data-i18n="range24h">24 hours</button>
<button type="button" data-user-traffic-range="month" data-i18n="rangeMonth">Month</button>
</div>
<div class="segmented" id="userTrafficView" aria-label="User traffic view" data-i18n-aria-label="ariaTrafficView">
<button type="button" data-user-traffic-view="chart" data-i18n="viewChart">Chart</button>
<button type="button" data-user-traffic-view="table" data-i18n="viewRows">Rows</button>
</div>
</div>
<div id="userTrafficChart" class="traffic-chart"></div>
<div class="table-wrap" id="userTrafficTableWrap">
<table>
<thead>
<tr>
<th data-i18n="tablePeriod">Period</th>
<th data-i18n="tableTrafficDelta">Traffic delta</th>
<th data-i18n="tableTrafficTotal">Total</th>
</tr>
</thead>
<tbody id="userTrafficTable"></tbody>
</table>
</div>
</div>
</section> </section>
<section class="page-panel" data-page="backups"> <section class="page-panel" data-page="backups">
@@ -321,6 +372,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/app.js?v=2.5.0-admin7" type="module"></script> <script src="/app.js?v=2.5.0-admin8" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -667,6 +667,10 @@ h2 {
margin-bottom: 14px; margin-bottom: 14px;
} }
.traffic-summary.compact {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
.health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } .health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } .health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); }
.health-stale, .health-stale,
@@ -830,6 +834,24 @@ td small {
gap: 8px; gap: 8px;
} }
.traffic-cell {
display: grid;
gap: 6px;
min-width: 150px;
}
.traffic-cell strong {
font-size: 14px;
}
.traffic-cell .soft {
width: max-content;
}
.user-traffic-panel {
margin-top: 18px;
}
.status-control { .status-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -105,6 +105,7 @@ logger = logging.getLogger(__name__)
GOTELEGRAM_VERSION = "2.5.0" GOTELEGRAM_VERSION = "2.5.0"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json" DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json"
USER_STATS_HISTORY = "/opt/gotelegram/user_stats_history.csv"
USER_LOCK_FILE = "/run/gotelegram/admin-users.lock" USER_LOCK_FILE = "/run/gotelegram/admin-users.lock"
TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt" TELEMT_SERVICE = "telemt"
@@ -124,6 +125,17 @@ ENV_FILE = "/opt/gotelegram-bot/.env"
ADMIN_WEB_SERVICE = "gotelegram-admin" ADMIN_WEB_SERVICE = "gotelegram-admin"
ADMIN_WEB_PORT = 1984 ADMIN_WEB_PORT = 1984
def format_bytes_human(value: int) -> str:
value = max(0, int(value or 0))
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
if value < 1024 * 1024 * 1024:
return f"{value / 1024 / 1024:.1f} MB"
return f"{value / 1024 / 1024 / 1024:.1f} GB"
# ── Загрузка ALLOWED_IDS ──────────────────────────────────────────────────── # ── Загрузка ALLOWED_IDS ────────────────────────────────────────────────────
# Поддерживает запятую, пробел, или их комбинацию как разделитель # Поддерживает запятую, пробел, или их комбинацию как разделитель
ALLOWED_IDS: set = set() ALLOWED_IDS: set = set()
@@ -1568,6 +1580,42 @@ def _extract_traffic_value(data: Any, keys: List[str]) -> int:
return 0 return 0
def user_traffic_history_summary(name: str) -> str:
rows: List[Dict[str, int]] = []
try:
with open(USER_STATS_HISTORY, "r", encoding="utf-8", errors="ignore") as f:
reader = csv.DictReader(f)
previous = None
for row in reader:
if row.get("user") != name:
continue
try:
item = {
"epoch": int(row.get("epoch") or 0),
"total_octets": int(row.get("total_octets") or 0),
}
except ValueError:
continue
item["total_delta"] = max(0, item["total_octets"] - previous["total_octets"]) if previous else 0
rows.append(item)
previous = item
except Exception:
rows = []
if not rows:
return "\n<i>История по ключу пока не накоплена.</i>"
latest = max(row["epoch"] for row in rows)
periods = [("15 мин", 15 * 60), ("1 час", 60 * 60), ("24 часа", 24 * 60 * 60), ("Месяц", 30 * 24 * 60 * 60)]
lines = ["\n<b>История трафика:</b>", "<pre>", f"{'Период':<8}{'Трафик':>10}", "" * 23]
for label, seconds in periods:
window = [row for row in rows if row["epoch"] >= latest - seconds]
total = sum(max(0, row.get("total_delta", 0)) for row in window)
lines.append(f"{label:<8}{format_bytes_human(total):>10}")
lines.append("</pre>")
return "\n".join(lines)
async def get_proxy_link_for_secret(secret: str) -> Optional[str]: async def get_proxy_link_for_secret(secret: str) -> Optional[str]:
"""Generate a fake-TLS proxy link for an arbitrary telemt user secret.""" """Generate a fake-TLS proxy link for an arbitrary telemt user secret."""
config = load_json(GOTELEGRAM_CONFIG) or {} config = load_json(GOTELEGRAM_CONFIG) or {}
@@ -1747,16 +1795,16 @@ async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str
details = "" details = ""
if api: if api:
data = api.get("data", api) data = api.get("data", api)
up = _extract_traffic_value(data, ["upload_bytes", "uplink_bytes", "tx_bytes", "sent_bytes", "up"]) total = int(data.get("total_octets") or 0) if isinstance(data, dict) else 0
down = _extract_traffic_value(data, ["download_bytes", "downlink_bytes", "rx_bytes", "received_bytes", "down"]) conns = int(data.get("current_connections") or 0) if isinstance(data, dict) else 0
active_ips = _extract_traffic_value(data, ["active_ips", "unique_ips"]) active_ips = int(data.get("active_unique_ips") or 0) if isinstance(data, dict) else 0
recent_ips = int(data.get("recent_unique_ips") or 0) if isinstance(data, dict) else 0
parts = [] parts = []
if up: parts.append(f"Трафик всего: <b>{format_bytes_human(total)}</b>")
parts.append(f"{up} B") parts.append(f"Подключения: <code>{conns}</code>")
if down: parts.append(f"Активные IP: <code>{active_ips}</code>")
parts.append(f"{down} B") if recent_ips:
if active_ips: parts.append(f"Недавние IP: <code>{recent_ips}</code>")
parts.append(f"active IPs: {active_ips}")
if parts: if parts:
details = "\n" + "\n".join(parts) details = "\n" + "\n".join(parts)
else: else:
@@ -1766,6 +1814,7 @@ async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str
details = "\n<i>Runtime API недоступен. Новые установки goTelegram Pro включают его автоматически.</i>" details = "\n<i>Runtime API недоступен. Новые установки goTelegram Pro включают его автоматически.</i>"
else: else:
details = "\n<i>Ключ отключён и сейчас не принимается telemt.</i>" details = "\n<i>Ключ отключён и сейчас не принимается telemt.</i>"
details += user_traffic_history_summary(name)
link_line = html.escape(link) if link else "link unavailable" link_line = html.escape(link) if link else "link unavailable"
status_line = "🟢 enabled" if enabled else "⏸ disabled" status_line = "🟢 enabled" if enabled else "⏸ disabled"

View File

@@ -22,6 +22,7 @@ source "$LIB_DIR/website.sh"
source "$LIB_DIR/templates_catalog.sh" source "$LIB_DIR/templates_catalog.sh"
source "$LIB_DIR/backup.sh" source "$LIB_DIR/backup.sh"
[ -f "$LIB_DIR/stats.sh" ] && source "$LIB_DIR/stats.sh" [ -f "$LIB_DIR/stats.sh" ] && source "$LIB_DIR/stats.sh"
[ -f "$LIB_DIR/shared443.sh" ] && source "$LIB_DIR/shared443.sh"
# Load language (from config.json or marker file, default en) # Load language (from config.json or marker file, default en)
load_language "$(detect_language)" load_language "$(detect_language)"
@@ -936,6 +937,7 @@ auto_install_admin_web_if_possible() {
if [ "$(admin_web_service_status)" != "not_installed" ] && \ if [ "$(admin_web_service_status)" != "not_installed" ] && \
[ -f "$ADMIN_WEB_DIR/server.py" ] && \ [ -f "$ADMIN_WEB_DIR/server.py" ] && \
cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \ cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \
cmp -s "$SCRIPT_DIR/admin-web/static/index.html" "$ADMIN_WEB_DIR/static/index.html" && \
cmp -s "$SCRIPT_DIR/admin-web/static/app.js" "$ADMIN_WEB_DIR/static/app.js" && \ cmp -s "$SCRIPT_DIR/admin-web/static/app.js" "$ADMIN_WEB_DIR/static/app.js" && \
cmp -s "$SCRIPT_DIR/admin-web/static/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then cmp -s "$SCRIPT_DIR/admin-web/static/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then
return 0 return 0

View File

@@ -86,6 +86,12 @@ create_backup() {
if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then
cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null
fi fi
if [ -f "$GOTELEGRAM_DIR/user_stats_history.csv" ]; then
cp "$GOTELEGRAM_DIR/user_stats_history.csv" "$tmp_dir/user_stats_history.csv" 2>/dev/null
fi
if [ -f "$GOTELEGRAM_DIR/shared-443.json" ]; then
cp "$GOTELEGRAM_DIR/shared-443.json" "$tmp_dir/shared-443.json" 2>/dev/null
fi
# Метаданные # Метаданные
local ip mode engine lang port domain local ip mode engine lang port domain
@@ -100,7 +106,7 @@ create_backup() {
cat > "$tmp_dir/metadata.json" << EOMETA cat > "$tmp_dir/metadata.json" << EOMETA
{ {
"backup_version": "1.4", "backup_version": "1.5",
"gotelegram_version": "$GOTELEGRAM_VERSION", "gotelegram_version": "$GOTELEGRAM_VERSION",
"created_at": "$(date -Iseconds)", "created_at": "$(date -Iseconds)",
"hostname": "$(hostname)", "hostname": "$(hostname)",
@@ -310,6 +316,13 @@ restore_backup() {
cp "$backup_dir/stats_history.csv" "$GOTELEGRAM_DIR/stats_history.csv" 2>/dev/null cp "$backup_dir/stats_history.csv" "$GOTELEGRAM_DIR/stats_history.csv" 2>/dev/null
log_success "История статистики восстановлена" log_success "История статистики восстановлена"
fi fi
if [ -f "$backup_dir/user_stats_history.csv" ]; then
cp "$backup_dir/user_stats_history.csv" "$GOTELEGRAM_DIR/user_stats_history.csv" 2>/dev/null
log_success "История статистики пользователей восстановлена"
fi
if [ -f "$backup_dir/shared-443.json" ]; then
cp "$backup_dir/shared-443.json" "$GOTELEGRAM_DIR/shared-443.json" 2>/dev/null
fi
# Восстанавливаем состояние бота # Восстанавливаем состояние бота
if [ -d "$backup_dir/bot" ]; then if [ -d "$backup_dir/bot" ]; then

View File

@@ -493,18 +493,25 @@ goTelegram Pro detected that 3x-ui/Xray already owns TCP/443. Two independent
processes cannot bind the same IP:port at the same time. A safe shared setup processes cannot bind the same IP:port at the same time. A safe shared setup
needs one front TLS/SNI dispatcher on 443 and internal backends, for example: needs one front TLS/SNI dispatcher on 443 and internal backends, for example:
- dispatcher: 0.0.0.0:443 - dispatcher: 0.0.0.0:443 (nginx stream ssl_preread)
- goTelegram Pro telemt: 127.0.0.1:7443 - goTelegram Pro telemt: 127.0.0.1:7443
- 3x-ui/Xray inbound: 127.0.0.1:9443 - 3x-ui/Xray inbound: 127.0.0.1:9443
- goTelegram Pro nginx mask site: 127.0.0.1:8443 - goTelegram Pro nginx mask site: 127.0.0.1:8443
The dispatcher must route Xray SNI domains to Xray and route the goTelegram Pro The dispatcher routes Xray SNI domains to Xray. Everything else goes to telemt;
SNI domain to telemt. If Xray and goTelegram Pro use the same SNI domain, automatic telemt then decides whether the session is MTProxy or regular HTTPS and forwards
sharing is not reliable: the first TLS ClientHello is intentionally identical. the website to nginx through dns_overrides.
goTelegram Pro intentionally does not rewrite the 3x-ui SQLite database or generated goTelegram Pro can generate the dispatcher with:
Xray config without explicit operator confirmation, because 3x-ui can overwrite
manual JSON edits on the next panel change. source /opt/gotelegram/lib/shared443.sh
shared443_enable <gotelegram-domain> <xray-sni-domain> 127.0.0.1:9443
Move the 3x-ui/Xray inbound from 0.0.0.0:443 to 127.0.0.1:9443 in the panel first,
or nginx will not be able to own the public 443 socket. goTelegram Pro intentionally
does not rewrite the 3x-ui SQLite database or generated Xray config without explicit
operator confirmation, because 3x-ui can overwrite manual JSON edits on the next
panel change.
EOF EOF
return 0 return 0
} }

248
lib/shared443.sh Normal file
View File

@@ -0,0 +1,248 @@
#!/bin/bash
# goTelegram Pro v2.5.0 — shared TCP/443 dispatcher helpers
SHARED443_CONFIG="${SHARED443_CONFIG:-/opt/gotelegram/shared-443.json}"
SHARED443_STREAM_CONF="${SHARED443_STREAM_CONF:-/etc/nginx/stream-conf.d/gotelegram-shared443.conf}"
SHARED443_TELEMT_PORT="${SHARED443_TELEMT_PORT:-7443}"
SHARED443_PUBLIC_PORT="${SHARED443_PUBLIC_PORT:-443}"
SHARED443_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
type log_error >/dev/null 2>&1 || source "$SHARED443_LIB_DIR/common.sh"
type install_nginx >/dev/null 2>&1 || source "$SHARED443_LIB_DIR/website.sh"
shared443_detect_nginx_stream() {
nginx -V 2>&1 | grep -Eq -- '--with-stream|ngx_stream_module|ngx_stream_ssl_preread_module'
}
shared443_install_stream_module() {
if shared443_detect_nginx_stream; then
return 0
fi
case "$(get_pkg_manager 2>/dev/null || echo unknown)" in
apt)
apt_update >/dev/null 2>&1 || true
apt_install libnginx-mod-stream || return 1
;;
dnf|yum)
install_pkg nginx-mod-stream || true
;;
esac
shared443_detect_nginx_stream
}
shared443_ensure_nginx_include() {
mkdir -p /etc/nginx/stream-conf.d
if nginx -T 2>/dev/null | grep -q '/etc/nginx/stream-conf.d/\*.conf'; then
return 0
fi
if grep -Eq '^[[:space:]]*stream[[:space:]]*\{' /etc/nginx/nginx.conf 2>/dev/null; then
log_warning "В nginx уже есть stream-блок, но нет include /etc/nginx/stream-conf.d/*.conf"
log_dim "Добавьте include вручную или перенесите $SHARED443_STREAM_CONF в существующий stream-блок."
return 1
fi
cp /etc/nginx/nginx.conf "/etc/nginx/nginx.conf.gotelegram.$(date +%Y%m%d_%H%M%S).bak" 2>/dev/null || true
cat >> /etc/nginx/nginx.conf <<'EOF'
# goTelegram Pro shared TCP/443 routes
stream {
include /etc/nginx/stream-conf.d/*.conf;
}
EOF
}
shared443_rewrite_telemt_bind() {
local listen_port="${1:-$SHARED443_TELEMT_PORT}"
local public_port="${2:-$SHARED443_PUBLIC_PORT}"
local listen_addr="${3:-127.0.0.1}"
command -v python3 >/dev/null 2>&1 || {
log_error "python3 нужен для безопасного изменения $TELEMT_CONFIG"
return 1
}
python3 - "$TELEMT_CONFIG" "$listen_port" "$public_port" "$listen_addr" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
listen_port = sys.argv[2]
public_port = sys.argv[3]
listen_addr = sys.argv[4]
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() if path.exists() else []
out = []
section = ""
server_seen = False
server_port_seen = False
server_addr_seen = False
links_seen = False
public_seen = False
def flush_section(next_line=None):
global section, server_port_seen, server_addr_seen, public_seen
if section == "server":
if not server_port_seen:
out.append(f"port = {listen_port}")
if not server_addr_seen:
out.append(f'listen_addr_ipv4 = "{listen_addr}"')
if section == "general.links" and not public_seen:
out.append(f"public_port = {public_port}")
if next_line is not None:
out.append(next_line)
for raw in lines:
stripped = raw.strip()
if stripped.startswith("[") and stripped.endswith("]"):
flush_section(raw)
section = stripped.strip("[]")
if section == "server":
server_seen = True
server_port_seen = False
server_addr_seen = False
elif section == "general.links":
links_seen = True
public_seen = False
continue
if section == "server" and stripped.startswith("port") and "=" in stripped:
out.append(f"port = {listen_port}")
server_port_seen = True
continue
if section == "server" and stripped.startswith("listen_addr_ipv4") and "=" in stripped:
out.append(f'listen_addr_ipv4 = "{listen_addr}"')
server_addr_seen = True
continue
if section == "general.links" and stripped.startswith("public_port") and "=" in stripped:
out.append(f"public_port = {public_port}")
public_seen = True
continue
out.append(raw)
flush_section()
if not links_seen:
if out and out[-1].strip():
out.append("")
out.extend(["[general.links]", f"public_port = {public_port}"])
if not server_seen:
if out and out[-1].strip():
out.append("")
out.extend(["[server]", f"port = {listen_port}", f'listen_addr_ipv4 = "{listen_addr}"'])
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
PY
}
shared443_write_stream_config() {
local domain="$1"
local xray_domain="${2:-}"
local xray_target="${3:-}"
local telemt_target="${4:-127.0.0.1:${SHARED443_TELEMT_PORT}}"
mkdir -p "$(dirname "$SHARED443_STREAM_CONF")"
{
echo "# goTelegram Pro shared TCP/443 dispatcher"
echo "# Browser/Telegram for goTelegram domain goes to telemt; telemt masks the site to nginx."
echo "map \$ssl_preread_server_name \$gotelegram_shared443_backend {"
echo " hostnames;"
if [[ -n "$xray_domain" && -n "$xray_target" ]]; then
echo " ${xray_domain} ${xray_target};"
fi
echo " default ${telemt_target};"
echo "}"
echo ""
echo "server {"
echo " listen 0.0.0.0:${SHARED443_PUBLIC_PORT};"
echo " proxy_pass \$gotelegram_shared443_backend;"
echo " ssl_preread on;"
echo " proxy_connect_timeout 5s;"
echo " proxy_timeout 10m;"
echo "}"
} > "$SHARED443_STREAM_CONF"
mkdir -p "$(dirname "$SHARED443_CONFIG")"
if command -v jq >/dev/null 2>&1; then
jq -n \
--arg domain "$domain" \
--arg telemt "$telemt_target" \
--arg xdomain "$xray_domain" \
--arg xtarget "$xray_target" \
--arg updated "$(date -Iseconds)" \
--argjson public_port "$SHARED443_PUBLIC_PORT" \
'{
enabled: true,
dispatcher: "nginx-stream",
public_port: $public_port,
domain: $domain,
telemt_target: $telemt,
site_target: "127.0.0.1:8443",
xray_routes: (if ($xdomain != "" and $xtarget != "") then [{public: ($xdomain + ":443"), target: $xtarget}] else [] end),
updated_at: $updated
}' > "$SHARED443_CONFIG"
else
cat > "$SHARED443_CONFIG" <<EOF
{"enabled":true,"dispatcher":"nginx-stream","public_port":${SHARED443_PUBLIC_PORT},"domain":"${domain}","telemt_target":"${telemt_target}","site_target":"127.0.0.1:8443","xray_routes":[],"updated_at":"$(date -Iseconds)"}
EOF
fi
chmod 600 "$SHARED443_CONFIG" 2>/dev/null || true
}
shared443_enable() {
local domain="$1"
local xray_domain="${2:-}"
local xray_target="${3:-}"
local telemt_target="127.0.0.1:${SHARED443_TELEMT_PORT}"
[[ -n "$domain" ]] || domain="$(config_get domain 2>/dev/null || echo "")"
[[ -n "$domain" ]] || {
log_error "Не указан домен goTelegram Pro для shared-443"
return 1
}
install_nginx || return 1
shared443_install_stream_module || {
log_error "nginx stream/ssl_preread недоступен"
return 1
}
shared443_ensure_nginx_include || return 1
shared443_rewrite_telemt_bind "$SHARED443_TELEMT_PORT" "$SHARED443_PUBLIC_PORT" "127.0.0.1" || return 1
systemctl restart "$TELEMT_SERVICE" 2>/dev/null || true
shared443_write_stream_config "$domain" "$xray_domain" "$xray_target" "$telemt_target"
if nginx -t 2>/dev/null; then
systemctl restart nginx
log_success "shared-443 включён: 0.0.0.0:${SHARED443_PUBLIC_PORT} -> nginx stream -> telemt ${telemt_target}"
if [[ -n "$xray_domain" && -n "$xray_target" ]]; then
log_success "Xray route: ${xray_domain}:443 -> ${xray_target}"
fi
else
log_error "nginx -t не прошёл после настройки shared-443"
nginx -t
return 1
fi
}
shared443_detect_direct_conflict() {
ss -ltnp 2>/dev/null | grep -E '(:|])443[[:space:]]' | grep -Eiv '(nginx|telemt)' || true
}
shared443_status() {
echo "shared-443 config: $SHARED443_CONFIG"
[ -f "$SHARED443_CONFIG" ] && cat "$SHARED443_CONFIG" || echo "not enabled"
local conflict
conflict="$(shared443_detect_direct_conflict)"
if [[ -n "$conflict" ]]; then
echo ""
echo "direct 443 listeners that need migration behind dispatcher:"
echo "$conflict"
fi
}
export -f shared443_detect_nginx_stream shared443_install_stream_module shared443_ensure_nginx_include
export -f shared443_rewrite_telemt_bind shared443_write_stream_config shared443_enable
export -f shared443_detect_direct_conflict shared443_status

View File

@@ -12,9 +12,11 @@ NC='\033[0m' # No Color
STATS_DIR="/run/gotelegram" STATS_DIR="/run/gotelegram"
HISTORY_FILE="/opt/gotelegram/stats_history.csv" HISTORY_FILE="/opt/gotelegram/stats_history.csv"
USER_HISTORY_FILE="/opt/gotelegram/user_stats_history.csv"
SNAPSHOTS_DIR="$STATS_DIR/snapshots" SNAPSHOTS_DIR="$STATS_DIR/snapshots"
CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json" CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json"
CONFIG_FILE="/opt/gotelegram/config.json" CONFIG_FILE="/opt/gotelegram/config.json"
TELEMT_CONFIG_FILE="/etc/telemt/config.toml"
# Initialize stats infrastructure # Initialize stats infrastructure
stats_init() { stats_init() {
@@ -51,6 +53,9 @@ stats_init() {
if [[ ! -f "$HISTORY_FILE" ]]; then if [[ ! -f "$HISTORY_FILE" ]]; then
echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
fi fi
if [[ ! -f "$USER_HISTORY_FILE" ]]; then
echo "epoch,user,total_octets,current_connections,active_unique_ips,recent_unique_ips" > "$USER_HISTORY_FILE" 2>/dev/null
fi
# Write initial snapshot # Write initial snapshot
stats_collect stats_collect
@@ -124,9 +129,63 @@ EOF
fi fi
fi fi
stats_collect_users "$ts"
rm -f "$temp_file" 2>/dev/null rm -f "$temp_file" 2>/dev/null
} }
# Print active telemt usernames from [access.users]. Usernames are restricted by
# goTelegram to A-Z/a-z/0-9/_.- so they are safe in URLs and CSV fields.
stats_active_users() {
[[ -f "$TELEMT_CONFIG_FILE" ]] || return 0
awk '
/^\[access\.users\]$/ { in_users=1; next }
in_users && /^\[/ { exit }
in_users && /^[[:space:]]*#/ { next }
in_users && /=/ {
key=$1
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
gsub(/^"|"$/, "", key)
if (key ~ /^[A-Za-z0-9_.-]{1,48}$/) print key
}
' "$TELEMT_CONFIG_FILE" 2>/dev/null
}
stats_collect_users() {
local ts="${1:-$(date +%s)}"
local current_minute=$((ts - (ts % 60)))
mkdir -p "$(dirname "$USER_HISTORY_FILE")" 2>/dev/null
if [[ ! -f "$USER_HISTORY_FILE" ]]; then
echo "epoch,user,total_octets,current_connections,active_unique_ips,recent_unique_ips" > "$USER_HISTORY_FILE" 2>/dev/null
fi
command -v curl &>/dev/null || return 0
command -v jq &>/dev/null || return 0
local user payload total conns active_ips recent_ips
while IFS= read -r user; do
[[ -n "$user" ]] || continue
if awk -F, -v ts="$current_minute" -v user="$user" '$1 == ts && $2 == user { found=1 } END { exit found ? 0 : 1 }' "$USER_HISTORY_FILE" 2>/dev/null; then
continue
fi
payload=$(curl -sS --max-time 2 "http://127.0.0.1:9091/v1/users/${user}" 2>/dev/null || true)
[[ -n "$payload" ]] || continue
total=$(echo "$payload" | jq -r '.data.total_octets // .total_octets // 0' 2>/dev/null)
conns=$(echo "$payload" | jq -r '.data.current_connections // .current_connections // 0' 2>/dev/null)
active_ips=$(echo "$payload" | jq -r '.data.active_unique_ips // .active_unique_ips // 0' 2>/dev/null)
recent_ips=$(echo "$payload" | jq -r '.data.recent_unique_ips // .recent_unique_ips // 0' 2>/dev/null)
[[ "$total" =~ ^[0-9]+$ ]] || total=0
[[ "$conns" =~ ^[0-9]+$ ]] || conns=0
[[ "$active_ips" =~ ^[0-9]+$ ]] || active_ips=0
[[ "$recent_ips" =~ ^[0-9]+$ ]] || recent_ips=0
echo "$current_minute,$user,$total,$conns,$active_ips,$recent_ips" >> "$USER_HISTORY_FILE" 2>/dev/null
done < <(stats_active_users)
stats_cleanup_user_history
}
# Read current snapshot as JSON # Read current snapshot as JSON
stats_read_current() { stats_read_current() {
if [[ -f "$CURRENT_SNAPSHOT" ]]; then if [[ -f "$CURRENT_SNAPSHOT" ]]; then
@@ -308,6 +367,23 @@ stats_cleanup_history() {
mv "$temp_file" "$HISTORY_FILE" 2>/dev/null mv "$temp_file" "$HISTORY_FILE" 2>/dev/null
} }
stats_cleanup_user_history() {
if [[ ! -f "$USER_HISTORY_FILE" ]]; then
return
fi
local now=$(date +%s)
local ts_365d=$((now - 31536000))
local temp_file=$(mktemp)
{
head -1 "$USER_HISTORY_FILE"
awk -F, -v ts="$ts_365d" '$1 >= ts' "$USER_HISTORY_FILE" | tail -n +2
} > "$temp_file" 2>/dev/null
mv "$temp_file" "$USER_HISTORY_FILE" 2>/dev/null
}
# Toggle stats collection on/off # Toggle stats collection on/off
toggle_stats() { toggle_stats() {
local current_state="false" local current_state="false"
@@ -432,13 +508,13 @@ remove_stats_collector() {
# Clean up directories and files # Clean up directories and files
rm -rf "$STATS_DIR" 2>/dev/null rm -rf "$STATS_DIR" 2>/dev/null
rm -f "$HISTORY_FILE" 2>/dev/null rm -f "$HISTORY_FILE" "$USER_HISTORY_FILE" 2>/dev/null
echo "Сервис статистики удалён" >&2 echo "Сервис статистики удалён" >&2
} }
# Export functions for external use # Export functions for external use
export -f stats_init stats_collect stats_read_current stats_calculate_rates export -f stats_init stats_collect stats_collect_users stats_active_users stats_read_current stats_calculate_rates
export -f show_traffic_stats format_bytes format_rate toggle_stats export -f show_traffic_stats format_bytes format_rate toggle_stats
export -f stats_cleanup_history install_stats_collector remove_stats_collector export -f stats_cleanup_history stats_cleanup_user_history install_stats_collector remove_stats_collector
export -f json_get export -f json_get