diff --git a/DOCS_AI.md b/DOCS_AI.md index d3381c8..ce1077f 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -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). - `[access]` — rate limits, доступ. - `[access.users]` — таблица `name = "secret"` (секрет — 32 hex). +- `[access.user_max_unique_ips]` — опциональная таблица `name = число`, ограничивает одновременные уникальные IP на ключ; в goTelegram Pro значение `0` в UI удаляет строку и означает безлимит. ### Почему `unknown_sni_action = Drop` нас кусал @@ -456,9 +457,9 @@ switch_language ru|en - write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. - язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `POST /api/settings/language` сохраняет RU/EN в общий конфиг, marker file и bot `.env`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override; - UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, полезный overview-блок по реальным TCP/UDP-слушателям 443, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`; -- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status`, `port_443` и `backup_schedule`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/users//traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/users//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//traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/users//qr` отдаёт PNG QR для Telegram proxy link через `qrencode`; `POST /api/users//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//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//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`. @@ -473,7 +474,7 @@ Traffic retention: обе CSV-истории хранятся максимум 3 Автоматически переписывать 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`. @@ -651,7 +652,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s: ## 17. Changelog -- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/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.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index ed8c940..dce93d3 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -145,7 +145,7 @@ CLI и бот переведены на русский и английский. - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - 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 часа / месяц детализация остаётся полной. @@ -164,6 +164,8 @@ goTelegram Pro не переписывает базу 3x-ui автоматиче Отключённые ключи убираются из активного 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. Обновление @@ -255,7 +257,7 @@ A: Сам MTProxy — да, это публичная технология из ## Changelog (коротко) -- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка, 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.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. diff --git a/admin-web/server.py b/admin-web/server.py index 99bda4e..c7ebfed 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -53,6 +53,7 @@ USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") SENSITIVE_CONFIG_KEYS = {"secret"} BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$") +MAX_UNIQUE_IP_LIMIT = 1000000 TRAFFIC_WINDOWS = { "15m": 15 * 60, "1h": 60 * 60, @@ -197,6 +198,38 @@ def read_telemt_users() -> dict[str, str]: 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]: raw = load_json(DISABLED_USERS_FILE, {}) or {} 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]]: active = read_telemt_users() disabled = read_disabled_users() + ip_limits = read_user_max_unique_ips() records: dict[str, dict[str, Any]] = {} 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(): - records[name] = {"secret": secret, "enabled": True} + records[name] = {"secret": secret, "enabled": True, "max_unique_ips": ip_limits.get(name, 0)} 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] +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: key = raw.strip() 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) +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: code, _, _ = run(["systemctl", "restart", name], timeout=25) if code != 0: @@ -1071,6 +1173,7 @@ def user_payload( name: str, secret: str, enabled: bool = True, + max_unique_ips: int = 0, include_runtime: bool = False, traffic_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -1080,6 +1183,7 @@ def user_payload( "link": proxy_link(secret), "main": name == "main", "enabled": bool(enabled), + "max_unique_ips": _int_value(max_unique_ips), } if traffic_snapshot: item["traffic"] = { @@ -1177,7 +1281,13 @@ class AdminHandler(BaseHTTPRequestHandler): items = [] for name in sorted(users, key=lambda item: (item != "main", item)): 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}) elif path.startswith("/api/users/") and path.endswith("/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") return 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": self.send_json({"ok": True, "data": list_backups()}) elif path == "/api/backups/schedule": @@ -1296,7 +1413,38 @@ class AdminHandler(BaseHTTPRequestHandler): 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, 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"): name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")]) if name == "main": @@ -1327,7 +1475,7 @@ class AdminHandler(BaseHTTPRequestHandler): 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, 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": ok, result = create_backup() self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) @@ -1399,8 +1547,11 @@ class AdminHandler(BaseHTTPRequestHandler): return active.pop(name, None) disabled.pop(name, None) + limits = read_user_max_unique_ips() + limits.pop(name, None) write_telemt_users(active) write_disabled_users(disabled) + write_user_max_unique_ips(limits) except Exception as exc: self.send_error_json(500, f"failed to save config: {exc}") return diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 0b1e79d..690aa3e 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -57,6 +57,9 @@ const i18n = { tableSecret: "Secret", tableLink: "Link", tableTraffic: "Traffic", + ipLimit: "IP limit", + ipLimitHint: "0 = unlimited", + saveIpLimit: "OK", tableTrafficDelta: "Traffic delta", tableTrafficTotal: "Total", tableActions: "Actions", @@ -165,6 +168,7 @@ const i18n = { languageSaved: "Language saved", keyEnabled: "Key enabled", keyDisabled: "Key disabled", + ipLimitSaved: "IP limit saved", visualTitle: "Port 443 map", visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.", port443Checked: "checked", @@ -276,6 +280,9 @@ const i18n = { tableSecret: "Секрет", tableLink: "Ссылка", tableTraffic: "Трафик", + ipLimit: "Лимит IP", + ipLimitHint: "0 = безлимит", + saveIpLimit: "OK", tableTrafficDelta: "Прирост трафика", tableTrafficTotal: "Всего", tableActions: "Действия", @@ -384,6 +391,7 @@ const i18n = { languageSaved: "Язык сохранён", keyEnabled: "Ключ включён", keyDisabled: "Ключ отключён", + ipLimitSaved: "Лимит IP сохранён", visualTitle: "Карта порта 443", visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.", port443Checked: "проверено", @@ -1138,6 +1146,7 @@ function renderUsers() { const traffic = user.traffic || {}; const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--"; 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 ` @@ -1161,14 +1170,25 @@ function renderUsers() {
- ${escapeHtml(trafficTotal)} - ${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))} - +
+ + ${escapeHtml(trafficTotal)} + ${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))} + + +
+
+ ${escapeHtml(t("ipLimit"))} + + +
- - - + +
+ + +
`; }).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() { const btn = $("#createBackupBtn"); btn.disabled = true; @@ -1590,6 +1636,7 @@ document.addEventListener("click", async (eventObj) => { return; } + if (eventObj.target.closest("input, select, textarea, label, form")) return; const row = eventObj.target.closest("[data-select-user-traffic]"); if (!row) return; selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true }); @@ -1618,6 +1665,14 @@ $("#addUserForm").addEventListener("submit", (eventObj) => { 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); $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value)); $("#promoClose").addEventListener("click", () => { diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 02ed21b..bd8fc8d 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -195,7 +195,7 @@
- +
@@ -400,6 +400,6 @@ - + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 26c76c9..d734b4c 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -790,6 +790,24 @@ table { 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, td { padding: 14px 16px; @@ -845,16 +863,41 @@ td small { color: var(--muted); } -.actions { +.action-buttons { display: flex; - justify-content: flex-end; + align-items: center; + justify-content: flex-start; gap: 8px; + flex-wrap: nowrap; +} + +.keys-table button { + white-space: nowrap; +} + +.mini-actions { + justify-content: flex-start; + flex-wrap: nowrap; } .traffic-cell { display: grid; - gap: 6px; - min-width: 150px; + gap: 10px; + 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 { @@ -862,7 +905,32 @@ td small { } .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 { @@ -944,8 +1012,16 @@ td small { .backup-actions { display: flex; align-items: center; - justify-content: flex-end; gap: 8px; +} + +.mini-actions { + justify-content: flex-start; + flex-wrap: nowrap; +} + +.backup-actions { + justify-content: flex-end; flex-wrap: wrap; } @@ -1235,6 +1311,7 @@ td small { } table, + .keys-table, thead, tbody, tr, @@ -1284,16 +1361,34 @@ td small { white-space: nowrap; } - .actions { + .action-buttons { display: flex; justify-content: stretch; flex-wrap: wrap; } - .actions button { + .action-buttons button { 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-schedule, .event, @@ -1304,11 +1399,13 @@ td small { } .mini-actions, + .action-buttons, .backup-actions { justify-content: stretch; } .mini-actions button, + .action-buttons button, .backup-actions button { flex: 1 1 130px; } diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 333c48c..3e071a4 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -278,6 +278,7 @@ _DOMAIN_RE = re.compile( r"(?!-)[A-Za-z0-9-]{2,63}(? List[str]: 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]: """Return users from [access.users] in telemt config.""" 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]: raw = load_json(DISABLED_USERS_FILE) or {} 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]]: records: Dict[str, Dict[str, Any]] = {} + limits = load_user_max_unique_ips() 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(): - records[name] = {"secret": secret, "enabled": True} + records[name] = {"secret": secret, "enabled": True, "max_unique_ips": limits.get(name, 0)} 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: """Persist [access.users] while keeping the rest of the TOML structure.""" 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") -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) api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}") if enabled else None 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" status_line = "🟢 enabled" if enabled else "⏸ disabled" + limit_line = "0 (безлимит)" if not max_unique_ips else str(max_unique_ips) return ( f"👤 {html.escape(name)}\n\n" f"Status: {status_line}\n" + f"Лимит IP: {html.escape(limit_line)}\n" f"Secret: {html.escape(secret)}\n\n" f"Ссылка:\n{link_line}\n" f"{details}" @@ -1845,9 +1941,11 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No return enabled = bool(record.get("enabled")) secret = str(record.get("secret", "")) + max_unique_ips = int(record.get("max_unique_ips") or 0) buttons = [ [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("🗑 Удалить", callback_data=f"user_del_{name}")], [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": buttons = [ [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(_t(user_id, "btn_back"), callback_data="menu_users")], ] await safe_edit_message( query, - await _user_detail_text(name, secret, enabled), + await _user_detail_text(name, secret, enabled, max_unique_ips), reply_markup=InlineKeyboardMarkup(buttons), parse_mode="HTML", 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"🌐 Лимит IP: {html.escape(name)}\n\n" + f"Текущее значение: {current}\n" + "Отправьте число: 0 — безлимит, 1 — только один активный IP, " + "2 — два активных 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 сохранён для {html.escape(name)}: {limit}", + reply_markup=_users_keyboard(load_user_records(), user_id), + parse_mode="HTML", + ) + + async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() @@ -1997,7 +2151,9 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T return active.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: await safe_edit_message(query, "❌ Не удалось сохранить config.toml") return @@ -3022,6 +3178,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await cb_user_view(update, context) elif data.startswith("user_qr_"): await cb_user_qr(update, context) + elif data.startswith("user_ip_limit_"): + await cb_user_ip_limit(update, context) elif data.startswith("user_toggle_"): await cb_user_toggle(update, context) 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): return 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): await create_user_from_text(update, context, update.message.text.strip()) return diff --git a/tests/test_admin_features.py b/tests/test_admin_features.py index 6a0cd5f..2940a8a 100644 --- a/tests/test_admin_features.py +++ b/tests/test_admin_features.py @@ -1,5 +1,7 @@ import importlib.util +import json import os +import re import sys import tempfile import unittest @@ -13,6 +15,8 @@ SERVER_PATH = ROOT / "admin-web" / "server.py" def load_server(tmpdir: Path): os.environ["GOTELEGRAM_BACKUP_DIR"] = str(tmpdir / "backups") 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" sys.modules.pop(module_name, None) spec = importlib.util.spec_from_file_location(module_name, SERVER_PATH) @@ -57,6 +61,84 @@ class AdminFeatureTests(unittest.TestCase): with self.assertRaises(ValueError): 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'
User
[\s\S]*?') + if __name__ == "__main__": unittest.main()