mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-06-10 02:42:47 +00:00
v2.5.0: add shared 443 and per-user traffic
This commit is contained in:
19
DOCS_AI.md
19
DOCS_AI.md
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
248
lib/shared443.sh
Normal 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
|
||||||
82
lib/stats.sh
82
lib/stats.sh
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user