19 Commits

Author SHA1 Message Date
anten-ka
4fec096676 fix: telemt -v -> --version, CSV header skip in bot stats 2026-04-12 08:09:03 +03:00
anten-ka
7075ff8696 v2.4.10: stats auto-install + i18n for show_proxy_info, traffic stats (SSH+bot) 2026-04-12 07:54:40 +03:00
anten-ka
0e38c2b5b6 v2.4.9: UBF v2.0 backup + manual secret recovery
- lib/backup.sh: complete rewrite for Unified Backup Format v2.0
  * metadata.json with backup_id (GT-YYMMDD-<last6hex>) and SHA-256 fingerprint
  * secrets.json with raw_secret, faketls_secret, proxy_link, bot_token
  * structured dirs: telemt/, gotelegram/, nginx/, letsencrypt/, site/, bot/
  * auto-detect and auto-migrate v1.1 -> v2.0 on restore
  * parse_manual_secret: accepts tg://proxy URL, ee-prefix, or raw 32-hex
  * manual_secret_input: interactive entry with env var export
- install.sh: new 3-option menu_install (new / restore / existing key)
  * install_lite_mode + install_pro_mode respect GOTELEGRAM_EXISTING_* env vars
- lib/lang/ru.sh + en.sh: v2.4.9 i18n strings (backup_*, manual_secret_*, install_menu_*)
- lib/common.sh + gotelegram-bot/bot.py: version bump to 2.4.9
2026-04-12 00:07:03 +03:00
anten-ka
9d9d12e150 v2.4.8: preflight port-conflict check
Добавлена предустановочная проверка свободы портов перед установкой
telemt. На вход приходит режим (lite/pro) и выбранный порт, проверяются
443 (lite: выбранный), 80 и 8443 (pro). Известный proxy/VPN/веб-софт
(xray, sing-box, v2ray, trojan, hysteria, mtg, shadowsocks, x-ui,
marzban, amneziawg, caddy, apache, haproxy, wireguard, openvpn)
распознаётся по имени процесса и показывается отдельным блоком.

При конфликте пользователь видит список занятых портов, получает
рекомендацию удалить конфликтующий софт или взять чистый VPS, и
может либо форсировать установку, либо отказаться — тогда показывается
15-секундный промо-блок с QR для донатов и возврат в меню.

Override для автоматизированных сценариев: GOTELEGRAM_SKIP_PREFLIGHT=1.

Files:
  lib/common.sh      — get_port_process, match_known_conflict, preflight_check
  lib/lang/ru.sh     — preflight_* i18n keys (ru)
  lib/lang/en.sh     — preflight_* i18n keys (en)
  install.sh         — preflight_check вызов в install_lite_mode / install_pro_mode;
                        show_promo_with_qr теперь принимает countdown arg
  gotelegram-bot/bot.py — version bump 2.4.7 → 2.4.8
2026-04-11 23:48:53 +03:00
anten-ka
8f249c35e5 v2.4.7: telemt download — try GitHub API first, fall back to direct CDN URL on 403/empty 2026-04-11 20:18:42 +03:00
anten-ka
bb2502e1fc v2.4.7: fix telemt download (skip GitHub API rate limit, direct CDN URL + musl fallback) 2026-04-11 20:15:18 +03:00
anten-ka
b10ea54ce9 install.sh: show promo QR only for tips, remove hoster QR codes 2026-04-10 20:48:35 +03:00
anten-ka
2f3045bcc0 bootstrap.sh: rotate PAT + support GOTELEGRAM_PAT env override 2026-04-10 20:44:20 +03:00
anten-ka
eb5175ccab v2.4.6: universal apt_lock_wait helper (fix nginx install on fresh VPS)
- lib/common.sh: add apt_lock_wait + apt_install + apt_update helpers
  * waits up to 300s for dpkg lock held by unattended-upgrades
  * uses DPkg::Lock::Timeout=120 native flag
  * captures stderr to show real error if install fails
- lib/website.sh: install_nginx/install_certbot use apt_install
  (fixes "Could not get lock /var/lib/dpkg/lock-frontend" during Pro setup)
- install.sh: bot_install uses apt_install (removes duplicated lock logic)
- lib/common.sh: ensure_deps uses apt_update + apt_install
2026-04-10 20:01:20 +03:00
anten-ka
3403975636 fix(v2.4.5): wait for apt lock + versioned python3.X-venv fallback
- bot_install: wait up to 300s for dpkg/apt lock (unattended-upgrades)
- use apt-get -o DPkg::Lock::Timeout=120 as extra safety
- detect python3 minor version and include python3.X-venv in package list
- stop silencing apt errors with 2>/dev/null — surface them to user
- python3-full is optional; fall back to core packages if unavailable
- venv fallback path also installs versioned package and retries
- templates_catalog.json: skip cp when source==dest (symlink install)
2026-04-10 15:59:46 +03:00
anten-ka
3919f201f5 fix(v2.4.4): robust venv creation + UTF-8 safe install frame
- install.sh bot_install: always ensure python3-venv+pip, verify pip exists, check pip install exit code, sanity-check imports
- install.sh: replace box-frame with simple bullet lines (printf %-Ns was byte-counting, breaking Cyrillic UTF-8)
- common.sh: 2.4.4
- bot.py: 2.4.4
2026-04-10 14:03:28 +03:00
anten-ka
4b63b79184 docs(v2.4.3): changelog + bot action bridge + flock race fix
- Header version bumped 2.4.1 -> 2.4.3 in both docs
- DOCS_HUMAN.md changelog: 2.4.2 (bot can change template/domain) + 2.4.3 (flock race fix)
- DOCS_AI.md section 11.1 (new): non-interactive action bridge contract - action list, JSON format, error codes, flock serialization semantics
- DOCS_AI.md section 10: bugs #24-28 (safe_edit_message TypeError, template_id field name, jq 1.5 compat, validation, flock race)
- DOCS_AI.md changelog: 2.4.2 + 2.4.3 entries
2026-04-10 13:39:10 +03:00
anten-ka
e9af6e969f fix(v2.4.3): flock serialization for bot_action_dispatch
Iteration 3 discovered a race condition: parallel CLI invocations of
change-lite-domain occasionally produced 'no secret in config' because
one process read config.json while another was mid-rewrite (jq -> tmp -> mv).
asyncio.Lock in bot.py only protects bot->bot races; CLI-level races and
bot+CLI mixed races were still possible.

- install.sh: bot_action_dispatch wraps dispatch in flock(1) on
  /var/lock/gotelegram-bot-action.lock, 30s timeout, EX_TEMPFAIL on timeout
- common.sh: flock added to critical deps; apt_pkg_for_cmd/dnf_pkg_for_cmd
  map flock -> util-linux
- common.sh: check_deps_present includes flock
- version bumped to 2.4.3 (common.sh + bot.py)
2026-04-10 13:35:22 +03:00
anten-ka
724eeb92d9 fix(v2.4.2): iter2 audit fixes
- bot.py: safe_edit_message now accepts disable_web_page_preview (CRIT: was TypeError in cb_pro_confirm success path)
- bot.py: status display uses template_id field (was 'template' — mismatch with save_gotelegram_config, template never showed)
- bot.py: cb_pro_confirm validates tpl_id against [A-Za-z0-9_-]{1,64} before subprocess (defense-in-depth)
- bot.py: cb_lite_domain validates domain shape
- bot.py: asyncio.Lock _BOT_ACTION_LOCK serializes concurrent change-template/change-lite-domain calls
- install.sh: bot_update_config_field uses shell `date -Iseconds` instead of jq's `now|todate` (jq 1.5 compat for Debian 10)
2026-04-10 13:30:47 +03:00
anten-ka
fc28a1a099 feat(v2.4.2): bot non-interactive install.sh actions
- install.sh: new bot_action_dispatch entry point for --action=X --json CLI invocation from the bot/scripts
- install.sh: bot_action_change_template — reuses download_template + deploy_template_to_nginx, updates config.json template_id
- install.sh: bot_action_change_lite_domain — regenerates telemt TOML with new fake-TLS mask domain, restarts telemt
- bot.py: run_bot_action() subprocess wrapper parses JSON result
- bot.py: cb_pro_confirm now performs real change-template when already in pro mode (fresh install still routes to CLI)
- bot.py: cb_lite_domain now performs real change-lite-domain when already in lite mode
- version -> 2.4.2
2026-04-10 13:19:26 +03:00
anten-ka
7b53566dad fix(bot): promo auto-delete + neutralize fake install/change stubs
Two user-reported bugs:

1. Promo spam: daily promo message from /start and the Promo menu
   button stayed in chat forever. Now they auto-delete after 30s.
   - new helper _delete_message_after()
   - cmd_start schedules deletion of the daily promo
   - cb_menu_promo sends promo as a SEPARATE ephemeral message
     (instead of editing the main menu in place), so the menu stays
     intact and only the promo self-destructs in 30s.

2. CRITICAL: 'install/change template from bot' was a stub that
   silently corrupted /opt/gotelegram/config.json. cb_lite_domain
   and cb_pro_confirm wrote a fake minimal config ({mode,template,
   port,installed_at}) without secret/domain/mask_host, and showed
   '[OK] installed!' — while never invoking install.sh, never
   downloading the template, never touching nginx. Proxy link
   generation then broke because secret was gone.
   User symptom: 'устанавливал другой шаблон через бота и всё
   повисло'.

   Fix: both callbacks now refuse cleanly and route the user to the
   CLI ('gotelegram' command → menu 1 → 1 or 7). Configuration is
   NOT touched. Full non-interactive install/change from the bot is
   left as future work.

Tested live on VPS:
- bot.py syntax OK (ast.parse on VPS)
- gotelegram-bot restarted, active
- corrupted config.json on VPS rebuilt from telemt TOML ground
  truth (mode=lite, secret, port, mask_host=google.com)
2026-04-10 13:00:04 +03:00
anten-ka
6b206a1697 fix(stats): add lib/stats.sh to bootstrap + auto-recreate history
Bug report: 'статистика не работает' — submenu_stats showed the
'stats module missing' fallback even though gotelegram-stats.service
was active. Root cause: bootstrap.sh FILES[] never included
lib/stats.sh, so on every (re)install the file was absent from
/opt/gotelegram/lib/ on disk. The running collector was sourcing it
from an in-memory bash process from a previous install, but
install.sh's `source stats.sh` at menu entry failed silently and
hit the 'stats_module_missing' branch.

Also: stats_collect silently skipped history writes if
stats_history.csv was deleted post-init (only stats_init created it
once). Now stats_collect auto-recreates the file with header.

- bootstrap.sh: add 'lib/stats.sh' to FILES[]
- lib/stats.sh: stats_collect auto-creates HISTORY_FILE if missing

Tested live on VPS: show_traffic_stats now outputs real counters
(23.1 MB proxy, 10 MB site, 394 KB/s peak).
2026-04-10 12:44:35 +03:00
anten-ka
6dc0000013 v2.4.1: full docs (DOCS_HUMAN.md + DOCS_AI.md)
- DOCS_HUMAN.md: user-facing guide (quick start, lite/pro, menu, bot,
  backup, requirements, FAQ, file locations, changelog)
- DOCS_AI.md: deep technical reference for AI agents (architecture,
  telemt v3 TOML format, subshell capture rules, bug history incl.
  start_telemt restart fix, github push workflow, VPS test workflow)
- lib/common.sh: bump GOTELEGRAM_VERSION 2.4.0 -> 2.4.1
2026-04-10 12:36:24 +03:00
anten-ka
663a5a2aae fix(install): restart telemt if already running so new config is applied
start_telemt() was a no-op when the service was already active, so
regenerating /etc/telemt/config.toml (e.g. reinstalling with a different
mask domain or switching lite<->pro) left the daemon running with the
OLD in-memory config. This caused the reported "lite-mode key does not
work" issue: after a fresh lite install over an existing pro install,
telemt kept tls_domain=anten-ka.com and dropped SNI=google.com clients
with unknown_sni_action=Drop even though /etc/telemt/config.toml said
tls_domain=google.com.

Fix: start_telemt now uses systemctl restart when the service is
already active, guaranteeing the fresh config is loaded.
2026-04-10 12:16:45 +03:00
15 changed files with 3222 additions and 303 deletions

668
DOCS_AI.md Normal file
View File

