diff --git a/DOCS_AI.md b/DOCS_AI.md index c446c88..00ac4b9 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -449,13 +449,24 @@ switch_language ru|en - 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; - 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//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//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//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 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`. -`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) @@ -631,7 +642,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s: ## 17. Changelog -- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная 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.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index bef84c2..bd468a2 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -145,7 +145,20 @@ CLI и бот переведены на русский и английский. - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - 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` защищён от удаления и отключения. @@ -182,7 +195,7 @@ Bootstrap.sh умеет сам обновлять всё, если запуст - **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот). - **Диск:** 2 ГБ (в основном под каталог шаблонов и бекапы). - **Права:** 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 не выдаст сертификат. --- @@ -202,7 +215,7 @@ A: Пункт 7 → сменить режим/шаблон. Можно такж A: Посмотри логи бота в пункте 12 → «Логи бота». Чаще всего — неверный токен или неверный admin ID в `.env`. **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: Это легально?** A: Сам MTProxy — да, это публичная технология из исходников Telegram. Запуск прокси, чтобы твои друзья могли пользоваться Telegram там, где он заблокирован — в большинстве юрисдикций легально. Проверь локальные законы. @@ -240,7 +253,7 @@ A: Сам MTProxy — да, это публичная технология из ## Changelog (коротко) -- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка и информация из 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.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. diff --git a/admin-web/server.py b/admin-web/server.py index 0db0082..25e6d11 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -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")) TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml")) 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")) BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups")) INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram")) 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")) 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") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) @@ -421,14 +423,81 @@ def read_telemt_edge_settings() -> dict[str, Any]: 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]]: config = load_json(GOTELEGRAM_CONFIG, {}) or {} mode = str(config.get("mode") or "") domain = str(config.get("domain") or "") settings = read_telemt_edge_settings() + shared = load_shared443_config() mask_port = int(settings.get("mask_port") or 0) tls_domain = str(settings.get("tls_domain") or domain) 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: internal, _ = collect_port_listeners(mask_port) 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]: 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 { "checked_at": int(time.time()), "configured_port": read_telemt_port(), "listeners": listeners, "routes": routed_behind_443(), + "shared_443": shared, "ok": not errors, "error": "; ".join(errors[:2]), } @@ -567,6 +643,78 @@ def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]: 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: return { "15m": 180, @@ -617,6 +765,32 @@ def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any 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: if not HISTORY_FILE.exists(): return 0 @@ -627,6 +801,18 @@ def count_history_rows() -> int: 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]: 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) @@ -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] = { "name": name, "secret": secret, @@ -748,6 +940,14 @@ def user_payload(name: str, secret: str, enabled: bool = True, include_runtime: "main": name == "main", "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: item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}") return item @@ -823,11 +1023,40 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_json({"ok": True, "data": overview_payload()}) elif path == "/api/users": users = read_user_records() + latest = latest_user_stats() items = [] for name in sorted(users, key=lambda item: (item != "main", item)): 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}) + 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/"): name = urllib.parse.unquote(path[len("/api/users/"):]) users = read_user_records() @@ -835,7 +1064,7 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(404, "user not found") return 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": self.send_json({"ok": True, "data": list_backups()}) elif path == "/api/stats": diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 171f33f..7f8638e 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -56,6 +56,9 @@ const i18n = { tableUser: "User", tableSecret: "Secret", tableLink: "Link", + tableTraffic: "Traffic", + tableTrafficDelta: "Traffic delta", + tableTrafficTotal: "Total", tableActions: "Actions", userPlaceholder: "client-name", addKey: "Add key", @@ -81,6 +84,15 @@ const i18n = { noHistory: "No traffic history yet", noTrafficForRange: "No data for this range yet", 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", connections: "Connections", uptime: "Uptime", @@ -150,6 +162,7 @@ const i18n = { port443NoRoutes: "No routed services detected", port443Via: "via {value}", roleMtproxy: "MTProxy", + roleEdge: "443 Edge", roleSite: "Website", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", @@ -243,6 +256,9 @@ const i18n = { tableUser: "Пользователь", tableSecret: "Секрет", tableLink: "Ссылка", + tableTraffic: "Трафик", + tableTrafficDelta: "Прирост трафика", + tableTrafficTotal: "Всего", tableActions: "Действия", userPlaceholder: "client-name", addKey: "Добавить ключ", @@ -268,6 +284,15 @@ const i18n = { noHistory: "Истории трафика пока нет", noTrafficForRange: "За этот период данных пока нет", noRuntime: "Данные среды выполнения недоступны", + userTrafficEyebrow: "По пользователю", + userTrafficTitle: "Трафик ключа", + selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика", + openStats: "Статистика", + trafficTotal: "Всего", + currentConnections: "Подключения", + activeIps: "Активные IP", + recentIps: "Недавние IP", + trafficRuntimeUnavailable: "Runtime недоступен", badConnections: "Ошибочные подключения", connections: "Подключения", uptime: "Аптайм", @@ -337,6 +362,7 @@ const i18n = { port443NoRoutes: "Маршрутизируемых сервисов не найдено", port443Via: "через {value}", roleMtproxy: "MTProxy", + roleEdge: "443 Edge", roleSite: "Сайт", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", @@ -389,6 +415,11 @@ const state = { trafficRange: "1h", trafficView: "chart", trafficLoading: false, + userTrafficUser: "", + userTrafficRange: "1h", + userTrafficView: "chart", + userTraffic: null, + userTrafficLoading: false, pendingUsers: new Set(), }; @@ -482,6 +513,7 @@ function applyI18n() { $("#visualTitle").textContent = t("visualTitle"); $("#visualText").textContent = t("visualText"); updateTrafficControls(); + updateUserTrafficControls(); updatePageTitle(); } @@ -491,6 +523,7 @@ function setTheme(theme) { localStorage.setItem("gotelegram-theme", state.theme); applyI18n(); if (state.overview) renderStats(); + if (state.userTraffic) renderUserTraffic(); } async function setLanguage(lang) { @@ -525,6 +558,10 @@ function setPage(page, push = true) { } if (next === "traffic") { 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) { const labels = { "15m": t("range15m"), @@ -886,14 +932,150 @@ function renderHistoryTable(rows) { `).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 = `
${escapeHtml(t("loading"))}
`; + $("#userTrafficTable").innerHTML = `${escapeHtml(t("loading"))}`; +} + +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 = `
+ ${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))} + ${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))} +
`; + 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 ``; + }).join(""); + const axis = t("chartMax").replace("{value}", fmtBytes(max)); + el.innerHTML = ` + ${grid} + + + ${escapeHtml(axis)} + ${escapeHtml(state.userTrafficUser || t("users"))} + `; +} + +function renderUserTrafficTable(rows) { + if (!rows.length) { + $("#userTrafficTable").innerHTML = `${escapeHtml(t("noHistory"))}`; + return; + } + $("#userTrafficTable").innerHTML = rows.map((row) => ` + + ${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} + ${escapeHtml(fmtBytes(row.total_delta))} + ${escapeHtml(fmtBytes(row.total_octets))} + + `).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 = `
${escapeHtml(t("selectUserTraffic"))}
`; + $("#userTrafficTable").innerHTML = `${escapeHtml(t("selectUserTraffic"))}`; + 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() { const tbody = $("#usersTable"); if (!state.users.length) { - tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; + tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; return; } tbody.innerHTML = state.users.map((user) => { 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 ` @@ -910,6 +1092,13 @@ function renderUsers() { ${escapeHtml(user.secret)} + +
+ ${escapeHtml(trafficTotal)} + ${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))} + +
+ @@ -993,6 +1182,9 @@ async function refreshAll() { renderUsers(); if (state.page === "traffic") { await refreshStats(); + } else if (state.page === "keys") { + ensureUserTrafficSelection(); + await refreshUserTraffic(); } } catch (err) { 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) { const next = trafficRanges.includes(range) ? range : "1h"; 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) { const data = await api("/api/users", { method: "POST", @@ -1206,6 +1433,14 @@ document.addEventListener("click", async (eventObj) => { } else if (button.dataset.trafficView) { state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; 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) { await copyText(button.dataset.copy); } else if (button.dataset.delete) { diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 46bf7f4..19d5f01 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -202,6 +202,7 @@ Status Secret Link + Traffic Actions @@ -209,6 +210,56 @@
+
+
+
+

Per user

+

User traffic

+
+
+ -- +
+
+
+
+ Total + -- +
+
+ Connections + -- +
+
+ Active IPs + -- +
+
+
+
+ + + + +
+
+ + +
+
+
+
+ + + + + + + + + +
PeriodTraffic deltaTotal
+
+
@@ -321,6 +372,6 @@ - + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 6d41ef3..cfd54ca 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -667,6 +667,10 @@ h2 { 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-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } .health-stale, @@ -830,6 +834,24 @@ td small { 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 { display: inline-flex; align-items: center; diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 29a418f..037d003 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -105,6 +105,7 @@ logger = logging.getLogger(__name__) GOTELEGRAM_VERSION = "2.5.0" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.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" TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_SERVICE = "telemt" @@ -124,6 +125,17 @@ ENV_FILE = "/opt/gotelegram-bot/.env" ADMIN_WEB_SERVICE = "gotelegram-admin" 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: set = set() @@ -1568,6 +1580,42 @@ def _extract_traffic_value(data: Any, keys: List[str]) -> int: 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История по ключу пока не накоплена." + + 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История трафика:", "
", 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("
") + return "\n".join(lines) + + async def get_proxy_link_for_secret(secret: str) -> Optional[str]: """Generate a fake-TLS proxy link for an arbitrary telemt user secret.""" config = load_json(GOTELEGRAM_CONFIG) or {} @@ -1747,16 +1795,16 @@ async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str details = "" if api: data = api.get("data", api) - up = _extract_traffic_value(data, ["upload_bytes", "uplink_bytes", "tx_bytes", "sent_bytes", "up"]) - down = _extract_traffic_value(data, ["download_bytes", "downlink_bytes", "rx_bytes", "received_bytes", "down"]) - active_ips = _extract_traffic_value(data, ["active_ips", "unique_ips"]) + total = int(data.get("total_octets") or 0) if isinstance(data, dict) else 0 + conns = int(data.get("current_connections") or 0) if isinstance(data, dict) else 0 + 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 = [] - if up: - parts.append(f"↑ {up} B") - if down: - parts.append(f"↓ {down} B") - if active_ips: - parts.append(f"active IPs: {active_ips}") + parts.append(f"Трафик всего: {format_bytes_human(total)}") + parts.append(f"Подключения: {conns}") + parts.append(f"Активные IP: {active_ips}") + if recent_ips: + parts.append(f"Недавние IP: {recent_ips}") if parts: details = "\n" + "\n".join(parts) else: @@ -1766,6 +1814,7 @@ async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str details = "\nRuntime API недоступен. Новые установки goTelegram Pro включают его автоматически." else: details = "\nКлюч отключён и сейчас не принимается telemt." + details += user_traffic_history_summary(name) link_line = html.escape(link) if link else "link unavailable" status_line = "🟢 enabled" if enabled else "⏸ disabled" diff --git a/install.sh b/install.sh index 74d7973..a8134d2 100755 --- a/install.sh +++ b/install.sh @@ -22,6 +22,7 @@ source "$LIB_DIR/website.sh" source "$LIB_DIR/templates_catalog.sh" source "$LIB_DIR/backup.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 "$(detect_language)" @@ -936,6 +937,7 @@ auto_install_admin_web_if_possible() { if [ "$(admin_web_service_status)" != "not_installed" ] && \ [ -f "$ADMIN_WEB_DIR/server.py" ] && \ cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/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/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then return 0 diff --git a/lib/backup.sh b/lib/backup.sh index f44e647..0e63425 100755 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -86,6 +86,12 @@ create_backup() { if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null 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 @@ -100,7 +106,7 @@ create_backup() { cat > "$tmp_dir/metadata.json" << EOMETA { - "backup_version": "1.4", + "backup_version": "1.5", "gotelegram_version": "$GOTELEGRAM_VERSION", "created_at": "$(date -Iseconds)", "hostname": "$(hostname)", @@ -310,6 +316,13 @@ restore_backup() { cp "$backup_dir/stats_history.csv" "$GOTELEGRAM_DIR/stats_history.csv" 2>/dev/null log_success "История статистики восстановлена" 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 diff --git a/lib/common.sh b/lib/common.sh index b0df001..ba2caf1 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -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 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 - 3x-ui/Xray inbound: 127.0.0.1:9443 - goTelegram Pro nginx mask site: 127.0.0.1:8443 -The dispatcher must route Xray SNI domains to Xray and route the goTelegram Pro -SNI domain to telemt. If Xray and goTelegram Pro use the same SNI domain, automatic -sharing is not reliable: the first TLS ClientHello is intentionally identical. +The dispatcher routes Xray SNI domains to Xray. Everything else goes to telemt; +telemt then decides whether the session is MTProxy or regular HTTPS and forwards +the website to nginx through dns_overrides. -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. +goTelegram Pro can generate the dispatcher with: + + source /opt/gotelegram/lib/shared443.sh + shared443_enable 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 return 0 } diff --git a/lib/shared443.sh b/lib/shared443.sh new file mode 100644 index 0000000..137bc53 --- /dev/null +++ b/lib/shared443.sh @@ -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" </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 diff --git a/lib/stats.sh b/lib/stats.sh index 86905b3..07f6d43 100644 --- a/lib/stats.sh +++ b/lib/stats.sh @@ -12,9 +12,11 @@ NC='\033[0m' # No Color STATS_DIR="/run/gotelegram" HISTORY_FILE="/opt/gotelegram/stats_history.csv" +USER_HISTORY_FILE="/opt/gotelegram/user_stats_history.csv" SNAPSHOTS_DIR="$STATS_DIR/snapshots" CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json" CONFIG_FILE="/opt/gotelegram/config.json" +TELEMT_CONFIG_FILE="/etc/telemt/config.toml" # Initialize stats infrastructure stats_init() { @@ -51,6 +53,9 @@ stats_init() { if [[ ! -f "$HISTORY_FILE" ]]; then echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null 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 stats_collect @@ -124,9 +129,63 @@ EOF fi fi + stats_collect_users "$ts" + 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 stats_read_current() { if [[ -f "$CURRENT_SNAPSHOT" ]]; then @@ -308,6 +367,23 @@ stats_cleanup_history() { 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() { local current_state="false" @@ -432,13 +508,13 @@ remove_stats_collector() { # Clean up directories and files 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 } # 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 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