mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-06-10 16:12:47 +00:00
v2.5.0: align key controls and add IP limits
This commit is contained in:
@@ -172,6 +172,7 @@ dns_overrides = ["anten-ka.com:8443:127.0.0.1"]
|
|||||||
- `[censorship.tls_fetch]` — параметры получения реального cert от tls_domain (используется только если `tls_emulation=false` И нет dns_overrides).
|
- `[censorship.tls_fetch]` — параметры получения реального cert от tls_domain (используется только если `tls_emulation=false` И нет dns_overrides).
|
||||||
- `[access]` — rate limits, доступ.
|
- `[access]` — rate limits, доступ.
|
||||||
- `[access.users]` — таблица `name = "secret"` (секрет — 32 hex).
|
- `[access.users]` — таблица `name = "secret"` (секрет — 32 hex).
|
||||||
|
- `[access.user_max_unique_ips]` — опциональная таблица `name = число`, ограничивает одновременные уникальные IP на ключ; в goTelegram Pro значение `0` в UI удаляет строку и означает безлимит.
|
||||||
|
|
||||||
### Почему `unknown_sni_action = Drop` нас кусал
|
### Почему `unknown_sni_action = Drop` нас кусал
|
||||||
|
|
||||||
@@ -456,9 +457,9 @@ 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` и `backup_schedule`; `/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/users/<name>/qr` отдаёт PNG QR для Telegram proxy link через `qrencode`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`.
|
- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status`, `port_443` и `backup_schedule`; `/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/users/<name>/qr` отдаёт PNG QR для Telegram proxy link через `qrencode`; `POST /api/users/<name>/max-ips` пишет `[access.user_max_unique_ips]` (`0` удаляет строку и означает безлимит); `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`.
|
||||||
|
|
||||||
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/enabled`, генерация proxy links и QR, общий 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, `POST /api/backups/schedule`, `POST /api/backups/restore` (background restore job, только basename из backup dir), структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`).
|
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/enabled`, per-user лимит одновременных уникальных IP через `[access.user_max_unique_ips]`, генерация proxy links и QR, общий 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, `POST /api/backups/schedule`, `POST /api/backups/restore` (background restore job, только basename из backup dir), структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`).
|
||||||
|
|
||||||
Traffic retention: обе CSV-истории хранятся максимум 365 дней. Последние 31 день остаются с поминутным разрешением, более старые точки автоматически уплотняются до одной последней cumulative-точки в час. Чистка/уплотнение запускается не чаще одного раза в час, а per-user сбор пишет данные не чаще одного раза в минуту, даже если systemd-loop вызывает `stats_collect` каждую секунду. Параметры можно переопределить переменными `STATS_RETENTION_DAYS`, `STATS_MINUTE_RETENTION_DAYS`, `STATS_CLEANUP_INTERVAL`.
|
Traffic retention: обе CSV-истории хранятся максимум 365 дней. Последние 31 день остаются с поминутным разрешением, более старые точки автоматически уплотняются до одной последней cumulative-точки в час. Чистка/уплотнение запускается не чаще одного раза в час, а per-user сбор пишет данные не чаще одного раза в минуту, даже если systemd-loop вызывает `stats_collect` каждую секунду. Параметры можно переопределить переменными `STATS_RETENTION_DAYS`, `STATS_MINUTE_RETENTION_DAYS`, `STATS_CLEANUP_INTERVAL`.
|
||||||
|
|
||||||
@@ -473,7 +474,7 @@ Traffic retention: обе CSV-истории хранятся максимум 3
|
|||||||
|
|
||||||
Автоматически переписывать SQLite/JSON 3x-ui нельзя: панель может перегенерировать Xray config и потерять ручные правки. Поэтому goTelegram Pro показывает конфликт прямого bind на `443`, даёт маршрут и включает dispatcher только на уровне собственных конфигов/nginx.
|
Автоматически переписывать 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` защищён от удаления и отключения. Лимит уникальных IP сохраняется в `[access.user_max_unique_ips]` и не теряется при disable/enable; при удалении ключа строка лимита удаляется. Операции с ключами в web-admin и Telegram-боте берут общий file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace и quoted keys (`"a.b"`), а telemt restart для add/delete/enable/disable/IP-limit ставится через `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.6 сохраняет `admin_web/server.py`, `admin_web/static/`, `disabled_users.json`, `stats_history.csv`, `user_stats_history.csv`, `backup_schedule.json` и `shared-443.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.6 сохраняет `admin_web/server.py`, `admin_web/static/`, `disabled_users.json`, `stats_history.csv`, `user_stats_history.csv`, `backup_schedule.json` и `shared-443.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`.
|
||||||
|
|
||||||
@@ -651,7 +652,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]` (добавить/отключить/включить/удалить/показать ссылку/QR/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-переключателями ключей, QR-кодами, 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.6` сохраняет bot `.env`, bot lang files, disabled user keys, backup schedule, 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.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]` (добавить/отключить/включить/удалить/показать ссылку/QR/runtime info/IP-limit); добавлена локальная 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-переключателями ключей, настройкой `[access.user_max_unique_ips]`, QR-кодами, 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.6` сохраняет bot `.env`, bot lang files, disabled user keys, backup schedule, 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,7 @@ 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, генерация ссылок и QR-кодов для импорта в Telegram, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список/создание/восстановление бекапов, расписание автобэкапов и просмотр логов с количеством строк и статусом `journalctl`.
|
В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, лимит одновременных уникальных IP на ключ через `[access.user_max_unique_ips]` (`0` — безлимит), генерация ссылок и QR-кодов для импорта в Telegram, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список/создание/восстановление бекапов, расписание автобэкапов и просмотр логов с количеством строк и статусом `journalctl`.
|
||||||
|
|
||||||
История трафика хранится максимум 1 год. Чтобы файлы не разрастались, последние 31 день пишутся поминутно, а более старая история автоматически уплотняется до одной точки в час. Для обычного просмотра 15 минут / 1 час / 24 часа / месяц детализация остаётся полной.
|
История трафика хранится максимум 1 год. Чтобы файлы не разрастались, последние 31 день пишутся поминутно, а более старая история автоматически уплотняется до одной точки в час. Для обычного просмотра 15 минут / 1 час / 24 часа / месяц детализация остаётся полной.
|
||||||
|
|
||||||
@@ -164,6 +164,8 @@ goTelegram Pro не переписывает базу 3x-ui автоматиче
|
|||||||
|
|
||||||
Отключённые ключи убираются из активного telemt-конфига и сохраняются в `/opt/gotelegram/disabled_users.json`, поэтому их можно включить обратно без потери secret. Основной ключ `main` защищён от удаления и отключения.
|
Отключённые ключи убираются из активного telemt-конфига и сохраняются в `/opt/gotelegram/disabled_users.json`, поэтому их можно включить обратно без потери secret. Основной ключ `main` защищён от удаления и отключения.
|
||||||
|
|
||||||
|
Лимит IP хранится в `/etc/telemt/config.toml` в секции `[access.user_max_unique_ips]`. Значение `0` в UI означает отсутствие строки в этой секции и безлимит. Значение `1`, `2` и выше ограничивает количество одновременно активных уникальных IP для выбранного ключа; если лимит уже занят, новый IP не проходит, пока один из старых не отключится.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Обновление
|
## 9. Обновление
|
||||||
@@ -255,7 +257,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]`): список, добавление, отключение/включение, удаление, ссылка, QR-код, текущий runtime и история трафика по ключу; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, QR-кодами, блоком реальных 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; добавлены ручные/ежедневные/еженедельные/ежемесячные бэкапы, восстановление из админки/бота с safety-бекапом и legacy-restore для старых `backup_*.tar.gz`; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher.
|
- **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]`): список, добавление, отключение/включение, удаление, ссылка, QR-код, текущий runtime, лимит уникальных IP через `[access.user_max_unique_ips]` и история трафика по ключу; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, настройкой IP-лимита, QR-кодами, блоком реальных 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; добавлены ручные/ежедневные/еженедельные/ежемесячные бэкапы, восстановление из админки/бота с safety-бекапом и legacy-restore для старых `backup_*.tar.gz`; добавлен безопасный детект 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.
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
|||||||
LANG_RE = re.compile(r"^(en|ru)$")
|
LANG_RE = re.compile(r"^(en|ru)$")
|
||||||
SENSITIVE_CONFIG_KEYS = {"secret"}
|
SENSITIVE_CONFIG_KEYS = {"secret"}
|
||||||
BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$")
|
BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$")
|
||||||
|
MAX_UNIQUE_IP_LIMIT = 1000000
|
||||||
TRAFFIC_WINDOWS = {
|
TRAFFIC_WINDOWS = {
|
||||||
"15m": 15 * 60,
|
"15m": 15 * 60,
|
||||||
"1h": 60 * 60,
|
"1h": 60 * 60,
|
||||||
@@ -197,6 +198,38 @@ def read_telemt_users() -> dict[str, str]:
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def read_toml_int_table(table: str) -> dict[str, int]:
|
||||||
|
if not TELEMT_CONFIG.exists():
|
||||||
|
return {}
|
||||||
|
values: dict[str, int] = {}
|
||||||
|
section = f"[{table}]"
|
||||||
|
in_table = False
|
||||||
|
for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if line == section:
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
if in_table and line.startswith("["):
|
||||||
|
break
|
||||||
|
if not in_table or not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split("=", 1)
|
||||||
|
name = parse_toml_key(name)
|
||||||
|
if not USER_RE.match(name):
|
||||||
|
continue
|
||||||
|
raw_value = value.strip().split("#", 1)[0].strip().strip('"').strip("'")
|
||||||
|
try:
|
||||||
|
number = int(raw_value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
values[name] = max(0, number)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def read_user_max_unique_ips() -> dict[str, int]:
|
||||||
|
return read_toml_int_table("access.user_max_unique_ips")
|
||||||
|
|
||||||
|
|
||||||
def read_disabled_users() -> dict[str, str]:
|
def read_disabled_users() -> dict[str, str]:
|
||||||
raw = load_json(DISABLED_USERS_FILE, {}) or {}
|
raw = load_json(DISABLED_USERS_FILE, {}) or {}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -227,11 +260,12 @@ def write_disabled_users(users: dict[str, str]) -> None:
|
|||||||
def read_user_records() -> dict[str, dict[str, Any]]:
|
def read_user_records() -> dict[str, dict[str, Any]]:
|
||||||
active = read_telemt_users()
|
active = read_telemt_users()
|
||||||
disabled = read_disabled_users()
|
disabled = read_disabled_users()
|
||||||
|
ip_limits = read_user_max_unique_ips()
|
||||||
records: dict[str, dict[str, Any]] = {}
|
records: dict[str, dict[str, Any]] = {}
|
||||||
for name, secret in disabled.items():
|
for name, secret in disabled.items():
|
||||||
records[name] = {"secret": secret, "enabled": False}
|
records[name] = {"secret": secret, "enabled": False, "max_unique_ips": ip_limits.get(name, 0)}
|
||||||
for name, secret in active.items():
|
for name, secret in active.items():
|
||||||
records[name] = {"secret": secret, "enabled": True}
|
records[name] = {"secret": secret, "enabled": True, "max_unique_ips": ip_limits.get(name, 0)}
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +277,25 @@ def _ordered_user_lines(users: dict[str, str]) -> list[str]:
|
|||||||
return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names]
|
return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names]
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_user_int_lines(values: dict[str, int]) -> list[str]:
|
||||||
|
positive: dict[str, int] = {}
|
||||||
|
for name, value in values.items():
|
||||||
|
name_s = str(name)
|
||||||
|
if not USER_RE.match(name_s):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if number > 0:
|
||||||
|
positive[name_s] = number
|
||||||
|
names = []
|
||||||
|
if "main" in positive:
|
||||||
|
names.append("main")
|
||||||
|
names.extend(sorted(n for n in positive if n != "main"))
|
||||||
|
return [f'{quote_toml_key(name)} = {positive[name]}' for name in names]
|
||||||
|
|
||||||
|
|
||||||
def parse_toml_key(raw: str) -> str:
|
def parse_toml_key(raw: str) -> str:
|
||||||
key = raw.strip()
|
key = raw.strip()
|
||||||
if len(key) >= 2 and key[0] == key[-1] == '"':
|
if len(key) >= 2 and key[0] == key[-1] == '"':
|
||||||
@@ -293,6 +346,55 @@ def write_telemt_users(users: dict[str, str]) -> None:
|
|||||||
tmp.replace(TELEMT_CONFIG)
|
tmp.replace(TELEMT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def write_toml_int_table(table: str, values: dict[str, int]) -> None:
|
||||||
|
TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else []
|
||||||
|
rendered = _ordered_user_int_lines(values)
|
||||||
|
header = f"[{table}]"
|
||||||
|
out: list[str] = []
|
||||||
|
in_table = False
|
||||||
|
found = False
|
||||||
|
|
||||||
|
for raw in lines:
|
||||||
|
if raw.strip() == header:
|
||||||
|
found = True
|
||||||
|
in_table = True
|
||||||
|
if rendered:
|
||||||
|
out.append(raw)
|
||||||
|
out.extend(rendered)
|
||||||
|
continue
|
||||||
|
if in_table and raw.strip().startswith("["):
|
||||||
|
in_table = False
|
||||||
|
if in_table:
|
||||||
|
continue
|
||||||
|
out.append(raw)
|
||||||
|
|
||||||
|
if not found and rendered:
|
||||||
|
if out and out[-1].strip():
|
||||||
|
out.append("")
|
||||||
|
out.append(header)
|
||||||
|
out.extend(rendered)
|
||||||
|
|
||||||
|
tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp")
|
||||||
|
tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
tmp.replace(TELEMT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def write_user_max_unique_ips(values: dict[str, int]) -> None:
|
||||||
|
write_toml_int_table("access.user_max_unique_ips", values)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_max_unique_ips(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("max_unique_ips must be an integer") from None
|
||||||
|
if number < 0 or number > MAX_UNIQUE_IP_LIMIT:
|
||||||
|
raise ValueError(f"max_unique_ips must be between 0 and {MAX_UNIQUE_IP_LIMIT}")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
def restart_service(name: str) -> bool:
|
def restart_service(name: str) -> bool:
|
||||||
code, _, _ = run(["systemctl", "restart", name], timeout=25)
|
code, _, _ = run(["systemctl", "restart", name], timeout=25)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -1071,6 +1173,7 @@ def user_payload(
|
|||||||
name: str,
|
name: str,
|
||||||
secret: str,
|
secret: str,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
|
max_unique_ips: int = 0,
|
||||||
include_runtime: bool = False,
|
include_runtime: bool = False,
|
||||||
traffic_snapshot: dict[str, Any] | None = None,
|
traffic_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -1080,6 +1183,7 @@ def user_payload(
|
|||||||
"link": proxy_link(secret),
|
"link": proxy_link(secret),
|
||||||
"main": name == "main",
|
"main": name == "main",
|
||||||
"enabled": bool(enabled),
|
"enabled": bool(enabled),
|
||||||
|
"max_unique_ips": _int_value(max_unique_ips),
|
||||||
}
|
}
|
||||||
if traffic_snapshot:
|
if traffic_snapshot:
|
||||||
item["traffic"] = {
|
item["traffic"] = {
|
||||||
@@ -1177,7 +1281,13 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
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"], traffic_snapshot=latest.get(name)))
|
items.append(user_payload(
|
||||||
|
name,
|
||||||
|
record["secret"],
|
||||||
|
record["enabled"],
|
||||||
|
record.get("max_unique_ips", 0),
|
||||||
|
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("/qr"):
|
elif path.startswith("/api/users/") and path.endswith("/qr"):
|
||||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")])
|
name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")])
|
||||||
@@ -1231,7 +1341,14 @@ 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, traffic_snapshot=latest_user_stats().get(name))})
|
self.send_json({"ok": True, "data": user_payload(
|
||||||
|
name,
|
||||||
|
record["secret"],
|
||||||
|
record["enabled"],
|
||||||
|
record.get("max_unique_ips", 0),
|
||||||
|
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/backups/schedule":
|
elif path == "/api/backups/schedule":
|
||||||
@@ -1296,7 +1413,38 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_error_json(500, f"failed to save config: {exc}")
|
self.send_error_json(500, f"failed to save config: {exc}")
|
||||||
return
|
return
|
||||||
restart_requested = request_service_restart("telemt")
|
restart_requested = request_service_restart("telemt")
|
||||||
self.send_json({"ok": True, "data": user_payload(name, secret, True), "restart": {"mode": "async", "requested": restart_requested}})
|
self.send_json({"ok": True, "data": user_payload(name, secret, True, 0), "restart": {"mode": "async", "requested": restart_requested}})
|
||||||
|
elif path.startswith("/api/users/") and path.endswith("/max-ips"):
|
||||||
|
name = urllib.parse.unquote(path[len("/api/users/"):-len("/max-ips")])
|
||||||
|
try:
|
||||||
|
limit = normalize_max_unique_ips(body.get("max_unique_ips"))
|
||||||
|
except ValueError as exc:
|
||||||
|
self.send_error_json(400, str(exc))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with FileLock(USER_LOCK_FILE):
|
||||||
|
records = read_user_records()
|
||||||
|
if name not in records:
|
||||||
|
self.send_error_json(404, "user not found")
|
||||||
|
return
|
||||||
|
limits = read_user_max_unique_ips()
|
||||||
|
if limit > 0:
|
||||||
|
limits[name] = limit
|
||||||
|
else:
|
||||||
|
limits.pop(name, None)
|
||||||
|
write_user_max_unique_ips(limits)
|
||||||
|
record = read_user_records()[name]
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_error_json(500, f"failed to save config: {exc}")
|
||||||
|
return
|
||||||
|
restart_requested = request_service_restart("telemt")
|
||||||
|
self.send_json({"ok": True, "data": user_payload(
|
||||||
|
name,
|
||||||
|
record["secret"],
|
||||||
|
record["enabled"],
|
||||||
|
record.get("max_unique_ips", 0),
|
||||||
|
traffic_snapshot=latest_user_stats().get(name),
|
||||||
|
), "restart": {"mode": "async", "requested": restart_requested}})
|
||||||
elif path.startswith("/api/users/") and path.endswith("/enabled"):
|
elif path.startswith("/api/users/") and path.endswith("/enabled"):
|
||||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")])
|
name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")])
|
||||||
if name == "main":
|
if name == "main":
|
||||||
@@ -1327,7 +1475,7 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_error_json(500, f"failed to save config: {exc}")
|
self.send_error_json(500, f"failed to save config: {exc}")
|
||||||
return
|
return
|
||||||
restart_requested = request_service_restart("telemt")
|
restart_requested = request_service_restart("telemt")
|
||||||
self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restart": {"mode": "async", "requested": restart_requested}})
|
self.send_json({"ok": True, "data": user_payload(name, secret, enabled, records[name].get("max_unique_ips", 0)), "restart": {"mode": "async", "requested": restart_requested}})
|
||||||
elif path == "/api/backups":
|
elif path == "/api/backups":
|
||||||
ok, result = create_backup()
|
ok, result = create_backup()
|
||||||
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
||||||
@@ -1399,8 +1547,11 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
active.pop(name, None)
|
active.pop(name, None)
|
||||||
disabled.pop(name, None)
|
disabled.pop(name, None)
|
||||||
|
limits = read_user_max_unique_ips()
|
||||||
|
limits.pop(name, None)
|
||||||
write_telemt_users(active)
|
write_telemt_users(active)
|
||||||
write_disabled_users(disabled)
|
write_disabled_users(disabled)
|
||||||
|
write_user_max_unique_ips(limits)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_error_json(500, f"failed to save config: {exc}")
|
self.send_error_json(500, f"failed to save config: {exc}")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const i18n = {
|
|||||||
tableSecret: "Secret",
|
tableSecret: "Secret",
|
||||||
tableLink: "Link",
|
tableLink: "Link",
|
||||||
tableTraffic: "Traffic",
|
tableTraffic: "Traffic",
|
||||||
|
ipLimit: "IP limit",
|
||||||
|
ipLimitHint: "0 = unlimited",
|
||||||
|
saveIpLimit: "OK",
|
||||||
tableTrafficDelta: "Traffic delta",
|
tableTrafficDelta: "Traffic delta",
|
||||||
tableTrafficTotal: "Total",
|
tableTrafficTotal: "Total",
|
||||||
tableActions: "Actions",
|
tableActions: "Actions",
|
||||||
@@ -165,6 +168,7 @@ const i18n = {
|
|||||||
languageSaved: "Language saved",
|
languageSaved: "Language saved",
|
||||||
keyEnabled: "Key enabled",
|
keyEnabled: "Key enabled",
|
||||||
keyDisabled: "Key disabled",
|
keyDisabled: "Key disabled",
|
||||||
|
ipLimitSaved: "IP limit saved",
|
||||||
visualTitle: "Port 443 map",
|
visualTitle: "Port 443 map",
|
||||||
visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.",
|
visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.",
|
||||||
port443Checked: "checked",
|
port443Checked: "checked",
|
||||||
@@ -276,6 +280,9 @@ const i18n = {
|
|||||||
tableSecret: "Секрет",
|
tableSecret: "Секрет",
|
||||||
tableLink: "Ссылка",
|
tableLink: "Ссылка",
|
||||||
tableTraffic: "Трафик",
|
tableTraffic: "Трафик",
|
||||||
|
ipLimit: "Лимит IP",
|
||||||
|
ipLimitHint: "0 = безлимит",
|
||||||
|
saveIpLimit: "OK",
|
||||||
tableTrafficDelta: "Прирост трафика",
|
tableTrafficDelta: "Прирост трафика",
|
||||||
tableTrafficTotal: "Всего",
|
tableTrafficTotal: "Всего",
|
||||||
tableActions: "Действия",
|
tableActions: "Действия",
|
||||||
@@ -384,6 +391,7 @@ const i18n = {
|
|||||||
languageSaved: "Язык сохранён",
|
languageSaved: "Язык сохранён",
|
||||||
keyEnabled: "Ключ включён",
|
keyEnabled: "Ключ включён",
|
||||||
keyDisabled: "Ключ отключён",
|
keyDisabled: "Ключ отключён",
|
||||||
|
ipLimitSaved: "Лимит IP сохранён",
|
||||||
visualTitle: "Карта порта 443",
|
visualTitle: "Карта порта 443",
|
||||||
visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.",
|
visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.",
|
||||||
port443Checked: "проверено",
|
port443Checked: "проверено",
|
||||||
@@ -1138,6 +1146,7 @@ function renderUsers() {
|
|||||||
const traffic = user.traffic || {};
|
const traffic = user.traffic || {};
|
||||||
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
|
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
|
||||||
const activeIps = Number(traffic.active_unique_ips) || 0;
|
const activeIps = Number(traffic.active_unique_ips) || 0;
|
||||||
|
const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0;
|
||||||
return `
|
return `
|
||||||
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""} ${selected ? "selected-row" : ""}" data-select-user-traffic="${escapeAttr(user.name)}" aria-selected="${selected ? "true" : "false"}">
|
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""} ${selected ? "selected-row" : ""}" data-select-user-traffic="${escapeAttr(user.name)}" aria-selected="${selected ? "true" : "false"}">
|
||||||
<td data-label="${escapeAttr(t("tableUser"))}">
|
<td data-label="${escapeAttr(t("tableUser"))}">
|
||||||
@@ -1161,14 +1170,25 @@ function renderUsers() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
||||||
<div class="traffic-cell">
|
<div class="traffic-cell">
|
||||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
<div class="traffic-main">
|
||||||
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
|
<span>
|
||||||
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
|
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||||
|
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
|
||||||
|
</span>
|
||||||
|
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
|
||||||
|
</div>
|
||||||
|
<form class="ip-limit-control" data-ip-limit-form="${escapeAttr(user.name)}" title="${escapeAttr(t("ipLimitHint"))}">
|
||||||
|
<span>${escapeHtml(t("ipLimit"))}</span>
|
||||||
|
<input type="number" min="0" max="1000000" step="1" value="${escapeAttr(maxUniqueIps)}" data-ip-limit-input="${escapeAttr(user.name)}" aria-label="${escapeAttr(t("ipLimit"))}: ${escapeAttr(user.name)}">
|
||||||
|
<button class="soft" type="submit" data-ip-limit-save="${escapeAttr(user.name)}">${escapeHtml(t("saveIpLimit"))}</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="${escapeAttr(t("tableActions"))}" class="actions">
|
<td data-label="${escapeAttr(t("tableActions"))}">
|
||||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
|
<div class="action-buttons">
|
||||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</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>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`; }).join("");
|
`; }).join("");
|
||||||
@@ -1394,6 +1414,32 @@ async function setUserEnabled(name, enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setUserMaxUniqueIps(name, value) {
|
||||||
|
const limit = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(limit) || limit < 0 || limit > 1000000) {
|
||||||
|
toast(t("ipLimitHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = $$("[data-ip-limit-form]").find((item) => item.dataset.ipLimitForm === name);
|
||||||
|
const controls = form ? Array.from(form.querySelectorAll("input, button")) : [];
|
||||||
|
controls.forEach((control) => { control.disabled = true; });
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/users/${encodeURIComponent(name)}/max-ips`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ max_unique_ips: limit }),
|
||||||
|
});
|
||||||
|
state.users = state.users.map((user) => user.name === name ? { ...user, max_unique_ips: data.max_unique_ips } : user);
|
||||||
|
renderUsers();
|
||||||
|
addEvent(t("ipLimitSaved"), `${name}: ${data.max_unique_ips}`);
|
||||||
|
toast(t("changesApplyInBackground"));
|
||||||
|
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
} finally {
|
||||||
|
controls.forEach((control) => { control.disabled = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createBackup() {
|
async function createBackup() {
|
||||||
const btn = $("#createBackupBtn");
|
const btn = $("#createBackupBtn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -1590,6 +1636,7 @@ document.addEventListener("click", async (eventObj) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventObj.target.closest("input, select, textarea, label, form")) return;
|
||||||
const row = eventObj.target.closest("[data-select-user-traffic]");
|
const row = eventObj.target.closest("[data-select-user-traffic]");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true });
|
selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true });
|
||||||
@@ -1618,6 +1665,14 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
|
|||||||
addUser(name).catch((err) => toast(err.message));
|
addUser(name).catch((err) => toast(err.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("submit", (eventObj) => {
|
||||||
|
const form = eventObj.target.closest("[data-ip-limit-form]");
|
||||||
|
if (!form) return;
|
||||||
|
eventObj.preventDefault();
|
||||||
|
const input = form.querySelector("[data-ip-limit-input]");
|
||||||
|
setUserMaxUniqueIps(form.dataset.ipLimitForm, input?.value || "0");
|
||||||
|
});
|
||||||
|
|
||||||
$("#refreshBtn").addEventListener("click", refreshAll);
|
$("#refreshBtn").addEventListener("click", refreshAll);
|
||||||
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
|
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
|
||||||
$("#promoClose").addEventListener("click", () => {
|
$("#promoClose").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table class="keys-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="tableUser">User</th>
|
<th data-i18n="tableUser">User</th>
|
||||||
@@ -400,6 +400,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/app.js?v=2.5.0-admin11" type="module"></script>
|
<script src="/app.js?v=2.5.0-admin12" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -790,6 +790,24 @@ table {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keys-table {
|
||||||
|
min-width: 1180px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-table th:nth-child(1),
|
||||||
|
.keys-table td:nth-child(1) { width: 11%; }
|
||||||
|
.keys-table th:nth-child(2),
|
||||||
|
.keys-table td:nth-child(2) { width: 15%; }
|
||||||
|
.keys-table th:nth-child(3),
|
||||||
|
.keys-table td:nth-child(3) { width: 27%; }
|
||||||
|
.keys-table th:nth-child(4),
|
||||||
|
.keys-table td:nth-child(4) { width: 16%; }
|
||||||
|
.keys-table th:nth-child(5),
|
||||||
|
.keys-table td:nth-child(5) { width: 18%; }
|
||||||
|
.keys-table th:nth-child(6),
|
||||||
|
.keys-table td:nth-child(6) { width: 13%; }
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@@ -845,16 +863,41 @@ td small {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-table button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-cell {
|
.traffic-cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
min-width: 150px;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-main span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-cell strong {
|
.traffic-cell strong {
|
||||||
@@ -862,7 +905,32 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.traffic-cell .soft {
|
.traffic-cell .soft {
|
||||||
width: max-content;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(54px, 72px) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control input {
|
||||||
|
width: 72px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-traffic-panel {
|
.user-traffic-panel {
|
||||||
@@ -944,8 +1012,16 @@ td small {
|
|||||||
.backup-actions {
|
.backup-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1311,7 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
table,
|
table,
|
||||||
|
.keys-table,
|
||||||
thead,
|
thead,
|
||||||
tbody,
|
tbody,
|
||||||
tr,
|
tr,
|
||||||
@@ -1284,16 +1361,34 @@ td small {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions button {
|
.action-buttons button {
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.traffic-main,
|
||||||
|
.ip-limit-control {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-limit-control input,
|
||||||
|
.ip-limit-control button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-item,
|
.backup-item,
|
||||||
.backup-schedule,
|
.backup-schedule,
|
||||||
.event,
|
.event,
|
||||||
@@ -1304,11 +1399,13 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-actions,
|
.mini-actions,
|
||||||
|
.action-buttons,
|
||||||
.backup-actions {
|
.backup-actions {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-actions button,
|
.mini-actions button,
|
||||||
|
.action-buttons button,
|
||||||
.backup-actions button {
|
.backup-actions button {
|
||||||
flex: 1 1 130px;
|
flex: 1 1 130px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ _DOMAIN_RE = re.compile(
|
|||||||
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
|
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
|
||||||
)
|
)
|
||||||
_USER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
_USER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||||
|
MAX_UNIQUE_IP_LIMIT = 1000000
|
||||||
|
|
||||||
|
|
||||||
class FileLock:
|
class FileLock:
|
||||||
@@ -1447,6 +1448,25 @@ def ordered_user_lines(users: Dict[str, str]) -> List[str]:
|
|||||||
return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names]
|
return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names]
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_user_int_lines(values: Dict[str, int]) -> List[str]:
|
||||||
|
positive: Dict[str, int] = {}
|
||||||
|
for name, value in values.items():
|
||||||
|
name_s = str(name)
|
||||||
|
if not _USER_NAME_RE.match(name_s):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if number > 0:
|
||||||
|
positive[name_s] = number
|
||||||
|
names: List[str] = []
|
||||||
|
if "main" in positive:
|
||||||
|
names.append("main")
|
||||||
|
names.extend(sorted(name for name in positive if name != "main"))
|
||||||
|
return [f'{quote_toml_key(name)} = {positive[name]}' for name in names]
|
||||||
|
|
||||||
|
|
||||||
def load_telemt_users() -> Dict[str, str]:
|
def load_telemt_users() -> Dict[str, str]:
|
||||||
"""Return users from [access.users] in telemt config."""
|
"""Return users from [access.users] in telemt config."""
|
||||||
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
|
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
|
||||||
@@ -1460,6 +1480,23 @@ def load_telemt_users() -> Dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_max_unique_ips() -> Dict[str, int]:
|
||||||
|
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
|
||||||
|
limits = telemt_cfg.get("access", {}).get("user_max_unique_ips", {})
|
||||||
|
if not isinstance(limits, dict):
|
||||||
|
return {}
|
||||||
|
clean: Dict[str, int] = {}
|
||||||
|
for name, value in limits.items():
|
||||||
|
name_s = str(name)
|
||||||
|
if not _USER_NAME_RE.match(name_s):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
clean[name_s] = max(0, int(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
def load_disabled_users() -> Dict[str, str]:
|
def load_disabled_users() -> Dict[str, str]:
|
||||||
raw = load_json(DISABLED_USERS_FILE) or {}
|
raw = load_json(DISABLED_USERS_FILE) or {}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -1495,13 +1532,70 @@ def save_disabled_users(users: Dict[str, str]) -> bool:
|
|||||||
|
|
||||||
def load_user_records() -> Dict[str, Dict[str, Any]]:
|
def load_user_records() -> Dict[str, Dict[str, Any]]:
|
||||||
records: Dict[str, Dict[str, Any]] = {}
|
records: Dict[str, Dict[str, Any]] = {}
|
||||||
|
limits = load_user_max_unique_ips()
|
||||||
for name, secret in load_disabled_users().items():
|
for name, secret in load_disabled_users().items():
|
||||||
records[name] = {"secret": secret, "enabled": False}
|
records[name] = {"secret": secret, "enabled": False, "max_unique_ips": limits.get(name, 0)}
|
||||||
for name, secret in load_telemt_users().items():
|
for name, secret in load_telemt_users().items():
|
||||||
records[name] = {"secret": secret, "enabled": True}
|
records[name] = {"secret": secret, "enabled": True, "max_unique_ips": limits.get(name, 0)}
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def save_toml_int_table(table: str, values: Dict[str, int]) -> bool:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True)
|
||||||
|
if os.path.exists(TELEMT_CONFIG):
|
||||||
|
with open(TELEMT_CONFIG, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
else:
|
||||||
|
lines = []
|
||||||
|
rendered = ordered_user_int_lines(values)
|
||||||
|
header = f"[{table}]"
|
||||||
|
out: List[str] = []
|
||||||
|
in_table = False
|
||||||
|
found = False
|
||||||
|
for raw in lines:
|
||||||
|
if raw.strip() == header:
|
||||||
|
found = True
|
||||||
|
in_table = True
|
||||||
|
if rendered:
|
||||||
|
out.append(raw)
|
||||||
|
out.extend(rendered)
|
||||||
|
continue
|
||||||
|
if in_table and raw.strip().startswith("["):
|
||||||
|
in_table = False
|
||||||
|
if in_table:
|
||||||
|
continue
|
||||||
|
out.append(raw)
|
||||||
|
if not found and rendered:
|
||||||
|
if out and out[-1].strip():
|
||||||
|
out.append("")
|
||||||
|
out.append(header)
|
||||||
|
out.extend(rendered)
|
||||||
|
tmp = f"{TELEMT_CONFIG}.tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(out).rstrip() + "\n")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, TELEMT_CONFIG)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save telemt int table {table}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_max_unique_ips(values: Dict[str, int]) -> bool:
|
||||||
|
return save_toml_int_table("access.user_max_unique_ips", values)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_max_unique_ips(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("Лимит должен быть целым числом")
|
||||||
|
if number < 0 or number > MAX_UNIQUE_IP_LIMIT:
|
||||||
|
raise ValueError(f"Лимит должен быть от 0 до {MAX_UNIQUE_IP_LIMIT}")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
def save_telemt_users(users: Dict[str, str]) -> bool:
|
def save_telemt_users(users: Dict[str, str]) -> bool:
|
||||||
"""Persist [access.users] while keeping the rest of the TOML structure."""
|
"""Persist [access.users] while keeping the rest of the TOML structure."""
|
||||||
try:
|
try:
|
||||||
@@ -1791,7 +1885,7 @@ async def cb_menu_users(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
|||||||
await safe_edit_message(query, text, reply_markup=_users_keyboard(users, user_id), parse_mode="HTML")
|
await safe_edit_message(query, text, reply_markup=_users_keyboard(users, user_id), parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str:
|
async def _user_detail_text(name: str, secret: str, enabled: bool = True, max_unique_ips: int = 0) -> str:
|
||||||
link = await get_proxy_link_for_secret(secret)
|
link = await get_proxy_link_for_secret(secret)
|
||||||
api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}") if enabled else None
|
api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}") if enabled else None
|
||||||
details = ""
|
details = ""
|
||||||
@@ -1820,9 +1914,11 @@ async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str
|
|||||||
|
|
||||||
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"
|
||||||
|
limit_line = "0 (безлимит)" if not max_unique_ips else str(max_unique_ips)
|
||||||
return (
|
return (
|
||||||
f"<b>👤 {html.escape(name)}</b>\n\n"
|
f"<b>👤 {html.escape(name)}</b>\n\n"
|
||||||
f"Status: <b>{status_line}</b>\n"
|
f"Status: <b>{status_line}</b>\n"
|
||||||
|
f"Лимит IP: <code>{html.escape(limit_line)}</code>\n"
|
||||||
f"Secret: <code>{html.escape(secret)}</code>\n\n"
|
f"Secret: <code>{html.escape(secret)}</code>\n\n"
|
||||||
f"<b>Ссылка:</b>\n<code>{link_line}</code>\n"
|
f"<b>Ссылка:</b>\n<code>{link_line}</code>\n"
|
||||||
f"{details}"
|
f"{details}"
|
||||||
@@ -1845,9 +1941,11 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
return
|
return
|
||||||
enabled = bool(record.get("enabled"))
|
enabled = bool(record.get("enabled"))
|
||||||
secret = str(record.get("secret", ""))
|
secret = str(record.get("secret", ""))
|
||||||
|
max_unique_ips = int(record.get("max_unique_ips") or 0)
|
||||||
|
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")],
|
[InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")],
|
||||||
|
[InlineKeyboardButton("🌐 Лимит IP", callback_data=f"user_ip_limit_{name}")],
|
||||||
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
|
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
|
||||||
[InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")],
|
[InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")],
|
||||||
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
||||||
@@ -1855,12 +1953,13 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
if name == "main":
|
if name == "main":
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")],
|
[InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")],
|
||||||
|
[InlineKeyboardButton("🌐 Лимит IP", callback_data=f"user_ip_limit_{name}")],
|
||||||
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
|
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
|
||||||
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
||||||
]
|
]
|
||||||
await safe_edit_message(
|
await safe_edit_message(
|
||||||
query,
|
query,
|
||||||
await _user_detail_text(name, secret, enabled),
|
await _user_detail_text(name, secret, enabled, max_unique_ips),
|
||||||
reply_markup=InlineKeyboardMarkup(buttons),
|
reply_markup=InlineKeyboardMarkup(buttons),
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
@@ -1909,6 +2008,61 @@ async def cb_user_qr(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cb_user_ip_limit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
user_id = _uid(update)
|
||||||
|
name = query.data.removeprefix("user_ip_limit_")
|
||||||
|
users = load_user_records()
|
||||||
|
record = users.get(name)
|
||||||
|
if not record:
|
||||||
|
await query.answer("Ключ не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
current = int(record.get("max_unique_ips") or 0)
|
||||||
|
context.user_data["awaiting_user_ip_limit"] = name
|
||||||
|
text = (
|
||||||
|
f"<b>🌐 Лимит IP: {html.escape(name)}</b>\n\n"
|
||||||
|
f"Текущее значение: <code>{current}</code>\n"
|
||||||
|
"Отправьте число: <code>0</code> — безлимит, <code>1</code> — только один активный IP, "
|
||||||
|
"<code>2</code> — два активных IP и так далее."
|
||||||
|
)
|
||||||
|
await safe_edit_message(
|
||||||
|
query,
|
||||||
|
text,
|
||||||
|
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data=f"user_view_{name}")]]),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_ip_limit_from_text(update: Update, context: ContextTypes.DEFAULT_TYPE, raw_value: str, name: str) -> None:
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
try:
|
||||||
|
limit = normalize_max_unique_ips(raw_value.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
await update.message.reply_text(f"❌ {html.escape(str(exc))}", parse_mode="HTML")
|
||||||
|
return
|
||||||
|
with FileLock(USER_LOCK_FILE):
|
||||||
|
records = load_user_records()
|
||||||
|
if name not in records:
|
||||||
|
await update.message.reply_text("❌ Ключ не найден.")
|
||||||
|
return
|
||||||
|
limits = load_user_max_unique_ips()
|
||||||
|
if limit > 0:
|
||||||
|
limits[name] = limit
|
||||||
|
else:
|
||||||
|
limits.pop(name, None)
|
||||||
|
saved = save_user_max_unique_ips(limits)
|
||||||
|
if not saved:
|
||||||
|
await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml")
|
||||||
|
return
|
||||||
|
await refresh_telemt_after_user_change()
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ Лимит IP сохранён для <code>{html.escape(name)}</code>: <code>{limit}</code>",
|
||||||
|
reply_markup=_users_keyboard(load_user_records(), user_id),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
@@ -1997,7 +2151,9 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T
|
|||||||
return
|
return
|
||||||
active.pop(name, None)
|
active.pop(name, None)
|
||||||
disabled.pop(name, None)
|
disabled.pop(name, None)
|
||||||
saved = save_telemt_users(active) and save_disabled_users(disabled)
|
limits = load_user_max_unique_ips()
|
||||||
|
limits.pop(name, None)
|
||||||
|
saved = save_telemt_users(active) and save_disabled_users(disabled) and save_user_max_unique_ips(limits)
|
||||||
if not saved:
|
if not saved:
|
||||||
await safe_edit_message(query, "❌ Не удалось сохранить config.toml")
|
await safe_edit_message(query, "❌ Не удалось сохранить config.toml")
|
||||||
return
|
return
|
||||||
@@ -3022,6 +3178,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
await cb_user_view(update, context)
|
await cb_user_view(update, context)
|
||||||
elif data.startswith("user_qr_"):
|
elif data.startswith("user_qr_"):
|
||||||
await cb_user_qr(update, context)
|
await cb_user_qr(update, context)
|
||||||
|
elif data.startswith("user_ip_limit_"):
|
||||||
|
await cb_user_ip_limit(update, context)
|
||||||
elif data.startswith("user_toggle_"):
|
elif data.startswith("user_toggle_"):
|
||||||
await cb_user_toggle(update, context)
|
await cb_user_toggle(update, context)
|
||||||
elif data.startswith("user_del_yes_"):
|
elif data.startswith("user_del_yes_"):
|
||||||
@@ -3054,6 +3212,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
|
|||||||
if not is_user_allowed(update.effective_user.id):
|
if not is_user_allowed(update.effective_user.id):
|
||||||
return
|
return
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
ip_limit_user = context.user_data.pop("awaiting_user_ip_limit", None)
|
||||||
|
if ip_limit_user:
|
||||||
|
await set_user_ip_limit_from_text(update, context, update.message.text.strip(), str(ip_limit_user))
|
||||||
|
return
|
||||||
if context.user_data.pop("awaiting_user_name", False):
|
if context.user_data.pop("awaiting_user_name", False):
|
||||||
await create_user_from_text(update, context, update.message.text.strip())
|
await create_user_from_text(update, context, update.message.text.strip())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -13,6 +15,8 @@ SERVER_PATH = ROOT / "admin-web" / "server.py"
|
|||||||
def load_server(tmpdir: Path):
|
def load_server(tmpdir: Path):
|
||||||
os.environ["GOTELEGRAM_BACKUP_DIR"] = str(tmpdir / "backups")
|
os.environ["GOTELEGRAM_BACKUP_DIR"] = str(tmpdir / "backups")
|
||||||
os.environ["GOTELEGRAM_DIR"] = str(tmpdir / "gotelegram")
|
os.environ["GOTELEGRAM_DIR"] = str(tmpdir / "gotelegram")
|
||||||
|
os.environ["TELEMT_CONFIG"] = str(tmpdir / "etc" / "telemt" / "config.toml")
|
||||||
|
os.environ["GOTELEGRAM_DISABLED_USERS"] = str(tmpdir / "gotelegram" / "disabled_users.json")
|
||||||
module_name = "gotelegram_admin_server_test"
|
module_name = "gotelegram_admin_server_test"
|
||||||
sys.modules.pop(module_name, None)
|
sys.modules.pop(module_name, None)
|
||||||
spec = importlib.util.spec_from_file_location(module_name, SERVER_PATH)
|
spec = importlib.util.spec_from_file_location(module_name, SERVER_PATH)
|
||||||
@@ -57,6 +61,84 @@ class AdminFeatureTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
server.backup_schedule_calendar("hourly")
|
server.backup_schedule_calendar("hourly")
|
||||||
|
|
||||||
|
def test_user_records_include_active_disabled_and_ip_limits(self):
|
||||||
|
with tempfile.TemporaryDirectory() as raw:
|
||||||
|
tmpdir = Path(raw)
|
||||||
|
server = load_server(tmpdir)
|
||||||
|
server.TELEMT_CONFIG.parent.mkdir(parents=True)
|
||||||
|
server.TELEMT_CONFIG.write_text(
|
||||||
|
"\n".join([
|
||||||
|
"[access.users]",
|
||||||
|
'main = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"',
|
||||||
|
'client = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"',
|
||||||
|
"",
|
||||||
|
"[access.user_max_unique_ips]",
|
||||||
|
"main = 0",
|
||||||
|
"client = 2",
|
||||||
|
"disabled = 1",
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
server.DISABLED_USERS_FILE.parent.mkdir(parents=True)
|
||||||
|
server.DISABLED_USERS_FILE.write_text(
|
||||||
|
json.dumps({"users": {"disabled": "cccccccccccccccccccccccccccccccc"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
records = server.read_user_records()
|
||||||
|
|
||||||
|
self.assertTrue(records["client"]["enabled"])
|
||||||
|
self.assertEqual(records["client"]["max_unique_ips"], 2)
|
||||||
|
self.assertFalse(records["disabled"]["enabled"])
|
||||||
|
self.assertEqual(records["disabled"]["max_unique_ips"], 1)
|
||||||
|
self.assertEqual(records["main"]["max_unique_ips"], 0)
|
||||||
|
|
||||||
|
def test_write_user_max_unique_ips_preserves_other_toml_sections(self):
|
||||||
|
with tempfile.TemporaryDirectory() as raw:
|
||||||
|
server = load_server(Path(raw))
|
||||||
|
server.TELEMT_CONFIG.parent.mkdir(parents=True)
|
||||||
|
server.TELEMT_CONFIG.write_text(
|
||||||
|
"\n".join([
|
||||||
|
"[server]",
|
||||||
|
"port = 443",
|
||||||
|
"",
|
||||||
|
"[access.users]",
|
||||||
|
'main = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"',
|
||||||
|
'client = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"',
|
||||||
|
"",
|
||||||
|
"[access.user_max_unique_ips]",
|
||||||
|
"client = 3",
|
||||||
|
"old = 4",
|
||||||
|
"",
|
||||||
|
"[network]",
|
||||||
|
'dns_overrides = ["example.com:8443:127.0.0.1"]',
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
server.write_user_max_unique_ips({"main": 1, "client": 0, "new": 5})
|
||||||
|
text = server.TELEMT_CONFIG.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("[server]\nport = 443", text)
|
||||||
|
self.assertIn("[network]\ndns_overrides", text)
|
||||||
|
self.assertIn("[access.user_max_unique_ips]", text)
|
||||||
|
self.assertIn('"main" = 1', text)
|
||||||
|
self.assertIn('"new" = 5', text)
|
||||||
|
self.assertNotIn("client = 3", text)
|
||||||
|
self.assertNotIn("old = 4", text)
|
||||||
|
|
||||||
|
def test_keys_table_keeps_actions_inside_table_cell_wrapper(self):
|
||||||
|
app_js = (ROOT / "admin-web" / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
styles = (ROOT / "admin-web" / "static" / "styles.css").read_text(encoding="utf-8")
|
||||||
|
index = (ROOT / "admin-web" / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertNotIn('class="actions"', app_js)
|
||||||
|
self.assertIn('class="action-buttons"', app_js)
|
||||||
|
self.assertNotIn("td.actions", styles)
|
||||||
|
self.assertRegex(index, r'<table class="keys-table">[\s\S]*?<tbody id="usersTable">')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user