@@ -0,0 +1,668 @@
# GoTelegram Pro — техническая документация для ИИ-агентов
**Версия:** 2.4.3
**Репозиторий:** `anten-ka/gotelegram_pro`
**Активная ветка:** `alfa-test` (ветка `test` заморожена и содержит stable для конечных пользователей)
**Целевая ОС:** Ubuntu 20.04+, Debian 11+
Этот документ описывает устройство проекта с максимальным количеством нюансов — все ловушки, на которые мы уже наступали, все причины «почему именно так», формат всех внешних интерфейсов, и checklist действий для типовых задач. Цель: чтобы новый агент мог продолжить работу без повторения ошибок и без регрессий.
---
## 1. Общая картина
GoTelegram Pro — это менеджер MTProxy для Telegram, собранный вокруг Rust-ядра **telemt** (порт mtproto-proxy с поддержкой fake-TLS, v3.3.x). Проект даёт три вещи:
1. **CLI-меню на bash** (`install.sh` + `lib/*.sh`) — установка, настройка, обновление, бекап, перезапуск, смена режима, управление сайтом-маскировкой, выбор шаблона. Единая точка входа `gotelegram` (symlink → `/opt/gotelegram/install.sh`).
2. **Сайт-маскировку** — поднимает рядом nginx с настоящим сайтом на настоящем домене (Let's Encrypt) из каталога 1801 HTML-шаблонов (html5up / startbootstrap / ThemeWagon / dawidolko). В stealth-режиме telemt слушает 443, маскировочный трафик проксирует на локальный nginx (127.0.0.1:8443) через `dns_overrides`.
3. **Telegram-бота** (`gotelegram-bot/bot.py`, python-telegram-bot v21+) — управление прокси со смартфона: статус/ссылка/QR/перезапуск/бекап/смена режима/смена домена/смена шаблона/переключение языка per-user (RU/EN).
Архитектура stealth-режима:
```
Клиент Telegram ──┐
├─► anten-ka.com:443 (telemt, 0.0.0.0:443)
Обычный браузер ───┘ │
│ dns_overrides:
▼ "anten-ka.com:8443:127.0.0.1"
127.0.0.1:8443 (nginx)
/var/www/gotelegram-site/ (HTML шаблон)
```
Lite-режим (без домена):
```
Клиент Telegram ─► IP:443 (telemt) ─► Telegram DC
Fake-TLS эмулируется (tls_emulation=true), mask-трафик падает никуда
tls_domain = google.com (или любой из QUICK_DOMAINS)
```
---
## 2. Репозиторий и ветки
```
anten-ka/gotelegram_pro
├── test ← frozen stable, пользователи ставятся отсюда через bootstrap.sh
└── alfa-test ← активная разработка, пуши туда
```
**Правило коммитов (из auto-memory):** все новые изменения идут ТОЛЬКО в `alfa-test`. `test` не трогаем без явной команды пользователя. Когда пользователь в диалоге скажет «влей в stable» — тогда мёржим alfa-test → test.
**Инструменты для коммитов (специфика окружения):**
- `git push` через Windows Git CLI **не работает** — credential helper вешает процесс.
- Linux sandbox **не имеет доступа к github.com** — прокси возвращает 403 на raw.githubusercontent.com и api.github.com.
- **Единственный работающий путь:** Python-скрипт через GitHub REST API, запускаемый через Desktop Commander с `shell="cmd.exe"` (НЕ powershell, иначе не захватывается stdout).
- PAT токен — см. CLAUDE.md.
- Workflow: `POST git/blobs` для каждого файла → `POST git/trees` (с `base_tree` от текущего HEAD) → `POST git/commits` (parents=[текущий HEAD]) → `PATCH git/refs/heads/alfa-test` (sha=новый commit) → при необходимости `PATCH git/refs/tags/vX.Y.Z`.
- **Важно про `base_tree`:** при частичном обновлении (1-2 файла) ОБЯЗАТЕЛЬНО передавать `base_tree` — иначе дерево получится только из переданных файлов, и все остальные файлы пропадут из коммита. `base_tree` можно опускать только при коммите с полным набором файлов.
---
## 3. Карта файлов
```
install.sh главная точка входа, CLI-меню из 14 пунктов
install_gotelegram_bot.sh legacy-установщик бота (функционал продублирован в install.sh)
bootstrap.sh установщик приватного репо через raw.githubusercontent.com с PAT
templates_catalog.json каталог 1801 шаблонов, 18 категорий, 4 источника (~460KB)
DOCS_HUMAN.md документация для пользователя (этот каталог)
DOCS_AI.md этот файл
lib/common.sh цвета, log_*, confirm, select_option, _valid_ip, config_get,
save_gotelegram_config, get_server_ip, run_with_spinner,
version, GOTELEGRAM_VERSION, пути
lib/telemt.sh download_telemt, install_telemt_full, start/stop/restart_telemt,
update_telemt, remove_telemt, telemt_status, telemt_logs,
install_telemt_service (systemd юнит)
lib/telemt_config.sh generate_telemt_toml (TOML v3), generate_proxy_link,
get_config_value, validate_telemt_config, QUICK_DOMAINS[],
select_quick_domain, select_port, show_proxy_info[_pro]
lib/templates_catalog.sh download_template, select_category, select_template,
show_template_preview, поддержка 5 источников
lib/website.sh nginx + certbot + деплой шаблона
lib/backup.sh create_backup, restore_backup, list_backups
lib/i18n.sh t(), tf(), switch_language(), загрузка lang/ru.sh и lang/en.sh
lib/lang/ru.sh 328 i18n ключей на русском
lib/lang/en.sh 328 i18n ключей на английском
lib/stats.sh телеметрия (опциональная)
gotelegram-bot/bot.py Telegram-бот (python-telegram-bot v21+)
gotelegram-bot/config.example.env
gotelegram-bot/requirements.txt
gotelegram-bot/README.md
gotelegram-bot/locales/*.json 99 ключей i18n для бота с per-user persistence
```
**Где что лежит на VPS после установки:**
| Что | Путь |
| --- | --- |
| Репо-скрипты | `/opt/gotelegram/` |
| Symlink запуска | `/usr/local/bin/gotelegram``/opt/gotelegram/install.sh` |
| Конфиг GoTelegram (JSON) | `/opt/gotelegram/config.json` |
| Конфиг telemt | `/etc/telemt/config.toml` |
| Бинарник telemt | `/usr/local/bin/telemt` |
| Systemd юнит telemt | `/etc/systemd/system/telemt.service` |
| Bot | `/opt/gotelegram-bot/` |
| Systemd юнит бота | `/etc/systemd/system/gotelegram-bot.service` |
| Сайт | `/var/www/gotelegram-site/` |
| nginx конфиг | `/etc/nginx/sites-available/gotelegram` |
| nginx enabled | `/etc/nginx/sites-enabled/gotelegram` |
| Бекапы | `/opt/gotelegram/backups/` |
| Лог GoTelegram | `/var/log/gotelegram.log` |
| Логи telemt | `journalctl -u telemt` |
| Логи бота | `journalctl -u gotelegram-bot` |
---
## 4. Формат telemt v3 TOML (КРИТИЧЕСКИ ВАЖНО)
telemt v3.3.39 использует собственный TOML-формат, **несовместимый с mtg и mtproto-proxy**. Попытка скормить ему старый формат (`[security]`, `[[users]]`, `[listen] bind_to`) приводит к тому, что telemt молча игнорирует секции и запускается с дефолтами → клиенты отваливаются с ошибкой SNI.
### Минимальный рабочий конфиг для Lite-режима
```toml
[server]
port = 443
listen_addr_ipv4 = "0.0.0.0"
[censorship]
tls_domain = "google.com"
mask = true
mask_port = 443
tls_emulation = true # true → telemt сам эмулирует TLS handshake от имени tls_domain
[access.users]
main = "HEX_SECRET_32" # 16 байт = 32 hex символа, БЕЗ префикса ee
```
### Минимальный рабочий конфиг для Pro-режима (stealth)
```toml
[server]
port = 443
listen_addr_ipv4 = "0.0.0.0"
[censorship]
tls_domain = "anten-ka.com"
mask = true
mask_port = 8443
tls_emulation = false # false → mask-трафик идёт на mask_port, nginx имеет свой Let's Encrypt cert
[access.users]
main = "HEX_SECRET_32"
[network]
dns_overrides = ["anten-ka.com:8443:127.0.0.1"]
# формат host:port:ip — именно три поля через двоеточие
```
### Поля по секциям (полный набор, который telemt принимает в дефолтном gen-config)
- `[general]` — modes, telemetry, links (опционально, можно не трогать).
- `[general.modes]` — включение/отключение режимов прокси.
- `[general.telemetry]` — отправка статистики (по умолчанию выключено).
- `[general.links]` — формат публикуемых ссылок.
- `[server]``port`, `listen_addr_ipv4`, `listen_addr_ipv6`.
- `[server.api]` — HTTP-админка (не используем).
- `[server.conntrack_control]` — ограничения на conntrack.
- `[timeouts]` — таймауты handshake, idle и т.д.
- `[network]``stun_servers`, `dns_overrides`.
- `[censorship]``tls_domain`, `mask`, `mask_port`, `tls_emulation`, `unknown_sni_action` (значения: `Drop`, `Proxy`).
- `[censorship.tls_fetch]` — параметры получения реального cert от tls_domain (используется только если `tls_emulation=false` И нет dns_overrides).
- `[access]` — rate limits, доступ.
- `[access.users]` — таблица `name = "secret"` (секрет — 32 hex).
### Почему `unknown_sni_action = Drop` нас кусал
В Pro-режиме при старом конфиге с `tls_domain = anten-ka.com` telemt дропает всех клиентов, которые приходят с другим SNI. Если потом перегенерировать конфиг в Lite (`tls_domain = google.com`), но НЕ перезапустить telemt полноценно (`systemctl start` no-op на живом сервисе), то у telemt в памяти остаётся старое `anten-ka.com`, и клиенты с SNI=google.com дропаются. Симптом пользователя: «ключ Lite не работает, telemt живой». Фикс — в `start_telemt()`, см. раздел 10, баг #23.
### Fake-TLS секрет (ee-формат)
Что присылается клиенту в ссылке `tg://proxy?...`:
```
секрет_в_ссылке = "ee" + <hex_secret_32chars> + <hex(tls_domain)>
```
Пример для secret=`2de6920b1e17ccd440933ba0600f578f6` (длинное — 32 hex) и domain=`google.com`:
- hex(google.com) = `676f6f676c652e636f6d`
- итог: `ee2de6920b1e17ccd440933ba0600f578f676f6f676c652e636f6d`
В конфиге telemt (`[access.users] main = ...`) секрет хранится **без** префикса `ee` и **без** hex-домена. telemt сам склеивает при handshake. Функция `generate_proxy_link()` в `lib/telemt_config.sh` делает эту конкатенацию при формировании ссылки.
### Systemd юнит
Генерируется функцией `install_telemt_service()` в `lib/telemt.sh`:
```ini
[Unit]
Description=GoTelegram MTProxy (telemt engine)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/telemt run /etc/telemt/config.toml
Restart=always
RestartSec=5
LimitNOFILE=65535
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
- `ProtectSystem=full` (НЕ `strict`!) — telemt пишет cache-файлы, при `strict` он падает.
- `User=root` — telemt нужен для bind на 443. Можно заменить на `telemt` + `AmbientCapabilities=CAP_NET_BIND_SERVICE`, но это усложнение ради эстетики.
---
## 5. Жизненный цикл установки (Lite)
Функция `install_lite_mode` в `install.sh`. Порядок:
1. `select_quick_domain` (lib/telemt_config.sh) — интерактивный выбор `tls_domain` из списка QUICK_DOMAINS[] (20 доменов: google.com, microsoft.com, cloudflare.com, apple.com, amazon.com, github.com …). Функция **ОБЯЗАТЕЛЬНО** выводит UI через `>&2`, потому что она вызывается через `$()` — см. раздел 10, баг #14.
2. `select_port` — порт (443 по умолчанию, проверяется занятость через `check_port`).
3. Генерация секрета: `openssl rand -hex 16` → 32 hex символа.
4. `install_telemt_full``download_telemt` (скачивает бинарник из telemt/telemt GitHub Releases с фильтром `telemt-x86_64-linux-gnu.tar.gz`) → `install_telemt_service``enable_telemt`.
5. `generate_telemt_toml "$secret" "$port" "lite" "$domain" "$port"` → пишет `/etc/telemt/config.toml`.
6. `save_gotelegram_config` → пишет `/opt/gotelegram/config.json` с полями `mode=lite`, `domain=""`, `mask_host=$domain`, `secret`, `port`.
7. `start_telemt`**restart** если уже active (см. баг #23), иначе `start`. Проверяет `systemctl is-active` через 2 сек.
8. `show_proxy_info` — выводит IP/port/secret/tls_domain и `tg://proxy?...` ссылку, QR через qrencode если есть.
## 6. Жизненный цикл установки (Pro / stealth)
Функция `install_pro_mode` в `install.sh`. Добавляет к Lite-флоу:
1. Запрос домена (`read_user_input domain`), `validate_domain` — проверка формата.
2. `dig +short $domain` → проверка DNS = IP VPS. Если не совпало — предупреждение, но продолжаем (пользователь может знать лучше).
3. `apt install nginx certbot python3-certbot-nginx` (если ещё нет).
4. `interactive_template_selection``select_category``select_template``show_template_preview` → возвращает `tpl_id`. Все UI через `>&2` — иначе ID замусоривается.
5. `download_template "$tpl_id"` → клонирует репо шаблона в temp, проверяет `index.html`, копирует в `/var/www/gotelegram-site/`. Для StartBootstrap проверяет `dist/index.html` — см. баг #21.
6. Запись временного nginx-конфига на порту 80 (для challenge), reload nginx.
7. `certbot --nginx -d $domain` → получает Let's Encrypt сертификат.
8. Переписывание nginx-конфига: listen `127.0.0.1:8443 ssl`, root `/var/www/gotelegram-site`, используем cert от certbot.
9. Генерация `telemt/config.toml` в режиме `pro` с `tls_emulation=false`, `mask_port=8443`, `dns_overrides = ["$domain:8443:127.0.0.1"]`.
10. `save_gotelegram_config mode=pro domain=$domain ...`.
11. `start_telemt` (restart-safe) → `show_proxy_info_pro` выводит ссылку через доменное имя.
---
## 7. Правила subshell-capture (железно)
Это **самое частое место ошибок** в проекте. Любая функция, которая вызывается через `$(...)` для получения значения, должна писать ВСЕ логи и UI в stderr (`>&2`). Единственный разрешённый вывод в stdout — это финальный `echo "$result"` с возвращаемым значением.
Что обязательно `>&2`:
- **Все** `log_info/success/warning/error/step/dim` в `lib/common.sh` (это уже сделано).
- `confirm`, `select_option` в `lib/common.sh` (сделано).
- `select_quick_domain`, `select_port` в `lib/telemt_config.sh` (сделано).
- `select_category`, `select_template`, `show_template_preview` в `lib/templates_catalog.sh` (сделано, баг #18 был о том, что 4 строки в show_template_preview выводили без >&2).
- Любой `echo`/`printf` внутри `download_template` при ошибочном пути — см. баг #22 (`ls -la` без `>&2` замусоривал вывод).
- Любая новая интерактивная функция, которую добавляешь.
Чек-лист когда пишешь новую функцию:
1. Она вызывается через `$()`? → все echo/printf/log_* через `>&2`, кроме финального.
2. Возвращает строку через echo → именно **одна** финальная строка, без лишних переводов строки.
3. Ошибочный путь (early return) → НЕ писать в stdout, только в stderr, `return 1`.
---
## 8. SCRIPT_DIR и symlink
`install.sh` запускается через symlink `/usr/local/bin/gotelegram → /opt/gotelegram/install.sh`. Если наивно использовать `dirname "${BASH_SOURCE[0]}"`, получим `/usr/local/bin`, а там нет каталога `lib/`. Правильный паттерн:
```bash
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
```
`readlink -f` резолвит symlink до реального файла → `SCRIPT_DIR=/opt/gotelegram`, и `source "$SCRIPT_DIR/lib/common.sh"` работает. Это зафиксировано в баге #19.
---
## 9. Каталог шаблонов
Файл `templates_catalog.json`, ~460KB, ~18000 строк. Формат:
```json
{
"version": "2.4.1",
"sources": ["html5up", "startbootstrap", "themewagon", "dawidolko"],
"categories": ["landing", "portfolio", "agency", "saas", "restaurant", ...],
"templates": [
{
"id": "h5up_massively",
"name": "Massively",
"source": "html5up",
"repo": "https://github.com/html5up-inc/massively.git",
"category": "personal",
"preview": "https://html5up.net/massively"
},
...
]
}
```
Источники и как они скачиваются (`download_template` в `lib/templates_catalog.sh`):
| Источник | Префикс id | Метод | Где index.html |
| --- | --- | --- | --- |
| html5up | `h5up_*` | `git clone --depth 1 $repo` | в корне |
| startbootstrap | `sb_*` | `git clone --depth 1`**ищем `dist/index.html`** → копируем `dist/*` | в `dist/` |
| themewagon | `tw_*` | `git clone --depth 1 $repo` (каждый шаблон в отдельном репо) | в корне |
| dawidolko | `dw_*` / `colorlib_*` | sparse checkout одного большого репо | в подпапке |
Fallback: если по известным правилам index.html не найден, `download_template` делает `find -name "index.html"` по всему клону и берёт первый. Это спасает для нестандартных структур.
**ColorlibHQ отброшен** — оказались WordPress-темы (PHP), index.html пустой. Не пытаться включать обратно.
---
## 10. История багов (не наступать на те же грабли)
Все решены в текущем HEAD (`alfa-test`). Перечисление для того, чтобы новый агент не «чинил» то, что уже починено, и понимал контекст.
1. `telemt.sh grep` — URL формат `telemt-x86_64-linux-gnu.tar.gz`, arch перед `linux`. Нужен `grep -iE "$arch_pattern"` + цепочка фильтров (`grep -i linux`, `grep -v sha256`, `grep gnu`, `head -1`).
2. `bot.py safe_edit_message` — обёртка для `query.edit_message_text`, ловит `BadRequest: message is not modified`.
3. `bot.py QR cleanup``os.remove(qr_file)` в `finally` блоке.
4. `common.sh _valid_ip` — проверка октетов 0-255, раньше пропускал 999.
5. `common.sh os-release injection` — блокирует `;`, backticks, `$(`, `${` при парсинге `/etc/os-release`.
6. `common.sh config_get` — return codes: `0=ok`, `2=файл отсутствует`, `3=невалидный JSON`.
7. `common.sh run_with_spinner` — stderr в temp-файл, показ первых 3 строк при ошибке.
8. `install.sh` — проверка пустого домена **перед** `validate_domain` (валидатор падал на пустой строке).
9. `install.sh mode_choice` — санитизация `${mode_choice:-}` чтобы `set -u` не ронял скрипт.
10. `backup.sh` — инициализация `final_file`/`tar_file`/`backup_file` перед ветвлением (иначе unset var).
11. `bot.py XSS``html.escape(preview_url, quote=True)` с двойными кавычками.
12. `common.sh` — версия была устаревшая, обновлена (сейчас 2.4.1).
13. `install.sh` — добавлен пункт 12 меню (Telegram-бот) с подменю установка/статус/логи/настройки/удаление.
14. **КРИТИЧЕСКИЙ subshell capture** — интерактивные функции (`select_quick_domain`, `select_port`, `select_category`, `select_template`, `show_template_preview`) выводили UI в stdout. Вызов через `$()` → меню уходило в переменную. Фикс: весь UI через `>&2`.
15. **КРИТИЧЕСКИЙ download_telemt extract**`find -newer` не находил извлечённый файл (таймстамп архива старше). Фикс: извлечение в отдельную директорию `$extract_dir` + проверка размера файла + fallback через `file` для определения ELF.
16. `common.sh log_*` — все `log_info/success/warning/error/step/dim` выводят в stderr (`>&2`).
17. `common.sh confirm/select_option` — UI через `>&2`.
18. **КРИТИЧЕСКИЙ show_template_preview stdout leak** — 4 строки (блок «Спасибо авторам» + разделители) выводили без `>&2`. Цепочка `interactive_template_selection → select_template → show_template_preview` → мусор подмешивался к `tpl_id``download_template` получал мусорный ID → «шаблон не содержит index.html». Фикс: `>&2` ко всем 4 echo.
19. **symlink SCRIPT_DIR**`readlink -f "${BASH_SOURCE[0]}"` для резолва symlink `/usr/local/bin/gotelegram`.
20. **bootstrap.sh** — скачивание приватного репо через `raw.githubusercontent.com/.../bootstrap.sh?token=...`, создание symlink, запуск install.sh.
21. **КРИТИЧЕСКИЙ StartBootstrap dist/** — sb_* шаблоны хранят production в `dist/`. Фикс: клонируем в `$sb_tmp`, проверяем `dist/index.html`, копируем `dist/*` в `$clone_dir`, + universal fallback через `find`.
22. **templates_catalog.sh ls stdout leak**`ls -la` в блоке ошибки `download_template` шёл в stdout. Фикс: `>&2`.
23. **КРИТИЧЕСКИЙ start_telemt stale config (v2.4.1)**`systemctl start` это no-op для уже активного сервиса. После переустановки Lite поверх Pro конфиг на диске менялся, но telemt держал в памяти старый `tls_domain=anten-ka.com` и дропал клиентов с SNI=google.com. Фикс: `start_telemt` теперь делает `restart` если сервис уже активен. См. `lib/telemt.sh:189-213`.
24. **КРИТИЧЕСКИЙ safe_edit_message TypeError (v2.4.2)** — в iter2 аудите субагент нашёл: `cb_pro_confirm` в success-пути вызывал `safe_edit_message(query, text, ..., disable_web_page_preview=True)`, но сигнатура обёртки этот kwarg не принимала. Runtime TypeError прямо в хэппи-пути смены шаблона из бота. Фикс: добавлен `disable_web_page_preview: Optional[bool] = None` + условный форвард через `**kwargs`.
25. **template_id field name mismatch (v2.4.2)**`save_gotelegram_config` всегда писал ключ `template_id`, но `bot.py` читал `config.get('template')` и писал `config['template']` в `handle_text_message`. Результат: статус шаблона в боте никогда не отображался, а поле в JSON появлялось фантомно и не использовалось. Фикс: везде только `template_id`. Исторические чтения совместимы через `config.get("template_id") or config.get("template")` для legacy конфигов.
26. **jq 1.5 compat в bot_update_config_field (v2.4.2)** — использовалось `jq '... | .updated_at = (now | todate)'`, но фильтр `now|todate` только с jq 1.6. На Debian 10 jq 1.5 падал с syntax error, config не обновлялся. Фикс: генерим timestamp через `date -Iseconds` в shell и передаём через `jq --arg t`.
27. **Отсутствующая валидация tpl_id/domain (v2.4.2)**`tpl_id` из `callback_data` и `domain` из текстового ввода передавались напрямую в subprocess как `--template=$x`. Defense-in-depth: в bot.py добавлены `_TPL_ID_RE = ^[A-Za-z0-9_-]{1,64}$` и `_DOMAIN_RE`, в install.sh — `validate_domain` перед `generate_telemt_toml`. Малформный ввод отвергается early.
28. **КРИТИЧЕСКИЙ race в bot_action_dispatch (v2.4.3)** — iter3-тест с 3 параллельными `change-lite-domain` дал `{"status":"error","code":"no_secret"}` на одном из вызовов. Причина: `bot_update_config_field` делает `jq ... > tmp && mv tmp config.json`; когда параллельный процесс заходил в `get_config_value secret` в момент между `>` и `mv`, он видел пустой/частичный файл. `asyncio.Lock` в боте ловил только внутри-процессные гонки, но не CLI-level. Фикс: `flock -w 30 9 < /var/lock/gotelegram-bot-action.lock` вокруг всего диспатчера. Новый error code: `lock_timeout` (`EX_TEMPFAIL`=75).
---
## 11. Telegram-бот
Файл `gotelegram-bot/bot.py`, python-telegram-bot v21+, systemd сервис `gotelegram-bot.service`.
Ключевые моменты:
- **Admin ID** — бот отвечает только пользователю с `ADMIN_TG_ID` из `.env`. Всё остальное игнорируется.
- **safe_edit_message** — обёртка над `query.edit_message_text` + `query.edit_message_caption`, глотает `BadRequest: message is not modified`. Начиная с v2.4.2 принимает опциональный `disable_web_page_preview` — не забудь прокинуть его если показываешь ссылку-превью (раньше вызов с этим kwarg ловил TypeError в runtime). Используй её всегда вместо прямого вызова.
- **Language per-user** — файл `locales/users.json` хранит `{user_id: "ru"/"en"}`. При каждом сообщении бот читает язык пользователя и подставляет строку через `t(key, lang)`.
- **QR-коды** — генерятся в `/tmp/gotelegram_qr_*.png`, отправляются как `InputFile`, удаляются в `finally`.
- **Шаблон preview в HTML** — URL экранируется через `html.escape(url, quote=True)` (баг #11).
- **Системные действия (v2.4.2+)** — бот реально умеет менять шаблон и домен маскировки. Хелпер `run_bot_action(action, **kwargs)` вызывает `subprocess.run(["/opt/gotelegram/install.sh", "--action=X", "--json", ...])` и парсит JSON. Два коллбэка: `cb_pro_confirm` (change-template) и `cb_lite_domain` (change-lite-domain). Оба обёрнуты в `async with _BOT_ACTION_LOCK` (глобальный `asyncio.Lock()`) — сериализуют параллельные callback'и внутри процесса бота. Входы валидируются ДО subprocess: `_TPL_ID_RE = r"^[A-Za-z0-9_-]{1,64}$"`, `_DOMAIN_RE` (RFC-like). Малформный ввод отвергается с понятным сообщением, не доходя до shell.
- **Поле `template_id` в config.json** — канонический ключ для текущего шаблона. Раньше (до v2.4.2) бот читал `config['template']` и писал в него же в `handle_text_message`, но `save_gotelegram_config` всегда писал `template_id` → поле статуса никогда не отображалось. Используй только `template_id`.
- **Устанавливается** из меню `install.sh → 12) Telegram-бот → Установить`. Пользователь вводит BotFather token + свой Telegram ID, `.env` пишется в `/opt/gotelegram-bot/.env`.
### 11.1 Non-interactive action bridge (install.sh ↔ bot)
Бот общается с CLI через жёсткий JSON-протокол. Единая точка входа — `bot_action_dispatch` в `install.sh`.
```bash
/opt/gotelegram/install.sh --action=<name> [--template=ID|--domain=HOST] --json
```
**Доступные action'ы:**
- `change-template --template=<tpl_id>` — только в Pro режиме. Скачивает шаблон, деплоит в nginx, обновляет `config.json.template_id`.
- `change-lite-domain --domain=<host>` — только в Lite режиме. Регенерит telemt TOML с новым `tls_domain`, валидирует, рестартит telemt, обновляет `config.json.{domain,mask_host}`.
**Формат ответа (stdout, последняя строка):**
```json
{"status":"success","message":"...","<extra>":"..."}
{"status":"error","message":"...","code":"<machine_code>"}
```
Коды ошибок: `missing_arg`, `invalid_domain`, `wrong_mode`, `unknown_template`, `download_failed`, `deploy_failed`, `no_secret`, `gen_failed`, `validate_failed`, `restart_failed`, `unknown_action`, `lock_timeout`.
**Сериализация (v2.4.3):** `bot_action_dispatch` оборачивает вызов в `flock -w 30 9 < /var/lock/gotelegram-bot-action.lock`. Это защищает от гонок при:
1. Одновременных callback'ах внутри бота (asyncio.Lock уже ловит это, но flock — defense-in-depth).
2. Параллельных CLI-вызовах (бот + ручной SSH, или два бот-процесса — теоретически).
Если таймаут лока (30 с), диспетчер возвращает `exit 75` (`EX_TEMPFAIL`) и JSON `{"status":"error","code":"lock_timeout"}`. `flock` идёт из `util-linux` — добавлен в `critical` зависимости в `ensure_deps`.
**История:** до v2.4.2 коллбэки были stub'ами («сделай через CLI»). В v2.4.2 подключили реальные action'ы. В iter3-тестировании на VPS обнаружилась гонка: `bot_update_config_field` делает `jq ... > tmp && mv tmp config.json`, и параллельный процесс мог прочитать `config.json` в промежутке, увидев пустоту → ошибка `no_secret`. v2.4.3 починил flock'ом.
---
## 12. i18n (bash CLI)
`lib/i18n.sh` экспортирует функции:
```bash
t "key" # вернуть строку на текущем языке
tf "key" arg1 arg2 # t + printf-интерполяция
switch_language ru|en
```
Под капотом — ассоциативный массив `I18N`, ключи в `lib/lang/ru.sh` и `lib/lang/en.sh`. ~328 ключей.
Добавление нового ключа:
1. Придумай стабильный key (snake_case, без пробелов): `menu_install`, `error_port_busy`.
2. Добавь строку в `lib/lang/ru.sh`: `I18N[menu_install]="Установить / обновить"`.
3. Добавь строку в `lib/lang/en.sh`: `I18N[menu_install]="Install / update"`.
4. В коде вызови `t menu_install`.
5. Если есть интерполяция — используй `%s`/`%d`: `I18N[info_port]="Порт %s свободен"` + `tf info_port "$port"`.
Выбор языка сохраняется в `$GOTELEGRAM_CONFIG``config.json` → ключ `lang`. Первый запуск — интерактивный выбор.
---
## 13. Бекапы
`lib/backup.sh`. Собирает в `.tar.gz`:
- `/etc/telemt/config.toml`
- `/opt/gotelegram/config.json`
- `/var/www/gotelegram-site/` (если есть)
- `/etc/letsencrypt/live/<domain>/` + `/etc/letsencrypt/archive/<domain>/` (если Pro)
- `/etc/nginx/sites-available/gotelegram` (если есть)
Складывает в `/opt/gotelegram/backups/backup_YYYY-MM-DD_HH-MM-SS.tar.gz`.
`restore_backup` разворачивает архив обратно, перезапускает telemt и nginx.
---
## 14. Checklist: как обновить один файл и запушить
1. Прочитай текущее состояние файла: `Read` для локальной версии + `git_get_contents` (raw.githubusercontent.com) если нужно убедиться что на GitHub то же самое.
2. Применяй правки через `Edit` в локальном каталоге `/sessions/.../gotelegram-v2/`.
3. Напиши `C:\Temp\push_<описание>.py`:
```python
import os, base64, json, urllib.request, ssl
TOKEN = "github_pat_..."
REPO = "anten-ka/gotelegram_pro"
BRANCH = "alfa-test"
API = f"https://api.github.com/repos/{REPO}"
headers = {
"Authorization": f"Bearer {TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "gotelegram-push",
}
def req(method, path, body=None):
data = json.dumps(body).encode() if body else None
r = urllib.request.Request(API + path, data=data, headers={**headers, "Content-Type": "application/json"}, method=method)
return json.loads(urllib.request.urlopen(r).read())
# 1. current ref
ref = req("GET", f"/git/refs/heads/{BRANCH}")
parent_sha = ref["object"]["sha"]
commit = req("GET", f"/git/commits/{parent_sha}")
base_tree = commit["tree"]["sha"]
# 2. blobs
files = {
"lib/common.sh": open("C:/.../gotelegram-v2/lib/common.sh","rb").read(),
"DOCS_AI.md": open("C:/.../gotelegram-v2/DOCS_AI.md","rb").read(),
}
tree_items = []
for path, content in files.items():
blob = req("POST", "/git/blobs", {"content": base64.b64encode(content).decode(), "encoding": "base64"})
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob["sha"]})
# 3. tree (С base_tree — обязательно при частичном апдейте)
tree = req("POST", "/git/trees", {"base_tree": base_tree, "tree": tree_items})
# 4. commit
new_commit = req("POST", "/git/commits", {
"message": "v2.4.1: docs + start_telemt restart-safe",
"tree": tree["sha"],
"parents": [parent_sha],
})
# 5. patch ref
req("PATCH", f"/git/refs/heads/{BRANCH}", {"sha": new_commit["sha"], "force": False})
print("pushed:", new_commit["sha"])
```
4. Запускай через Desktop Commander `start_process` с `shell="cmd.exe"`:
```
cmd /c "python C:\Temp\push_docs.py"
```
НЕ через PowerShell — там stdout не захватывается в нашем окружении.
5. Проверь результат: `GET /commits/<sha>` или открой ветку в браузере вручную.
**Частые грабли:**
- Забыл `base_tree` → все остальные файлы исчезли из коммита. ВСЕГДА передавай `base_tree` кроме случая «чистый коммит со всеми файлами».
- `cp1251` в cmd ломает юникод → пиши в файл через Python с `encoding='utf-8'`, не выводи кириллицу в stdout.
- GitHub API кеширует raw-ответы по path → при проверке обновления используй `?ref=<commit_sha>`, не ветку.
---
## 15. Checklist: тестирование на VPS
VPS: `95.163.176.222`, root, пароль в CLAUDE.md.
**Путь из Linux sandbox:** `ssh`/`sshpass` нет, `pip install paramiko` падает (прокси 403). Идём через Windows Python, где paramiko уже установлен.
**Хелперы:**
- `C:\Temp\ssh_cmd.py` — однократная команда, читает из `C:\Temp\ssh_input.txt`, пишет в `C:\Temp\ssh_output.txt`.
- `C:\Temp\ssh_a1.py` / `ssh_a2.py` / `ssh_a3.py` — для параллельных агентов (разные output-файлы).
**Базовый шаблон ssh_cmd.py:**
```python
import sys, paramiko
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("95.163.176.222", username="root", password="...", timeout=20)
cmd = open("C:/Temp/ssh_input.txt", "r", encoding="utf-8").read()
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=60)
out = stdout.read().decode(errors="replace")
err = stderr.read().decode(errors="replace")
open("C:/Temp/ssh_output.txt", "w", encoding="utf-8").write(f"STDOUT:\n{out}\nSTDERR:\n{err}")
ssh.close()
```
**Заливка одного файла через SFTP:**
```python
sftp = ssh.open_sftp()
sftp.put("C:/.../lib/telemt.sh", "/opt/gotelegram/lib/telemt.sh")
sftp.close()
```
**CRLF:** файлы из GitHub API приходят с `\r\n` → ОБЯЗАТЕЛЬНО `sed -i 's/\r$//' /opt/gotelegram/install.sh /opt/gotelegram/lib/*.sh` перед запуском, иначе bash падает с `bad interpreter`.
**chmod:** `chmod +x /opt/gotelegram/install.sh /opt/gotelegram/install_gotelegram_bot.sh` — GitHub раздаёт как 644.
**Быстрая проверка live-состояния:**
```bash
systemctl is-active telemt nginx gotelegram-bot
telemt --version
cat /etc/telemt/config.toml | grep tls_domain
journalctl -u telemt --no-pager -n 30
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1:8443
```
**Проверка что Lite-ключ реально работает через Fake-TLS:**
```python
import ssl, socket
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection(("95.163.176.222", 443), timeout=5) as s:
with ctx.wrap_socket(s, server_hostname="google.com") as ss:
print(ss.version(), ss.getpeercert(True)[:40])
```
Ожидаемо: `TLSv1.3`, сертификат с CN=`*.google.com` (его telemt эмулирует через tls_fetch).
---
## 16. Меню (install.sh)
```
── Прокси ──
1) menu_install (Lite/Pro выбор, domain, template, certbot)
2) menu_status (telemt_status + show_proxy_info)
3) menu_link (ссылка + QR)
4) menu_share (текст «для друзей»)
5) menu_restart (restart_telemt)
6) menu_logs (telemt_logs 40)
7) menu_change_mode (lite↔pro, смена шаблона, смена домена маскировки)
── Управление ──
8) interactive_backup (create_backup)
9) interactive_restore (select_backup + restore_backup)
10) update_telemt (check_telemt_update → download → restart)
11) menu_website (nginx restart, certbot renew)
── Бот и прочее ──
12) menu_bot (install_bot / start / stop / logs / change_token / remove)
13) menu_remove (только прокси / только бот / всё)
14) menu_promo (подарочные ссылки — маркетинг)
0) exit
```
Диспатчер в `install.sh` (`bot_action_dispatch`) принимает `--action=` для автоматизации из бота. Полный контракт описан в разделе 11.1.
---
## 17. Changelog
- **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.1 (2026-04-10)** — баг #23: `start_telemt` делает `restart` если сервис активен (иначе stale in-memory config после переустановки Lite поверх Pro). Полная документация проекта — `DOCS_HUMAN.md` и `DOCS_AI.md` (этот файл).
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репозитория (валидация URL, таймауты, лимит размера клона).
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
- **2.2.1** — критические фиксы `$()` capture (все UI через `>&2`), StartBootstrap dist, symlink SCRIPT_DIR через `readlink -f`, XSS в HTML-превью бота, OS-release injection.
- **2.2** — переход с mtg на telemt v3, новый TOML-формат конфига, stealth-архитектура.
---
## 18. Быстрый справочник: «хочу сделать X»
**Добавить новый пункт меню:**
1. `install.sh`: добавь `menu_new_thing()`, впиши в диспатчер `case` в главном цикле.
2. Добавь i18n ключи `menu_new_thing` в ru.sh и en.sh.
3. Если функция интерактивная + возвращает значение → ВСЁ UI через `>&2`.
**Добавить новый домен в QUICK_DOMAINS:**
- `lib/telemt_config.sh` → массив `QUICK_DOMAINS=(...)`. Убедись что домен не заблокирован ни в одной из популярных стран и действительно отвечает на 443 с валидным сертификатом (telemt при `tls_emulation=true` вынимает реальный cert через tls_fetch).
**Сменить версию:**
- `lib/common.sh:6` → `GOTELEGRAM_VERSION="X.Y.Z"`.
- `DOCS_HUMAN.md` / `DOCS_AI.md` → раздел Changelog + версия в шапке.
- Тэг (опционально): `PATCH /git/refs/tags/vX.Y.Z` на новый commit sha.
**Добавить новый шаблон в каталог:**
- Найди git-репо со статическим HTML (index.html в корне или в `dist/`).
- Придумай id: `<source_prefix>_<name>` (например `h5up_future`, `sb_portfolio_x`).
- Добавь запись в `templates_catalog.json` с `id`, `name`, `source`, `repo`, `category`, `preview`.
- Убедись что `download_template` знает префикс источника (`case "$tpl_id" in h5up_*) ... sb_*) ... esac`).
**Отладить «ключ не работает»:**
1. `systemctl is-active telemt` → живой?
2. `cat /etc/telemt/config.toml` → какой `tls_domain`? Какой `port`? Какой `secret`?
3. `journalctl -u telemt --no-pager -n 50` → есть `unknown_sni_action=Drop`? `port in use`? `failed to bind`?
4. Сравни `tls_domain` в конфиге и hex-домен в конце ссылки клиента (`hex(domain) === суффикс секрета после ee+32hex`).
5. Если telemt жив но дропает — **restart** (не start). Это баг #23.
6. Если порт занят — `ss -ltnp | grep :443` → убей конкурента.
7. Если Pro и не открывается сайт в браузере — `curl -k https://127.0.0.1:8443` (nginx жив?), `dig +short $domain` (DNS правильный?).
---
## 19. Где НЕ копаться
- `install_gotelegram_bot.sh` — legacy, функционал дублирован в install.sh пункте 12. Можно удалить после того как убедимся что никто им не пользуется.
- `lib/stats.sh` — опциональная телеметрия, не критичная для работы.
- `ColorlibHQ` в каталоге — wordpress-темы, отброшены. Не возвращать.
- Старый формат конфига mtg (`[security]`, `[[users]]`, `bind_to`) — telemt v3 его игнорирует. Не пытаться «починить» совместимость.
---
## 20. Контрольные точки и инварианты
Перед любым пушем в `alfa-test`:
1. `bash -n install.sh lib/*.sh` — синтаксис bash ОК.
2. Все новые `$()`-вызываемые функции пишут UI через `>&2`.
3. Все пути к lib/ идут через `$SCRIPT_DIR/lib/...`, а `SCRIPT_DIR` — через `readlink -f`.
4. `GOTELEGRAM_VERSION` обновлена если изменения меняют поведение.
5. Changelog в `DOCS_HUMAN.md` и `DOCS_AI.md` дополнен.
6. Если бекап-формат изменился — прописать в `restore_backup` обратную совместимость.
7. `generate_telemt_toml` не роняет telemt v3 (проверить `telemt run --check config.toml`).
После пуша:
1. Подождать пока `alfa-test` обновится (GitHub API мгновенно, raw кеш ~30 сек).
2. На VPS: `bash bootstrap.sh?token=...&ref=alfa-test` (или ручной `git pull` в клоне) + `sed -i 's/\r$//'` + `chmod +x` + `systemctl restart telemt`.
3. `telemt_status` → running. `journalctl -u telemt` → нет ошибок. Ссылка открывается в Telegram-клиенте.
---
**Если в чём-то сомневаешься — открой `CLAUDE.md` в корне `MT-proxy/`. Там суммированы все ранее пройденные грабли и рабочие паттерны под Windows + Desktop Commander + paramiko.**

225
DOCS_HUMAN.md Normal file
View File

@@ -0,0 +1,225 @@
# GoTelegram Pro — руководство пользователя
**Версия:** 2.4.3
**Репозиторий:** `anten-ka/gotelegram_pro`
**Для кого:** владельцы VPS, которым нужен надёжный MTProxy для Telegram с маскировкой под обычный HTTPS-сайт.
---
## 1. Что это такое
GoTelegram Pro — это готовый менеджер прокси-сервера MTProxy для Telegram. Он делает три вещи, которые иначе пришлось бы собирать вручную:
1. Ставит и настраивает ядро **telemt** (это современный Rust-порт mtproto-proxy с fake-TLS маскировкой).
2. Запускает рядом обычный HTTPS-сайт на настоящем домене, так что провайдеру со стороны всё выглядит как посещение безобидного лендинга — а на самом деле в том же соединении ходит Telegram-трафик. Это называется «stealth» или «Pro-режим».
3. Даёт Telegram-бота, через которого можно управлять прокси со смартфона: ссылка, статус, перезапуск, бекап, смена маскировочного домена, выбор шаблона сайта.
Всё управляется одним меню из 14 пунктов (`gotelegram` в терминале) — не нужно лазить по конфигам.
---
## 2. Быстрый старт
На чистом Ubuntu/Debian VPS под root:
```bash
bash <(curl -sL "https://raw.githubusercontent.com/anten-ka/gotelegram_pro/test/bootstrap.sh?token=YOUR_PAT")
```
`bootstrap.sh` скачает все файлы из приватного репозитория, создаст симлинк `/usr/local/bin/gotelegram` и запустит главное меню. Через минуту команда `gotelegram` уже будет работать откуда угодно.
Дальше в меню:
- **1) Установить / обновить** — ставит прокси, спрашивает режим (Lite или Pro) и домен маскировки.
- **2) Статус** — показывает, жив ли telemt, IP, порт, маскировку и готовую `tg://proxy?...` ссылку.
- **3) Ссылка** — тот же ключ отдельно, вместе с QR-кодом.
Дальше можно открыть Telegram → Настройки → Данные и память → Прокси → добавить по ссылке. Готово.
---
## 3. Lite vs Pro — что выбрать
### Lite (быстрый, без домена)
- Работает сразу без какого-либо домена.
- Ссылка выглядит как IP-адрес: `tg://proxy?server=95.163.176.222&port=443&secret=ee...`
- Провайдеру видно: кто-то сходил на IP:443, TLS с SNI=google.com (или другой популярный домен, который ты выберешь из списка).
- Минус: IP-адрес виден, и если провайдер блокирует по списку known-bad-IP, ключ перестанет работать.
- Подходит, если домена пока нет, а пользоваться нужно уже сейчас.
### Pro (стелс, со своим доменом)
- Нужен настоящий домен, который указывает A-записью на IP VPS.
- Провайдеру видно: HTTPS-трафик к `твой-домен.com:443` — выглядит как обычный сайт.
- По этому же домену снаружи открывается реальный HTML-сайт (любой из 1800+ шаблонов в каталоге).
- Внутри: telemt слушает 443, nginx слушает 127.0.0.1:8443, маскировочный трафик telemt проксирует на nginx через `dns_overrides`, так что сайт реально открывается в браузере. SSL — настоящий Let's Encrypt.
- Ссылка: `tg://proxy?server=твой-домен.com&port=443&secret=ee...`
- Плюс: выглядит идентично обычному сайту, провайдер не может отличить.
- Минус: нужен домен и корректно настроенный DNS (плюс несколько минут ожидания сертификата).
**Короткое правило:** есть домен — ставь Pro. Нет — начни с Lite и потом переключишься через пункт меню «Сменить режим».
---
## 4. Меню целиком
```
── Прокси ──
1) Установить / обновить — первый раз или апгрейд
2) Статус — IP, порт, маскировка, живость
3) Ссылка — tg://proxy?... + QR
4) Поделиться — текстовое сообщение «для друзей»
5) Перезапуск — systemctl restart telemt
6) Логи — последние 40 строк telemt
7) Сменить режим / шаблон — Lite↔Pro, сменить сайт-шаблон
── Управление ──
8) Бекап — tar.gz всех конфигов и ключей
9) Восстановить — откат из бекапа
10) Обновить telemt — скачать свежий бинарник
11) Сайт (SSL) — ручная перегенерация сертификата
── Бот и прочее ──
12) Telegram-бот — установить / настроить бота
13) Удалить всё — снести прокси / бота / вообще всё
14) Промо — подарочные ссылки
0) Выход
```
---
## 5. Telegram-бот
Пункт меню **12) Telegram-бот** разворачивает отдельный Python-сервис (`python-telegram-bot` v21+), который:
- Показывает статус прокси, ссылку и QR.
- Умеет перезапускать telemt прямо из чата.
- Делает бекап и восстанавливает.
- Переключает режим (Lite ↔ Pro).
- Меняет маскировочный домен.
- Меняет сайт-шаблон из каталога на 1800+ готовых HTML-шаблонов (4 источника: html5up, startbootstrap, ThemeWagon, dawidolko).
- Поддерживает **2 языка**: русский и английский. Каждый пользователь выбирает свой, настройка сохраняется.
Чтобы запустить бота, нужны только два параметра — токен от `@BotFather` и твой Telegram ID (чтобы никто кроме тебя бота не использовал). Меню подсказывает где что ввести.
---
## 6. Язык интерфейса
CLI и бот переведены на русский и английский. Выбор языка идёт:
- В CLI — при первом запуске спрашивает один раз и запоминает в `config.json`.
- В боте — отдельная кнопка «🇬🇧/🇷🇺», переключение на лету, сохранение per-user.
Вся логика интерфейса, ошибок, подсказок, меню — переведена.
---
## 7. Бекап и восстановление
Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, данные nginx-сайта и сертификаты. По умолчанию в `/opt/gotelegram/backups/`.
Пункт 9 принимает такой архив и восстанавливает всё обратно (конфиг, сервис, ссылка — всё то же, что было).
Хорошая практика: делать бекап каждый раз перед пунктами 1 (переустановка) и 10 (обновление telemt).
---
## 8. Обновление
Два типа обновлений:
- **Обновление ядра telemt** (пункт 10) — тянет свежий бинарник с GitHub Releases `telemt/telemt`, сохраняет старый в `.bak`, перезапускает сервис. Конфиг остаётся как был.
- **Обновление самого GoTelegram** (пункт 1) — переустанавливает скрипты и lib/. Файл `config.json` не трогается, ключ и домен не меняются.
Bootstrap.sh умеет сам обновлять всё, если запустить его повторно.
---
## 9. Удаление
Пункт **13) Удалить всё** даёт выбор:
- Удалить только прокси (оставить бота).
- Удалить только бота.
- Удалить всё целиком, включая `/opt/gotelegram`, `/etc/telemt`, symlink `gotelegram`, systemd юниты, nginx-конфиг, бекапы и сайт из `/var/www/gotelegram-site`.
После «удалить всё» VPS возвращается к состоянию до установки (кроме скачанных пакетов типа `jq`, `nginx`, `certbot` — они остаются).
---
## 10. Требования к VPS
- **ОС:** Ubuntu 20.04+ или Debian 11+ (протестировано на Ubuntu 22.04).
- **RAM:** 512 МБ минимум, 1 ГБ комфортно (telemt сам по себе ест мало, но рядом nginx + бот).
- **Диск:** 2 ГБ (в основном под каталог шаблонов и бекапы).
- **Права:** root или sudo.
- **Порты:** 443 должен быть свободен (ни apache, ни nginx, ни ничего другого не должно на нём висеть). Если занят — скрипт предупредит.
- **Для Pro-режима:** домен с настроенным A-record на IP VPS. DNS должен отвечать ДО установки, иначе Let's Encrypt не выдаст сертификат.
---
## 11. Частые вопросы
**Q: Ключ перестал работать, telemt живой.**
A: 95% случаев — это когда после переустановки telemt не перечитал свежий конфиг (было исправлено в 2.4.1, см. changelog). Перезапусти вручную: `systemctl restart telemt`. Если не помогло — смотри логи (пункт 6 меню) и проверь `/etc/telemt/config.toml` на предмет правильного `tls_domain`.
**Q: Pro-режим не получил сертификат.**
A: Проверь, что домен резолвится в правильный IP: `dig +short твой-домен.com`. Должен быть IP VPS. Если DNS правильный — проверь, что 80 и 443 никем не заняты кроме telemt (certbot на момент выдачи сертификата временно занимает 80). Попробуй пункт 11 (ручная перегенерация SSL).
**Q: Как сменить домен маскировки в Lite-режиме?**
A: Пункт 7 → сменить режим/шаблон. Можно также просто переустановить (пункт 1) — текущий конфиг сохранится в бекапе.
**Q: Бот не реагирует.**
A: Посмотри логи бота в пункте 12 → «Логи бота». Чаще всего — неверный токен или неверный admin ID в `.env`.
**Q: Могу ли я поставить несколько прокси на одном VPS?**
A: На одном IP на порту 443 — нет, telemt один. На разных портах — можно, но скрипт этого не поддерживает из коробки, нужно руками.
**Q: Это легально?**
A: Сам MTProxy — да, это публичная технология из исходников Telegram. Запуск прокси, чтобы твои друзья могли пользоваться Telegram там, где он заблокирован — в большинстве юрисдикций легально. Проверь локальные законы.
---
## 12. Где что лежит
| Что | Где |
| --- | --- |
| Скрипты (`install.sh`, `lib/`) | `/opt/gotelegram/` |
| Симлинк запуска | `/usr/local/bin/gotelegram` |
| Конфиг telemt | `/etc/telemt/config.toml` |
| Конфиг GoTelegram (JSON) | `/opt/gotelegram/config.json` |
| Бинарник telemt | `/usr/local/bin/telemt` |
| Systemd юнит | `/etc/systemd/system/telemt.service` |
| Бот | `/opt/gotelegram-bot/` |
| Systemd юнит бота | `/etc/systemd/system/gotelegram-bot.service` |
| Сайт (Pro-режим) | `/var/www/gotelegram-site/` |
| nginx site | `/etc/nginx/sites-available/gotelegram` |
| Бекапы | `/opt/gotelegram/backups/` |
| Лог GoTelegram | `/var/log/gotelegram.log` |
| Логи telemt | `journalctl -u telemt` |
| Логи бота | `journalctl -u gotelegram-bot` |
---
## 13. Контакты и развитие
- Баги и пожелания — issues в репозитории `anten-ka/gotelegram_pro`.
- Владелец: Vitalii (`anten-ka`).
- Ветки: `test` (заморожена, stable для пользователей), `alfa-test` (активная разработка).
---
## Changelog (коротко)
- **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.1** — фикс: `start_telemt` теперь делает `restart` если сервис уже запущен. Раньше переустановка Lite поверх Pro оставляла в памяти старый конфиг, и клиенты получали «Unknown TLS SNI drop». Плюс полная документация проекта (этот файл и `DOCS_AI.md`).
- **2.4.0** — i18n EN/RU для CLI (328 ключей) и бота (99 ключей JSON с per-user persistence). Возможность загрузить свой шаблон сайта из произвольного git-репо (с валидацией URL, таймаутами, лимитом размера клона).
- **2.3.x** — каталог шаблонов расширен до 1801, 4 источника, 18 категорий. Поддержка StartBootstrap `dist/` структуры.
- **2.2.1** — критические фиксы `$()` capture (все UI-вывод через `>&2`), StartBootstrap dist-структура, symlink SCRIPT_DIR через `readlink -f`, XSS в HTML-превью бота, OS-release injection.
- **2.2** — переход с mtg на telemt v3, новый TOML-формат конфига, stealth-архитектура.
Полный changelog — в commit-истории `anten-ka/gotelegram_pro`.

View File

@@ -6,7 +6,7 @@ set -euo pipefail
REPO="anten-ka/gotelegram_pro"
BRANCH="${GOTELEGRAM_BRANCH:-test}"
PAT="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"
PAT="${GOTELEGRAM_PAT:-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}"
INSTALL_DIR="/opt/gotelegram"
# Use raw.githubusercontent.com (CDN) — faster and avoids Contents API caching
# issues that occasionally return 404 for recently added files on non-default branches.
@@ -76,6 +76,7 @@ FILES=(
"lib/backup.sh"
"lib/website.sh"
"lib/templates_catalog.sh"
"lib/stats.sh"
"lib/i18n.sh"
"lib/lang/en.sh"
"lib/lang/ru.sh"

View File

@@ -100,13 +100,14 @@ logger = logging.getLogger(__name__)
# CONFIGURATION
# ============================================================================
GOTELEGRAM_VERSION = "2.4.0"
GOTELEGRAM_VERSION = "2.4.10"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
WEBSITE_ROOT = "/var/www/gotelegram-site"
BACKUP_DIR = "/opt/gotelegram/backups"
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
INSTALL_SH = "/opt/gotelegram/install.sh"
PROMO_LINK_1 = "https://vk.cc/ct29NQ"
PROMO_LINK_2 = "https://vk.cc/cUxAhj"
@@ -239,6 +240,76 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
return (-1, "", str(e))
# Per-host mutex preventing concurrent install.sh --action invocations. Two
# admins hitting "change template" at the same second could race each other
# and corrupt /var/www/gotelegram-site. One global lock is fine — these are
# rare operations and should serialize cleanly.
_BOT_ACTION_LOCK = asyncio.Lock()
# Allowed template-id shape: catalog ids are [a-zA-Z0-9_-], never longer than 64.
# This is a defense-in-depth check before we hand the value to subprocess.
_TPL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Allowed Lite mask domain shape — simple DNS hostname, up to 253 chars total.
# Each label 163 chars, labels separated by dots, alphanumerics + hyphens.
_DOMAIN_RE = re.compile(
r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+"
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
)
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
"""Invoke install.sh --action=X --json and parse the JSON result.
Args:
action: action name (e.g. "change-template", "change-lite-domain")
timeout: seconds to wait for completion (long ops: template download can take time)
**params: arbitrary key→value pairs, each passed as --key=value
Returns:
dict with at least {"status": "success|error", "message": "..."}.
Transport errors are mapped to {"status":"error","message":..., "code":"transport"}
"""
cmd = ["bash", INSTALL_SH, f"--action={action}", "--json"]
for k, v in params.items():
if v is None:
continue
cmd.append(f"--{k.replace('_', '-')}={v}")
code, stdout, stderr = await sh(*cmd, timeout=timeout)
stdout = (stdout or "").strip()
# install.sh may print multiple log lines to stderr; the JSON is on stdout.
# Pick the last non-empty line that looks like JSON (robust to any stray output).
json_line = None
for line in reversed(stdout.splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
json_line = line
break
if json_line:
try:
data = json.loads(json_line)
if isinstance(data, dict) and "status" in data:
return data
except json.JSONDecodeError as e:
logger.warning(f"run_bot_action: JSON parse failed: {e} | line={json_line!r}")
# No JSON from install.sh — synthesize an error result
tail = (stderr or "")[-300:] if stderr else ""
logger.error(
f"run_bot_action({action}): no JSON output, rc={code}, "
f"stdout={stdout[-300:]!r}, stderr={tail!r}"
)
return {
"status": "error",
"message": "install.sh did not return a JSON result",
"code": "transport",
"rc": str(code),
}
def load_json(path: str) -> Optional[Dict]:
"""Load JSON file."""
try:
@@ -271,12 +342,23 @@ def save_json(path: str, data: Dict) -> bool:
return False
async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool:
"""Safely edit message, handling cases where message was deleted or not modified."""
async def safe_edit_message(
query,
text: str,
reply_markup=None,
parse_mode=None,
disable_web_page_preview: Optional[bool] = None,
) -> bool:
"""Safely edit message, handling cases where message was deleted or not modified.
`disable_web_page_preview` is forwarded to edit_message_text when set; omitting
it keeps Telegram's default (enabled).
"""
kwargs = {"reply_markup": reply_markup, "parse_mode": parse_mode}
if disable_web_page_preview is not None:
kwargs["disable_web_page_preview"] = disable_web_page_preview
try:
await query.edit_message_text(
text, reply_markup=reply_markup, parse_mode=parse_mode
)
await query.edit_message_text(text, **kwargs)
return True
except BadRequest as e:
err_msg = str(e).lower()
@@ -288,6 +370,17 @@ async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None
raise # Re-raise unexpected BadRequest
async def _delete_message_after(message, delay: int = 30) -> None:
"""Delete a Telegram message after `delay` seconds. Errors are swallowed
(message may already be deleted by the user). Used for ephemeral content
like promo blocks that should auto-cleanup."""
try:
await asyncio.sleep(delay)
await message.delete()
except Exception as e:
logger.debug(f"_delete_message_after: {e}")
async def check_service_status(service: str) -> bool:
"""Check if systemd service is running."""
code, _, _ = await sh("systemctl", "is-active", service)
@@ -296,7 +389,7 @@ async def check_service_status(service: str) -> bool:
async def get_telemt_version() -> str:
"""Get telemt version."""
code, stdout, _ = await sh("telemt", "-v")
code, stdout, _ = await sh("telemt", "--version")
if code == 0:
return stdout.strip().split()[-1] if stdout else "unknown"
return "unknown"
@@ -465,12 +558,13 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML"
)
# Промо раз в сутки
# Промо раз в сутки — сообщение само удаляется через 30 секунд
if should_show_promo_bot():
mark_promo_shown_bot()
await update.message.reply_text(
get_promo_text(), parse_mode="HTML"
promo_msg = await update.message.reply_text(
get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
)
asyncio.create_task(_delete_message_after(promo_msg, 30))
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -556,11 +650,13 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
config = load_json(GOTELEGRAM_CONFIG)
if config:
lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}")
if "template" in config:
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(config['template']))}")
if "domain" in config:
# install.sh/save_gotelegram_config uses "template_id" (not "template")
tpl = config.get("template_id") or config.get("template")
if tpl:
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(tpl))}")
if config.get("domain"):
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
if "port" in config:
if config.get("port"):
lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {html.escape(str(config['port']))}")
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...)
@@ -591,7 +687,7 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
return "\n".join(lines)
async def get_traffic_stats() -> str:
async def get_traffic_stats(user_id: Optional[int] = None) -> str:
"""Get formatted traffic statistics."""
# Read current snapshot
current_file = "/run/gotelegram/stats_current.json"
@@ -601,20 +697,23 @@ async def get_traffic_stats() -> str:
with open(current_file, "r") as f:
current = json.load(f)
except Exception:
return "📊 <b>Статистика</b>\n\n<i>Данные недоступны. Убедитесь что модуль статистики включён.</i>"
return f"📊 <b>{_t(user_id, 'stats_title', 'Statistics')}</b>\n\n<i>{_t(user_id, 'stats_unavailable', 'Data unavailable. Make sure stats module is enabled.')}</i>"
# Read history
# Read history (skip header and non-numeric rows)
history = []
try:
with open(history_file, "r") as f:
reader = csv.reader(f)
for row in reader:
if len(row) >= 3:
history.append({
"ts": int(row[0]),
"proxy": int(row[1]),
"site": int(row[2]),
})
try:
history.append({
"ts": int(row[0]),
"proxy": int(row[1]),
"site": int(row[2]),
})
except (ValueError, TypeError):
continue # skip header or malformed rows
except Exception:
pass
@@ -658,21 +757,27 @@ async def get_traffic_stats() -> str:
return format_bytes(diff), format_rate(rate)
periods = [
("1 мин", 60),
("5 мин", 300),
("60 мин", 3600),
("1 день", 86400),
("7 дней", 604800),
("30 дней", 2592000),
("365 дней", 31536000),
(_t(user_id, "stats_1min", "1 min"), 60),
(_t(user_id, "stats_5min", "5 min"), 300),
(_t(user_id, "stats_60min", "60 min"), 3600),
(_t(user_id, "stats_1day", "1 day"), 86400),
(_t(user_id, "stats_7days", "7 days"), 604800),
(_t(user_id, "stats_30days", "30 days"), 2592000),
(_t(user_id, "stats_365days", "365 days"), 31536000),
]
lines = ["📊 <b>Статистика трафика</b>\n"]
hdr_period = _t(user_id, "stats_hdr_period", "Period")
hdr_traffic = _t(user_id, "stats_hdr_traffic", "Traffic")
hdr_rate = _t(user_id, "stats_hdr_rate", "Rate")
for label, key in [("Proxy (telemt)", "proxy"), ("Сайт (nginx)", "site")]:
lines = [f"📊 <b>{_t(user_id, 'stats_traffic_title', 'Traffic statistics')}</b>\n"]
lbl_proxy = _t(user_id, "stats_proxy_label", "Proxy (telemt)")
lbl_site = _t(user_id, "stats_site_label", "Site (nginx)")
for label, key in [(lbl_proxy, "proxy"), (lbl_site, "site")]:
lines.append(f"\n<b>{label}:</b>")
lines.append("<pre>")
lines.append(f"{'Период':<10}{'Трафик':>10}{'Скорость':>10}")
lines.append(f"{hdr_period:<10}{hdr_traffic:>10}{hdr_rate:>10}")
lines.append("" * 36)
for name, secs in periods:
total, rate = calc_for_period(secs, key)
@@ -687,11 +792,12 @@ async def cb_menu_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
query = update.callback_query
await query.answer()
stats_text = await get_traffic_stats()
uid = _uid(update)
stats_text = await get_traffic_stats(uid)
keyboard = [
[InlineKeyboardButton("🔄 Обновить", callback_data="menu_stats")],
[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
[InlineKeyboardButton(_t(uid, "btn_refresh", "🔄 Refresh"), callback_data="menu_stats")],
[InlineKeyboardButton(_t(uid, "btn_back"), callback_data="menu_main")],
]
await safe_edit_message(
@@ -796,7 +902,14 @@ async def cb_install_mode_lite(update: Update, context: ContextTypes.DEFAULT_TYP
async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Lite domain selection callback."""
"""Lite domain selection callback — real implementation (v2.4.2+).
Branches on current mode:
* lite mode (active): invoke `install.sh --action=change-lite-domain`
which regenerates the telemt TOML with a new fake-TLS mask domain and
restarts the service. Preserves secret/port.
* any other mode: route to CLI. Fresh Lite install is interactive.
"""
query = update.callback_query
data = query.data
try:
@@ -806,38 +919,79 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer("Invalid domain selection")
return
# Defense-in-depth: LITE_DOMAINS is trusted, but validate the shape anyway
# in case someone extends the list with garbage later.
if not _DOMAIN_RE.match(domain):
logger.warning(f"cb_lite_domain: rejecting malformed domain {domain!r}")
await query.answer("Invalid domain")
return
await query.answer()
await safe_edit_message(query,f"⏳ Installing with domain: {domain}...")
# Simulate installation (in real scenario, call install script)
config = {
"mode": "lite",
"domain": domain,
"port": 443,
"installed_at": datetime.now().isoformat(),
}
config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "")
if save_json(GOTELEGRAM_CONFIG, config):
if current_mode != "lite":
text = (
f"<b>Lite mode installed!</b>\n\n"
f"<b>Domain:</b> {domain}\n"
f"<b>Mode:</b> Lite\n\n"
f"Service starting... Check status in 10 seconds."
"<b>⚠️ Установка Lite из бота пока не поддерживается</b>\n\n"
f"Выбранный домен: <code>{html.escape(domain)}</code>\n\n"
"Чтобы установить Lite, запустите на сервере:\n"
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Lite</b>\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
return
# Lite active — switch fake-TLS mask domain in place
if _BOT_ACTION_LOCK.locked():
await safe_edit_message(
query,
"<b>⏳ Другая операция уже выполняется</b>\n\n"
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
return
progress_text = (
"<b>⏳ Меняю маскировочный домен...</b>\n\n"
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
"Перегенерирую конфиг telemt и перезапускаю сервис."
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
async with _BOT_ACTION_LOCK:
result = await run_bot_action("change-lite-domain", timeout=30, domain=domain)
if result.get("status") == "success":
text = (
"<b>✅ Маскировочный домен обновлён</b>\n\n"
f"Новый домен: <code>{html.escape(domain)}</code>\n\n"
"telemt перезапущен. <b>Важно:</b> старые ссылки подключения больше "
"не будут работать — нужно заново раздать новые."
)
else:
await safe_edit_message(query,
"❌ Failed to save configuration",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
),
err_msg = result.get("message", "unknown error")
err_code = result.get("code", "")
text = (
"<b>❌ Не удалось сменить домен</b>\n\n"
f"Домен: <code>{html.escape(domain)}</code>\n"
f"Причина: <code>{html.escape(err_msg)}</code>"
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
+ "\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Pro mode - show template categories."""
@@ -1106,42 +1260,113 @@ async def cb_pro_template(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Confirm and install pro template."""
"""Confirm Pro template selection — real implementation (v2.4.2+).
Branches on current mode:
* pro mode (active deployment): invoke `install.sh --action=change-template`
which downloads the new template and redeploys it to nginx. Reuses the
existing domain + SSL cert.
* any other mode (or no install at all): route to CLI. Fresh Pro install
still requires interactive flow (domain, email, DNS check) — not safe
to run headless from the bot.
Historic context: v2.4.1 stub used to overwrite config.json with a fake
blob; that was replaced with a safe message in v2.4.1 hotfix; now in
v2.4.2 we wire the real change-template path through install.sh.
"""
query = update.callback_query
data = query.data
tpl_id = data.removeprefix("pro_confirm_")
await query.answer()
await safe_edit_message(query,"⏳ Installing template...")
config = {
"mode": "pro",
"template": tpl_id,
"port": 443,
"installed_at": datetime.now().isoformat(),
}
# Defense-in-depth: even though subprocess.exec uses list args (no shell),
# we still enforce the catalog id shape before handing it to install.sh.
if not _TPL_ID_RE.match(tpl_id):
logger.warning(f"cb_pro_confirm: rejecting malformed tpl_id {tpl_id!r}")
await safe_edit_message(
query,
"<b>❌ Некорректный идентификатор шаблона</b>\n\n"
"Выбран неподдерживаемый шаблон. Вернитесь в меню и попробуйте снова.",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
return
if save_json(GOTELEGRAM_CONFIG, config):
# Read current config to decide: in-place change-template vs fresh install
config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "")
if current_mode != "pro":
# Fresh install / mode switch — still routes to CLI (needs domain, SSL)
text = (
f"<b>Pro mode installed!</b>\n\n"
f"<b>Template:</b> {html.escape(tpl_id)}\n"
f"<b>Mode:</b> Pro\n\n"
f"Service starting... Check status in 10 seconds."
"<b>⚠️ Установка Pro из бота пока не поддерживается</b>\n\n"
f"Выбранный шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"Pro-режим требует ввода домена, email и проверки DNS. "
"Чтобы установить Pro, запустите на сервере:\n"
"<code>gotelegram</code> → <b>1) Прокси → 1) Установить/Обновить → Pro</b>\n\n"
"Существующая конфигурация <b>не была изменена</b>."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
return
# Pro mode is active — perform change-template in place
if _BOT_ACTION_LOCK.locked():
await safe_edit_message(
query,
"<b>⏳ Другая операция уже выполняется</b>\n\n"
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
return
progress_text = (
"<b>⏳ Меняю шаблон сайта...</b>\n\n"
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n\n"
"Скачиваю репозиторий и разворачиваю в nginx. "
"Это может занять 3090 секунд."
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
# Template download + git clone can be slow — generous timeout.
# Mutex serializes with any concurrent change-lite-domain/change-template.
async with _BOT_ACTION_LOCK:
result = await run_bot_action("change-template", timeout=180, template=tpl_id)
if result.get("status") == "success":
domain = result.get("domain", config.get("domain", ""))
text = (
"<b>✅ Шаблон обновлён</b>\n\n"
f"Новый шаблон: <code>{html.escape(tpl_id)}</code>\n"
f"Сайт: <a href=\"https://{html.escape(domain, quote=True)}\">https://{html.escape(domain)}</a>\n\n"
"Прокси продолжает работать без перерыва."
)
else:
await safe_edit_message(query,
"❌ Failed to save configuration",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton("« Back", callback_data="menu_install")]]
),
err_msg = result.get("message", "unknown error")
err_code = result.get("code", "")
text = (
"<b>❌ Не удалось сменить шаблон</b>\n\n"
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n"
f"Причина: <code>{html.escape(err_msg)}</code>"
+ (f" (<code>{html.escape(err_code)}</code>)" if err_code else "")
+ "\n\n"
"Существующая конфигурация <b>не была изменена</b>. "
"Попробуйте другой шаблон или запустите <code>gotelegram</code> из консоли."
)
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True)
# ============================================================================
# PROXY LINK & SHARE
@@ -1799,14 +2024,16 @@ def mark_promo_shown_bot() -> None:
async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Promo information — always shown from menu."""
"""Promo information — shown as a separate ephemeral message that
auto-deletes after 30s so it does not clutter the chat. The main menu
message stays intact (we don't edit it in place)."""
query = update.callback_query
await query.answer()
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
promo_msg = await query.message.reply_text(
get_promo_text(), parse_mode="HTML", disable_web_page_preview=True
)
await safe_edit_message(query, get_promo_text(), reply_markup=keyboard, parse_mode="HTML")
asyncio.create_task(_delete_message_after(promo_msg, 30))
async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -2072,9 +2299,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
if not ok:
await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
return
# Success — record in GoTelegram config
# Success — record in GoTelegram config. Use "template_id" (canonical
# field name written by install.sh/save_gotelegram_config).
config = load_json(GOTELEGRAM_CONFIG) or {}
config["template"] = tpl_id
config["template_id"] = tpl_id
config["template_source"] = url
save_json(GOTELEGRAM_CONFIG, config)
await update.message.reply_text(

View File

@@ -112,5 +112,21 @@
"cg_timeout": "❌ Clone timeout (repository too large or slow)",
"cg_too_big": "❌ Repository too large (>100MB)",
"cg_no_index": "❌ No index.html found in repository",
"cg_ok_fmt": "✅ Custom template downloaded: %s"
"cg_ok_fmt": "✅ Custom template downloaded: %s",
"stats_title": "Statistics",
"stats_unavailable": "Data unavailable. Make sure stats module is enabled.",
"stats_traffic_title": "Traffic statistics",
"stats_proxy_label": "Proxy (telemt)",
"stats_site_label": "Site (nginx)",
"stats_hdr_period": "Period",
"stats_hdr_traffic": "Traffic",
"stats_hdr_rate": "Rate",
"stats_1min": "1 min",
"stats_5min": "5 min",
"stats_60min": "60 min",
"stats_1day": "1 day",
"stats_7days": "7 days",
"stats_30days": "30 days",
"stats_365days": "365 days"
}

View File

@@ -112,5 +112,21 @@
"cg_timeout": "❌ Таймаут клонирования (репозиторий слишком большой или медленный)",
"cg_too_big": "❌ Репозиторий слишком большой (>100МБ)",
"cg_no_index": "❌ В репозитории не найден index.html",
"cg_ok_fmt": "✅ Свой шаблон загружен: %s"
"cg_ok_fmt": "✅ Свой шаблон загружен: %s",
"stats_title": "Статистика",
"stats_unavailable": "Данные недоступны. Убедитесь что модуль статистики включён.",
"stats_traffic_title": "Статистика трафика",
"stats_proxy_label": "Proxy (telemt)",
"stats_site_label": "Сайт (nginx)",
"stats_hdr_period": "Период",
"stats_hdr_traffic": "Трафик",
"stats_hdr_rate": "Скорость",
"stats_1min": "1 мин",
"stats_5min": "5 мин",
"stats_60min": "60 мин",
"stats_1day": "1 день",
"stats_7days": "7 дней",
"stats_30days": "30 дней",
"stats_365days": "365 дней"
}

529
install.sh Executable file → Normal file
View File

@@ -258,6 +258,49 @@ menu_install() {
fi
fi
# Always start from a clean slate — any leftover env vars from a previous
# manual-key entry must not leak into a "new install" flow.
unset GOTELEGRAM_EXISTING_SECRET GOTELEGRAM_EXISTING_DOMAIN GOTELEGRAM_EXISTING_PORT
# ── Step 1: install source picker ────────────────────────────────────────
echo ""
echo -e " ${BOLD}${WHITE}$(_t_or install_source_title 'Источник установки')${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
echo -e " ${CYAN}1)${NC} ${GREEN}$(_t_or install_menu_new 'Новая установка')${NC}"
echo -e " ${DIM}$(_t_or install_menu_new_desc 'Сгенерировать новый ключ и настроить с нуля')${NC}"
echo ""
echo -e " ${CYAN}2)${NC} ${BLUE}$(_t_or install_menu_restore 'Восстановить из бекапа')${NC}"
echo -e " ${DIM}$(_t_or install_menu_restore_desc 'Полное восстановление из файла .tar.gz[.enc]')${NC}"
echo ""
echo -e " ${CYAN}3)${NC} ${YELLOW}$(_t_or install_menu_existing_key 'Использовать существующий ключ')${NC}"
echo -e " ${DIM}$(_t_or install_menu_existing_key_desc 'Ввести ссылку tg://proxy или ключ вручную')${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
echo -ne " ${WHITE}$(_t_or install_source_choice 'Выберите источник')${NC} "
read -r src_choice
src_choice="${src_choice:-}"
case "$src_choice" in
1) : ;; # fall through to mode picker
2)
if type interactive_restore &>/dev/null; then
interactive_restore
else
log_error "backup.sh not loaded"
fi
return
;;
3)
if type manual_secret_input &>/dev/null; then
manual_secret_input || return
else
log_error "backup.sh not loaded"
return
fi
;;
*) log_error "$(tf install_bad_choice "${src_choice:-<empty>}")" ; return ;;
esac
# ── Step 2: lite/pro mode picker ─────────────────────────────────────────
echo ""
echo -e " ${BOLD}${WHITE}$(t install_select_mode)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
@@ -274,11 +317,19 @@ menu_install() {
read -r mode_choice
mode_choice="${mode_choice:-}"
# If user provided an ee-prefixed key with a domain, hint at pro mode
if [ -n "${GOTELEGRAM_EXISTING_DOMAIN:-}" ] && [ "$mode_choice" = "1" ]; then
log_warning "$(_t_or install_hint_pro_mode 'Ключ содержит домен — обычно это Pro режим')"
fi
case "$mode_choice" in
1) install_lite_mode ;;
2) install_pro_mode ;;
*) log_error "$(tf install_bad_choice "${mode_choice:-<empty>}")" ;;
esac
# Clean up env vars after install, just in case
unset GOTELEGRAM_EXISTING_SECRET GOTELEGRAM_EXISTING_DOMAIN GOTELEGRAM_EXISTING_PORT
}
# ── Lite mode ───────────────────────────────────────────────────────────────
@@ -290,14 +341,30 @@ install_lite_mode() {
domain=$(select_quick_domain)
[ $? -ne 0 ] && return
# Port selection
# Port selection — if user provided a port via existing-key flow, reuse it
local port
port=$(select_port)
[ $? -ne 0 ] && return
if [ -n "${GOTELEGRAM_EXISTING_PORT:-}" ] && [[ "$GOTELEGRAM_EXISTING_PORT" =~ ^[0-9]+$ ]]; then
port="$GOTELEGRAM_EXISTING_PORT"
log_info "$(_t_or install_reuse_port 'Используется порт из ключа'): ${port}"
else
port=$(select_port)
[ $? -ne 0 ] && return
fi
# Generate secret
# Preflight: port conflict check (checks the external port only for lite)
if ! preflight_check "lite" "$port"; then
show_promo_with_qr 15
return
fi
# Secret: reuse if provided via manual_secret_input, otherwise generate new
local secret
secret=$(generate_hex 32)
if [ -n "${GOTELEGRAM_EXISTING_SECRET:-}" ]; then
secret="$GOTELEGRAM_EXISTING_SECRET"
log_info "$(_t_or install_reuse_secret 'Используется переданный ключ'): ${secret:0:8}...${secret: -4}"
else
secret=$(generate_hex 32)
fi
# Confirm
local ip
@@ -330,6 +397,11 @@ install_lite_mode() {
# Save GoTelegram config
save_gotelegram_config "telemt" "lite" "$port" "$secret" "$domain" "" ""
# Auto-install stats collector so stats work from the start
if type install_stats_collector &>/dev/null; then
install_stats_collector 2>/dev/null
fi
# Credits
show_credits
@@ -342,10 +414,22 @@ install_lite_mode() {
install_pro_mode() {
log_step "$(t install_pro_step)"
# Enter domain
echo ""
echo -ne " ${WHITE}$(t install_enter_domain)${NC} "
read -r user_domain
# Preflight: pro mode needs 443, 80 and 8443 (internal nginx mask)
if ! preflight_check "pro"; then
show_promo_with_qr 15
return
fi
# Enter domain — if provided via existing-key flow, reuse it
local user_domain=""
if [ -n "${GOTELEGRAM_EXISTING_DOMAIN:-}" ]; then
user_domain="$GOTELEGRAM_EXISTING_DOMAIN"
log_info "$(_t_or install_reuse_domain 'Используется домен из ключа'): ${user_domain}"
else
echo ""
echo -ne " ${WHITE}$(t install_enter_domain)${NC} "
read -r user_domain
fi
if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then
log_error "$(tf install_bad_domain "${user_domain:-<empty>}")"
@@ -387,8 +471,14 @@ install_pro_mode() {
# Generate fake-TLS secret (ee + secret + hex domain)
# ee prefix tells Telegram client to masquerade traffic as TLS to domain
# Reuse existing secret if manual_secret_input provided it
local raw_secret
raw_secret=$(generate_hex 32)
if [ -n "${GOTELEGRAM_EXISTING_SECRET:-}" ]; then
raw_secret="$GOTELEGRAM_EXISTING_SECRET"
log_info "$(_t_or install_reuse_secret 'Используется переданный ключ'): ${raw_secret:0:8}...${raw_secret: -4}"
else
raw_secret=$(generate_hex 32)
fi
local domain_hex
domain_hex=$(printf '%s' "$user_domain" | xxd -p | tr -d '\n')
local faketls_secret="ee${raw_secret}${domain_hex}"
@@ -427,6 +517,11 @@ install_pro_mode() {
tpl_id=$(basename "$template_dir")
save_gotelegram_config "telemt" "pro" "443" "$raw_secret" "$user_domain" "$user_domain" "$tpl_id"
# Auto-install stats collector so stats work from the start
if type install_stats_collector &>/dev/null; then
install_stats_collector 2>/dev/null
fi
# Result — use domain and fake-TLS link
show_proxy_info_pro "$user_domain" "$faketls_secret"
echo -e " ${WHITE}$(t svc_site):${NC} ${GREEN}https://${user_domain}${NC}"
@@ -751,11 +846,36 @@ menu_bot() {
bot_install() {
log_step "$(t bot_install_step)"
# Python
if ! command -v python3 &>/dev/null; then
# Python + venv + pip (always ensure — python3 can be present without venv/pip)
local need_py=0
command -v python3 &>/dev/null || need_py=1
# python3-venv not having its own command; probe by trying 'python3 -m venv --help'
if ! python3 -m venv --help &>/dev/null; then need_py=1; fi
# pip check
if ! python3 -m pip --version &>/dev/null; then need_py=1; fi
if [ "$need_py" = "1" ]; then
log_info "$(t bot_install_python)"
if command -v apt-get &>/dev/null; then
apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv
# Detect Python version for versioned venv package (Debian 12 / Ubuntu 24.04 need python3.12-venv)
local py_ver=""
if command -v python3 &>/dev/null; then
py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
fi
apt_update
# Build package list with versioned venv fallback
local pkg_list=(python3 python3-venv python3-pip)
[ -n "$py_ver" ] && pkg_list+=("python${py_ver}-venv")
# python3-full optional
if ! apt_install "${pkg_list[@]}" python3-full; then
log_warning "python3-full unavailable, installing core packages only..."
apt_install "${pkg_list[@]}" || {
log_error "Failed to install Python packages. Run manually: apt install ${pkg_list[*]}"
return 1
}
fi
elif command -v dnf &>/dev/null; then
dnf install -y -q python3 python3-pip
elif command -v yum &>/dev/null; then
@@ -782,17 +902,60 @@ bot_install() {
return 1
fi
# Templates catalog
[ -f "$SCRIPT_DIR/templates_catalog.json" ] && \
cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/"
# Venv
if [ ! -d "$BOT_DIR/venv" ]; then
log_info "$(t bot_create_venv)"
python3 -m venv "$BOT_DIR/venv"
# Templates catalog — skip if source and dest are the same file (symlink install case)
if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then
local src_tc="$SCRIPT_DIR/templates_catalog.json"
local dst_tc="$GOTELEGRAM_DIR/templates_catalog.json"
if [ "$(readlink -f "$src_tc" 2>/dev/null)" != "$(readlink -f "$dst_tc" 2>/dev/null)" ]; then
cp "$src_tc" "$dst_tc"
fi
fi
# Venv — create, and verify pip exists (python3-venv can silently create broken venv)
if [ ! -d "$BOT_DIR/venv" ] || [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
log_info "$(t bot_create_venv)"
rm -rf "$BOT_DIR/venv"
if ! python3 -m venv "$BOT_DIR/venv" 2>/tmp/venv_err; then
log_error "venv creation failed:"
cat /tmp/venv_err >&2 2>/dev/null
# Try to fix by installing versioned python3.X-venv package
if command -v apt-get &>/dev/null; then
local py_ver
py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
log_info "reinstalling python${py_ver}-venv..."
apt_install python3-venv python3-pip "python${py_ver}-venv" python3-full || \
apt_install python3-venv python3-pip "python${py_ver}-venv" || true
rm -rf "$BOT_DIR/venv"
python3 -m venv "$BOT_DIR/venv" || { log_error "venv still broken, aborting. Manual fix: apt install python${py_ver}-venv python3-pip"; return 1; }
else
return 1
fi
fi
fi
if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
log_info "bootstrapping pip via ensurepip..."
"$BOT_DIR/venv/bin/python" -m ensurepip --upgrade 2>/dev/null || true
fi
if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then
log_error "pip missing in venv — install python3-venv manually: apt install python3-venv python3-pip"
return 1
fi
log_info "$(t bot_install_deps)"
"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
if ! "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q 2>/tmp/pip_err; then
log_error "pip install failed:"
tail -n 5 /tmp/pip_err >&2
return 1
fi
# Sanity check: verify critical imports succeed
if ! "$BOT_DIR/venv/bin/python" -c "import telegram, toml, dotenv" 2>/tmp/imp_err; then
log_error "dependency import check failed:"
cat /tmp/imp_err >&2
return 1
fi
# Configuration
if [ ! -f "$BOT_DIR/.env" ]; then
@@ -862,14 +1025,13 @@ SVCEOF
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
if [ -z "$has_ids" ]; then
echo ""
echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
printf " ${YELLOW}${NC} ${BOLD}%-52s${NC} ${YELLOW}${NC}\n" "$(t bot_wait_admin_title)"
echo -e " ${YELLOW}${NC} ${YELLOW}${NC}"
printf " ${YELLOW}${NC} %s ${CYAN}/start${NC}%*s${YELLOW}${NC}\n" "$(t bot_wait_admin_msg1)" 0 ""
printf " ${YELLOW}${NC} %-52s ${YELLOW}${NC}\n" "$(t bot_wait_admin_msg2)"
echo -e " ${YELLOW}${NC} ${YELLOW}${NC}"
printf " ${YELLOW}${NC} ${DIM}%-52s${NC} ${YELLOW}${NC}\n" "$(t bot_wait_admin_skip)"
echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
# Simple bullet-style block (no box — printf %-Ns breaks on UTF-8 multibyte chars)
echo -e " ${YELLOW}${NC} ${BOLD}$(t bot_wait_admin_title)${NC}"
echo ""
echo -e " $(t bot_wait_admin_msg1) ${CYAN}/start${NC}"
echo -e " $(t bot_wait_admin_msg2)"
echo ""
echo -e " ${DIM}$(t bot_wait_admin_skip)${NC}"
echo ""
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
@@ -1071,21 +1233,15 @@ mark_promo_shown() {
}
# ── Promo with QR + delay (on install + once per day) ───────────────────
# QR показываем ТОЛЬКО для чаевых/донатов. Для хостеров оставлены только
# текстовые ссылки и промокоды (см. _promo_block) — QR-коды хостеров
# визуально конкурировали с чаевыми и перегружали экран.
show_promo_with_qr() {
local countdown="${1:-5}"
_promo_block
# QR codes
# QR только для чаевых
if command -v qrencode &>/dev/null; then
echo -e " ${DIM}$(t promo_qr_host1)${NC}"
qrencode -t UTF8 -m 1 "https://vk.cc/ct29NQ" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
done
echo ""
echo -e " ${DIM}$(t promo_qr_host2)${NC}"
qrencode -t UTF8 -m 1 "https://vk.cc/cUxAhj" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
done
echo ""
echo -e " ${DIM}$(t promo_qr_tips)${NC}"
qrencode -t UTF8 -m 1 "https://pay.cloudtips.ru/p/7410814f" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
@@ -1094,8 +1250,9 @@ show_promo_with_qr() {
mark_promo_shown
# 5-second countdown
for i in 5 4 3 2 1; do
# Countdown (default 5s, caller may pass longer for preflight abort)
local i
for ((i=countdown; i>0; i--)); do
echo -ne "\r ${DIM}$(tf promo_menu_in "$i")${NC} "
sleep 1
done
@@ -1136,11 +1293,297 @@ menu_language() {
esac
}
# ══════════════════════════════════════════════════════════════════════════════
# Non-interactive action dispatcher (bot / CI / scripting interface)
# ══════════════════════════════════════════════════════════════════════════════
# Usage examples:
# gotelegram --action=change-template --template=th_ariclaw --json
# gotelegram --action=change-lite-domain --domain=google.com --json
#
# Rules for action handlers:
# - Only JSON may be written to stdout (the caller parses it).
# - All human-oriented logging must go to stderr (log_* already do that).
# - Exit code 0 on success, non-zero on failure (caller still parses JSON).
# ══════════════════════════════════════════════════════════════════════════════
bot_emit_json() {
# bot_emit_json <status> <message> [key=value ...]
local status="$1"; shift
local message="$1"; shift
local extra="" kv k v
for kv in "$@"; do
k="${kv%%=*}"
v="${kv#*=}"
# escape backslashes and double quotes in value
v="${v//\\/\\\\}"
v="${v//\"/\\\"}"
extra="${extra},\"${k}\":\"${v}\""
done
# escape message
local msg_esc="${message//\\/\\\\}"
msg_esc="${msg_esc//\"/\\\"}"
printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra"
}
# Update a single key in config.json without rewriting the whole file.
# Uses `date -Iseconds` rather than jq's `now | todate` — the latter requires
# jq 1.6+ which is not available on Debian 10 or older CentOS.
bot_update_config_field() {
local key="$1"
local value="$2"
if [ ! -f "$GOTELEGRAM_CONFIG" ]; then
return 1
fi
local tmp now
tmp=$(mktemp) || return 1
now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z)
if jq --arg k "$key" --arg v "$value" --arg t "$now" \
'.[$k] = $v | .updated_at = $t' \
"$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then
mv "$tmp" "$GOTELEGRAM_CONFIG"
chmod 600 "$GOTELEGRAM_CONFIG"
return 0
fi
rm -f "$tmp"
return 1
}
# ── Action: change-template (pro mode only) ──────────────────────────────────
bot_action_change_template() {
local tpl_id="$1"
local json_out="${2:-0}"
if [ -z "$tpl_id" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "template id is required" "code=missing_arg"
log_error "change-template: --template is required"
return 2
fi
# Must be in pro mode
local mode
mode=$(config_get mode 2>/dev/null || echo "")
if [ "$mode" != "pro" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "change-template requires pro mode (current: ${mode:-none})" "code=wrong_mode"
log_error "change-template: current mode is '${mode:-none}', requires 'pro'"
return 3
fi
# Validate template id exists in catalog
if ! get_template_info "$tpl_id" >/dev/null 2>&1; then
[ "$json_out" = "1" ] && bot_emit_json "error" "unknown template: $tpl_id" "code=unknown_template"
log_error "change-template: template not found in catalog: $tpl_id"
return 4
fi
# Make sure git (and other deps) are present. download_template uses git
# clone under the hood — on a minimal host (bootstrap-only install) git may
# not be installed yet, and the clone would fail silently.
ensure_deps >&2
log_info "change-template: downloading $tpl_id..."
local template_dir
template_dir=$(download_template "$tpl_id")
if [ $? -ne 0 ] || [ -z "$template_dir" ] || [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "download failed for $tpl_id" "code=download_failed"
log_error "change-template: download_template failed for $tpl_id"
return 5
fi
log_info "change-template: deploying to nginx..."
if ! deploy_template_to_nginx "$template_dir" >&2; then
[ "$json_out" = "1" ] && bot_emit_json "error" "deploy failed" "code=deploy_failed"
return 6
fi
# Reload nginx (no full restart needed for static files — but be safe)
systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null
# Update config.json template_id field
bot_update_config_field "template_id" "$tpl_id" || \
log_warning "change-template: could not update config.json template_id"
local domain
domain=$(config_get domain 2>/dev/null || echo "")
log_success "change-template: $tpl_id deployed"
if [ "$json_out" = "1" ]; then
bot_emit_json "success" "template changed to $tpl_id" \
"template=$tpl_id" "domain=$domain" "mode=pro"
fi
return 0
}
# ── Action: change-lite-domain ───────────────────────────────────────────────
# Regenerates telemt TOML with a new fake-TLS mask domain. Lite mode only.
bot_action_change_lite_domain() {
local new_domain="$1"
local json_out="${2:-0}"
if [ -z "$new_domain" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "domain is required" "code=missing_arg"
log_error "change-lite-domain: --domain is required"
return 2
fi
if ! validate_domain "$new_domain" 2>/dev/null; then
[ "$json_out" = "1" ] && bot_emit_json "error" "invalid domain: $new_domain" "code=invalid_domain"
log_error "change-lite-domain: invalid domain: $new_domain"
return 3
fi
local mode
mode=$(config_get mode 2>/dev/null || echo "")
if [ "$mode" != "lite" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "change-lite-domain requires lite mode (current: ${mode:-none})" "code=wrong_mode"
log_error "change-lite-domain: current mode is '${mode:-none}', requires 'lite'"
return 4
fi
local secret port
secret=$(get_config_value secret 2>/dev/null || echo "")
port=$(get_config_value port 2>/dev/null || echo "443")
if [ -z "$secret" ]; then
[ "$json_out" = "1" ] && bot_emit_json "error" "no secret in config" "code=no_secret"
log_error "change-lite-domain: no secret in config.json"
return 5
fi
log_info "change-lite-domain: regenerating telemt TOML..."
generate_telemt_toml "$secret" "$port" "lite" "$new_domain" "443" >&2 || {
[ "$json_out" = "1" ] && bot_emit_json "error" "config generation failed" "code=gen_failed"
return 6
}
validate_telemt_config >&2 || {
[ "$json_out" = "1" ] && bot_emit_json "error" "config validation failed" "code=validate_failed"
return 7
}
restart_telemt >&2 || {
[ "$json_out" = "1" ] && bot_emit_json "error" "telemt restart failed" "code=restart_failed"
return 8
}
# Update both domain and mask_host fields in config.json
bot_update_config_field "mask_host" "$new_domain" || \
log_warning "change-lite-domain: could not update mask_host"
bot_update_config_field "domain" "$new_domain" || \
log_warning "change-lite-domain: could not update domain"
log_success "change-lite-domain: switched to $new_domain"
if [ "$json_out" = "1" ]; then
bot_emit_json "success" "lite mask domain changed to $new_domain" \
"domain=$new_domain" "mode=lite" "port=$port"
fi
return 0
}
# Main dispatcher — called from main() when --action=X is present.
# Uses a file lock (flock) so concurrent CLI invocations (from multiple bot
# users, or from bot + manual CLI) serialize cleanly. Without this, two
# parallel `change-lite-domain` calls raced on the jq-rewrite of config.json
# and one process would see a truncated file ("no secret in config").
bot_action_dispatch() {
local lock_file="/var/lock/gotelegram-bot-action.lock"
# Make sure /var/lock exists (it does on Debian/Ubuntu; be defensive for minimal images)
[ -d /var/lock ] || mkdir -p /var/lock 2>/dev/null || true
if command -v flock >/dev/null 2>&1; then
# Wait up to 30 seconds for the lock — bot actions are fast (<5s
# typical), so 30s is plenty for legitimate serialization but short
# enough to surface a stuck process.
(
flock -w 30 9 || {
# If we time out, emit JSON error for the bot parent.
local json_out=0 a
for a in "$@"; do
[ "$a" = "--json" ] && json_out=1
done
if [ "$json_out" = "1" ]; then
bot_emit_json "error" "another action in progress (lock timeout)" "code=lock_timeout"
fi
exit 75 # EX_TEMPFAIL
}
_bot_action_dispatch_locked "$@"
) 9>"$lock_file"
return $?
else
# No flock installed — run unlocked with a warning. ensure_deps/check_deps
# normally ensures util-linux is present, so this branch is defensive.
log_warning "flock not available — bot actions not serialized"
_bot_action_dispatch_locked "$@"
return $?
fi
}
_bot_action_dispatch_locked() {
local action="" tpl_id="" domain="" json_out=0 arg
for arg in "$@"; do
case "$arg" in
--action=*) action="${arg#--action=}" ;;
--template=*) tpl_id="${arg#--template=}" ;;
--domain=*) domain="${arg#--domain=}" ;;
--json) json_out=1 ;;
esac
done
case "$action" in
change-template)
bot_action_change_template "$tpl_id" "$json_out"
return $?
;;
change-lite-domain)
bot_action_change_lite_domain "$domain" "$json_out"
return $?
;;
"")
log_error "no --action specified"
return 64
;;
*)
[ "$json_out" = "1" ] && bot_emit_json "error" "unknown action: $action" "code=unknown_action"
log_error "unknown action: $action"
return 64
;;
esac
}
# ── Точка входа / Entry point ───────────────────────────────────────────────
main() {
# Non-interactive action mode: if --action=X is in args, dispatch and exit.
# Must run BEFORE interactive banner/menus so the bot gets clean JSON.
local a has_action=0
for a in "$@"; do
case "$a" in --action=*) has_action=1; break ;; esac
done
if [ "$has_action" = "1" ]; then
check_root
init_dirs
# Для bot-экшенов тоже нужны зависимости (git для change-template), но
# без шумного apt-get update если всё уже на месте.
if ! check_deps_present; then
ensure_deps >&2 || exit 1
fi
bot_action_dispatch "$@"
exit $?
fi
check_root
init_dirs
# Первый запуск: если критические зависимости отсутствуют — ставим их ДО
# того как пользователь дойдёт до меню. На последующих запусках это просто
# дёшево проверяет command -v по всем командам и ничего не делает.
if ! check_deps_present; then
log_step "Первый запуск: проверяю зависимости..."
ensure_deps || {
log_error "Не удалось установить зависимости. См. сообщения выше."
exit 1
}
fi
# First-run language picker (before banner so banner appears in chosen lang)
first_run_language_picker

497
lib/backup.sh Executable file → Normal file
View File

@@ -1,71 +1,179 @@
#!/bin/bash
# GoTelegram v2.4 — backup and restore (i18n-aware)
# GoTelegram v2.4.9Unified Backup Format (UBF) v2.0
#
# UBF v2.0 layout (inside the tarball):
# gotelegram_backup_YYYYMMDD_HHMMSS_<shortid>/
# ├── metadata.json # backup_id, versions, fingerprint, ...
# ├── secrets.json # raw_secret, faketls_secret, proxy_link, bot_token
# ├── telemt/config.toml
# ├── gotelegram/config.json
# ├── gotelegram/.language
# ├── nginx/site.conf
# ├── letsencrypt/
# │ ├── live/<domain>/{fullchain,privkey,chain,cert}.pem
# │ └── renewal/<domain>.conf
# ├── site/ (nginx document root)
# └── bot/.env
#
# Backup ID format: GT-YYMMDD-<last6hex-of-raw-secret>
# Archive name: gotelegram_backup_YYYYMMDD_HHMMSS_<shortid>.tar.gz[.enc]
#
# Encryption: AES-256-CBC + PBKDF2 (optional, password-based)
# Integrity: SHA-256 sidecar file (<archive>.sha256)
#
# Restore path automatically detects v1.1 (legacy) vs v2.0 layouts by reading
# metadata.json.backup_version. When restoring a v1.1 archive the script
# immediately writes a fresh v2.0 backup alongside the old one, so subsequent
# reinstalls can benefit from the new format.
# ── Создание бекапа ──────────────────────────────────────────────────────────
# ── Utility: generate a backup ID from a raw secret ─────────────────────────
# Format: GT-YYMMDD-<last6hex>. Deterministic per-day per-key; easy to read.
generate_backup_id() {
local raw_secret="$1"
local date_part
date_part=$(date +%y%m%d)
local last6="000000"
if [ -n "$raw_secret" ] && [ ${#raw_secret} -ge 6 ]; then
last6="${raw_secret: -6}"
last6=$(echo "$last6" | tr 'A-F' 'a-f')
fi
echo "GT-${date_part}-${last6}"
}
# ── Utility: SHA-256 fingerprint of a raw secret ────────────────────────────
secret_fingerprint() {
local raw_secret="$1"
[ -z "$raw_secret" ] && { echo ""; return; }
printf '%s' "$raw_secret" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}'
}
# ── Utility: hex-encode an ASCII string (for fake-TLS secret) ───────────────
_hex_encode() {
printf '%s' "$1" | xxd -p | tr -d '\n'
}
# ── Создание бекапа (UBF v2.0) ──────────────────────────────────────────────
create_backup() {
local password="$1"
local password="${1:-}"
local output_dir="${2:-$BACKUP_DIR}"
# Pull current config (so backup_id can include the real secret)
local raw_secret domain mode engine port lang tpl_id mask_host
raw_secret=$(config_get secret 2>/dev/null || echo "")
domain=$(config_get domain 2>/dev/null || echo "")
mode=$(config_get mode 2>/dev/null || echo "unknown")
engine=$(config_get engine 2>/dev/null || echo "telemt")
port=$(config_get port 2>/dev/null || echo "443")
lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en")
tpl_id=$(config_get template_id 2>/dev/null || echo "")
mask_host=$(config_get mask_host 2>/dev/null || echo "")
# Sanitise port
[[ "$port" =~ ^[0-9]+$ ]] || port=443
# Backup id / short id (last 6 of secret, or random if unknown)
local backup_id short_id
backup_id=$(generate_backup_id "$raw_secret")
short_id="${backup_id##*-}"
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local backup_name="gotelegram_backup_${timestamp}"
local backup_name="gotelegram_backup_${timestamp}_${short_id}"
local tmp_dir="/tmp/${backup_name}"
mkdir -p "$tmp_dir" "$output_dir"
# Собираем файлы
log_info "$(_t_or backup_collecting 'Собираю конфигурацию...')"
# telemt конфиг
# ── telemt ──
if [ -f "$TELEMT_CONFIG" ]; then
cp "$TELEMT_CONFIG" "$tmp_dir/config.toml"
mkdir -p "$tmp_dir/telemt"
cp "$TELEMT_CONFIG" "$tmp_dir/telemt/config.toml"
fi
# GoTelegram конфиг
# ── gotelegram ──
mkdir -p "$tmp_dir/gotelegram"
if [ -f "$GOTELEGRAM_CONFIG" ]; then
cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json"
cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram/config.json"
fi
# Language marker (i18n)
if [ -f "$GOTELEGRAM_DIR/.language" ]; then
cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/.language"
cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/gotelegram/.language"
fi
# nginx конфиг (stealth mode)
# ── nginx ──
if [ -f "$NGINX_SITE_CONF" ]; then
cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf"
mkdir -p "$tmp_dir/nginx"
cp "$NGINX_SITE_CONF" "$tmp_dir/nginx/site.conf"
fi
# SSL сертификаты
local domain
domain=$(config_get domain 2>/dev/null)
# ── Let's Encrypt (full tree: live/<d>/*.pem + renewal/<d>.conf) ──
if [ -n "$domain" ] && [ -d "/etc/letsencrypt/live/$domain" ]; then
mkdir -p "$tmp_dir/certs"
cp "/etc/letsencrypt/live/$domain/fullchain.pem" "$tmp_dir/certs/" 2>/dev/null
cp "/etc/letsencrypt/live/$domain/privkey.pem" "$tmp_dir/certs/" 2>/dev/null
log_dim "SSL сертификаты включены"
mkdir -p "$tmp_dir/letsencrypt/live/$domain"
# Follow symlinks — letsencrypt's live/ tree is symlinks into archive/
cp -L "/etc/letsencrypt/live/$domain/"*.pem "$tmp_dir/letsencrypt/live/$domain/" 2>/dev/null
if [ -f "/etc/letsencrypt/renewal/${domain}.conf" ]; then
mkdir -p "$tmp_dir/letsencrypt/renewal"
cp "/etc/letsencrypt/renewal/${domain}.conf" "$tmp_dir/letsencrypt/renewal/"
fi
log_dim "$(_t_or backup_ssl_included 'SSL-сертификаты включены (+ chain + renewal)')"
fi
# Шаблон сайта (если есть)
# ── Website template ──
if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then
mkdir -p "$tmp_dir/site"
cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/"
cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/" 2>/dev/null
log_dim "$(_t_or backup_site_included 'Шаблон сайта включён')"
fi
# Метаданные
local ip mode engine lang port domain
# ── Telegram bot ──
if [ -f "$BOT_DIR/.env" ]; then
mkdir -p "$tmp_dir/bot"
cp "$BOT_DIR/.env" "$tmp_dir/bot/.env"
chmod 600 "$tmp_dir/bot/.env" 2>/dev/null
log_dim "$(_t_or backup_bot_included 'Конфиг Telegram-бота включён')"
fi
# ── secrets.json ──
local faketls_secret="" proxy_link="" bot_token=""
if [ -n "$raw_secret" ] && [ "$mode" = "pro" ] && [ -n "$domain" ]; then
faketls_secret="ee${raw_secret}$(_hex_encode "$domain")"
fi
if type generate_proxy_link &>/dev/null; then
if [ "$mode" = "pro" ] && [ -n "$domain" ]; then
proxy_link=$(generate_proxy_link "$domain" "$port" "$raw_secret" "$domain" 2>/dev/null || echo "")
elif [ -n "$raw_secret" ]; then
local ip
ip=$(get_server_ip)
proxy_link=$(generate_proxy_link "$ip" "$port" "$raw_secret" "$mask_host" 2>/dev/null || echo "")
fi
fi
if [ -f "$BOT_DIR/.env" ]; then
bot_token=$(grep -E '^BOT_TOKEN=' "$BOT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-)
bot_token="${bot_token%\"}"
bot_token="${bot_token#\"}"
fi
cat > "$tmp_dir/secrets.json" << EOSEC
{
"version": "1",
"raw_secret": "${raw_secret}",
"faketls_secret": "${faketls_secret}",
"proxy_link": "${proxy_link}",
"bot_token": "${bot_token}",
"exported_at": "$(date -Iseconds)"
}
EOSEC
chmod 600 "$tmp_dir/secrets.json"
# ── metadata.json v2.0 ──
local ip fingerprint
ip=$(get_server_ip)
mode=$(config_get mode 2>/dev/null || echo "unknown")
engine=$(config_get engine 2>/dev/null || echo "telemt")
lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en")
port=$(config_get port 2>/dev/null || echo "443")
# Ensure port is numeric; fall back to 443 if garbage
[[ "$port" =~ ^[0-9]+$ ]] || port=443
domain=$(config_get domain 2>/dev/null || echo "")
fingerprint=$(secret_fingerprint "$raw_secret")
cat > "$tmp_dir/metadata.json" << EOMETA
{
"backup_version": "1.1",
"backup_version": "2.0",
"backup_id": "${backup_id}",
"gotelegram_version": "$GOTELEGRAM_VERSION",
"created_at": "$(date -Iseconds)",
"hostname": "$(hostname)",
@@ -74,34 +182,37 @@ create_backup() {
"mode": "$mode",
"language": "$lang",
"port": $port,
"domain": "$domain"
"domain": "$domain",
"template_id": "$tpl_id",
"mask_host": "$mask_host",
"secret_fingerprint_sha256": "$fingerprint",
"has_secrets": true,
"has_letsencrypt": $([ -d "$tmp_dir/letsencrypt" ] && echo true || echo false),
"has_site": $([ -d "$tmp_dir/site" ] && echo true || echo false),
"has_bot": $([ -d "$tmp_dir/bot" ] && echo true || echo false)
}
EOMETA
# Архивируем
# ── Archive ──
local tar_file="/tmp/${backup_name}.tar.gz"
if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then
log_error "$(_t_or backup_archive_err 'Ошибка создания архива')"
rm -rf "$tmp_dir"
rm -f "$tar_file"
rm -rf "$tmp_dir"; rm -f "$tar_file"
return 1
fi
if [ ! -f "$tar_file" ]; then
log_error "$(_t_or backup_archive_missing 'Архив не создан')"
rm -rf "$tmp_dir"
return 1
fi
# Шифруем если задан пароль
# ── Encrypt (optional) ──
local final_file=""
if [ -n "$password" ]; then
final_file="${output_dir}/${backup_name}.tar.gz.enc"
openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null
if [ $? -ne 0 ]; then
if ! openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null; then
log_error "$(_t_or backup_encrypt_err 'Ошибка шифрования')"
rm -f "$tar_file"
rm -rf "$tmp_dir"
rm -f "$tar_file"; rm -rf "$tmp_dir"
return 1
fi
rm -f "$tar_file"
@@ -111,27 +222,36 @@ EOMETA
mv "$tar_file" "$final_file"
fi
# SHA256 подпись
# SHA-256 sidecar
sha256sum "$final_file" > "${final_file}.sha256" 2>/dev/null
# Очистка
# Cleanup
rm -rf "$tmp_dir"
local size
size=$(du -h "$final_file" | cut -f1)
if type tf &>/dev/null; then
log_success "$(tf backup_created_fmt "$final_file" "$size")"
else
log_success "Бекап создан: $final_file ($size)"
echo "" >&2
echo -e " ${BOLD}${GREEN}$(_t_or backup_created 'Бекап создан')${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo -e " ${WHITE}$(_t_or backup_id_label 'Backup ID'):${NC} ${BOLD}${CYAN}${backup_id}${NC}" >&2
echo -e " ${WHITE}$(_t_or backup_file_label 'Файл'):${NC} ${final_file}" >&2
echo -e " ${WHITE}$(_t_or backup_size_label 'Размер'):${NC} ${size}" >&2
if [ -n "$raw_secret" ]; then
echo -e " ${WHITE}$(_t_or backup_key_label 'Ключ в бекапе (fingerprint)'):${NC} ${DIM}${fingerprint:0:32}...${NC}" >&2
fi
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo "" >&2
# Only the final file path on stdout (callers capture it)
echo "$final_file"
return 0
}
# ── Восстановление из бекапа ────────────────────────────────────────────────
# ── Восстановление из бекапа (auto-detect v1.1 vs v2.0) ─────────────────────
restore_backup() {
local backup_file="$1"
local password="$2"
local password="${2:-}"
if [ ! -f "$backup_file" ]; then
if type tf &>/dev/null; then
@@ -145,17 +265,16 @@ restore_backup() {
local tmp_dir="/tmp/gotelegram_restore_$$"
mkdir -p "$tmp_dir"
# Расшифровываем если нужно
# ── Decrypt if needed ──
local tar_file=""
if echo "$backup_file" | grep -q '\.enc$'; then
if [ -z "$password" ]; then
echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): "
echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): " >&2
read -rs password
echo ""
echo "" >&2
fi
tar_file="/tmp/gotelegram_restore_$$.tar.gz"
openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null
if [ $? -ne 0 ]; then
if ! openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null; then
log_error "$(_t_or backup_bad_pass 'Неверный пароль или повреждённый файл')"
rm -rf "$tmp_dir" "$tar_file"
return 1
@@ -164,105 +283,155 @@ restore_backup() {
tar_file="$backup_file"
fi
# Распаковываем
tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null
if [ $? -ne 0 ]; then
# ── Extract ──
if ! tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null; then
log_error "$(_t_or backup_extract_err 'Ошибка распаковки архива')"
rm -rf "$tmp_dir"
[ "$tar_file" != "$backup_file" ] && rm -f "$tar_file"
return 1
fi
# Находим папку бекапа
# Find the single top-level dir inside the archive
local backup_dir
backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1)
backup_dir=$(find "$tmp_dir" -maxdepth 1 -mindepth 1 -type d -name "gotelegram_backup_*" | head -1)
[ -z "$backup_dir" ] && backup_dir="$tmp_dir"
# Проверяем метаданные
# ── Parse metadata.json ──
local bk_version="1.1" bk_id="" bk_mode="" bk_domain="" bk_ip="" bk_lang="" bk_date=""
if [ -f "$backup_dir/metadata.json" ]; then
local bk_version bk_mode bk_ip bk_lang bk_date
bk_version=$(jq -r '.gotelegram_version // "unknown"' "$backup_dir/metadata.json")
bk_version=$(jq -r '.backup_version // "1.1"' "$backup_dir/metadata.json")
bk_id=$(jq -r '.backup_id // empty' "$backup_dir/metadata.json")
bk_mode=$(jq -r '.mode // "unknown"' "$backup_dir/metadata.json")
bk_ip=$(jq -r '.ip // "unknown"' "$backup_dir/metadata.json")
bk_domain=$(jq -r '.domain // empty' "$backup_dir/metadata.json")
bk_ip=$(jq -r '.ip // "-"' "$backup_dir/metadata.json")
bk_lang=$(jq -r '.language // "-"' "$backup_dir/metadata.json")
bk_date=$(jq -r '.created_at // "-"' "$backup_dir/metadata.json")
echo ""
echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}"
echo -e " $(_t_or backup_version_label 'Версия'): $bk_version | $(_t_or backup_mode_label 'Режим'): $bk_mode | IP: $bk_ip | $(_t_or backup_lang_label 'Язык'): $bk_lang"
echo -e " $(_t_or backup_date_label 'Дата'): $bk_date"
echo ""
fi
echo "" >&2
echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
if [ -n "$bk_id" ]; then
echo -e " ${WHITE}$(_t_or backup_id_label 'Backup ID'):${NC} ${BOLD}${CYAN}${bk_id}${NC}" >&2
fi
echo -e " ${WHITE}$(_t_or backup_format_label 'Формат'):${NC} UBF ${bk_version}" >&2
echo -e " ${WHITE}$(_t_or backup_mode_label 'Режим'):${NC} ${bk_mode}${bk_domain:+ | $bk_domain}" >&2
echo -e " ${WHITE}$(_t_or backup_lang_label 'Язык'):${NC} ${bk_lang} | IP: ${bk_ip}" >&2
echo -e " ${WHITE}$(_t_or backup_date_label 'Дата'):${NC} ${bk_date}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo "" >&2
if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then
rm -rf "$tmp_dir"
[ "$tar_file" != "$backup_file" ] && rm -f "$tar_file"
return 0
fi
# Останавливаем сервисы
stop_telemt 2>/dev/null
systemctl stop nginx 2>/dev/null
# Восстанавливаем telemt конфиг
if [ -f "$backup_dir/config.toml" ]; then
# ── Detect layout ──
# v2.0 paths: telemt/config.toml, gotelegram/config.json, nginx/site.conf, letsencrypt/live/<d>/
# v1.1 paths: config.toml, gotelegram.json, nginx.conf, certs/
local src_telemt src_gt src_lang src_nginx src_le_live src_le_renewal src_site src_bot
if [ "$bk_version" = "2.0" ] || [ -d "$backup_dir/telemt" ]; then
src_telemt="$backup_dir/telemt/config.toml"
src_gt="$backup_dir/gotelegram/config.json"
src_lang="$backup_dir/gotelegram/.language"
src_nginx="$backup_dir/nginx/site.conf"
src_le_live=$(find "$backup_dir/letsencrypt/live" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | head -1)
src_le_renewal="$backup_dir/letsencrypt/renewal"
src_site="$backup_dir/site"
src_bot="$backup_dir/bot/.env"
else
src_telemt="$backup_dir/config.toml"
src_gt="$backup_dir/gotelegram.json"
src_lang="$backup_dir/.language"
src_nginx="$backup_dir/nginx.conf"
src_le_live="$backup_dir/certs" # v1.1 dumps certs flat
src_le_renewal=""
src_site="$backup_dir/site"
src_bot="" # v1.1 never backed up bot
fi
# ── telemt config ──
if [ -f "$src_telemt" ]; then
mkdir -p /etc/telemt
cp "$backup_dir/config.toml" "$TELEMT_CONFIG"
cp "$src_telemt" "$TELEMT_CONFIG"
chmod 600 "$TELEMT_CONFIG"
log_success "$(_t_or backup_restored_telemt 'telemt конфиг восстановлен')"
fi
# Восстанавливаем GoTelegram конфиг
if [ -f "$backup_dir/gotelegram.json" ]; then
# ── GoTelegram config ──
if [ -f "$src_gt" ]; then
mkdir -p "$GOTELEGRAM_DIR"
cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG"
cp "$src_gt" "$GOTELEGRAM_CONFIG"
log_success "$(_t_or backup_restored_gotelegram 'GoTelegram конфиг восстановлен')"
fi
# Восстанавливаем language marker (i18n)
if [ -f "$backup_dir/.language" ]; then
# ── Language ──
if [ -f "$src_lang" ]; then
mkdir -p "$GOTELEGRAM_DIR"
cp "$backup_dir/.language" "$GOTELEGRAM_DIR/.language"
cp "$src_lang" "$GOTELEGRAM_DIR/.language"
log_success "$(_t_or backup_restored_lang 'Язык интерфейса восстановлен')"
fi
# Восстанавливаем nginx конфиг
if [ -f "$backup_dir/nginx.conf" ]; then
# ── nginx ──
if [ -f "$src_nginx" ]; then
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
cp "$backup_dir/nginx.conf" "$NGINX_SITE_CONF"
cp "$src_nginx" "$NGINX_SITE_CONF"
ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK"
log_success "$(_t_or backup_restored_nginx 'nginx конфиг восстановлен')"
fi
# Восстанавливаем SSL
if [ -d "$backup_dir/certs" ]; then
local domain
domain=$(config_get domain 2>/dev/null)
if [ -n "$domain" ]; then
local cert_dir="/etc/letsencrypt/live/$domain"
mkdir -p "$cert_dir"
cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null
log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')"
# ── Let's Encrypt (v2.0: full tree; v1.1: just flat certs/) ──
if [ -n "$bk_domain" ] && [ -d "$src_le_live" ]; then
local live_dir="/etc/letsencrypt/live/$bk_domain"
mkdir -p "$live_dir"
cp "$src_le_live/"*.pem "$live_dir/" 2>/dev/null
if [ -n "$src_le_renewal" ] && [ -f "$src_le_renewal/${bk_domain}.conf" ]; then
mkdir -p /etc/letsencrypt/renewal
cp "$src_le_renewal/${bk_domain}.conf" "/etc/letsencrypt/renewal/"
fi
log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')"
fi
# Восстанавливаем шаблон сайта
if [ -d "$backup_dir/site" ]; then
# ── Site ──
if [ -d "$src_site" ] && [ -f "$src_site/index.html" ]; then
mkdir -p "$WEBSITE_ROOT"
cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/"
cp -r "$src_site"/* "$WEBSITE_ROOT/"
chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null
log_success "$(_t_or backup_restored_site 'Шаблон сайта восстановлен')"
fi
# Запускаем сервисы
if is_telemt_installed; then
# ── Bot .env (v2.0 only) ──
if [ -n "$src_bot" ] && [ -f "$src_bot" ]; then
mkdir -p "$BOT_DIR"
cp "$src_bot" "$BOT_DIR/.env"
chmod 600 "$BOT_DIR/.env"
log_success "$(_t_or backup_restored_bot 'Конфиг Telegram-бота восстановлен')"
fi
# ── Start services ──
if type is_telemt_installed &>/dev/null && is_telemt_installed; then
start_telemt
fi
systemctl start nginx 2>/dev/null
# Очистка
# ── Cleanup ──
rm -rf "$tmp_dir"
[ "$tar_file" != "$backup_file" ] && rm -f "$tar_file"
log_success "$(_t_or backup_restore_done 'Восстановление завершено!')"
show_proxy_info
# ── Auto-migrate v1.1 → v2.0 ──
if [ "$bk_version" != "2.0" ]; then
log_info "$(_t_or backup_automigrate 'Конвертирую старый бекап в UBF v2.0...')"
create_backup "" >/dev/null 2>&1 && \
log_success "$(_t_or backup_migrated 'Свежий UBF v2.0 бекап сохранён в $BACKUP_DIR')"
fi
type show_proxy_info &>/dev/null && show_proxy_info
return 0
}
@@ -273,24 +442,26 @@ list_backups() {
return 1
fi
echo ""
echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
echo "" >&2
echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..70})${NC}" >&2
local i=1
for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do
[ -f "$f" ] || continue
[[ "$f" == *.sha256 ]] && continue
local size date_str name
local size name date_str id_tail encrypted=""
size=$(du -h "$f" | cut -f1)
name=$(basename "$f")
date_str=$(echo "$name" | grep -oE '[0-9]{8}_[0-9]{6}' | head -1)
local encrypted=""
id_tail=$(echo "$name" | grep -oE '_[0-9a-f]{6}\.tar' | head -1 | tr -d '_.tar')
[[ "$f" == *.enc ]] && encrypted=" 🔒"
echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}"
local id_display=""
[ -n "$id_tail" ] && id_display=" ${DIM}[...${id_tail}]${NC}"
echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}${id_display}" >&2
((i++))
done
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..70})${NC}" >&2
}
# ── Очистка старых бекапов ───────────────────────────────────────────────────
@@ -337,7 +508,7 @@ interactive_backup() {
fi
fi
create_backup "$password"
create_backup "$password" >/dev/null
cleanup_old_backups
}
@@ -371,3 +542,115 @@ interactive_restore() {
restore_backup "$backup_file"
}
# ── Manual secret recovery (v2.4.9) ──────────────────────────────────────────
# Parser accepts any of the 3 formats and emits key=value lines on stdout:
# tg://proxy?server=X&port=Y&secret=Z → raw_secret, server, port, domain (if ee-prefix)
# ee<32hex><hex_domain> → raw_secret, domain
# <32hex> → raw_secret only
# Returns 0 on success, 1 on parse failure.
parse_manual_secret() {
local input="$1"
input=$(echo "$input" | tr -d ' \t\n\r')
[ -z "$input" ] && return 1
local raw_secret="" domain="" server="" port=""
if echo "$input" | grep -q '^tg://proxy?'; then
local qs="${input#tg://proxy?}"
local kv k v
local -a kvs
IFS='&' read -ra kvs <<< "$qs"
for kv in "${kvs[@]}"; do
k="${kv%%=*}"
v="${kv#*=}"
case "$k" in
server) server="$v" ;;
port) port="$v" ;;
secret) raw_secret="$v" ;;
esac
done
[ -z "$raw_secret" ] && return 1
# Strip hex-escapes that Telegram sometimes URL-encodes
raw_secret=$(echo "$raw_secret" | tr -d '%')
fi
# After pulling from URL (if any), raw_secret might still be ee-prefixed.
# Otherwise, try the raw_secret as the whole input.
local candidate="${raw_secret:-$input}"
if [[ "$candidate" =~ ^[eE][eE][0-9a-fA-F]{32}[0-9a-fA-F]*$ ]]; then
raw_secret="${candidate:2:32}"
local hex_domain="${candidate:34}"
if [ -n "$hex_domain" ]; then
local decoded
decoded=$(echo "$hex_domain" | xxd -r -p 2>/dev/null)
# Validate decoded looks like a domain
if echo "$decoded" | grep -qE '^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then
domain="$decoded"
fi
fi
elif [[ "$candidate" =~ ^[0-9a-fA-F]{32}$ ]]; then
raw_secret="$candidate"
else
return 1
fi
[[ "$raw_secret" =~ ^[0-9a-fA-F]{32}$ ]] || return 1
raw_secret=$(echo "$raw_secret" | tr 'A-F' 'a-f')
echo "raw_secret=$raw_secret"
[ -n "$domain" ] && echo "domain=$domain"
[ -n "$server" ] && echo "server=$server"
[ -n "$port" ] && echo "port=$port"
return 0
}
# ── Interactive: user types their old key, we parse it ─────────────────────
manual_secret_input() {
echo "" >&2
echo -e " ${BOLD}${WHITE}🔑 $(_t_or manual_secret_title 'Ввод существующего ключа')${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo -e " ${DIM}$(_t_or manual_secret_help1 'Поддерживаются форматы:')${NC}" >&2
echo -e " ${DIM} • tg://proxy?server=...&port=...&secret=...${NC}" >&2
echo -e " ${DIM} • ee<32hex><hexdomain> (fake-TLS)${NC}" >&2
echo -e " ${DIM} • 32hex (только raw secret)${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo -ne " ${WHITE}$(_t_or manual_secret_prompt 'Вставьте ключ'):${NC} " >&2
read -r user_input
if [ -z "$user_input" ]; then
log_error "$(_t_or manual_secret_empty 'Ключ не введён')"
return 1
fi
local parsed
if ! parsed=$(parse_manual_secret "$user_input"); then
log_error "$(_t_or manual_secret_bad 'Не удалось распознать формат ключа')"
return 1
fi
local p_raw="" p_domain="" p_server="" p_port=""
while IFS='=' read -r k v; do
case "$k" in
raw_secret) p_raw="$v" ;;
domain) p_domain="$v" ;;
server) p_server="$v" ;;
port) p_port="$v" ;;
esac
done <<< "$parsed"
echo "" >&2
echo -e " ${GREEN}$(_t_or manual_secret_parsed 'Ключ распознан')${NC}" >&2
echo -e " ${WHITE}raw_secret:${NC} ${DIM}${p_raw:0:8}...${p_raw: -4}${NC}" >&2
[ -n "$p_domain" ] && echo -e " ${WHITE}domain:${NC} ${CYAN}${p_domain}${NC}" >&2
[ -n "$p_server" ] && echo -e " ${WHITE}server:${NC} ${CYAN}${p_server}${NC}" >&2
[ -n "$p_port" ] && echo -e " ${WHITE}port:${NC} ${CYAN}${p_port}${NC}" >&2
echo "" >&2
# Export for the subsequent install flow to pick up
export GOTELEGRAM_EXISTING_SECRET="$p_raw"
export GOTELEGRAM_EXISTING_DOMAIN="$p_domain"
export GOTELEGRAM_EXISTING_PORT="$p_port"
return 0
}

380
lib/common.sh Executable file → Normal file
View File

@@ -3,7 +3,7 @@
# Colors, logging, spinner, system helpers, v1 compat, i18n-aware
# ── Version ───────────────────────────────────────────────────────────────────
GOTELEGRAM_VERSION="2.4.0"
GOTELEGRAM_VERSION="2.4.10"
GOTELEGRAM_NAME="GoTelegram"
# ── Пути ──────────────────────────────────────────────────────────────────────
@@ -240,32 +240,215 @@ get_pkg_manager() {
install_pkg() {
local pkg="$1"
case "$(get_pkg_manager)" in
apt) apt-get install -y -qq "$pkg" ;;
apt) apt_install "$pkg" ;;
dnf) dnf install -y -q "$pkg" ;;
yum) yum install -y -q "$pkg" ;;
*) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;;
esac
}
ensure_deps() {
local missing=()
for cmd in curl jq openssl git qrencode; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
# ── apt lock wait + install ─────────────────────────────────────────────────
# На свежих Ubuntu/Debian unattended-upgrades часто держит dpkg lock на старте
# → любой apt-get install падает с "Could not get lock /var/lib/dpkg/lock-frontend".
# Эти функции ждут освобождения лока до 300с, потом запускают apt с нативным
# таймаутом DPkg::Lock::Timeout. Использовать везде, где раньше был
# "apt-get install ...".
apt_lock_wait() {
local max_wait="${1:-300}"
local waited=0
local warned=0
while fuser /var/lib/dpkg/lock-frontend &>/dev/null \
|| fuser /var/lib/dpkg/lock &>/dev/null \
|| fuser /var/lib/apt/lists/lock &>/dev/null \
|| pgrep -f '^/usr/bin/unattended-upgrade' &>/dev/null; do
if [ "$warned" = "0" ]; then
log_warning "apt/dpkg locked by unattended-upgrades, waiting up to ${max_wait}s..."
warned=1
fi
sleep 3
waited=$((waited + 3))
if [ "$waited" -ge "$max_wait" ]; then
log_error "apt lock not released after ${max_wait}s"
log_dim "Manual fix: systemctl stop unattended-upgrades && killall -9 unattended-upgr 2>/dev/null; dpkg --configure -a"
return 1
fi
done
if [ ${#missing[@]} -gt 0 ]; then
if type tf &>/dev/null; then
log_step "$(tf deps_installing "${missing[*]}")"
else
log_step "Installing dependencies: ${missing[*]}"
fi
case "$(get_pkg_manager)" in
apt) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;;
dnf) dnf install -y -q "${missing[@]}" ;;
yum) yum install -y -q "${missing[@]}" ;;
esac
[ "$warned" = "1" ] && log_success "apt lock released (waited ${waited}s)"
return 0
}
# apt_install <pkg> [pkg2 ...] — ждёт lock + ставит пакеты + показывает ошибку
apt_install() {
[ $# -eq 0 ] && return 0
apt_lock_wait || return 1
export DEBIAN_FRONTEND=noninteractive
local opts="-o DPkg::Lock::Timeout=120"
local err_file; err_file=$(mktemp 2>/dev/null || echo /tmp/apt_err.$$)
if ! apt-get $opts install -y -qq "$@" 2>"$err_file"; then
log_error "apt-get install failed: $*"
[ -s "$err_file" ] && tail -n 5 "$err_file" | sed 's/^/ /' >&2
rm -f "$err_file"
return 1
fi
rm -f "$err_file"
return 0
}
# apt_update — тихий update с ожиданием лока
apt_update() {
apt_lock_wait || return 1
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=120 update -qq 2>/dev/null || true
return 0
}
# ── Зависимости GoTelegram ──────────────────────────────────────────────────
# Полный список внешних команд, которые скрипт использует. Для каждой команды
# указан пакет на apt и dnf/yum (имена различаются: например dig = dnsutils на
# Debian, bind-utils на RHEL).
#
# КРИТИЧЕСКИЕ (без них скрипт просто не работает):
# jq — парсинг config.json, templates_catalog.json
# curl — скачивание telemt и проверки HTTPS
# openssl — генерация секретов, шифрование бекапов, SSL проверка
# git — клонирование шаблонов через download_template
# xxd — hex-encode домена для fake-TLS секрета (ee-prefix)
# tar — распаковка telemt архива и бекапы
# dig — DNS-проверка домена в Pro-режиме
#
# ЖЕЛАТЕЛЬНЫЕ (есть fallback, но с ними лучше):
# qrencode — QR-коды для прокси-ссылок
# bc — красивое форматирование чисел в статистике
#
# Pro-режим доустанавливает nginx/certbot через install_nginx/install_certbot
# (они большие и нужны только если пользователь выбрал Pro).
# Маппинг команды -> (apt_pkg, dnf_pkg). apt_pkg_for_cmd <cmd>
apt_pkg_for_cmd() {
case "$1" in
dig) echo "dnsutils" ;;
xxd) echo "xxd" ;; # Ubuntu 22+: отдельный пакет, fallback ниже
nslookup) echo "dnsutils" ;;
host) echo "dnsutils" ;;
ss) echo "iproute2" ;;
netstat) echo "net-tools" ;;
flock) echo "util-linux" ;;
*) echo "$1" ;; # команда == имя пакета
esac
}
dnf_pkg_for_cmd() {
case "$1" in
dig|nslookup|host) echo "bind-utils" ;;
xxd) echo "vim-common" ;;
ss) echo "iproute" ;;
netstat) echo "net-tools" ;;
flock) echo "util-linux" ;;
*) echo "$1" ;;
esac
}
ensure_deps() {
# Критические зависимости — без них скрипт не работает.
# flock используется bot_action_dispatch для сериализации параллельных
# вызовов (иначе гонка на config.json при одновременных change-template /
# change-lite-domain из бота).
local critical=(curl jq openssl git xxd tar dig flock)
# Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли
local optional=(qrencode bc)
local missing_critical=() missing_optional=() cmd
for cmd in "${critical[@]}"; do
command -v "$cmd" &>/dev/null || missing_critical+=("$cmd")
done
for cmd in "${optional[@]}"; do
command -v "$cmd" &>/dev/null || missing_optional+=("$cmd")
done
local all_missing=("${missing_critical[@]}" "${missing_optional[@]}")
[ ${#all_missing[@]} -eq 0 ] && return 0
# Собираем список пакетов для выбранного менеджера
local pkg_mgr pkg pkgs=()
pkg_mgr=$(get_pkg_manager)
for cmd in "${all_missing[@]}"; do
case "$pkg_mgr" in
apt) pkg=$(apt_pkg_for_cmd "$cmd") ;;
dnf|yum) pkg=$(dnf_pkg_for_cmd "$cmd") ;;
*) pkg="$cmd" ;;
esac
pkgs+=("$pkg")
done
# Убираем дубликаты (например dig+nslookup оба = dnsutils)
local uniq_pkgs=()
for pkg in "${pkgs[@]}"; do
local found=0 p
for p in "${uniq_pkgs[@]}"; do
[ "$p" = "$pkg" ] && { found=1; break; }
done
[ "$found" = "0" ] && uniq_pkgs+=("$pkg")
done
if type tf &>/dev/null; then
log_step "$(tf deps_installing "${all_missing[*]}")"
else
log_step "Installing dependencies: ${all_missing[*]} (packages: ${uniq_pkgs[*]})"
fi
case "$pkg_mgr" in
apt)
apt_update
apt_install "${uniq_pkgs[@]}" || true
;;
dnf) dnf install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;;
yum) yum install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;;
*)
log_error "Unknown package manager — install manually: ${uniq_pkgs[*]}"
return 1
;;
esac
# Фолбэки для xxd: на некоторых системах нужен vim-common вместо xxd
if ! command -v xxd &>/dev/null && [ "$pkg_mgr" = "apt" ]; then
apt_install vim-common || true
fi
# Повторная проверка критических команд
local still_missing=()
for cmd in "${critical[@]}"; do
command -v "$cmd" &>/dev/null || still_missing+=("$cmd")
done
if [ ${#still_missing[@]} -gt 0 ]; then
log_error "Critical dependencies still missing: ${still_missing[*]}"
log_error "Install manually and re-run gotelegram"
return 1
fi
# Опциональные — только предупреждение
local still_missing_opt=()
for cmd in "${optional[@]}"; do
command -v "$cmd" &>/dev/null || still_missing_opt+=("$cmd")
done
if [ ${#still_missing_opt[@]} -gt 0 ]; then
log_warning "Optional deps missing (features degraded): ${still_missing_opt[*]}"
fi
log_success "Dependencies ready"
return 0
}
# Быстрая проверка — только смотрит что критические установлены, ничего не ставит.
# Возвращает 0 если всё ок, 1 если что-то отсутствует. Используется на старте
# main() чтобы не дёргать apt-get update при каждом запуске меню.
check_deps_present() {
local cmd
for cmd in curl jq openssl git xxd tar dig flock; do
command -v "$cmd" &>/dev/null || return 1
done
return 0
}
check_port() {
@@ -280,6 +463,167 @@ check_port() {
return 1 # свободен
}
# ── Preflight: port conflict detection ───────────────────────────────────────
# Проверяет, что нужные для установки порты свободны. Если порт занят —
# определяет процесс и сопоставляет с известным списком proxy/VPN софта
# (xray, sing-box, v2ray, trojan, hysteria, mtg, shadowsocks, x-ui/3x-ui,
# marzban, amneziawg, caddy, apache, haproxy). Пользователь видит явное
# предупреждение и может либо прервать установку, либо продолжить на свой
# страх и риск (GOTELEGRAM_SKIP_PREFLIGHT=1 — полностью отключить проверку).
#
# Используемые порты GoTelegram:
# 443 — telemt (внешний, MTProxy + fake-TLS) — lite и pro
# 80 — nginx redirect + certbot ACME HTTP-01 — только pro
# 8443 — nginx internal mask (127.0.0.1:8443) — только pro
# get_port_process <port> → "<pid>|<comm>" если занят, иначе пусто
get_port_process() {
local port="$1"
local line="" pid="" proc=""
line=$(ss -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" | head -1)
if [ -z "$line" ]; then
line=$(netstat -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" | head -1)
fi
if [ -n "$line" ]; then
pid=$(echo "$line" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
if [ -z "$pid" ]; then
# netstat format: "12345/procname"
pid=$(echo "$line" | grep -oE '[0-9]+/[^ ]+' | head -1 | cut -d/ -f1)
fi
fi
if [ -z "$pid" ]; then
pid=$(fuser -n tcp "$port" 2>/dev/null | tr -s ' ' | awk '{print $1}' | head -1)
pid="${pid:-}"
fi
if [ -n "$pid" ] && [ "$pid" -gt 0 ] 2>/dev/null; then
proc=$(ps -p "$pid" -o comm= 2>/dev/null | tr -d ' \n')
[ -z "$proc" ] && proc="unknown"
echo "${pid}|${proc}"
return 0
fi
if [ -n "$line" ]; then
# Port is occupied but process cannot be identified (kernel socket / no root)
echo "0|unknown"
return 0
fi
return 1
}
# match_known_conflict <comm> → печатает человекочитаемое имя если это
# известный proxy/VPN/web софт. Возвращает 0 если нашли, 1 иначе.
match_known_conflict() {
local proc="$1"
case "$proc" in
*xray*|*Xray*) echo "Xray"; return 0 ;;
*sing-box*|*sing_box*|*singbox*) echo "sing-box"; return 0 ;;
*v2ray*|*V2Ray*) echo "V2Ray"; return 0 ;;
*trojan*) echo "Trojan"; return 0 ;;
*hysteria*) echo "Hysteria"; return 0 ;;
*mtg*) echo "mtg (old MTProxy)"; return 0 ;;
*ss-server*|*ss-local*|*shadowsocks*|*ssserver*) echo "Shadowsocks"; return 0 ;;
*x-ui*|*3x-ui*|*xui*) echo "x-ui / 3x-ui panel"; return 0 ;;
*marzban*) echo "Marzban panel"; return 0 ;;
*amneziawg*|*awg-go*|*awg*) echo "AmneziaWG"; return 0 ;;
*caddy*) echo "Caddy web server"; return 0 ;;
*apache2*|*httpd*) echo "Apache httpd"; return 0 ;;
*haproxy*) echo "HAProxy"; return 0 ;;
*nginx*) echo "nginx (already running)"; return 0 ;;
*tgproxy*|*mtproxy*|*mtproto*) echo "MTProto Proxy (other impl)"; return 0 ;;
*wireguard*|*wg-quick*) echo "WireGuard"; return 0 ;;
*openvpn*) echo "OpenVPN"; return 0 ;;
esac
return 1
}
# preflight_check <mode> [port]
# mode = "lite" | "pro"
# port = selected port for lite mode (default 443)
# Returns:
# 0 — OK to proceed (no conflicts, or user confirmed to force)
# 1 — user aborted (caller should show promo and return)
preflight_check() {
local mode="${1:-lite}"
local lite_port="${2:-443}"
# Escape hatch
if [ "${GOTELEGRAM_SKIP_PREFLIGHT:-0}" = "1" ]; then
log_dim "preflight: skipped (GOTELEGRAM_SKIP_PREFLIGHT=1)"
return 0
fi
local required_ports=()
if [ "$mode" = "pro" ]; then
required_ports=(443 80 8443)
else
# lite: проверяем только выбранный внешний порт
required_ports=("$lite_port")
fi
local known_conflicts=() unknown_conflicts=() info port pid proc label
for port in "${required_ports[@]}"; do
info=$(get_port_process "$port")
if [ -n "$info" ]; then
pid="${info%%|*}"
proc="${info##*|}"
if label=$(match_known_conflict "$proc"); then
known_conflicts+=("${port}|${label}|${pid}|${proc}")
else
unknown_conflicts+=("${port}|${pid}|${proc}")
fi
fi
done
if [ ${#known_conflicts[@]} -eq 0 ] && [ ${#unknown_conflicts[@]} -eq 0 ]; then
log_dim "preflight: ports ${required_ports[*]} свободны"
return 0
fi
# Показываем баннер конфликта
echo "" >&2
echo -e " ${BOLD}${YELLOW}$(_t_or preflight_title 'Предустановочная проверка: обнаружены конфликты портов')${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
local item p label2 pid2 proc2 rest
if [ ${#known_conflicts[@]} -gt 0 ]; then
echo -e " ${RED}$(_t_or preflight_known 'Известный proxy/VPN/веб-софт занимает нужные порты:')${NC}" >&2
for item in "${known_conflicts[@]}"; do
p="${item%%|*}"
rest="${item#*|}"
label2="${rest%%|*}"
rest="${rest#*|}"
pid2="${rest%%|*}"
proc2="${rest##*|}"
echo -e " ${RED}${NC} ${BOLD}:${p}${NC}${BOLD}${label2}${NC} ${DIM}(pid=${pid2}, cmd=${proc2})${NC}" >&2
done
fi
if [ ${#unknown_conflicts[@]} -gt 0 ]; then
echo -e " ${YELLOW}$(_t_or preflight_unknown 'Порты заняты неизвестными процессами:')${NC}" >&2
for item in "${unknown_conflicts[@]}"; do
p="${item%%|*}"
rest="${item#*|}"
pid2="${rest%%|*}"
proc2="${rest##*|}"
echo -e " ${YELLOW}${NC} ${BOLD}:${p}${NC} ${DIM}(pid=${pid2}, cmd=${proc2})${NC}" >&2
done
fi
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
echo -e " ${WHITE}$(_t_or preflight_needed 'GoTelegram нужны порты:')${NC} ${CYAN}${required_ports[*]}${NC}" >&2
echo -e " ${WHITE}$(_t_or preflight_hint_header 'Рекомендации:')${NC}" >&2
echo -e " ${DIM}$(_t_or preflight_hint1 'Остановите и удалите конфликтующие сервисы (systemctl stop ...)')${NC}" >&2
echo -e " ${DIM}$(_t_or preflight_hint2 'Либо возьмите чистый VPS без других прокси')${NC}" >&2
echo -e " ${DIM}$(_t_or preflight_hint3 'Установка поверх, скорее всего, завершится некорректно')${NC}" >&2
echo -e " ${DIM}$(_t_or preflight_skip_hint 'Override: GOTELEGRAM_SKIP_PREFLIGHT=1 gotelegram')${NC}" >&2
echo "" >&2
if confirm "$(_t_or preflight_proceed 'Продолжить установку всё равно (скорее всего не заработает)?')"; then
log_warning "$(_t_or preflight_forced 'Установка продолжена вопреки конфликтам — возможны ошибки')"
return 0
fi
log_info "$(_t_or preflight_aborted 'Установка отменена из-за конфликтов портов')"
return 1
}
check_disk_space() {
local min_mb="${1:-500}"
local avail_mb

106
lib/lang/en.sh Executable file → Normal file
View File

@@ -44,6 +44,33 @@ I18N[net_mode]="Mode:"
I18N[net_domain]="Domain:"
I18N[connection_link]="Telegram connection link:"
I18N[proxy_not_configured]="Proxy is not configured. Select option 1."
# ── show_proxy_info labels ─────────────────────────────────────────────
I18N[info_status_running]="Running"
I18N[info_status_stopped]="Stopped"
I18N[info_status_not_installed]="Not installed"
I18N[info_proxy_status]="Proxy status"
I18N[info_engine]="Engine"
I18N[info_ip]="IP"
I18N[info_domain]="Domain"
I18N[info_port]="Port"
I18N[info_mode]="Mode"
I18N[info_mask]="Mask host"
I18N[info_secret]="Secret"
I18N[info_link]="Link"
# ── show_traffic_stats labels ──────────────────────────────────────────
I18N[stats_sh_proxy]="Proxy (telemt, port 443)"
I18N[stats_sh_site]="Site (nginx, port 8443)"
I18N[stats_sh_hdr_period]="Period"
I18N[stats_sh_hdr_inbound]="Inbound"
I18N[stats_sh_hdr_rate]="Rate"
I18N[stats_sh_packets]="Packets"
I18N[stats_sh_1min]="1 min"
I18N[stats_sh_5min]="5 min"
I18N[stats_sh_60min]="60 min"
I18N[stats_sh_1day]="1 day"
I18N[stats_sh_7days]="7 days"
I18N[stats_sh_30days]="30 days"
I18N[stats_sh_365days]="365 days"
I18N[menu_proxy]="Proxy ▸"
I18N[menu_stats]="Statistics ▸"
I18N[menu_manage]="Management ▸"
@@ -347,6 +374,20 @@ I18N[backup_pass_short]="Password too short (minimum 6 characters)"
I18N[backup_pick_prompt]="Backup number (or path to file)"
I18N[backup_not_found]="Backup not found"
# ── Preflight (v2.4.8) ──────────────────────────────────────────────────
I18N[preflight_title]="Preflight: port conflicts detected"
I18N[preflight_known]="Known proxy/VPN/web software is using required ports:"
I18N[preflight_unknown]="Required ports are held by unknown processes:"
I18N[preflight_needed]="GoTelegram requires ports:"
I18N[preflight_hint_header]="Recommended actions:"
I18N[preflight_hint1]="Stop and remove the conflicting services (systemctl stop ...)"
I18N[preflight_hint2]="Or use a clean VPS without other proxies"
I18N[preflight_hint3]="Installing on top will most likely fail"
I18N[preflight_skip_hint]="Bypass: GOTELEGRAM_SKIP_PREFLIGHT=1 gotelegram"
I18N[preflight_proceed]="Continue installation anyway (likely to fail)?"
I18N[preflight_forced]="Installation continued despite conflicts — errors likely"
I18N[preflight_aborted]="Installation aborted due to port conflicts"
# ── Errors / misc ───────────────────────────────────────────────────────
I18N[err_need_root]="Run the script with sudo / as root"
I18N[err_os_unknown]="Failed to detect OS. Linux is required."
@@ -373,3 +414,68 @@ I18N[v1_migration_cancelled]="Migration cancelled. v1 left intact."
I18N[v1_stopping]="Stopping v1 container..."
I18N[v1_config_saved]="v1 config saved to %s"
I18N[v1_port_freed]="v1 stopped. Port %s freed."
# ── v2.4.9: UBF v2.0 backup + manual secret recovery ─────────────────────
I18N[install_source_title]="Installation source"
I18N[install_source_choice]="Choose source [1-3]:"
I18N[install_menu_new]="Fresh installation"
I18N[install_menu_new_desc]="Generate a new key and set up from scratch"
I18N[install_menu_restore]="Restore from backup"
I18N[install_menu_restore_desc]="Full restore from a .tar.gz[.enc] file"
I18N[install_menu_existing_key]="Use existing key"
I18N[install_menu_existing_key_desc]="Paste a tg://proxy link or a key manually"
I18N[install_hint_pro_mode]="The key contains a domain — this is usually Pro mode"
I18N[install_reuse_secret]="Using the provided key"
I18N[install_reuse_domain]="Using domain from the key"
I18N[install_reuse_port]="Using port from the key"
I18N[manual_secret_title]="Enter existing key"
I18N[manual_secret_help1]="Supported formats:"
I18N[manual_secret_prompt]="Paste the key"
I18N[manual_secret_empty]="Key is empty"
I18N[manual_secret_bad]="Could not parse the key format"
I18N[manual_secret_parsed]="Key parsed"
I18N[backup_id_label]="Backup ID"
I18N[backup_file_label]="File"
I18N[backup_size_label]="Size"
I18N[backup_key_label]="Key in backup (fingerprint)"
I18N[backup_format_label]="Format"
I18N[backup_mode_label]="Mode"
I18N[backup_lang_label]="Language"
I18N[backup_date_label]="Date"
I18N[backup_label]="Backup"
I18N[backup_ssl_included]="SSL certificates included (+ chain + renewal)"
I18N[backup_site_included]="Website template included"
I18N[backup_bot_included]="Telegram bot config included"
I18N[backup_restored_bot]="Telegram bot config restored"
I18N[backup_automigrate]="Converting legacy backup to UBF v2.0..."
I18N[backup_migrated]="Fresh UBF v2.0 backup saved"
I18N[backup_collecting]="Collecting configuration..."
I18N[backup_archive_err]="Archive creation failed"
I18N[backup_archive_missing]="Archive was not created"
I18N[backup_encrypt_err]="Encryption failed"
I18N[backup_encrypted]="Backup encrypted (AES-256-CBC)"
I18N[backup_created]="Backup created"
I18N[backup_enter_pass]="Enter password"
I18N[backup_repeat_pass]="Repeat password"
I18N[backup_pass_mismatch]="Passwords do not match"
I18N[backup_pass_short]="Password too short (min 6 chars)"
I18N[backup_bad_pass]="Wrong password or corrupted file"
I18N[backup_extract_err]="Archive extraction failed"
I18N[backup_confirm_restore]="Restore configuration? Current settings will be overwritten."
I18N[backup_restored_telemt]="telemt config restored"
I18N[backup_restored_gotelegram]="GoTelegram config restored"
I18N[backup_restored_lang]="Interface language restored"
I18N[backup_restored_nginx]="nginx config restored"
I18N[backup_restored_ssl]="SSL certificates restored"
I18N[backup_restored_site]="Website template restored"
I18N[backup_restore_done]="Restore completed!"
I18N[backup_create_title]="Create backup"
I18N[backup_encrypt_prompt]="Encrypt the backup with a password?"
I18N[backup_none]="No backups found"
I18N[backup_list_title]="Available backups"
I18N[backup_pick_prompt]="Backup number (or file path)"
I18N[backup_not_found]="Backup not found"
I18N[backup_file_not_found_fmt]="File not found: %s"
I18N[backup_cleanup_fmt]="Deleted %s old backups (kept %s)"

106
lib/lang/ru.sh Executable file → Normal file
View File

@@ -44,6 +44,33 @@ I18N[net_mode]="Режим:"
I18N[net_domain]="Домен:"
I18N[connection_link]="Ссылка для Telegram:"
I18N[proxy_not_configured]="Прокси не настроен. Выберите пункт 1."
# ── show_proxy_info labels ─────────────────────────────────────────────
I18N[info_status_running]="Работает"
I18N[info_status_stopped]="Остановлен"
I18N[info_status_not_installed]="Не установлен"
I18N[info_proxy_status]="Статус прокси"
I18N[info_engine]="Ядро"
I18N[info_ip]="IP"
I18N[info_domain]="Домен"
I18N[info_port]="Порт"
I18N[info_mode]="Режим"
I18N[info_mask]="Маскировка"
I18N[info_secret]="Secret"
I18N[info_link]="Ссылка"
# ── show_traffic_stats labels ──────────────────────────────────────────
I18N[stats_sh_proxy]="Proxy (telemt, порт 443)"
I18N[stats_sh_site]="Сайт (nginx, порт 8443)"
I18N[stats_sh_hdr_period]="Период"
I18N[stats_sh_hdr_inbound]="Входящий"
I18N[stats_sh_hdr_rate]="Скорость"
I18N[stats_sh_packets]="Пакетов"
I18N[stats_sh_1min]="1 мин"
I18N[stats_sh_5min]="5 мин"
I18N[stats_sh_60min]="60 мин"
I18N[stats_sh_1day]="1 день"
I18N[stats_sh_7days]="7 дней"
I18N[stats_sh_30days]="30 дней"
I18N[stats_sh_365days]="365 дней"
I18N[menu_proxy]="Прокси ▸"
I18N[menu_stats]="Статистика ▸"
I18N[menu_manage]="Управление ▸"
@@ -347,6 +374,20 @@ I18N[backup_pass_short]="Пароль слишком короткий (мини
I18N[backup_pick_prompt]="Номер бекапа (или путь к файлу)"
I18N[backup_not_found]="Бекап не найден"
# ── Preflight (v2.4.8) ──────────────────────────────────────────────────
I18N[preflight_title]="Предустановочная проверка: обнаружены конфликты портов"
I18N[preflight_known]="Известный proxy/VPN/веб-софт занимает нужные порты:"
I18N[preflight_unknown]="Порты заняты неизвестными процессами:"
I18N[preflight_needed]="GoTelegram нужны порты:"
I18N[preflight_hint_header]="Рекомендации:"
I18N[preflight_hint1]="Остановите и удалите конфликтующие сервисы (systemctl stop ...)"
I18N[preflight_hint2]="Либо возьмите чистый VPS без других прокси"
I18N[preflight_hint3]="Установка поверх, скорее всего, завершится некорректно"
I18N[preflight_skip_hint]="Обойти проверку: GOTELEGRAM_SKIP_PREFLIGHT=1 gotelegram"
I18N[preflight_proceed]="Продолжить установку всё равно (скорее всего не заработает)?"
I18N[preflight_forced]="Установка продолжена вопреки конфликтам — возможны ошибки"
I18N[preflight_aborted]="Установка отменена из-за конфликтов портов"
# ── Errors / misc ───────────────────────────────────────────────────────
I18N[err_need_root]="Запустите скрипт с sudo / от root"
I18N[err_os_unknown]="Не удалось определить ОС. Требуется Linux."
@@ -373,3 +414,68 @@ I18N[v1_migration_cancelled]="Миграция отменена. v1 оставл
I18N[v1_stopping]="Остановка v1 контейнера..."
I18N[v1_config_saved]="Конфиг v1 сохранён в %s"
I18N[v1_port_freed]="v1 остановлен. Порт %s освобождён."
# ── v2.4.9: UBF v2.0 backup + manual secret recovery ─────────────────────
I18N[install_source_title]="Источник установки"
I18N[install_source_choice]="Выберите источник [1-3]:"
I18N[install_menu_new]="Новая установка"
I18N[install_menu_new_desc]="Сгенерировать новый ключ и настроить с нуля"
I18N[install_menu_restore]="Восстановить из бекапа"
I18N[install_menu_restore_desc]="Полное восстановление из файла .tar.gz[.enc]"
I18N[install_menu_existing_key]="Использовать существующий ключ"
I18N[install_menu_existing_key_desc]="Ввести ссылку tg://proxy или ключ вручную"
I18N[install_hint_pro_mode]="Ключ содержит домен — обычно это Pro режим"
I18N[install_reuse_secret]="Используется переданный ключ"
I18N[install_reuse_domain]="Используется домен из ключа"
I18N[install_reuse_port]="Используется порт из ключа"
I18N[manual_secret_title]="Ввод существующего ключа"
I18N[manual_secret_help1]="Поддерживаются форматы:"
I18N[manual_secret_prompt]="Вставьте ключ"
I18N[manual_secret_empty]="Ключ не введён"
I18N[manual_secret_bad]="Не удалось распознать формат ключа"
I18N[manual_secret_parsed]="Ключ распознан"
I18N[backup_id_label]="Backup ID"
I18N[backup_file_label]="Файл"
I18N[backup_size_label]="Размер"
I18N[backup_key_label]="Ключ в бекапе (fingerprint)"
I18N[backup_format_label]="Формат"
I18N[backup_mode_label]="Режим"
I18N[backup_lang_label]="Язык"
I18N[backup_date_label]="Дата"
I18N[backup_label]="Бекап"
I18N[backup_ssl_included]="SSL-сертификаты включены (+ chain + renewal)"
I18N[backup_site_included]="Шаблон сайта включён"
I18N[backup_bot_included]="Конфиг Telegram-бота включён"
I18N[backup_restored_bot]="Конфиг Telegram-бота восстановлен"
I18N[backup_automigrate]="Конвертирую старый бекап в UBF v2.0..."
I18N[backup_migrated]="Свежий UBF v2.0 бекап сохранён"
I18N[backup_collecting]="Собираю конфигурацию..."
I18N[backup_archive_err]="Ошибка создания архива"
I18N[backup_archive_missing]="Архив не создан"
I18N[backup_encrypt_err]="Ошибка шифрования"
I18N[backup_encrypted]="Бекап зашифрован (AES-256-CBC)"
I18N[backup_created]="Бекап создан"
I18N[backup_enter_pass]="Введите пароль"
I18N[backup_repeat_pass]="Повторите пароль"
I18N[backup_pass_mismatch]="Пароли не совпадают"
I18N[backup_pass_short]="Пароль слишком короткий (минимум 6 символов)"
I18N[backup_bad_pass]="Неверный пароль или повреждённый файл"
I18N[backup_extract_err]="Ошибка распаковки архива"
I18N[backup_confirm_restore]="Восстановить конфигурацию? Текущие настройки будут перезаписаны."
I18N[backup_restored_telemt]="telemt конфиг восстановлен"
I18N[backup_restored_gotelegram]="GoTelegram конфиг восстановлен"
I18N[backup_restored_lang]="Язык интерфейса восстановлен"
I18N[backup_restored_nginx]="nginx конфиг восстановлен"
I18N[backup_restored_ssl]="SSL сертификаты восстановлены"
I18N[backup_restored_site]="Шаблон сайта восстановлен"
I18N[backup_restore_done]="Восстановление завершено!"
I18N[backup_create_title]="Создание бекапа"
I18N[backup_encrypt_prompt]="Зашифровать бекап паролем?"
I18N[backup_none]="Бекапов нет"
I18N[backup_list_title]="Доступные бекапы"
I18N[backup_pick_prompt]="Номер бекапа (или путь к файлу)"
I18N[backup_not_found]="Бекап не найден"
I18N[backup_file_not_found_fmt]="Файл не найден: %s"
I18N[backup_cleanup_fmt]="Удалено %s старых бекапов (оставлено %s)"

426
lib/stats.sh Normal file
View File

@@ -0,0 +1,426 @@
#!/bin/bash
# stats.sh — Traffic statistics module for GoTelegram
# Tracks proxy (telemt port 443) and site (nginx port 8443) traffic
# Uses iptables counters + real-time snapshots + historical CSV
# Color codes (from common.sh)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
STATS_DIR="/run/gotelegram"
HISTORY_FILE="/opt/gotelegram/stats_history.csv"
SNAPSHOTS_DIR="$STATS_DIR/snapshots"
CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json"
CONFIG_FILE="/opt/gotelegram/config.json"
# Initialize stats infrastructure
stats_init() {
# Create runtime directory
mkdir -p "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
chmod 755 "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
# Create iptables chain if not exists
if ! iptables -L GOTELEGRAM_STATS -n >/dev/null 2>&1; then
iptables -N GOTELEGRAM_STATS 2>/dev/null
fi
# Add chain to INPUT if not already present
if ! iptables -C INPUT -j GOTELEGRAM_STATS 2>/dev/null; then
iptables -I INPUT -j GOTELEGRAM_STATS 2>/dev/null
fi
# Add rule for proxy traffic (port 443, TCP)
if ! iptables -C GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null; then
iptables -A GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null
fi
# Add rule for site traffic (loopback, port 8443, TCP)
if ! iptables -C GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null; then
iptables -A GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null
fi
# Initialize CSV header if file doesn't exist
if [[ ! -f "$HISTORY_FILE" ]]; then
echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
fi
# Write initial snapshot
stats_collect
}
# Collect current traffic statistics from iptables
stats_collect() {
local proxy_bytes=0 proxy_pkts=0 site_bytes=0 site_pkts=0
local ts=$(date +%s)
local temp_file=$(mktemp)
# Parse iptables output: format is "pkts bytes target"
# We need to extract bytes (2nd column) for each rule
local iptables_output=$(iptables -L GOTELEGRAM_STATS -v -n -x 2>/dev/null)
# Extract counters for port 443 (proxy)
proxy_bytes=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $2}')
proxy_pkts=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $1}')
# Extract counters for port 8443 on loopback (site)
site_bytes=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $2}')
site_pkts=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $1}')
# Default to 0 if not found
proxy_bytes=${proxy_bytes:-0}
proxy_pkts=${proxy_pkts:-0}
site_bytes=${site_bytes:-0}
site_pkts=${site_pkts:-0}
# Write current snapshot as JSON
if command -v jq &>/dev/null; then
echo "{\"ts\":$ts,\"proxy_bytes\":$proxy_bytes,\"proxy_pkts\":$proxy_pkts,\"site_bytes\":$site_bytes,\"site_pkts\":$site_pkts}" > "$CURRENT_SNAPSHOT" 2>/dev/null
else
cat > "$CURRENT_SNAPSHOT" 2>/dev/null <<EOF
{"ts":$ts,"proxy_bytes":$proxy_bytes,"proxy_pkts":$proxy_pkts,"site_bytes":$site_bytes,"site_pkts":$site_pkts}
EOF
fi
# Save snapshot for rate calculation (one per minute)
local minute_key
minute_key=$(date +%Y%m%d%H%M 2>/dev/null)
local snapshot_file="$SNAPSHOTS_DIR/snap_${minute_key}.json"
cp "$CURRENT_SNAPSHOT" "$snapshot_file" 2>/dev/null
# Append to history CSV (once per minute, check if last entry is fresh)
# Auto-recreate the file with header if it was deleted — otherwise the
# collector would silently stop writing history after any wipe (v2.4.1 fix).
if [[ ! -f "$HISTORY_FILE" ]]; then
mkdir -p "$(dirname "$HISTORY_FILE")" 2>/dev/null
echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
fi
if [[ -f "$HISTORY_FILE" ]]; then
local last_ts
last_ts=$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f1)
last_ts="${last_ts:-0}"
local current_minute=$((ts - (ts % 60)))
if [[ "$last_ts" -eq 0 ]] || [[ $((current_minute - last_ts)) -ge 60 ]]; then
echo "$current_minute,$proxy_bytes,$site_bytes" >> "$HISTORY_FILE" 2>/dev/null
# Cleanup old entries (keep only 365 days)
stats_cleanup_history
fi
fi
rm -f "$temp_file" 2>/dev/null
}
# Read current snapshot as JSON
stats_read_current() {
if [[ -f "$CURRENT_SNAPSHOT" ]]; then
cat "$CURRENT_SNAPSHOT"
else
echo "{}"
fi
}
# Extract value from JSON (fallback if jq not available)
json_get() {
local json="$1"
local key="$2"
if command -v jq &>/dev/null; then
echo "$json" | jq -r ".${key}" 2>/dev/null || echo "0"
else
echo "$json" | grep -o "\"$key\":[^,}]*" | cut -d: -f2 | tr -d ' "' || echo "0"
fi
}
# Convert bytes to human-readable format
format_bytes() {
local bytes=$1
if (( bytes < 1024 )); then
printf "%.0f B" "$bytes"
elif (( bytes < 1024 * 1024 )); then
printf "%.1f KB" "$(echo "scale=1; $bytes / 1024" | bc 2>/dev/null || echo "$((bytes / 1024))")"
elif (( bytes < 1024 * 1024 * 1024 )); then
printf "%.1f MB" "$(echo "scale=1; $bytes / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024))")"
elif (( bytes < 1024 * 1024 * 1024 * 1024 )); then
printf "%.1f GB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024))")"
else
printf "%.1f TB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024 / 1024))")"
fi
}
# Convert bytes/sec to human-readable rate
format_rate() {
local bytes_per_sec=$1
if (( bytes_per_sec < 1024 )); then
printf "%.0f B/s" "$bytes_per_sec"
elif (( bytes_per_sec < 1024 * 1024 )); then
printf "%.1f KB/s" "$(echo "scale=1; $bytes_per_sec / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024))")"
elif (( bytes_per_sec < 1024 * 1024 * 1024 )); then
printf "%.1f MB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024))")"
else
printf "%.1f GB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024 / 1024))")"
fi
}
# Safely convert value to integer (returns 0 for empty/non-numeric)
_to_int() {
local val="${1:-0}"
# Strip non-numeric chars, default to 0
val="${val//[^0-9]/}"
echo "${val:-0}"
}
# Calculate diff safely (never negative, never crashes on empty)
_safe_diff() {
local a=$(_to_int "$1")
local b=$(_to_int "$2")
local d=$((a - b))
(( d < 0 )) && d=0
echo "$d"
}
# Calculate traffic rates and totals from history
stats_calculate_rates() {
local traffic_type="$1" # "proxy" or "site"
local col_idx=2 # proxy_bytes is column 2
[[ "$traffic_type" == "site" ]] && col_idx=3
local now
now=$(date +%s)
# Get latest data line (skip header with grep -E '^[0-9]')
local bytes_now
bytes_now=$(_to_int "$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
local periods="60 300 3600 86400 604800 2592000 31536000"
local results=""
for secs in $periods; do
local target_ts=$((now - secs))
# Find closest entry at or after target timestamp (skip header)
local old_val
old_val=$(_to_int "$(awk -F, -v ts="$target_ts" '$1 ~ /^[0-9]/ && $1 <= ts' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
local diff
diff=$(_safe_diff "$bytes_now" "$old_val")
local rate=$(( secs > 0 ? diff / secs : 0 ))
local bytes_fmt rate_fmt
bytes_fmt=$(format_bytes "$diff")
rate_fmt=$(format_rate "$rate")
if [ -z "$results" ]; then
results="${bytes_fmt}|${rate_fmt}"
else
results="${results}|${bytes_fmt}|${rate_fmt}"
fi
done
echo "$results"
}
# Main display function for traffic statistics
show_traffic_stats() {
# Ensure stats are collected
stats_collect
# Get current counters
local current_json=$(stats_read_current)
local proxy_pkts=$(json_get "$current_json" "proxy_pkts")
local site_pkts=$(json_get "$current_json" "site_pkts")
# Calculate rates for proxy
local proxy_rates=$(stats_calculate_rates "proxy")
IFS='|' read -r p1m p1mr p5m p5mr p60m p60mr p1d p1dr p7d p7dr p30d p30dr p365d p365dr <<< "$proxy_rates"
# Calculate rates for site
local site_rates=$(stats_calculate_rates "site")
IFS='|' read -r s1m s1mr s5m s5mr s60m s60mr s1d s1dr s7d s7dr s30d s30dr s365d s365dr <<< "$site_rates"
# i18n labels (fall back to English if t() not loaded)
local lbl_proxy; lbl_proxy="$(_t_or stats_sh_proxy 'Proxy (telemt, port 443)')"
local lbl_site; lbl_site="$(_t_or stats_sh_site 'Site (nginx, port 8443)')"
local lbl_hdr; lbl_hdr="$(_t_or stats_sh_hdr_period 'Period')$(_t_or stats_sh_hdr_inbound 'Inbound')$(_t_or stats_sh_hdr_rate 'Rate')"
local lbl_pkts; lbl_pkts="$(_t_or stats_sh_packets 'Packets')"
local l1m; l1m="$(_t_or stats_sh_1min '1 min')"
local l5m; l5m="$(_t_or stats_sh_5min '5 min')"
local l60m; l60m="$(_t_or stats_sh_60min '60 min')"
local l1d; l1d="$(_t_or stats_sh_1day '1 day')"
local l7d; l7d="$(_t_or stats_sh_7days '7 days')"
local l30d; l30d="$(_t_or stats_sh_30days '30 days')"
local l365d; l365d="$(_t_or stats_sh_365days '365 days')"
# Display proxy stats
{
echo ""
echo -e "${BLUE} ${lbl_proxy}:${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
echo -e "${BLUE} ${lbl_hdr}${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
printf " %-9s │ %14s │ %s\n" "$l1m" "$p1m" "$p1mr"
printf " %-9s │ %14s │ %s\n" "$l5m" "$p5m" "$p5mr"
printf " %-9s │ %14s │ %s\n" "$l60m" "$p60m" "$p60mr"
printf " %-9s │ %14s │ %s\n" "$l1d" "$p1d" "$p1dr"
printf " %-9s │ %14s │ %s\n" "$l7d" "$p7d" "$p7dr"
printf " %-9s │ %14s │ %s\n" "$l30d" "$p30d" "$p30dr"
printf " %-9s │ %14s │ %s\n" "$l365d" "$p365d" "$p365dr"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
printf " %s: %d\n\n" "$lbl_pkts" "$proxy_pkts"
echo -e "${BLUE} ${lbl_site}:${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
echo -e "${BLUE} ${lbl_hdr}${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
printf " %-9s │ %14s │ %s\n" "$l1m" "$s1m" "$s1mr"
printf " %-9s │ %14s │ %s\n" "$l5m" "$s5m" "$s5mr"
printf " %-9s │ %14s │ %s\n" "$l60m" "$s60m" "$s60mr"
printf " %-9s │ %14s │ %s\n" "$l1d" "$s1d" "$s1dr"
printf " %-9s │ %14s │ %s\n" "$l7d" "$s7d" "$s7dr"
printf " %-9s │ %14s │ %s\n" "$l30d" "$s30d" "$s30dr"
printf " %-9s │ %14s │ %s\n" "$l365d" "$s365d" "$s365dr"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
printf " %s: %d\n" "$lbl_pkts" "$site_pkts"
echo ""
} >&2
}
# Clean up history older than 365 days
stats_cleanup_history() {
if [[ ! -f "$HISTORY_FILE" ]]; then
return
fi
local now=$(date +%s)
local ts_365d=$((now - 31536000))
local temp_file=$(mktemp)
# Keep header + entries from last 365 days
{
head -1 "$HISTORY_FILE"
awk -F, -v ts="$ts_365d" '$1 >= ts' "$HISTORY_FILE" | tail -n +2
} > "$temp_file" 2>/dev/null
mv "$temp_file" "$HISTORY_FILE" 2>/dev/null
}
# Toggle stats collection on/off
toggle_stats() {
local current_state="false"
# Read current state from config
if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
current_state=$(jq -r '.stats_enabled // false' "$CONFIG_FILE" 2>/dev/null)
fi
# Toggle
if [[ "$current_state" == "true" ]]; then
# Disable stats
if [[ -f "$CONFIG_FILE" ]]; then
if command -v jq &>/dev/null; then
jq '.stats_enabled = false' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
fi
fi
# Remove iptables rules
iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
iptables -F GOTELEGRAM_STATS 2>/dev/null
iptables -X GOTELEGRAM_STATS 2>/dev/null
# Clean up directories
rm -rf "$STATS_DIR" 2>/dev/null
echo "Сбор статистики ОТКЛЮЧЕН" >&2
else
# Enable stats
if [[ -f "$CONFIG_FILE" ]]; then
if command -v jq &>/dev/null; then
jq '.stats_enabled = true' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
fi
fi
# Initialize stats collection
stats_init
echo "Сбор статистики ВКЛЮЧЕН" >&2
fi
}
# Install systemd service for stats collection
install_stats_collector() {
local service_file="/etc/systemd/system/gotelegram-stats.service"
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "Требуется root для установки сервиса" >&2
return 1
fi
# Get script directory (resolve symlinks)
local script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
local lib_dir=$(dirname "$script_dir")
# Create systemd service file
cat > "$service_file" <<'EOF'
[Unit]
Description=GoTelegram Traffic Stats Collector
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/bin/bash -c 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/stats.sh; stats_init; while true; do stats_collect; sleep 1; done'
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$service_file"
systemctl daemon-reload
systemctl enable gotelegram-stats.service
systemctl start gotelegram-stats.service
echo "Сервис gotelegram-stats установлен и запущен" >&2
}
# Remove stats collector service
remove_stats_collector() {
if [[ $EUID -ne 0 ]]; then
echo "Требуется root для удаления сервиса" >&2
return 1
fi
systemctl stop gotelegram-stats.service 2>/dev/null
systemctl disable gotelegram-stats.service 2>/dev/null
rm -f /etc/systemd/system/gotelegram-stats.service
systemctl daemon-reload
# Remove iptables rules
iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
iptables -F GOTELEGRAM_STATS 2>/dev/null
iptables -X GOTELEGRAM_STATS 2>/dev/null
# Clean up directories and files
rm -rf "$STATS_DIR" 2>/dev/null
rm -f "$HISTORY_FILE" 2>/dev/null
echo "Сервис статистики удалён" >&2
}
# Export functions for external use
export -f stats_init stats_collect stats_read_current stats_calculate_rates
export -f show_traffic_stats format_bytes format_rate toggle_stats
export -f stats_cleanup_history install_stats_collector remove_stats_collector
export -f json_get

101
lib/telemt.sh Executable file → Normal file
View File

@@ -19,27 +19,55 @@ get_latest_telemt_version() {
}
get_telemt_download_url() {
local arch
# 1) Сначала пробуем GitHub Releases API — он отдаёт точное имя ассета
# последнего релиза (в т.ч. если в репо есть несколько архитектур,
# pre-release и т.д.). Это наш предпочтительный путь.
local resp url arch
arch=$(get_arch)
local resp
resp=$(curl -s --max-time 10 "$TELEMT_RELEASE_API" 2>/dev/null)
if [ -z "$resp" ]; then return 1; fi
if [ -n "$resp" ]; then
url=$(echo "$resp" | jq -r --arg a "$arch" '
.assets[]?.browser_download_url
| select(test("linux"))
| select(
($a == "amd64" and (test("x86_64|amd64"))) or
($a == "arm64" and (test("aarch64|arm64"))) or
($a == "armv7" and (test("armv7"))) or
(test($a))
)
| select(test("gnu"))
' 2>/dev/null | head -1)
if [ -n "$url" ] && [ "$url" != "null" ]; then
echo "$url"
return 0
fi
fi
# URL format: telemt-x86_64-linux-gnu.tar.gz (arch BEFORE linux)
local arch_pattern
# 2) Fallback: API не ответил / отдал 403 (rate limit на shared-IP VPS),
# отдал пустой JSON, или jq не нашёл подходящий ассет.
# Берём прямой "magic redirect" CDN-URL — он не считается в API rate
# limit и всегда указывает на последний релиз.
local arch_name
case "$arch" in
amd64) arch_pattern="(amd64|x86_64)" ;;
arm64) arch_pattern="(arm64|aarch64)" ;;
armv7) arch_pattern="(armv7|arm)" ;;
*) arch_pattern="${arch}" ;;
amd64) arch_name="x86_64" ;;
arm64) arch_name="aarch64" ;;
armv7) arch_name="armv7" ;;
*) arch_name="$arch" ;;
esac
echo "https://github.com/${TELEMT_GITHUB}/releases/latest/download/telemt-${arch_name}-linux-gnu.tar.gz"
}
echo "$resp" | jq -r ".assets[].browser_download_url" 2>/dev/null \
| grep -iE "$arch_pattern" \
| grep -i "linux" \
| grep -v "sha256" \
| grep "gnu" \
| head -1
# Fallback URL using musl libc (some minimal distros / older glibc)
get_telemt_download_url_musl() {
local arch arch_name
arch=$(get_arch)
case "$arch" in
amd64) arch_name="x86_64" ;;
arm64) arch_name="aarch64" ;;
armv7) arch_name="armv7" ;;
*) arch_name="$arch" ;;
esac
echo "https://github.com/${TELEMT_GITHUB}/releases/latest/download/telemt-${arch_name}-linux-musl.tar.gz"
}
# ── Установленная версия ─────────────────────────────────────────────────────
@@ -69,18 +97,35 @@ download_telemt() {
log_info "Скачивание: $url"
if ! curl -L -s --max-time 120 -o "$tmp_file" "$url"; then
log_error "Ошибка скачивания telemt"
rm -f "$tmp_file"
return 1
log_warning "Не удалось скачать gnu-сборку, пробую musl..."
url=$(get_telemt_download_url_musl)
log_info "Скачивание: $url"
if ! curl -L -s --max-time 120 -o "$tmp_file" "$url"; then
log_error "Ошибка скачивания telemt (gnu и musl)"
rm -f "$tmp_file"
return 1
fi
fi
# Проверяем что файл не пустой и не HTML
local file_size
file_size=$(stat -c%s "$tmp_file" 2>/dev/null || echo 0)
if [ "$file_size" -lt 1000 ]; then
log_error "Скачанный файл слишком маленький ($file_size байт) — возможна ошибка сети"
rm -f "$tmp_file"
return 1
# Try musl as fallback if gnu came back empty/error-html
log_warning "Файл подозрительно мал ($file_size байт), пробую musl-сборку..."
url=$(get_telemt_download_url_musl)
log_info "Скачивание: $url"
if ! curl -L -s --max-time 120 -o "$tmp_file" "$url"; then
log_error "Ошибка скачивания telemt (musl fallback)"
rm -f "$tmp_file"
return 1
fi
file_size=$(stat -c%s "$tmp_file" 2>/dev/null || echo 0)
if [ "$file_size" -lt 1000 ]; then
log_error "Скачанный файл слишком маленький ($file_size байт) — возможна ошибка сети"
rm -f "$tmp_file"
return 1
fi
fi
# Определяем тип файла и распаковываем
@@ -187,8 +232,20 @@ EOF
}
# ── Управление сервисом ──────────────────────────────────────────────────────
# start_telemt ensures telemt is running with the CURRENT on-disk config.
# If the service is already active we must restart (not plain start) — otherwise
# the running process keeps its old in-memory config and the freshly generated
# /etc/telemt/config.toml is silently ignored. This was the root cause of the
# "lite-mode key doesn't work after reinstall" bug: telemt had loaded the
# previous Pro config (tls_domain=anten-ka.com) and was rejecting SNI=google.com
# clients with unknown_sni_action=Drop even though the on-disk config said
# tls_domain=google.com.
start_telemt() {
systemctl start "$TELEMT_SERVICE" 2>/dev/null
if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then
systemctl restart "$TELEMT_SERVICE" 2>/dev/null
else
systemctl start "$TELEMT_SERVICE" 2>/dev/null
fi
sleep 2
if systemctl is-active --quiet "$TELEMT_SERVICE"; then
log_success "telemt запущен"

44
lib/telemt_config.sh Executable file → Normal file
View File

@@ -285,26 +285,26 @@ show_proxy_info() {
local status_icon status_text
case "$status" in
running) status_icon="✅"; status_text="Работает" ;;
stopped) status_icon="⏸️"; status_text="Остановлен" ;;
*) status_icon="❌"; status_text="Не установлен" ;;
running) status_icon="✅"; status_text="$(t info_status_running)" ;;
stopped) status_icon="⏸️"; status_text="$(t info_status_stopped)" ;;
*) status_icon="❌"; status_text="$(t info_status_not_installed)" ;;
esac
echo ""
echo -e " ${BOLD}${WHITE}${status_icon} Статус прокси: ${status_text}${NC}"
echo -e " ${BOLD}${WHITE}${status_icon} $(t info_proxy_status): ${status_text}${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}"
echo -e " ${WHITE}Ядро:${NC} telemt (Rust)"
echo -e " ${WHITE}$(t info_engine):${NC} telemt (Rust)"
if [ "$mode" = "pro" ] && [ -n "$domain" ]; then
echo -e " ${WHITE}Домен:${NC} ${CYAN}${domain}${NC}"
echo -e " ${WHITE}$(t info_domain):${NC} ${CYAN}${domain}${NC}"
else
echo -e " ${WHITE}IP:${NC} ${CYAN}${ip}${NC}"
echo -e " ${WHITE}$(t info_ip):${NC} ${CYAN}${ip}${NC}"
fi
echo -e " ${WHITE}Порт:${NC} ${CYAN}${port}${NC}"
echo -e " ${WHITE}Режим:${NC} ${CYAN}${mode}${NC}"
echo -e " ${WHITE}Маскировка:${NC} ${CYAN}${mask_host}${NC}"
echo -e " ${WHITE}Secret:${NC} ${CYAN}${secret:0:16}...${NC}"
echo -e " ${WHITE}$(t info_port):${NC} ${CYAN}${port}${NC}"
echo -e " ${WHITE}$(t info_mode):${NC} ${CYAN}${mode}${NC}"
echo -e " ${WHITE}$(t info_mask):${NC} ${CYAN}${mask_host}${NC}"
echo -e " ${WHITE}$(t info_secret):${NC} ${CYAN}${secret:0:16}...${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}"
echo -e " ${WHITE}Ссылка:${NC}"
echo -e " ${WHITE}$(t info_link):${NC}"
echo -e " ${GREEN}${link}${NC}"
echo ""
@@ -323,20 +323,20 @@ show_proxy_info_pro() {
local link="tg://proxy?server=${domain}&port=443&secret=${faketls_secret}"
echo ""
echo -e " ${BOLD}${WHITE}Pro-прокси настроен${NC}"
echo -e " ${BOLD}${WHITE}$(t info_proxy_status): $(t info_status_running) (Pro)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
echo -e " ${WHITE}Ядро:${NC} telemt (Rust)"
echo -e " ${WHITE}Домен:${NC} ${CYAN}${domain}${NC}"
echo -e " ${WHITE}Порт:${NC} ${CYAN}443${NC} (внешний, telemt)"
echo -e " ${WHITE}Режим:${NC} ${MAGENTA}Pro (fake-TLS)${NC}"
echo -e " ${WHITE}nginx:${NC} ${CYAN}127.0.0.1:8443${NC} (внутренний)"
echo -e " ${WHITE}Secret:${NC} ${CYAN}${faketls_secret:0:20}...${NC}"
echo -e " ${WHITE}$(t info_engine):${NC} telemt (Rust)"
echo -e " ${WHITE}$(t info_domain):${NC} ${CYAN}${domain}${NC}"
echo -e " ${WHITE}$(t info_port):${NC} ${CYAN}443${NC} (telemt)"
echo -e " ${WHITE}$(t info_mode):${NC} ${MAGENTA}Pro (fake-TLS)${NC}"
echo -e " ${WHITE}nginx:${NC} ${CYAN}127.0.0.1:8443${NC}"
echo -e " ${WHITE}$(t info_secret):${NC} ${CYAN}${faketls_secret:0:20}...${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
echo -e " ${WHITE}Ссылка для Telegram:${NC}"
echo -e " ${WHITE}$(t info_link):${NC}"
echo -e " ${GREEN}${link}${NC}"
echo ""
echo -e " ${DIM}Провайдер видит: HTTPS-трафик к ${domain}:443${NC}"
echo -e " ${DIM}Telegram-клиент маскирует соединение под TLS${NC}"
echo -e " ${DIM}ISP sees: HTTPS → ${domain}:443${NC}"
echo -e " ${DIM}Telegram client masquerades as TLS${NC}"
echo ""
# QR если доступен

View File

@@ -9,9 +9,9 @@ install_nginx() {
fi
log_info "Установка nginx..."
case "$(get_pkg_manager)" in
apt) apt-get update -qq && apt-get install -y -qq nginx ;;
dnf) dnf install -y -q nginx ;;
yum) yum install -y -q nginx ;;
apt) apt_update && apt_install nginx || return 1 ;;
dnf) dnf install -y -q nginx || return 1 ;;
yum) yum install -y -q nginx || return 1 ;;
esac
systemctl enable nginx 2>/dev/null
}
@@ -24,9 +24,9 @@ install_certbot() {
fi
log_info "Установка certbot..."
case "$(get_pkg_manager)" in
apt) apt-get install -y -qq certbot python3-certbot-nginx ;;
dnf) dnf install -y -q certbot python3-certbot-nginx ;;
yum) yum install -y -q certbot python3-certbot-nginx ;;
apt) apt_install certbot python3-certbot-nginx || return 1 ;;
dnf) dnf install -y -q certbot python3-certbot-nginx || return 1 ;;
yum) yum install -y -q certbot python3-certbot-nginx || return 1 ;;
esac
}