v2.5.0: align key controls and add IP limits

This commit is contained in:
Виталий Литвинов
2026-04-25 15:19:28 +03:00
parent bd3fc1af18
commit 507a2979e5
8 changed files with 583 additions and 33 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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()