diff --git a/gotelegram-bot/README.md b/gotelegram-bot/README.md index dd97fc3..a01a37b 100644 --- a/gotelegram-bot/README.md +++ b/gotelegram-bot/README.md @@ -1,70 +1,70 @@ -# GoTelegram MTProxy Bot - -Telegram-бот для управления MTProxy на сервере — те же функции, что и у CLI `gotelegram`, но через бота. - -## Команды - -| Команда | Описание | -|--------|----------| -| `/start`, `/help` | Справка | -| `/install` | Установить или обновить прокси (выбор домена и порта) | -| `/status` | Статус и данные подключения (IP, порт, secret, ссылка) | -| `/link` | Только ссылка `tg://proxy` | -| `/restart` | Перезапустить контейнер | -| `/logs` | Последние логи контейнера | -| `/remove` | Удалить прокси | -| `/promo` | Промо хостинга | - -## Установка на сервер - -### Публичный репозиторий (одной командой) - -```bash -curl -sL https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh -``` - -При установке скрипт запросит **BOT_TOKEN** (получить у [@BotFather](https://t.me/BotFather)). - -### Закрытый репозиторий (установка по ключу) - -Для **приватного** репо используется клонирование по **SSH-ключу** или по **токену (PAT)**. Подробно: **[INSTALL_PRIVATE.md](../INSTALL_PRIVATE.md)** в корне репозитория. - -Кратко: -- **По SSH:** скопируйте `bootstrap_install.sh` на сервер, затем - `GIT_REPO_SSH=git@github.com:USER/REPO.git sudo bash bootstrap_install.sh` -- **По токену:** - `GITHUB_TOKEN=ghp_xxx GIT_REPO_HTTPS=https://github.com/USER/REPO.git sudo -E bash bootstrap_install.sh` -- Или клонируйте репо вручную и запустите: - `sudo ./install_gotelegram_bot.sh` - -### Локально (файлы уже рядом со скриптом) - -```bash -sudo ./install_gotelegram_bot.sh -``` - -## Конфигурация - -Файл: `/opt/gotelegram-bot/.env` - -- **BOT_TOKEN** — токен от @BotFather (обязательно). -- **ALLOWED_IDS** — опционально. Список ID пользователей через запятую; если не задан, бот доступен всем. - -После изменения `.env` перезапуск сервиса: - -```bash -sudo systemctl restart gotelegram-bot -``` - -## Требования на сервере - -- Linux (systemd), Docker, Python 3. -- Перед использованием бота на сервере должен быть установлен Docker (бот сам поднимает контейнер `nineseconds/mtg:2` по команде `/install`). - -## Управление сервисом - -```bash -sudo systemctl status gotelegram-bot -sudo systemctl restart gotelegram-bot -journalctl -u gotelegram-bot -f -``` +# GoTelegram MTProxy Bot + +Telegram-бот для управления MTProxy на сервере — те же функции, что и у CLI `gotelegram`, но через бота. + +## Команды + +| Команда | Описание | +|--------|----------| +| `/start`, `/help` | Справка | +| `/install` | Установить или обновить прокси (выбор домена и порта) | +| `/status` | Статус и данные подключения (IP, порт, secret, ссылка) | +| `/link` | Только ссылка `tg://proxy` | +| `/restart` | Перезапустить контейнер | +| `/logs` | Последние логи контейнера | +| `/remove` | Удалить прокси | +| `/promo` | Промо хостинга | + +## Установка на сервер + +### Публичный репозиторий (одной командой) + +```bash +curl -sL https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh +``` + +При установке скрипт запросит **BOT_TOKEN** (получить у [@BotFather](https://t.me/BotFather)). + +### Закрытый репозиторий (установка по ключу) + +Для **приватного** репо используется клонирование по **SSH-ключу** или по **токену (PAT)**. Подробно: **[INSTALL_PRIVATE.md](../INSTALL_PRIVATE.md)** в корне репозитория. + +Кратко: +- **По SSH:** скопируйте `bootstrap_install.sh` на сервер, затем + `GIT_REPO_SSH=git@github.com:USER/REPO.git sudo bash bootstrap_install.sh` +- **По токену:** + `GITHUB_TOKEN=ghp_xxx GIT_REPO_HTTPS=https://github.com/USER/REPO.git sudo -E bash bootstrap_install.sh` +- Или клонируйте репо вручную и запустите: + `sudo ./install_gotelegram_bot.sh` + +### Локально (файлы уже рядом со скриптом) + +```bash +sudo ./install_gotelegram_bot.sh +``` + +## Конфигурация + +Файл: `/opt/gotelegram-bot/.env` + +- **BOT_TOKEN** — токен от @BotFather (обязательно). +- **ALLOWED_IDS** — опционально. Список ID пользователей через запятую; если не задан, бот доступен всем. + +После изменения `.env` перезапуск сервиса: + +```bash +sudo systemctl restart gotelegram-bot +``` + +## Требования на сервере + +- Linux (systemd), Docker, Python 3. +- Перед использованием бота на сервере должен быть установлен Docker (бот сам поднимает контейнер `nineseconds/mtg:2` по команде `/install`). + +## Управление сервисом + +```bash +sudo systemctl status gotelegram-bot +sudo systemctl restart gotelegram-bot +journalctl -u gotelegram-bot -f +``` diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 98aa6e4..aaf4ac5 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -1,634 +1,1397 @@ -#!/usr/bin/env python3 -""" -GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере. -Кнопочное меню, проверка портов, совместимость с Amnezia/3x-ui, -кнопка «Поделиться ключом», TCP+UDP для звонков. -""" - -import asyncio -import html -import json -import os -import re -from pathlib import Path - -_env_path = Path(__file__).resolve().parent / ".env" -if not _env_path.exists(): - _env_path = Path("/etc/gotelegram-bot/.env") -if _env_path.exists(): - with open(_env_path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) - -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ( - Application, - CommandHandler, - CallbackQueryHandler, - ContextTypes, - MessageHandler, - filters, -) - -# ── Конфиг ──────────────────────────────────────────────────────────────────── -BOT_TOKEN = os.environ.get("BOT_TOKEN") -_allowed = os.environ.get("ALLOWED_IDS", "").strip() -try: - ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None -except ValueError: - ALLOWED_IDS = None - -CONTAINER_NAME = "mtproto-proxy" -CONFIG_FILE = Path("/opt/gotelegram-bot/proxy.json") -DOMAINS = [ - "google.com", "wikipedia.org", "habr.com", "github.com", - "coursera.org", "udemy.com", "medium.com", "stackoverflow.com", - "bbc.com", "cnn.com", "reuters.com", "nytimes.com", - "lenta.ru", "rbc.ru", "ria.ru", "kommersant.ru", - "stepik.org", "duolingo.com", "khanacademy.org", "ted.com", -] -PROMO_LINK = "https://vk.cc/ct29NQ" -TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" - - -# ── Утилиты ────────────────────────────────────────────────────────────────── -def _ok(uid: int) -> bool: - return ALLOWED_IDS is None or uid in ALLOWED_IDS - - -def _decode(data: bytes) -> str: - return (data or b"").decode("utf-8", errors="replace").strip() - - -async def sh(*args: str, timeout: int = 60) -> tuple[int, str, str]: - proc = await asyncio.create_subprocess_exec( - *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - ) - try: - out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - return -1, "", "Timeout" - return proc.returncode or 0, _decode(out), _decode(err) - - -async def get_ip() -> str: - for url in ("https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me"): - code, out, _ = await sh("curl", "-s", "-4", "--max-time", "5", url, timeout=8) - if code == 0 and out: - m = re.search(r"(\d{1,3}\.){3}\d{1,3}", out) - if m: - return m.group(0) - return "0.0.0.0" - - -async def proxy_running() -> bool: - code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}", timeout=10) - return code == 0 and CONTAINER_NAME in out - - -async def docker_val(fmt: str) -> str: - code, out, _ = await sh("docker", "inspect", CONTAINER_NAME, "--format", fmt, timeout=10) - return out.strip() if code == 0 else "" - - -async def check_port(port: int) -> str | None: - """Если порт занят — возвращает описание процесса; иначе None.""" - # Пропускаем, если порт занят нашим же контейнером - if await proxy_running(): - hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}") - if str(port) in hp.split(): - return None - code, out, _ = await sh("ss", "-tlnp", timeout=5) - if code != 0: - code, out, _ = await sh("netstat", "-tlnp", timeout=5) - for line in out.splitlines(): - if f":{port} " in line or f":{port}\t" in line: - return line - return None - - -async def docker_containers_info() -> str: - code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}", timeout=10) - if code != 0 or not out: - return "" - return out - - -def save_config(data: dict) -> None: - CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - - -def load_config() -> dict: - if CONFIG_FILE.exists(): - try: - return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - except Exception: - pass - return {} - - -# ── Получение данных прокси ────────────────────────────────────────────────── -async def proxy_info() -> dict | None: - if not await proxy_running(): - return None - cmd_str = await docker_val("{{range .Config.Cmd}}{{.}} {{end}}") - secret = cmd_str.split()[-1] if cmd_str else "" - hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}") - port = hp or "443" - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - cfg = load_config() - return {"ip": ip, "port": port, "secret": secret, "link": link, "domain": cfg.get("domain", "—")} - - -# ── Главное меню (кнопки) ──────────────────────────────────────────────────── -def main_menu_kb() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([ - [InlineKeyboardButton("🔧 Установить / Обновить", callback_data="menu_install")], - [InlineKeyboardButton("📊 Статус", callback_data="menu_status"), - InlineKeyboardButton("🔗 Ссылка", callback_data="menu_link")], - [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], - [InlineKeyboardButton("🔄 Перезапуск", callback_data="menu_restart"), - InlineKeyboardButton("📋 Логи", callback_data="menu_logs")], - [InlineKeyboardButton("🗑 Удалить", callback_data="menu_remove"), - InlineKeyboardButton("🏷 Промо", callback_data="menu_promo")], - ]) - - -HELP_TEXT = ( - "🚀 GoTelegram MTProxy Bot\n\n" - "Управление MTProxy (Fake TLS) на сервере.\n" - "TCP + UDP (звонки) поддержаны.\n\n" - "Используйте кнопки ниже или команды:\n" - "/install /status /link /share /restart /logs /remove /promo" -) - - -async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user: - return - if not _ok(update.effective_user.id): - msg = update.message or (update.callback_query and update.callback_query.message) - if msg: - await msg.reply_text("⛔ Доступ запрещён.") - return - if update.message: - await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) - elif update.callback_query: - await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) - - -# ── Статус ─────────────────────────────────────────────────────────────────── -async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - await msg.reply_text("⛔") - return - info = await proxy_info() - if not info: - text = "❌ Прокси не запущен.\nНажмите Установить для настройки." - else: - containers = await docker_containers_info() - other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l) - text = ( - "✅ Прокси работает\n\n" - f"IP: {html.escape(info['ip'])}\n" - f"Порт: {html.escape(info['port'])}\n" - f"Домен: {html.escape(info['domain'])}\n" - f"Secret: {html.escape(info['secret'])}\n\n" - f"Ссылка:\n{html.escape(info['link'])}" - ) - if other: - text += f"\n\n📦 Другие контейнеры:\n
{html.escape(other)}
" - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - - -# ── Ссылка ─────────────────────────────────────────────────────────────────── -async def cmd_link(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - info = await proxy_info() - if not info: - text = "❌ Прокси не запущен." - else: - text = f"{html.escape(info['link'])}" - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - - -# ── Поделиться ключом ──────────────────────────────────────────────────────── -async def cmd_share(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - info = await proxy_info() - if not info: - text = "❌ Прокси не запущен." - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, reply_markup=kb) - else: - await msg.reply_text(text, reply_markup=kb) - return - - # tg://proxy ссылка, которую Telegram распознает при пересылке - tg_link = info["link"] - # Красивое сообщение для пересылки - share_text = ( - f"🔐 MTProxy для Telegram\n\n" - f"🌍 Сервер: {html.escape(info['ip'])}\n" - f"🔌 Порт: {html.escape(info['port'])}\n" - f"🔑 Secret: {html.escape(info['secret'])}\n\n" - f"👉 Подключиться одним нажатием:\n" - f"{html.escape(tg_link)}\n\n" - f"Просто нажмите на ссылку или перешлите это сообщение." - ) - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - if update.callback_query: - await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb) - - -# ── Удалить ────────────────────────────────────────────────────────────────── -async def cmd_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - chat = msg.chat - if update.callback_query: - await update.callback_query.edit_message_text("⏳ Удаляю прокси...") - else: - await chat.send_message("⏳ Удаляю прокси...") - await sh("docker", "stop", CONTAINER_NAME, timeout=15) - await sh("docker", "rm", CONTAINER_NAME, timeout=10) - text = "✅ Прокси удалён." if not await proxy_running() else "⚠️ Не удалось удалить." - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - await chat.send_message(text, reply_markup=kb) - - -# ── Рестарт ────────────────────────────────────────────────────────────────── -async def cmd_restart(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - if not await proxy_running(): - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) - else: - await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) - return - chat = msg.chat - if update.callback_query: - await update.callback_query.edit_message_text("⏳ Перезапуск...") - code, _, err = await sh("docker", "restart", CONTAINER_NAME, timeout=30) - text = "✅ Перезапущен." if code == 0 else f"❌ Ошибка: {err or 'unknown'}" - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - await chat.send_message(text, reply_markup=kb) - - -# ── Логи ───────────────────────────────────────────────────────────────────── -async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - if not await proxy_running(): - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) - else: - await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) - return - code, out, err = await sh("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15) - text = (out or "") + (("\n" + err) if err else "") or "Нет вывода." - if len(text) > 4000: - text = text[-4000:] - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) - - -# ── Промо ──────────────────────────────────────────────────────────────────── -async def cmd_promo(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - text = ( - "💰 Хостинг со скидкой до -60%\n" - f"Ссылка: {PROMO_LINK}\n\n" - "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n" - f"Донат: {TIP_LINK}" - ) - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - - -# ── Установка: домен → порт → проверка → запуск ───────────────────────────── - -async def install_step_domain(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: - return - if not _ok(update.effective_user.id): - return - buttons = [] - row = [] - for i, d in enumerate(DOMAINS): - row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}")) - if len(row) == 2: - buttons.append(row) - row = [] - if row: - buttons.append(row) - text = "🌐 Выберите домен для маскировки (Fake TLS):" - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) - - -async def install_step_port(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - domain = ctx.user_data.get("install_domain", "google.com") - - # Проверяем порт 443 - busy_443 = await check_port(443) - busy_8443 = await check_port(8443) - - rows = [] - label_443 = "443 (рекомендуется)" if not busy_443 else "443 ⚠️ занят" - label_8443 = "8443" if not busy_8443 else "8443 ⚠️ занят" - rows.append([ - InlineKeyboardButton(label_443, callback_data="port_443"), - InlineKeyboardButton(label_8443, callback_data="port_8443"), - ]) - rows.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]) - - port_info = "" - if busy_443: - port_info += f"\n⚠️ Порт 443 занят:\n
{html.escape(busy_443[:300])}
\n" - if busy_8443: - port_info += f"\n⚠️ Порт 8443 занят:\n
{html.escape(busy_8443[:300])}
\n" - - text = ( - f"Домен: {html.escape(domain)}\n\n" - "🔌 Выберите порт или введите свой (1-65535):" - f"{port_info}" - ) - ctx.user_data["install_wait_port"] = True - await query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(rows)) - - -async def install_port_chosen(update: Update, ctx: ContextTypes.DEFAULT_TYPE, port_str: str) -> None: - """Порт выбран кнопкой или текстом — проверяем и ставим.""" - port = int(port_str) - msg = None - chat = None - - if update.callback_query: - msg = update.callback_query.message - elif update.message: - msg = update.message - if not msg: - return - chat = msg.chat - - # Проверка занятости - busy = await check_port(port) - if busy: - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton(f"Всё равно использовать {port}", callback_data=f"force_{port}")], - [InlineKeyboardButton("Выбрать другой порт", callback_data="reselect_port")], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - text = ( - f"⚠️ Порт {port} занят!\n\n" - f"
{html.escape(busy[:500])}
\n\n" - "Можно использовать всё равно (если это ваш процесс) или выбрать другой." - ) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await chat.send_message(text, parse_mode="HTML", reply_markup=kb) - ctx.user_data["install_port"] = port_str - return - - ctx.user_data["install_port"] = port_str - ctx.user_data["install_wait_port"] = False - await do_install(update, ctx) - - -async def do_install(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - domain = ctx.user_data.get("install_domain") or "google.com" - port = ctx.user_data.get("install_port") or "443" - - msg = None - if update.callback_query: - msg = update.callback_query.message - await msg.edit_text("⏳ Генерация secret и запуск контейнера...", reply_markup=None) - elif update.message: - msg = update.message - await msg.reply_text("⏳ Генерация secret и запуск контейнера...") - if not msg: - return - chat = msg.chat - - # Docker check - code, _, _ = await sh("docker", "info", timeout=10) - if code != 0: - await chat.send_message( - "❌ Docker не установлен или не запущен.\n" - "Установите: curl -fsSL https://get.docker.com | sh", - parse_mode="HTML", - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]), - ) - return - - # generate secret - code, secret_out, err = await sh( - "docker", "run", "--rm", "nineseconds/mtg:2", "generate-secret", "--hex", domain, - timeout=60, - ) - if code != 0: - await chat.send_message(f"❌ Генерация secret: {err or secret_out}") - return - secret = secret_out.strip().split()[-1] if secret_out.strip() else "" - if not secret: - await chat.send_message("❌ Пустой secret.") - return - - # Остановка старого - await sh("docker", "stop", CONTAINER_NAME, timeout=15) - await sh("docker", "rm", CONTAINER_NAME, timeout=10) - - # Запуск с TCP + UDP (UDP нужен для звонков Telegram) - # --network host не используем, чтобы не мешать Amnezia/3x-ui - code, _, err = await sh( - "docker", "run", "-d", - "--name", CONTAINER_NAME, - "--restart", "always", - "-p", f"{port}:{port}/tcp", - "-p", f"{port}:{port}/udp", - "nineseconds/mtg:2", - "simple-run", - "-n", "1.1.1.1", - "-t", "1.0.0.1", # tag DNS для TLS - "-i", "prefer-ipv4", - f"0.0.0.0:{port}", secret, - timeout=90, - ) - if code != 0: - await chat.send_message(f"❌ Запуск контейнера: {err}") - return - - save_config({"domain": domain, "port": port, "secret": secret}) - - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - text = ( - "✅ Прокси установлен!\n\n" - f"🌍 IP: {html.escape(ip)}\n" - f"🔌 Порт: {html.escape(port)} (TCP + UDP)\n" - f"🎭 Домен: {html.escape(domain)}\n" - f"🔑 Secret: {html.escape(secret)}\n\n" - f"👉 Ссылка:\n{html.escape(link)}\n\n" - "📞 Звонки в Telegram поддержаны (UDP)." - ) - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - await chat.send_message(text, parse_mode="HTML", reply_markup=kb) - for k in ("install_domain", "install_port", "install_wait_port"): - ctx.user_data.pop(k, None) - - -# ── Callback router ────────────────────────────────────────────────────────── -async def callback_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - if not query or not update.effective_user: - return - await query.answer() - if not _ok(update.effective_user.id): - await query.edit_message_text("⛔ Доступ запрещён.") - return - - data = query.data or "" - - if data == "menu_main": - await start(update, ctx) - elif data == "menu_install": - await install_step_domain(update, ctx) - elif data == "menu_status": - await cmd_status(update, ctx) - elif data == "menu_link": - await cmd_link(update, ctx) - elif data == "menu_share": - await cmd_share(update, ctx) - elif data == "menu_restart": - await cmd_restart(update, ctx) - elif data == "menu_logs": - await cmd_logs(update, ctx) - elif data == "menu_remove": - await cmd_remove(update, ctx) - elif data == "menu_promo": - await cmd_promo(update, ctx) - - elif data.startswith("dom_"): - try: - idx = int(data[4:]) - except ValueError: - await query.edit_message_text("❌ Ошибка. /install") - return - if not (0 <= idx < len(DOMAINS)): - await query.edit_message_text("❌ Неверный выбор. /install") - return - ctx.user_data["install_domain"] = DOMAINS[idx] - await install_step_port(update, ctx) - - elif data == "port_443": - await install_port_chosen(update, ctx, "443") - elif data == "port_8443": - await install_port_chosen(update, ctx, "8443") - elif data.startswith("force_"): - port_str = data[6:] - ctx.user_data["install_port"] = port_str - ctx.user_data["install_wait_port"] = False - await do_install(update, ctx) - elif data == "reselect_port": - await install_step_port(update, ctx) - - -# ── Ввод порта текстом ────────────────────────────────────────────────────── -async def text_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - if not update.message or not ctx.user_data.get("install_wait_port"): - return - text = (update.message.text or "").strip() - if not re.match(r"^\d+$", text): - return - port = int(text) - if not (1 <= port <= 65535): - await update.message.reply_text("Введите число от 1 до 65535.") - return - await install_port_chosen(update, ctx, str(port)) - - -# ── main ───────────────────────────────────────────────────────────────────── -def main() -> None: - if not BOT_TOKEN: - raise SystemExit("Задайте BOT_TOKEN в .env") - app = Application.builder().token(BOT_TOKEN).build() - app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("help", start)) - app.add_handler(CommandHandler("install", install_step_domain)) - app.add_handler(CommandHandler("status", cmd_status)) - app.add_handler(CommandHandler("link", cmd_link)) - app.add_handler(CommandHandler("share", cmd_share)) - app.add_handler(CommandHandler("remove", cmd_remove)) - app.add_handler(CommandHandler("restart", cmd_restart)) - app.add_handler(CommandHandler("logs", cmd_logs)) - app.add_handler(CommandHandler("promo", cmd_promo)) - app.add_handler(CallbackQueryHandler(callback_handler)) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler)) - app.run_polling(allowed_updates=Update.ALL_TYPES) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +GoTelegram v2.2 Bot - MTProxy Management for Linux +Manages telemt engine via Telegram interface with full CLI feature parity +Uses python-telegram-bot v21+ +""" + +import asyncio +import html +import json +import logging +import os +import re +import subprocess +import toml +from datetime import datetime +from pathlib import Path +from typing import Tuple, Optional, List, Dict, Any + +from dotenv import load_dotenv +from telegram import ( + Update, + InlineKeyboardButton, + InlineKeyboardMarkup, + InputFile, +) +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + ContextTypes, + filters, +) +from telegram.error import TelegramError, BadRequest + +# Load environment variables +load_dotenv() + +# Logging configuration +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +GOTELEGRAM_VERSION = "2.2.0" +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" + +PROMO_LINK = "https://vk.cc/ct29NQ" +TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" + +BOT_TOKEN = os.getenv("BOT_TOKEN") +ALLOWED_IDS_STR = os.getenv("ALLOWED_IDS", "") +ALLOWED_IDS: set = set() +for _id_str in ALLOWED_IDS_STR.split(","): + _id_str = _id_str.strip() + if _id_str: + try: + ALLOWED_IDS.add(int(_id_str)) + except ValueError: + logging.warning(f"Invalid ALLOWED_IDS entry: {_id_str}") + +QUICK_DOMAINS = [ + "google.com", + "microsoft.com", + "cloudflare.com", + "apple.com", + "amazon.com", + "github.com", + "stackoverflow.com", + "medium.com", + "wikipedia.org", + "coursera.org", + "udemy.com", + "habr.com", + "stepik.org", + "duolingo.com", + "khanacademy.org", + "bbc.com", + "reuters.com", + "nytimes.com", + "ted.com", + "zoom.us", +] + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + + +async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]: + """Execute shell command asynchronously. + + Args: + *args: Command and arguments + timeout: Timeout in seconds + + Returns: + Tuple of (return_code, stdout, stderr) + """ + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=timeout + ) + return ( + process.returncode, + stdout.decode("utf-8", errors="replace"), + stderr.decode("utf-8", errors="replace"), + ) + except asyncio.TimeoutError: + try: + process.kill() + await process.wait() + except Exception: + pass + return (-1, "", f"Command timeout after {timeout}s") + except Exception as e: + return (-1, "", str(e)) + + +def load_json(path: str) -> Optional[Dict]: + """Load JSON file.""" + try: + with open(path, "r") as f: + return json.load(f) + except Exception as e: + logger.warning(f"Failed to load {path}: {e}") + return None + + +def load_toml(path: str) -> Optional[Dict]: + """Load TOML file.""" + try: + with open(path, "r") as f: + return toml.load(f) + except Exception as e: + logger.warning(f"Failed to load {path}: {e}") + return None + + +def save_json(path: str, data: Dict) -> bool: + """Save JSON file.""" + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + return True + except Exception as e: + logger.error(f"Failed to save {path}: {e}") + 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.""" + try: + await query.edit_message_text( + text, reply_markup=reply_markup, parse_mode=parse_mode + ) + return True + except BadRequest as e: + err_msg = str(e).lower() + if "message is not modified" in err_msg: + return True # No change needed, not an error + if "message to edit not found" in err_msg or "message can't be edited" in err_msg: + logger.warning(f"Cannot edit message: {e}") + return False + raise # Re-raise unexpected BadRequest + + +async def check_service_status(service: str) -> bool: + """Check if systemd service is running.""" + code, _, _ = await sh("systemctl", "is-active", service) + return code == 0 + + +async def get_telemt_version() -> str: + """Get telemt version.""" + code, stdout, _ = await sh("telemt", "-v") + if code == 0: + return stdout.strip().split()[-1] if stdout else "unknown" + return "unknown" + + +def is_docker_running() -> bool: + """Check if Docker daemon is running.""" + try: + subprocess.run( + ["docker", "ps"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + return True + except Exception: + return False + + +async def check_old_container() -> Optional[str]: + """Check for old mtg Docker container (v1 migration).""" + if not is_docker_running(): + return None + code, stdout, _ = await sh("docker", "ps", "-a", "--format", "{{.Names}}") + if code == 0 and "mtg" in stdout: + return "mtg" + return None + + +# ============================================================================ +# ACCESS CONTROL +# ============================================================================ + + +def is_user_allowed(user_id: int) -> bool: + """Check if user ID is in ALLOWED_IDS.""" + if not ALLOWED_IDS: + return True + return user_id in ALLOWED_IDS + + +async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """Check authorization and send error if not allowed.""" + if not is_user_allowed(update.effective_user.id): + await update.message.reply_text( + f"Access denied. Your ID: {update.effective_user.id}" + ) + logger.warning( + f"Unauthorized access attempt from user {update.effective_user.id}" + ) + return False + return True + + +# ============================================================================ +# MAIN MENU +# ============================================================================ + + +def get_main_menu() -> InlineKeyboardMarkup: + """Generate main menu keyboard.""" + buttons = [ + [ + InlineKeyboardButton("⚙️ Install", callback_data="menu_install"), + InlineKeyboardButton("📊 Status", callback_data="menu_status"), + ], + [ + InlineKeyboardButton("🔗 Link", callback_data="menu_link"), + InlineKeyboardButton("📤 Share", callback_data="menu_share"), + ], + [ + InlineKeyboardButton("🔄 Restart", callback_data="menu_restart"), + InlineKeyboardButton("📋 Logs", callback_data="menu_logs"), + ], + [ + InlineKeyboardButton("⚡ Change Mode/Template", callback_data="menu_change"), + InlineKeyboardButton("💾 Backup", callback_data="menu_backup"), + ], + [ + InlineKeyboardButton("↩️ Restore", callback_data="menu_restore"), + InlineKeyboardButton("📡 Update telemt", callback_data="menu_update"), + ], + [ + InlineKeyboardButton("🌐 Website/SSL", callback_data="menu_website"), + InlineKeyboardButton("🎁 Promo", callback_data="menu_promo"), + ], + [ + InlineKeyboardButton("🗑️ Remove", callback_data="menu_remove"), + InlineKeyboardButton("ℹ️ Credits", callback_data="menu_credits"), + ], + [InlineKeyboardButton("❌ Close", callback_data="close_menu")], + ] + return InlineKeyboardMarkup(buttons) + + +# ============================================================================ +# COMMANDS +# ============================================================================ + + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Start command - show main menu.""" + if not await require_auth(update, context): + return + + welcome = ( + f"GoTelegram v{GOTELEGRAM_VERSION}\n\n" + "🤖 MTProxy Management Bot\n" + "Powered by telemt engine\n\n" + "Select an action from the menu below:" + ) + await update.message.reply_text( + welcome, reply_markup=get_main_menu(), parse_mode="HTML" + ) + + +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Help command - show available commands.""" + if not await require_auth(update, context): + return + + help_text = ( + "GoTelegram Bot Commands\n\n" + "/start - Show main menu\n" + "/help - Show this help message\n" + "/status - Quick status check\n" + "/logs - Show recent logs\n\n" + "Use the inline menu for all other operations." + ) + await update.message.reply_text(help_text, parse_mode="HTML") + + +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Quick status check.""" + if not await require_auth(update, context): + return + + await update.message.reply_text("⏳ Checking status...", parse_mode="HTML") + status_text = await get_status_text() + await update.message.reply_text(status_text, parse_mode="HTML") + + +async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show recent logs.""" + if not await require_auth(update, context): + return + + code, stdout, stderr = await sh( + "journalctl", "-u", TELEMT_SERVICE, "-n", "20", "--no-pager" + ) + if code == 0: + log_text = stdout[-1500:] if len(stdout) > 1500 else stdout + await update.message.reply_text( + f"
{html.escape(log_text)}
", + parse_mode="HTML", + ) + else: + await update.message.reply_text("Failed to retrieve logs") + + +# ============================================================================ +# STATUS +# ============================================================================ + + +async def get_status_text() -> str: + """Generate status report.""" + lines = ["📊 Current Status\n"] + + # Service status + is_running = await check_service_status(TELEMT_SERVICE) + lines.append(f"Service: {'✅ Running' if is_running else '❌ Stopped'}") + + # Telemt version + version = await get_telemt_version() + lines.append(f"Telemt: v{version}") + + # Config status + config = load_json(GOTELEGRAM_CONFIG) + if config: + lines.append(f"Mode: {html.escape(str(config.get('mode', 'unknown')))}") + if "template" in config: + lines.append(f"Template: {html.escape(str(config['template']))}") + if "domain" in config: + lines.append(f"Domain: {html.escape(str(config['domain']))}") + if "port" in config: + lines.append(f"Port: {html.escape(str(config['port']))}") + + # Telemt config + telemt_cfg = load_toml(TELEMT_CONFIG) + if telemt_cfg: + cfg = telemt_cfg.get("config", {}) + if "listen_port" in cfg: + lines.append(f"Listen Port: {cfg['listen_port']}") + + # Backups + backup_count = 0 + try: + if os.path.exists(BACKUP_DIR): + backup_count = len([f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")]) + except Exception: + pass + lines.append(f"Backups: {backup_count}") + + # Old container check + old_container = await check_old_container() + if old_container: + lines.append(f"\n⚠️ Found old container: {html.escape(old_container)}") + lines.append("Run 'Install' to migrate") + + return "\n".join(lines) + + +async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Status callback.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Checking status...") + + status_text = await get_status_text() + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query, + status_text, reply_markup=keyboard, parse_mode="HTML" + ) + + +# ============================================================================ +# INSTALL +# ============================================================================ + + +def get_install_mode_menu() -> InlineKeyboardMarkup: + """Install mode selection menu.""" + buttons = [ + [InlineKeyboardButton("⚡ Quick Mode", callback_data="install_mode_quick")], + [InlineKeyboardButton("🔒 Stealth Mode", callback_data="install_mode_stealth")], + [InlineKeyboardButton("« Back", callback_data="menu_main")], + ] + return InlineKeyboardMarkup(buttons) + + +async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Install menu callback.""" + query = update.callback_query + await query.answer() + + # Check for old container + old_container = await check_old_container() + if old_container: + text = ( + f"⚠️ Migration from v1 detected\n\n" + f"Found Docker container: {html.escape(old_container)}\n\n" + f"Would you like to:\n" + f"1. Migrate from v1 (recommended)\n" + f"2. Fresh install (will remove old container)\n\n" + f"Select below or choose install mode" + ) + buttons = [ + [InlineKeyboardButton("🔄 Migrate from v1", callback_data="install_migrate")], + [InlineKeyboardButton("✨ Fresh Install", callback_data="install_mode_quick")], + [InlineKeyboardButton("« Back", callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + else: + text = "Select installation mode:" + keyboard = get_install_mode_menu() + + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + + +async def cb_install_mode_quick(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Quick mode domain selection.""" + query = update.callback_query + await query.answer() + + # Show domains with pagination (4 per row, 2 rows) + buttons = [] + for i in range(0, len(QUICK_DOMAINS), 2): + row = [] + for j in range(2): + if i + j < len(QUICK_DOMAINS): + domain = QUICK_DOMAINS[i + j] + row.append( + InlineKeyboardButton( + domain, callback_data=f"quick_dom_{i+j}" + ) + ) + buttons.append(row) + + buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")]) + + text = "Select a domain for quick mode:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard) + + +async def cb_quick_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Quick domain selection callback.""" + query = update.callback_query + data = query.data + try: + domain_idx = int(data.split("_")[-1]) + domain = QUICK_DOMAINS[domain_idx] + except (ValueError, IndexError): + await query.answer("Invalid domain selection") + return + + await query.answer() + await safe_edit_message(query,f"⏳ Installing with domain: {domain}...") + + # Simulate installation (in real scenario, call install script) + config = { + "mode": "quick", + "domain": domain, + "port": 443, + "installed_at": datetime.now().isoformat(), + } + + if save_json(GOTELEGRAM_CONFIG, config): + text = ( + f"✅ Quick mode installed!\n\n" + f"Domain: {domain}\n" + f"Mode: Quick\n\n" + f"Service starting... Check status in 10 seconds." + ) + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + else: + await safe_edit_message(query, + "❌ Failed to save configuration", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_install")]] + ), + ) + + +async def cb_install_mode_stealth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Stealth mode - show template categories.""" + query = update.callback_query + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog or "categories" not in catalog: + await safe_edit_message(query, + "❌ Template catalog not found", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_install")]] + ), + ) + return + + buttons = [] + for cat in catalog.get("categories", []): + buttons.append( + [ + InlineKeyboardButton( + f"📁 {cat['name']}", callback_data=f"stealth_cat_{cat['id']}" + ) + ] + ) + buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")]) + + text = "Stealth Mode - Select Template Category:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard) + + +async def cb_stealth_category(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show templates in category.""" + query = update.callback_query + data = query.data + cat_id = data.removeprefix("stealth_cat_") + + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog: + await safe_edit_message(query,"❌ Template catalog not found") + return + + # Find category and templates + category = None + templates = [] + for cat in catalog.get("categories", []): + if cat["id"] == cat_id: + category = cat + templates = cat.get("templates", []) + break + + if not category: + await safe_edit_message(query,"❌ Category not found") + return + + buttons = [] + for tpl in templates: + buttons.append( + [ + InlineKeyboardButton( + f"🎨 {tpl['name']}", callback_data=f"stealth_tpl_{tpl['id']}" + ) + ] + ) + buttons.append([InlineKeyboardButton("« Back", callback_data="install_mode_stealth")]) + + text = f"Select template from {html.escape(category['name'])}:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_stealth_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show template preview and confirm.""" + query = update.callback_query + data = query.data + tpl_id = data.removeprefix("stealth_tpl_") + + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog: + await safe_edit_message(query,"❌ Template catalog not found") + return + + # Find template + template = None + for cat in catalog.get("categories", []): + for tpl in cat.get("templates", []): + if tpl["id"] == tpl_id: + template = tpl + break + if template: + break + + if not template: + await safe_edit_message(query,"❌ Template not found") + return + + tpl_name = html.escape(template.get('name', 'Unknown')) + tpl_desc = html.escape(template.get('description', 'N/A')) + text = ( + f"🎨 Template Preview\n\n" + f"Name: {tpl_name}\n" + f"Description: {tpl_desc}\n\n" + ) + if "preview_url" in template: + preview_url = html.escape(template['preview_url'], quote=True) + text += f'View Live Preview\n\n' + + text += "Confirm installation?" + + buttons = [ + [ + InlineKeyboardButton( + "✅ Install", callback_data=f"stealth_confirm_{tpl_id}" + ) + ], + [InlineKeyboardButton("« Back", callback_data="install_mode_stealth")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_stealth_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Confirm and install stealth template.""" + query = update.callback_query + data = query.data + tpl_id = data.removeprefix("stealth_confirm_") + + await query.answer() + await safe_edit_message(query,"⏳ Installing template...") + + config = { + "mode": "stealth", + "template": tpl_id, + "port": 443, + "installed_at": datetime.now().isoformat(), + } + + if save_json(GOTELEGRAM_CONFIG, config): + text = ( + f"✅ Stealth mode installed!\n\n" + f"Template: {html.escape(tpl_id)}\n" + f"Mode: Stealth\n\n" + f"Service starting... Check status in 10 seconds." + ) + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + else: + await safe_edit_message(query, + "❌ Failed to save configuration", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_install")]] + ), + ) + + +# ============================================================================ +# PROXY LINK & SHARE +# ============================================================================ + + +async def get_proxy_link() -> Optional[str]: + """Generate proxy link from config.""" + config = load_json(GOTELEGRAM_CONFIG) + if not config: + return None + + # Get secret from telemt TOML config + secret = config.get("secret", "") + if not secret: + telemt_cfg = load_toml(TELEMT_CONFIG) + if telemt_cfg: + users = telemt_cfg.get("users", []) + if isinstance(users, list) and users: + secret = users[0].get("secret", "") + if not secret: + return None + + # Get server IP + code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org") + server = stdout.strip() if code == 0 and stdout.strip() else "0.0.0.0" + + port = config.get("port", 443) + + return f"tg://proxy?server={server}&port={port}&secret={secret}" + + +async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Generate and show proxy link.""" + query = update.callback_query + await query.answer() + + link = await get_proxy_link() + if not link: + text = "❌ Proxy not installed yet. Run install first." + else: + text = ( + f"🔗 Proxy Link\n\n" + f"{html.escape(link)}\n\n" + f"Open in Telegram to connect." + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Share link as QR code.""" + query = update.callback_query + await query.answer() + + link = await get_proxy_link() + if not link: + text = "❌ Proxy not installed yet." + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard) + return + + # Try to generate QR code + qr_file = None + code, _, _ = await sh("which", "qrencode") + if code == 0: + qr_path = "/tmp/proxy_qr.png" + code, _, _ = await sh("qrencode", "-o", qr_path, link) + if code == 0 and os.path.exists(qr_path): + qr_file = qr_path + + if qr_file: + try: + with open(qr_file, "rb") as f: + await query.message.reply_photo( + photo=f, + caption=f"📤 Proxy QR Code\n\n{html.escape(link)}", + parse_mode="HTML", + ) + except Exception as e: + logger.error(f"Failed to send QR code: {e}") + await safe_edit_message(query, + f"🔗 Proxy Link\n\n{html.escape(link)}", + parse_mode="HTML", + ) + else: + await safe_edit_message(query, + f"🔗 Proxy Link\n\n{html.escape(link)}", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + + +# ============================================================================ +# RESTART & LOGS +# ============================================================================ + + +async def cb_menu_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Restart service.""" + query = update.callback_query + await query.answer() + + text = "⏳ Restarting telemt service..." + await safe_edit_message(query,text) + + code, _, stderr = await sh("systemctl", "restart", TELEMT_SERVICE) + if code == 0: + text = "✅ Service restarted successfully" + else: + text = f"❌ Failed to restart:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + + +async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show recent logs.""" + query = update.callback_query + await query.answer() + + code, stdout, _ = await sh( + "journalctl", "-u", TELEMT_SERVICE, "-n", "30", "--no-pager" + ) + + if code == 0: + log_text = stdout[-1000:] if len(stdout) > 1000 else stdout + text = f"📋 Recent Logs\n\n
{html.escape(log_text)}
" + else: + text = "❌ Failed to retrieve logs" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# BACKUP & RESTORE +# ============================================================================ + + +async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Backup menu.""" + query = update.callback_query + await query.answer() + + # List existing backups + backups = [] + try: + if os.path.exists(BACKUP_DIR): + backups = sorted( + [f for f in os.listdir(BACKUP_DIR) + if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], + reverse=True, + ) + except Exception: + pass + + buttons = [[InlineKeyboardButton("💾 Create Backup", callback_data="backup_create")]] + + if backups: + buttons.append( + [InlineKeyboardButton("📋 List Backups", callback_data="backup_list")] + ) + + buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")]) + + text = f"💾 Backup Management\n\nExisting backups: {len(backups)}" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_backup_create(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Create backup.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Creating backup...") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.tar.gz") + + os.makedirs(BACKUP_DIR, exist_ok=True) + code, _, stderr = await sh( + "tar", "-czf", backup_file, GOTELEGRAM_CONFIG, TELEMT_CONFIG + ) + + if code == 0: + text = f"✅ Backup created:\n{html.escape(backup_file)}" + else: + text = f"❌ Backup failed:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_backup")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """List backups.""" + query = update.callback_query + await query.answer() + + backups = [] + try: + if os.path.exists(BACKUP_DIR): + backups = sorted( + [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], + reverse=True, + ) + except Exception: + pass + + if not backups: + text = "No backups found" + else: + text = "📋 Available Backups\n\n" + for backup in backups[:10]: + path = os.path.join(BACKUP_DIR, backup) + size = os.path.getsize(path) / (1024 * 1024) + text += f"{html.escape(backup)} ({size:.2f} MB)\n" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_backup")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Restore menu.""" + query = update.callback_query + await query.answer() + + backups = [] + try: + if os.path.exists(BACKUP_DIR): + backups = sorted( + [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], + reverse=True, + ) + except Exception: + pass + + if not backups: + text = "❌ No backups available" + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + else: + text = "Select backup to restore:" + buttons = [] + for i, backup in enumerate(backups[:10]): + buttons.append( + [ + InlineKeyboardButton( + backup, callback_data=f"restore_idx_{i}" + ) + ] + ) + buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")]) + keyboard = InlineKeyboardMarkup(buttons) + # Store backup list in user_data for retrieval + context.user_data["backup_list"] = backups[:10] + + await safe_edit_message(query,text, reply_markup=keyboard) + + +async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Execute backup restoration.""" + query = update.callback_query + data = query.data + + try: + idx = int(data.removeprefix("restore_idx_")) + except ValueError: + await query.answer("Invalid backup selection") + return + + backup_list = context.user_data.get("backup_list", []) + if idx < 0 or idx >= len(backup_list): + await query.answer("Backup not found") + return + + backup_name = backup_list[idx] + backup_path = os.path.join(BACKUP_DIR, backup_name) + + await query.answer() + await safe_edit_message(query,f"⏳ Restoring from {html.escape(backup_name)}...") + + if not os.path.exists(backup_path): + text = "❌ Backup file not found" + else: + # Simple restore: extract tar to overwrite configs + code, _, stderr = await sh( + "tar", "-xzf", backup_path, "-C", "/", timeout=60 + ) + if code == 0: + # Restart services + await sh("systemctl", "restart", TELEMT_SERVICE) + text = f"✅ Restored from {html.escape(backup_name)}" + else: + text = f"❌ Restore failed:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# UPDATE & MODE/TEMPLATE CHANGE +# ============================================================================ + + +async def cb_menu_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Update telemt by re-running the install script's update logic.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Checking for telemt updates...") + + # Get current version + cur_code, cur_out, _ = await sh("telemt", "--version") + current = cur_out.strip() if cur_code == 0 else "unknown" + + # Check latest release from GitHub + code, stdout, stderr = await sh( + "curl", "-s", "--max-time", "10", + "https://api.github.com/repos/telemt/telemt/releases/latest", + ) + + if code != 0 or not stdout.strip(): + text = "❌ Failed to check for updates" + else: + try: + release = json.loads(stdout) + latest = release.get("tag_name", "unknown") + if latest == current: + text = f"✅ telemt is already up to date ({html.escape(current)})" + else: + text = ( + f"ℹ️ Update available: {html.escape(current)} → {html.escape(latest)}\n\n" + f"Run the CLI installer to update:\n" + f"sudo bash install.sh → menu item 10" + ) + except json.JSONDecodeError: + text = "❌ Failed to parse release info" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_change(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Change mode or template.""" + query = update.callback_query + await query.answer() + + buttons = [ + [InlineKeyboardButton("⚡ Switch to Quick Mode", callback_data="change_quick")], + [InlineKeyboardButton("🔒 Switch to Stealth Mode", callback_data="change_stealth")], + [InlineKeyboardButton("« Back", callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query, + "Change mode or template:", reply_markup=keyboard + ) + + +async def cb_change_quick(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Switch to quick mode — show domain selection.""" + query = update.callback_query + await query.answer() + # Reuse the quick mode domain selection flow + await cb_install_mode_quick(update, context) + + +async def cb_change_stealth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Switch to stealth mode — show template categories.""" + query = update.callback_query + await query.answer() + # Reuse the stealth mode template selection flow + await cb_install_mode_stealth(update, context) + + +async def cb_install_migrate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Migrate from v1 (mtg Docker) to v2 (telemt).""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Migrating from v1...") + + # Stop old mtg container + code, _, stderr = await sh("docker", "stop", "mtproto-proxy", timeout=30) + if code != 0: + code, _, stderr = await sh("docker", "stop", "mtg", timeout=30) + + # Remove old container + await sh("docker", "rm", "mtproto-proxy", timeout=15) + await sh("docker", "rm", "mtg", timeout=15) + + text = ( + "✅ v1 container stopped and removed\n\n" + "Now select installation mode for v2:" + ) + keyboard = get_install_mode_menu() + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# WEBSITE & SSL +# ============================================================================ + + +async def cb_menu_website(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Website and SSL management.""" + query = update.callback_query + await query.answer() + + buttons = [ + [InlineKeyboardButton("🔄 Renew SSL Certificate", callback_data="ssl_renew")], + [InlineKeyboardButton("📊 SSL Status", callback_data="ssl_status")], + [InlineKeyboardButton("« Back", callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query, + "Website & SSL Management:", reply_markup=keyboard + ) + + +async def cb_ssl_renew(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Renew SSL certificate.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Renewing SSL certificate...") + + code, stdout, stderr = await sh("certbot", "renew", timeout=120) + + if code == 0: + text = "✅ SSL certificate renewed successfully" + else: + text = f"❌ Renewal failed:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_website")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show SSL status.""" + query = update.callback_query + await query.answer() + + code, stdout, _ = await sh("certbot", "certificates") + + if code == 0: + text = f"📊 SSL Certificates\n\n
{html.escape(stdout[:1000])}
" + else: + text = "❌ Failed to get SSL status" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_website")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# PROMO & CREDITS +# ============================================================================ + + +async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Promo information.""" + query = update.callback_query + await query.answer() + + text = ( + f"🎁 GoTelegram Promo\n\n" + f"Share the love! Invite friends to use GoTelegram.\n\n" + f"Promo Link\n\n" + f"Support development:\n" + f"Send a Tip" + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Credits and acknowledgements.""" + query = update.callback_query + await query.answer() + + text = ( + f"ℹ️ Credits & Acknowledgements\n\n" + f"GoTelegram v{GOTELEGRAM_VERSION}\n\n" + f"Built with love for the Telegram community\n\n" + f"Special thanks to:\n\n" + f"🙏 telemt - MTProxy engine\n" + f" High-performance proxy core\n\n" + f"🎨 HTML5UP - Beautiful web templates\n" + f" Responsive design & themes\n\n" + f"📚 Learning Zone - Educational resources\n" + f" Community learning support\n\n" + f"🚀 Start Bootstrap - Bootstrap templates\n" + f" Professional design framework\n\n" + f"💬 Community - Your feedback & support\n\n" + f"GoTelegram is open-source and community-driven" + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# REMOVE +# ============================================================================ + + +async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Remove installation.""" + query = update.callback_query + await query.answer() + + text = ( + "⚠️ Remove GoTelegram\n\n" + "This will completely remove the installation.\n" + "Are you sure?" + ) + + buttons = [ + [InlineKeyboardButton("❌ Yes, Remove", callback_data="remove_confirm")], + [InlineKeyboardButton("« Back", callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Confirm removal.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Removing GoTelegram...") + + # Stop service + await sh("systemctl", "stop", TELEMT_SERVICE) + + # Remove directories + for path in ["/opt/gotelegram", WEBSITE_ROOT]: + await sh("rm", "-rf", path) + + text = "✅ GoTelegram removed successfully" + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# CALLBACK ROUTING +# ============================================================================ + + +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Route all callbacks.""" + query = update.callback_query + data = query.data + + # Access control + if not is_user_allowed(update.effective_user.id): + await query.answer("Access denied") + return + + # Main menu + if data == "menu_main": + await query.answer() + buttons = get_main_menu() + text = ( + f"GoTelegram v{GOTELEGRAM_VERSION}\n\n" + "🤖 MTProxy Management\n" + "Select an action:" + ) + await safe_edit_message(query,text, reply_markup=buttons, parse_mode="HTML") + return + + if data == "close_menu": + await query.answer() + await query.delete_message() + return + + # Dispatch to handlers + handlers = { + "menu_install": cb_menu_install, + "menu_status": cb_menu_status, + "menu_link": cb_menu_link, + "menu_share": cb_menu_share, + "menu_restart": cb_menu_restart, + "menu_logs": cb_menu_logs, + "menu_backup": cb_menu_backup, + "menu_restore": cb_menu_restore, + "menu_update": cb_menu_update, + "menu_change": cb_menu_change, + "menu_website": cb_menu_website, + "menu_promo": cb_menu_promo, + "menu_credits": cb_menu_credits, + "menu_remove": cb_menu_remove, + "install_mode_quick": cb_install_mode_quick, + "install_mode_stealth": cb_install_mode_stealth, + "backup_create": cb_backup_create, + "backup_list": cb_backup_list, + "ssl_renew": cb_ssl_renew, + "ssl_status": cb_ssl_status, + "remove_confirm": cb_remove_confirm, + "change_quick": cb_change_quick, + "change_stealth": cb_change_stealth, + "install_migrate": cb_install_migrate, + } + + # Pattern-based handlers + if data.startswith("quick_dom_"): + await cb_quick_domain(update, context) + elif data.startswith("stealth_cat_"): + await cb_stealth_category(update, context) + elif data.startswith("stealth_tpl_"): + await cb_stealth_template(update, context) + elif data.startswith("stealth_confirm_"): + await cb_stealth_confirm(update, context) + elif data.startswith("restore_idx_"): + await cb_restore_backup(update, context) + elif data in handlers: + await handlers[data](update, context) + else: + await query.answer("Unknown action") + + +# ============================================================================ +# ERROR HANDLERS +# ============================================================================ + + +async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Log errors caused by Updates.""" + logger.error(f"Exception while handling an update:", exc_info=context.error) + + +# ============================================================================ +# MAIN APPLICATION +# ============================================================================ + + +def main() -> None: + """Start the bot.""" + if not BOT_TOKEN: + logger.error("BOT_TOKEN not set in .env file") + return + + # Create the Application + application = Application.builder().token(BOT_TOKEN).build() + + # Command handlers + application.add_handler(CommandHandler("start", cmd_start)) + application.add_handler(CommandHandler("help", cmd_help)) + application.add_handler(CommandHandler("status", cmd_status)) + application.add_handler(CommandHandler("logs", cmd_logs)) + + # Callback query handler (buttons) + application.add_handler(CallbackQueryHandler(handle_callback)) + + # Error handler + application.add_error_handler(error_handler) + + # Run the bot + logger.info(f"GoTelegram v{GOTELEGRAM_VERSION} bot starting...") + application.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() diff --git a/gotelegram-bot/config.example.env b/gotelegram-bot/config.example.env index f4b44da..59750e9 100644 --- a/gotelegram-bot/config.example.env +++ b/gotelegram-bot/config.example.env @@ -1,4 +1,9 @@ -# Скопируйте в .env и заполните +# GoTelegram v2.2 Bot Configuration +# Copy this to .env and fill in your values + +# Telegram Bot Token from @BotFather BOT_TOKEN=your_bot_token_from_@BotFather -# Опционально: список ID пользователей с доступом (через запятую). Пусто = все. -# ALLOWED_IDS=123456789,987654321 + +# Comma-separated list of allowed Telegram user IDs +# Leave empty to allow all users +# ALLOWED_IDS=123456789,987654321 \ No newline at end of file diff --git a/gotelegram-bot/requirements.txt b/gotelegram-bot/requirements.txt index ea4513c..7c6803c 100644 --- a/gotelegram-bot/requirements.txt +++ b/gotelegram-bot/requirements.txt @@ -1 +1,3 @@ python-telegram-bot>=21.0 +python-dotenv>=1.0.0 +toml>=0.10.2 \ No newline at end of file diff --git a/install.sh b/install.sh index 3f25f48..48acebd 100644 --- a/install.sh +++ b/install.sh @@ -1,1320 +1,460 @@ -#!/bin/bash -# GoTelegram MTProxy — всё в одном файле. -# Установка: curl -sL -H "Authorization: token TOKEN" https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install.sh -o /usr/local/bin/gotelegram && chmod +x /usr/local/bin/gotelegram && gotelegram - -# ── Цвета ──────────────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -CYAN='\033[0;36m' -YELLOW='\033[1;33m' -MAGENTA='\033[0;35m' -BLUE='\033[0;34m' -WHITE='\033[1;37m' -NC='\033[0m' - -# ── Спиннер и прогресс-бар ──────────────────────────────────────────────────── -spin_pid="" -spinner_start() { - local msg="${1:-Подождите...}" - ( - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - local i=0 - while true; do - printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2 - i=$(( (i+1) % ${#frames[@]} )) - sleep 0.12 - done - ) & - spin_pid=$! -} -spinner_stop() { - [ -n "$spin_pid" ] && kill "$spin_pid" 2>/dev/null && wait "$spin_pid" 2>/dev/null - spin_pid="" - printf "\r\033[K" >&2 -} - -progress_bar() { - local current="$1" total="$2" label="${3:-}" - local pct=$(( current * 100 / total )) - local filled=$(( pct / 2 )) - local empty=$(( 50 - filled )) - local bar="" - for ((i=0; i&2 - [ "$current" -eq "$total" ] && echo "" >&2 -} - -run_with_progress() { - local label="$1"; shift - spinner_start "$label" - "$@" >/dev/null 2>&1 - local rc=$? - spinner_stop - if [ $rc -eq 0 ]; then - echo -e " ${GREEN}✓${NC} $label" - else - echo -e " ${RED}✗${NC} $label ${RED}(ошибка)${NC}" - fi - return $rc -} - -# ── Конфиг ─────────────────────────────────────────────────────────────────── -CONTAINER_NAME="mtproto-proxy" -BOT_DIR="/opt/gotelegram-bot" -SERVICE_NAME="gotelegram-bot" -TIP_LINK="https://pay.cloudtips.ru/p/7410814f" -PROMO_LINK="https://vk.cc/ct29NQ" - -# ── Проверка root ──────────────────────────────────────────────────────────── -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}Запустите с sudo / root.${NC}" - exit 1 -fi - -# ── Установка пакетов ──────────────────────────────────────────────────────── -install_pkg() { - if command -v apt-get &>/dev/null; then - apt-get update -qq && apt-get install -y -qq "$@" - elif command -v dnf &>/dev/null; then - dnf install -y "$@" 2>/dev/null - elif command -v yum &>/dev/null; then - yum install -y "$@" - fi -} - -install_base_deps() { - local steps=0 total=4 # curl, docker, qrencode, docker-start - - progress_bar $steps $total "Проверка зависимостей..." - if ! command -v curl &>/dev/null; then - run_with_progress "Установка curl" install_pkg curl - fi - steps=$((steps+1)); progress_bar $steps $total "curl" - - if ! command -v docker &>/dev/null; then - spinner_start "Установка Docker (это может занять 1-2 минуты)..." - curl -fsSL https://get.docker.com | sh >/dev/null 2>&1 - systemctl enable --now docker >/dev/null 2>&1 - spinner_stop - echo -e " ${GREEN}✓${NC} Docker установлен" - fi - steps=$((steps+1)); progress_bar $steps $total "docker" - - if ! command -v qrencode &>/dev/null; then - run_with_progress "Установка qrencode" install_pkg qrencode - fi - steps=$((steps+1)); progress_bar $steps $total "qrencode" - - if ! docker info &>/dev/null 2>&1; then - systemctl start docker 2>/dev/null || true - sleep 2 - fi - steps=$((steps+1)); progress_bar $steps $total "Готово" - echo "" -} - -# ── Утилиты ────────────────────────────────────────────────────────────────── -get_ip() { - local ip - ip=$(curl -s -4 --max-time 5 https://api.ipify.org 2>/dev/null \ - || curl -s -4 --max-time 5 https://icanhazip.com 2>/dev/null \ - || echo "0.0.0.0") - echo "$ip" | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1 -} - -check_port() { - local port="$1" - # Если порт занят нашим контейнером — ОК - if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then - local hp - hp=$(docker inspect "$CONTAINER_NAME" --format='{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}' 2>/dev/null) - for p in $hp; do [ "$p" = "$port" ] && return 1; done - fi - # Проверяем через ss или netstat - local line - line=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) - [ -z "$line" ] && line=$(netstat -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) - if [ -n "$line" ]; then - echo "$line" - return 0 - fi - return 1 -} - -show_containers() { - local list - list=$(docker ps --format "{{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null | grep -v "^${CONTAINER_NAME}") - if [ -n "$list" ]; then - echo -e "${CYAN} Другие контейнеры на сервере:${NC}" - echo "$list" | while IFS= read -r l; do echo " $l"; done - fi -} - -proxy_is_running() { - docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$" -} - -# ── Показать данные подключения ────────────────────────────────────────────── -show_config() { - if ! proxy_is_running; then - echo -e "${RED}Прокси не запущен! Выберите пункт 1 для установки.${NC}" - return - fi - local SECRET IP PORT LINK - SECRET=$(docker inspect "$CONTAINER_NAME" --format='{{range .Config.Cmd}}{{.}} {{end}}' 2>/dev/null | awk '{print $NF}') - IP=$(get_ip) - PORT=$(docker inspect "$CONTAINER_NAME" --format='{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}' 2>/dev/null | awk '{print $1}') - PORT=${PORT:-443} - LINK="tg://proxy?server=$IP&port=$PORT&secret=$SECRET" - - echo "" - echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${GREEN}║ ДАННЫЕ ПОДКЛЮЧЕНИЯ ║${NC}" - echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" - echo -e " IP: ${WHITE}$IP${NC}" - echo -e " Port: ${WHITE}$PORT${NC} (TCP + UDP)" - echo -e " Secret: ${WHITE}$SECRET${NC}" - echo "" - echo -e " Ссылка: ${BLUE}$LINK${NC}" - echo "" - if command -v qrencode &>/dev/null; then - echo -e "${CYAN} Наведите камеру телефона на QR-код для подключения:${NC}" - echo "" - qrencode -t ANSIUTF8 "$LINK" - fi - echo "" - show_containers -} - -# ── ПРОМО ──────────────────────────────────────────────────────────────────── -show_promo() { - clear - echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${MAGENTA}║ ХОСТИНГ СО СКИДКОЙ ДО -60% ОТ ANTEN-KA ║${NC}" - echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════════╝${NC}" - echo "" - echo -e "${CYAN} Хостинг #1: $PROMO_LINK ${NC}" - echo -e "${MAGENTA}❖ ••••••••••••••••••• АКТУАЛЬНЫЕ ПРОМОКОДЫ •••••••••••••••••• ❖${NC}" - printf " ${YELLOW}%-12s${NC} : ${WHITE}%s${NC}\n" "OFF60" "Скидка 60% на ПЕРВЫЙ МЕСЯЦ" - printf " ${YELLOW}%-12s${NC} : ${WHITE}%s${NC}\n" "antenka20" "Буст 20% + 3% (оплата за 3 МЕС)" - printf " ${YELLOW}%-12s${NC} : ${WHITE}%s${NC}\n" "antenka6" "Буст 15% + 5% (оплата за 6 МЕС)" - printf " ${YELLOW}%-12s${NC} : ${WHITE}%s${NC}\n" "antenka12" "Буст 5% + 5% (оплата за 12 МЕС)" - echo -e "${MAGENTA}❖ •••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• ❖${NC}" - if command -v qrencode &>/dev/null; then - qrencode -t ANSIUTF8 "$PROMO_LINK" - fi - echo "" - echo -e "${CYAN} Хостинг #2: https://vk.cc/cUxAhj ${NC}" - echo -e "${MAGENTA}❖ ••••••••••••••••••• АКТУАЛЬНЫЕ ПРОМОКОДЫ •••••••••••••••••• ❖${NC}" - printf " ${YELLOW}%-12s${NC} : ${WHITE}%s${NC}\n" "OFF60" "Скидка 60% на ПЕРВЫЙ МЕСЯЦ" - echo -e "${MAGENTA}❖ •••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• ❖${NC}" - if command -v qrencode &>/dev/null; then - qrencode -t ANSIUTF8 "https://vk.cc/cUxAhj" - fi - echo "--------------------------------------------------------------" - read -p "Нажмите [ENTER] для возврата в меню..." -} - -# ── 1) Установить / Обновить MTProxy ───────────────────────────────────────── -menu_install() { - clear - echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}║ Выберите домен для маскировки (Fake TLS) ║${NC}" - echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" - - local domains=( - "google.com" "wikipedia.org" "habr.com" "github.com" - "coursera.org" "udemy.com" "medium.com" "stackoverflow.com" - "bbc.com" "cnn.com" "reuters.com" "nytimes.com" - "lenta.ru" "rbc.ru" "ria.ru" "kommersant.ru" - "stepik.org" "duolingo.com" "khanacademy.org" "ted.com" - ) - - for i in "${!domains[@]}"; do - printf " ${YELLOW}%2d)${NC} %-22s" "$((i+1))" "${domains[$i]}" - [[ $(( (i+1) % 2 )) -eq 0 ]] && echo "" - done - echo "" - echo -e " ${CYAN}21)${NC} Ввести свой домен" - echo "" - - local d_idx DOMAIN - read -p "Ваш выбор [1-21]: " d_idx - if [ "$d_idx" = "21" ]; then - read -p " Введите домен (например, example.com): " DOMAIN - DOMAIN=$(echo "$DOMAIN" | tr -d '[:space:]') - if [ -z "$DOMAIN" ] || ! echo "$DOMAIN" | grep -qE '\.'; then - echo -e " ${RED}Некорректный домен. Используется google.com${NC}" - DOMAIN="google.com" - fi - else - DOMAIN=${domains[$((d_idx-1))]} - DOMAIN=${DOMAIN:-google.com} - fi - echo -e " Домен: ${GREEN}$DOMAIN${NC}" - - # ── Выбор порта с проверкой занятости ──────────────────────────────────── - echo "" - echo -e "${CYAN}--- Выберите порт ---${NC}" - - local busy_line - echo -n " 1) 443 (Рекомендуется) " - if busy_line=$(check_port 443); then - echo -e "${RED}[ЗАНЯТ: $busy_line]${NC}" - else - echo -e "${GREEN}[свободен]${NC}" - fi - - echo -n " 2) 8443 " - if busy_line=$(check_port 8443); then - echo -e "${RED}[ЗАНЯТ: $busy_line]${NC}" - else - echo -e "${GREEN}[свободен]${NC}" - fi - - echo -e " 3) Свой порт" - - local p_choice PORT - read -p " Выбор: " p_choice - case $p_choice in - 2) PORT=8443 ;; - 3) - while true; do - read -p " Введите порт (1-65535): " PORT - [[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1 && PORT <= 65535 )) && break - echo -e " ${RED}Неверный порт.${NC}" - done - ;; - *) PORT=443 ;; - esac - - # Финальная проверка выбранного порта - if busy_line=$(check_port "$PORT"); then - echo "" - echo -e " ${YELLOW}Порт $PORT занят:${NC}" - echo -e " ${RED}$busy_line${NC}" - echo -e " 1) Всё равно использовать (если это ваш процесс)" - echo -e " 2) Отмена" - local force_choice - read -p " Выбор: " force_choice - if [ "$force_choice" != "1" ]; then - echo -e " ${YELLOW}Отменено.${NC}" - read -p " Нажмите Enter..." - return - fi - fi - - echo "" - echo -e "${YELLOW}[*] Настройка прокси (домен: $DOMAIN, порт: $PORT)...${NC}" - echo "" - - # Docker проверка - if ! docker info &>/dev/null 2>&1; then - echo -e "${RED}Docker не запущен!${NC}" - read -p "Нажмите Enter..." - return - fi - - local SECRET install_steps=5 install_cur=0 - - # Шаг 1: pull образа (всегда проверяем/обновляем) - install_cur=$((install_cur+1)); progress_bar $install_cur $install_steps "Загрузка образа mtg..." - spinner_start "Загрузка Docker-образа mtg..." - docker pull nineseconds/mtg:2 >/dev/null 2>&1 - spinner_stop - if ! docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q "nineseconds/mtg"; then - echo -e " ${RED}✗${NC} Не удалось загрузить образ mtg. Проверьте интернет." - read -p "Нажмите Enter..." - return - fi - echo -e " ${GREEN}✓${NC} Образ mtg готов" - - # Шаг 2: генерация secret - install_cur=$((install_cur+1)); progress_bar $install_cur $install_steps "Генерация secret..." - spinner_start "Генерация secret для $DOMAIN..." - SECRET=$(docker run --rm nineseconds/mtg:2 generate-secret --hex "$DOMAIN" 2>&1) - local secret_rc=$? - spinner_stop - if [ $secret_rc -ne 0 ] || [ -z "$SECRET" ]; then - echo -e " ${RED}✗${NC} Ошибка генерации secret." - [ -n "$SECRET" ] && echo -e " ${RED}$SECRET${NC}" - read -p "Нажмите Enter..." - return - fi - echo -e " ${GREEN}✓${NC} Secret сгенерирован" - - # Шаг 3: остановка старого - install_cur=$((install_cur+1)); progress_bar $install_cur $install_steps "Очистка..." - docker stop "$CONTAINER_NAME" &>/dev/null - docker rm "$CONTAINER_NAME" &>/dev/null - echo -e " ${GREEN}✓${NC} Старый контейнер удалён" - - # Шаг 4: запуск нового - install_cur=$((install_cur+1)); progress_bar $install_cur $install_steps "Запуск контейнера..." - spinner_start "Запуск MTProxy (TCP + UDP)..." - docker run -d --name "$CONTAINER_NAME" --restart always \ - -p "$PORT":"$PORT"/tcp \ - -p "$PORT":"$PORT"/udp \ - nineseconds/mtg:2 simple-run \ - -n 1.1.1.1 -i prefer-ipv4 \ - 0.0.0.0:"$PORT" "$SECRET" > /dev/null 2>&1 - sleep 2 - spinner_stop - - if ! proxy_is_running; then - echo -e " ${RED}✗${NC} Контейнер не запустился. Проверьте: docker logs $CONTAINER_NAME" - read -p "Нажмите Enter..." - return - fi - echo -e " ${GREEN}✓${NC} Контейнер запущен" - - # Шаг 5: сохранение - install_cur=$((install_cur+1)); progress_bar $install_cur $install_steps "Готово!" - mkdir -p "$BOT_DIR" - cat > "$BOT_DIR/proxy.json" << CFGEOF -{"domain": "$DOMAIN", "port": "$PORT", "secret": "$SECRET"} -CFGEOF - - echo "" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - echo -e "${GREEN} Прокси установлен! (TCP + UDP, звонки поддержаны)${NC}" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - show_config - read -p "Нажмите Enter для возврата в меню..." -} - -# ── 3) Настроить Telegram-бот ───────────────────────────────────────────────── -menu_setup_bot() { - clear - echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}║ Настройка Telegram-бота ║${NC}" - echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" - - # Проверка статуса - if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then - echo -e " Статус бота: ${GREEN}работает${NC}" - echo "" - # Показываем текущие настройки - local cur_ids - cur_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2) - if [ -n "$cur_ids" ]; then - echo -e " Администратор(ы): ${WHITE}$cur_ids${NC}" - else - echo -e " Администратор: ${YELLOW}не задан (бот доступен всем)${NC}" - fi - echo "" - echo -e " 1) Обновить файлы бота и перезапустить" - echo -e " 2) Изменить BOT_TOKEN" - echo -e " 3) Изменить администратора (ALLOWED_IDS)" - echo -e " 4) Остановить бота" - echo -e " 0) Назад" - local sub - read -p " Выбор: " sub - case $sub in - 1) - write_bot_files - install_bot_deps - systemctl restart "$SERVICE_NAME" - echo -e "${GREEN}[*] Бот обновлён и перезапущен.${NC}" - ;; - 2) - echo -e "${YELLOW}Введите новый BOT_TOKEN:${NC}" - local tok - read -r tok - tok=$(echo "$tok" | tr -d '[:space:]') - if [ -n "$tok" ]; then - sed -i "s/^BOT_TOKEN=.*/BOT_TOKEN=$tok/" "$BOT_DIR/.env" - chmod 600 "$BOT_DIR/.env" - systemctl restart "$SERVICE_NAME" - echo -e "${GREEN}[*] Токен обновлён, бот перезапущен.${NC}" - else - echo -e "${RED}Пустой токен, отмена.${NC}" - fi - ;; - 3) - echo -e "${YELLOW}Введите Telegram ID администратора (или несколько через запятую):${NC}" - echo -e " ${CYAN}Узнать ID: @userinfobot, @getmyid_bot или @RawDataBot${NC}" - echo -e " ${CYAN}Оставьте пустым — бот будет доступен всем.${NC}" - local new_ids - read -r new_ids - new_ids=$(echo "$new_ids" | tr -d '[:space:]') - # Удаляем старую строку ALLOWED_IDS - sed -i "/^ALLOWED_IDS=/d" "$BOT_DIR/.env" - if [ -n "$new_ids" ]; then - echo "ALLOWED_IDS=$new_ids" >> "$BOT_DIR/.env" - echo -e "${GREEN}[*] Администратор(ы): $new_ids. Перезапуск...${NC}" - else - echo -e "${GREEN}[*] Ограничение снято, бот доступен всем. Перезапуск...${NC}" - fi - systemctl restart "$SERVICE_NAME" - ;; - 4) - systemctl stop "$SERVICE_NAME" - echo -e "${YELLOW}Бот остановлен.${NC}" - ;; - *) return ;; - esac - read -p "Нажмите Enter..." - return - fi - - echo -e " Статус бота: ${RED}не установлен / не запущен${NC}" - echo "" - echo -e " Бот позволяет управлять MTProxy из Telegram:" - echo -e " установка, статус, ссылка, поделиться ключом и т.д." - echo "" - read -p " Установить бота? (y/n): " yn - [ "$yn" != "y" ] && [ "$yn" != "Y" ] && return - - # Зависимости Python - echo -e "${GREEN}[*] Проверка Python...${NC}" - if ! command -v python3 &>/dev/null; then - install_pkg python3 python3-pip - fi - command -v python3 &>/dev/null || { echo -e "${RED}python3 не найден!${NC}"; read -p "Enter..."; return; } - - local PY_VER - PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3") - if ! python3 -m venv --help &>/dev/null 2>&1; then - echo -e "${YELLOW}[*] Установка python${PY_VER}-venv...${NC}" - install_pkg "python${PY_VER}-venv" 2>/dev/null - install_pkg python3-venv 2>/dev/null - install_pkg python3-pip 2>/dev/null - python3 -m venv --help &>/dev/null 2>&1 || { - echo -e "${RED}Не удалось установить venv. Выполните: apt install python${PY_VER}-venv${NC}" - read -p "Enter..."; return - } - fi - - # Файлы бота - write_bot_files - - # venv + pip - install_bot_deps - - # BOT_TOKEN + ALLOWED_IDS - if [ ! -f "$BOT_DIR/.env" ]; then - echo "" - echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" - echo -e " ${CYAN}(Откройте @BotFather в Telegram → /newbot → скопируйте токен)${NC}" - local TOKEN="" - while [ -z "$TOKEN" ]; do - read -r TOKEN - TOKEN=$(echo "$TOKEN" | tr -d '[:space:]') - [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}" - done - - echo "" - echo -e "${YELLOW}Введите ваш Telegram ID (администратор бота):${NC}" - echo -e " ${CYAN}Как узнать свой ID:${NC}" - echo -e " • Бот ${WHITE}@userinfobot${NC} — напишите ему /start" - echo -e " • Бот ${WHITE}@getmyid_bot${NC} — напишите ему /start" - echo -e " • Бот ${WHITE}@RawDataBot${NC} — напишите ему /start" - echo -e " ${CYAN}Можно указать несколько через запятую: 123456,789012${NC}" - echo -e " ${CYAN}Оставьте пустым, чтобы бот был доступен всем.${NC}" - local ADMIN_IDS="" - read -r ADMIN_IDS - ADMIN_IDS=$(echo "$ADMIN_IDS" | tr -d '[:space:]') - - { - echo "BOT_TOKEN=$TOKEN" - [ -n "$ADMIN_IDS" ] && echo "ALLOWED_IDS=$ADMIN_IDS" - } > "$BOT_DIR/.env" - chmod 600 "$BOT_DIR/.env" - - if [ -n "$ADMIN_IDS" ]; then - echo -e "${GREEN}[*] .env создан. Администратор(ы): $ADMIN_IDS${NC}" - else - echo -e "${GREEN}[*] .env создан. Бот доступен всем пользователям.${NC}" - fi - else - echo -e "${GREEN}[*] .env уже есть — используем существующий.${NC}" - fi - - # systemd - cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF -[Unit] -Description=GoTelegram MTProxy Bot -After=network.target docker.service - -[Service] -Type=simple -WorkingDirectory=$BOT_DIR -ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py -Restart=always -RestartSec=5 -Environment=PATH=$BOT_DIR/venv/bin:/usr/bin:/usr/local/bin - -[Install] -WantedBy=multi-user.target -EOF - - systemctl daemon-reload - systemctl enable "$SERVICE_NAME" 2>/dev/null - systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" - - echo "" - echo -e "${GREEN}[*] Telegram-бот установлен и запущен!${NC}" - echo -e " Проверка: systemctl status $SERVICE_NAME" - echo -e " Логи: journalctl -u $SERVICE_NAME -f" - read -p " Нажмите Enter..." -} - -write_bot_files() { - mkdir -p "$BOT_DIR" - - cat > "$BOT_DIR/requirements.txt" << 'REQEOF' -python-telegram-bot>=21.0 -REQEOF - - cat > "$BOT_DIR/bot.py" << 'BOTEOF' -#!/usr/bin/env python3 -import asyncio, html, json, os, re -from pathlib import Path -_env_path = Path(__file__).resolve().parent / ".env" -if _env_path.exists(): - with open(_env_path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters - -BOT_TOKEN = os.environ.get("BOT_TOKEN") -_allowed = os.environ.get("ALLOWED_IDS", "").strip() -try: - ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None -except ValueError: - ALLOWED_IDS = None - -CONTAINER_NAME = "mtproto-proxy" -CONFIG_FILE = Path("/opt/gotelegram-bot/proxy.json") -DOMAINS = [ - "google.com","wikipedia.org","habr.com","github.com", - "coursera.org","udemy.com","medium.com","stackoverflow.com", - "bbc.com","cnn.com","reuters.com","nytimes.com", - "lenta.ru","rbc.ru","ria.ru","kommersant.ru", - "stepik.org","duolingo.com","khanacademy.org","ted.com", -] -PROMO_LINK = "https://vk.cc/ct29NQ" -TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" - -def _ok(uid): - return ALLOWED_IDS is None or uid in ALLOWED_IDS -def _decode(data): - return (data or b"").decode("utf-8", errors="replace").strip() - -async def sh(*args, timeout=60): - proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) - try: - out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except asyncio.TimeoutError: - proc.kill(); await proc.wait(); return -1, "", "Timeout" - return proc.returncode or 0, _decode(out), _decode(err) - -async def get_ip(): - for url in ("https://api.ipify.org","https://icanhazip.com","https://ifconfig.me"): - code, out, _ = await sh("curl","-s","-4","--max-time","5",url, timeout=8) - if code == 0 and out: - m = re.search(r"(\d{1,3}\.){3}\d{1,3}", out) - if m: return m.group(0) - return "0.0.0.0" - -async def proxy_running(): - code, out, _ = await sh("docker","ps","--format","{{.Names}}", timeout=10) - return code == 0 and CONTAINER_NAME in out - -async def docker_val(fmt): - code, out, _ = await sh("docker","inspect",CONTAINER_NAME,"--format",fmt, timeout=10) - return out.strip() if code == 0 else "" - -async def check_port(port): - if await proxy_running(): - hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}") - if str(port) in hp.split(): return None - for cmd in ["/usr/bin/ss", "/usr/sbin/ss", "/sbin/ss", "/bin/ss", "ss", "/usr/bin/netstat", "netstat"]: - try: - code, out, _ = await sh(cmd, "-tlnp", timeout=5) - except Exception: - continue - if code == 0 and out: - for line in out.splitlines(): - if re.search(rf":{port}\b", line): return line - return None - return None - -async def docker_containers_info(): - code, out, _ = await sh("docker","ps","--format","{{.Names}}\t{{.Image}}\t{{.Ports}}", timeout=10) - return out if code == 0 else "" - -def save_config(data): - CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - -def load_config(): - if CONFIG_FILE.exists(): - try: return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - except Exception: pass - return {} - -async def proxy_info(): - if not await proxy_running(): return None - cmd_str = await docker_val("{{range .Config.Cmd}}{{.}} {{end}}") - secret = cmd_str.split()[-1] if cmd_str else "" - hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}") - port = hp.split()[0] if hp.strip() else "443" - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - cfg = load_config() - return {"ip":ip,"port":port,"secret":secret,"link":link,"domain":cfg.get("domain","—")} - -def main_menu_kb(): - return InlineKeyboardMarkup([ - [InlineKeyboardButton("🔧 Установить / Обновить", callback_data="menu_install")], - [InlineKeyboardButton("📊 Статус", callback_data="menu_status"), - InlineKeyboardButton("🔗 Ссылка", callback_data="menu_link")], - [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], - [InlineKeyboardButton("🔄 Перезапуск", callback_data="menu_restart"), - InlineKeyboardButton("📋 Логи", callback_data="menu_logs")], - [InlineKeyboardButton("🗑 Удалить", callback_data="menu_remove"), - InlineKeyboardButton("🏷 Промо", callback_data="menu_promo")], - ]) - -HELP_TEXT = ( - "🚀 GoTelegram MTProxy Bot\n\n" - "Управление MTProxy (Fake TLS) на сервере.\n" - "TCP + UDP (звонки) поддержаны.\n\n" - "Используйте кнопки ниже или команды:\n" - "/install /status /link /share /restart /logs /remove /promo" -) - -async def start(update, ctx): - if not update.effective_user: return - if not _ok(update.effective_user.id): - msg = update.message or (update.callback_query and update.callback_query.message) - if msg: await msg.reply_text("⛔ Доступ запрещён.") - return - if update.message: - await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) - elif update.callback_query: - await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) - -async def cmd_status(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): await msg.reply_text("⛔"); return - info = await proxy_info() - if not info: - text = "❌ Прокси не запущен.\nНажмите Установить." - else: - containers = await docker_containers_info() - other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l) - text = ("✅ Прокси работает\n\n" - f"IP: {html.escape(info['ip'])}\n" - f"Порт: {html.escape(info['port'])}\n" - f"Домен: {html.escape(info['domain'])}\n" - f"Secret: {html.escape(info['secret'])}\n\n" - f"Ссылка:\n{html.escape(info['link'])}") - if other: - text += f"\n\n📦 Другие контейнеры:\n
{html.escape(other)}
" - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - -async def cmd_link(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - info = await proxy_info() - text = f"{html.escape(info['link'])}" if info else "❌ Прокси не запущен." - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - -async def cmd_share(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - info = await proxy_info() - if not info: - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) - else: await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) - return - tg_link = info["link"] - share_text = ( - f"🔐 MTProxy для Telegram\n\n" - f"🌍 Сервер: {html.escape(info['ip'])}\n" - f"🔌 Порт: {html.escape(info['port'])}\n" - f"🔑 Secret: {html.escape(info['secret'])}\n\n" - f"👉 Подключиться одним нажатием:\n" - f"{html.escape(tg_link)}\n\n" - f"Просто нажмите на ссылку или перешлите это сообщение.") - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - if update.callback_query: - await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb) - -async def cmd_remove(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - chat = msg.chat - if update.callback_query: await update.callback_query.edit_message_text("⏳ Удаляю прокси...") - else: await chat.send_message("⏳ Удаляю прокси...") - await sh("docker","stop",CONTAINER_NAME, timeout=15) - await sh("docker","rm",CONTAINER_NAME, timeout=10) - text = "✅ Прокси удалён." if not await proxy_running() else "⚠️ Не удалось удалить." - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - await chat.send_message(text, reply_markup=kb) - -async def cmd_restart(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - if not await proxy_running(): - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) - else: await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) - return - chat = msg.chat - if update.callback_query: await update.callback_query.edit_message_text("⏳ Перезапуск...") - code, _, err = await sh("docker","restart",CONTAINER_NAME, timeout=30) - text = "✅ Перезапущен." if code == 0 else f"❌ Ошибка: {err or 'unknown'}" - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - await chat.send_message(text, reply_markup=kb) - -async def cmd_logs(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - if not await proxy_running(): - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) - else: await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) - return - code, out, err = await sh("docker","logs","--tail","40",CONTAINER_NAME, timeout=15) - text = (out or "") + (("\n" + err) if err else "") or "Нет вывода." - if len(text) > 4000: text = text[-4000:] - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) - -async def cmd_promo(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - text = ("💰 Хостинг со скидкой до -60%\n\n" - f"Хостинг #1: {PROMO_LINK}\n" - "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n" - "Хостинг #2: https://vk.cc/cUxAhj\n" - "Промокод: OFF60\n\n" - f"☕ Донат: {TIP_LINK}") - kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) - -async def install_step_domain(update, ctx): - msg = update.message or (update.callback_query and update.callback_query.message) - if not update.effective_user or not msg: return - if not _ok(update.effective_user.id): return - buttons, row = [], [] - for i, d in enumerate(DOMAINS): - row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}")) - if len(row) == 2: buttons.append(row); row = [] - if row: buttons.append(row) - text = "🌐 Выберите домен для маскировки (Fake TLS):" - if update.callback_query: - await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) - else: - await msg.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) - -async def install_step_port(update, ctx): - query = update.callback_query - domain = ctx.user_data.get("install_domain", "google.com") - busy_443 = await check_port(443) - busy_8443 = await check_port(8443) - rows = [] - l443 = "443 (рекомендуется)" if not busy_443 else "443 ⚠️ занят" - l8443 = "8443" if not busy_8443 else "8443 ⚠️ занят" - rows.append([InlineKeyboardButton(l443, callback_data="port_443"), InlineKeyboardButton(l8443, callback_data="port_8443")]) - rows.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]) - pi = "" - if busy_443: pi += f"\n⚠️ Порт 443 занят:\n
{html.escape(busy_443[:300])}
\n" - if busy_8443: pi += f"\n⚠️ Порт 8443 занят:\n
{html.escape(busy_8443[:300])}
\n" - text = f"Домен: {html.escape(domain)}\n\n🔌 Выберите порт или введите свой (1-65535):{pi}" - ctx.user_data["install_wait_port"] = True - await query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(rows)) - -async def install_port_chosen(update, ctx, port_str): - port = int(port_str) - msg = update.callback_query.message if update.callback_query else update.message - if not msg: return - chat = msg.chat - busy = await check_port(port) - if busy: - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton(f"Всё равно использовать {port}", callback_data=f"force_{port}")], - [InlineKeyboardButton("Выбрать другой порт", callback_data="reselect_port")], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - text = f"⚠️ Порт {port} занят!\n\n
{html.escape(busy[:500])}
\n\nМожно использовать всё равно или выбрать другой." - if update.callback_query: await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) - else: await chat.send_message(text, parse_mode="HTML", reply_markup=kb) - ctx.user_data["install_port"] = port_str - return - ctx.user_data["install_port"] = port_str - ctx.user_data["install_wait_port"] = False - await do_install(update, ctx) - -async def do_install(update, ctx): - domain = ctx.user_data.get("install_domain") or "google.com" - port = ctx.user_data.get("install_port") or "443" - if update.callback_query: - msg = update.callback_query.message - await msg.edit_text("⏳ Генерация secret и запуск контейнера...", reply_markup=None) - elif update.message: - msg = update.message - await msg.reply_text("⏳ Генерация secret и запуск контейнера...") - else: return - chat = msg.chat - code, _, _ = await sh("docker","info", timeout=10) - if code != 0: - await chat.send_message("❌ Docker не запущен.", parse_mode="HTML", - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])) - return - code, secret_out, err = await sh("docker","run","--rm","nineseconds/mtg:2","generate-secret","--hex",domain, timeout=60) - if code != 0: await chat.send_message(f"❌ Генерация secret: {err or secret_out}"); return - secret = secret_out.strip().split()[-1] if secret_out.strip() else "" - if not secret: await chat.send_message("❌ Пустой secret."); return - await sh("docker","stop",CONTAINER_NAME, timeout=15) - await sh("docker","rm",CONTAINER_NAME, timeout=10) - code, _, err = await sh("docker","run","-d","--name",CONTAINER_NAME,"--restart","always", - "-p",f"{port}:{port}/tcp","-p",f"{port}:{port}/udp", - "nineseconds/mtg:2","simple-run","-n","1.1.1.1","-i","prefer-ipv4", - f"0.0.0.0:{port}",secret, timeout=90) - if code != 0: await chat.send_message(f"❌ Запуск контейнера: {err}"); return - save_config({"domain":domain,"port":port,"secret":secret}) - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - text = ("✅ Прокси установлен!\n\n" - f"🌍 IP: {html.escape(ip)}\n" - f"🔌 Порт: {html.escape(port)} (TCP + UDP)\n" - f"🎭 Домен: {html.escape(domain)}\n" - f"🔑 Secret: {html.escape(secret)}\n\n" - f"👉 Ссылка:\n{html.escape(link)}\n\n📞 Звонки поддержаны (UDP).") - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], - [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], - ]) - await chat.send_message(text, parse_mode="HTML", reply_markup=kb) - for k in ("install_domain","install_port","install_wait_port"): ctx.user_data.pop(k, None) - -async def callback_handler(update, ctx): - query = update.callback_query - if not query or not update.effective_user: return - await query.answer() - if not _ok(update.effective_user.id): await query.edit_message_text("⛔ Доступ запрещён."); return - data = query.data or "" - if data == "menu_main": await start(update, ctx) - elif data == "menu_install": await install_step_domain(update, ctx) - elif data == "menu_status": await cmd_status(update, ctx) - elif data == "menu_link": await cmd_link(update, ctx) - elif data == "menu_share": await cmd_share(update, ctx) - elif data == "menu_restart": await cmd_restart(update, ctx) - elif data == "menu_logs": await cmd_logs(update, ctx) - elif data == "menu_remove": await cmd_remove(update, ctx) - elif data == "menu_promo": await cmd_promo(update, ctx) - elif data.startswith("dom_"): - try: idx = int(data[4:]) - except ValueError: await query.edit_message_text("❌ Ошибка."); return - if not (0 <= idx < len(DOMAINS)): await query.edit_message_text("❌ Неверный выбор."); return - ctx.user_data["install_domain"] = DOMAINS[idx] - await install_step_port(update, ctx) - elif data == "port_443": await install_port_chosen(update, ctx, "443") - elif data == "port_8443": await install_port_chosen(update, ctx, "8443") - elif data.startswith("force_"): - ctx.user_data["install_port"] = data[6:] - ctx.user_data["install_wait_port"] = False - await do_install(update, ctx) - elif data == "reselect_port": await install_step_port(update, ctx) - -async def text_handler(update, ctx): - if not update.message or not ctx.user_data.get("install_wait_port"): return - text = (update.message.text or "").strip() - if not re.match(r"^\d+$", text): return - port = int(text) - if not (1 <= port <= 65535): await update.message.reply_text("Введите число от 1 до 65535."); return - await install_port_chosen(update, ctx, str(port)) - -def main(): - if not BOT_TOKEN: raise SystemExit("Задайте BOT_TOKEN в .env") - app = Application.builder().token(BOT_TOKEN).build() - app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("help", start)) - app.add_handler(CommandHandler("install", install_step_domain)) - app.add_handler(CommandHandler("status", cmd_status)) - app.add_handler(CommandHandler("link", cmd_link)) - app.add_handler(CommandHandler("share", cmd_share)) - app.add_handler(CommandHandler("remove", cmd_remove)) - app.add_handler(CommandHandler("restart", cmd_restart)) - app.add_handler(CommandHandler("logs", cmd_logs)) - app.add_handler(CommandHandler("promo", cmd_promo)) - app.add_handler(CallbackQueryHandler(callback_handler)) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler)) - app.run_polling(allowed_updates=Update.ALL_TYPES) - -if __name__ == "__main__": - main() -BOTEOF -} - -install_bot_deps() { - local bot_steps=3 bot_cur=0 - - bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Создание venv..." - - # Если venv сломан (нет pip), удаляем и пересоздаём - if [ -d "$BOT_DIR/venv" ] && [ ! -f "$BOT_DIR/venv/bin/pip" ]; then - echo -e " ${YELLOW}!${NC} venv повреждён (нет pip), пересоздаю..." - rm -rf "$BOT_DIR/venv" - fi - - if [ ! -d "$BOT_DIR/venv" ]; then - # Убеждаемся что ensurepip доступен - if ! python3 -m ensurepip --version &>/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 || echo "3") - echo -e " ${YELLOW}!${NC} Установка python${PY_VER}-venv (с ensurepip)..." - install_pkg "python${PY_VER}-venv" 2>/dev/null - install_pkg python3-venv python3-pip 2>/dev/null - fi - spinner_start "Создание Python venv..." - python3 -m venv "$BOT_DIR/venv" 2>/dev/null - spinner_stop - if [ ! -f "$BOT_DIR/venv/bin/pip" ]; then - echo -e " ${RED}✗${NC} venv создан, но pip отсутствует." - echo -e " ${YELLOW}Выполните вручную: apt install python3-venv && rm -rf $BOT_DIR/venv${NC}" - return 1 - fi - fi - echo -e " ${GREEN}✓${NC} Python venv готов" - - bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Обновление pip..." - spinner_start "Обновление pip..." - "$BOT_DIR/venv/bin/pip" install --upgrade pip -q 2>/dev/null - spinner_stop - echo -e " ${GREEN}✓${NC} pip обновлён" - - bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Установка зависимостей..." - spinner_start "Установка python-telegram-bot (до 1 мин)..." - "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q 2>/dev/null - local rc=$? - spinner_stop - if [ $rc -ne 0 ]; then - echo -e " ${RED}✗${NC} pip install не удался." - return 1 - fi - # Проверяем что модуль реально доступен - if ! "$BOT_DIR/venv/bin/python" -c "import telegram" 2>/dev/null; then - echo -e " ${RED}✗${NC} Модуль telegram не найден после установки." - return 1 - fi - echo -e " ${GREEN}✓${NC} Зависимости установлены" -} - -# ── 7) Полное меню удаления ──────────────────────────────────────────────────── -menu_remove() { - clear - echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${RED}║ УДАЛЕНИЕ КОМПОНЕНТОВ ║${NC}" - echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" - echo "" - echo -e " ${YELLOW}1)${NC} Удалить только контейнер MTProxy" - echo -e " (Docker и другие контейнеры останутся)" - echo "" - echo -e " ${RED}2)${NC} Удалить контейнер MTProxy + Docker полностью" - echo -e " ${RED}⚠ ВСЕ контейнеры и образы будут уничтожены!${NC}" - echo "" - echo -e " ${WHITE}0)${NC} Назад" - echo "" - local choice - read -p " Выбор: " choice - - case $choice in - 1) remove_container_only ;; - 2) remove_with_docker ;; - *) return ;; - esac -} - -remove_container_only() { - clear - echo -e "${YELLOW}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${YELLOW}║ Удаление контейнера MTProxy ║${NC}" - echo -e "${YELLOW}╚══════════════════════════════════════════════════════════════╝${NC}" - echo "" - echo -e " Будет удалено:" - echo -e " • Контейнер ${WHITE}$CONTAINER_NAME${NC}" - echo -e " • Telegram-бот (сервис ${WHITE}$SERVICE_NAME${NC})" - echo -e " • Файлы бота (${WHITE}$BOT_DIR${NC})" - echo -e " • Скрипт ${WHITE}/usr/local/bin/gotelegram${NC}" - echo "" - echo -e " Docker и другие контейнеры ${GREEN}НЕ будут затронуты${NC}." - echo "" - - # Подтверждение 1 - local yn - read -p " Вы уверены? (y/N): " yn - if [ "$yn" != "y" ] && [ "$yn" != "Y" ]; then - echo -e " ${GREEN}Отменено.${NC}" - read -p " Нажмите Enter..." - return - fi - - # Подтверждение 2 — случайное слово - local words=("УДАЛИТЬ" "СТЕРЕТЬ" "ПРОКСИ" "ОЧИСТКА" "ФИНАЛ" "СБРОС") - local confirm_word="${words[$((RANDOM % ${#words[@]}))]}" - echo "" - echo -e " ${RED}Для подтверждения введите слово:${NC} ${WHITE}${confirm_word}${NC}" - local input_word - read -p " >>> " input_word - if [ "$input_word" != "$confirm_word" ]; then - echo -e " ${GREEN}Слово не совпало. Удаление отменено.${NC}" - read -p " Нажмите Enter..." - return - fi - - echo "" - # Удаление - spinner_start "Остановка и удаление контейнера..." - docker stop "$CONTAINER_NAME" &>/dev/null - docker rm "$CONTAINER_NAME" &>/dev/null - spinner_stop - echo -e " ${GREEN}✓${NC} Контейнер удалён" - - spinner_start "Остановка Telegram-бота..." - systemctl stop "$SERVICE_NAME" 2>/dev/null - systemctl disable "$SERVICE_NAME" 2>/dev/null - rm -f "/etc/systemd/system/${SERVICE_NAME}.service" - systemctl daemon-reload 2>/dev/null - spinner_stop - echo -e " ${GREEN}✓${NC} Сервис бота удалён" - - rm -rf "$BOT_DIR" - echo -e " ${GREEN}✓${NC} Файлы бота удалены" - - rm -f /usr/local/bin/gotelegram - echo -e " ${GREEN}✓${NC} Скрипт gotelegram удалён" - - echo "" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - echo -e "${GREEN} Удаление завершено. Docker остался на месте.${NC}" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - read -p " Нажмите Enter..." -} - -remove_with_docker() { - clear - echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${RED}║ ⚠ ПОЛНОЕ УДАЛЕНИЕ: MTProxy + Docker + всё ⚠ ║${NC}" - echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" - echo "" - echo -e " ${RED}ВНИМАНИЕ! Будет удалено ВСЁ:${NC}" - echo -e " • Контейнер ${WHITE}$CONTAINER_NAME${NC}" - echo -e " • Telegram-бот и файлы" - echo -e " • Скрипт gotelegram" - echo -e " • ${RED}Docker Engine полностью${NC}" - echo -e " • ${RED}ВСЕ Docker-контейнеры, образы и тома${NC}" - echo "" - - # Показываем что ещё есть в Docker - local other_containers - other_containers=$(docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | grep -v "^${CONTAINER_NAME}") - if [ -n "$other_containers" ]; then - echo -e " ${RED}⚠ На сервере есть ДРУГИЕ контейнеры, которые тоже будут уничтожены:${NC}" - echo -e " ${RED}────────────────────────────────────────────────────────────────${NC}" - echo "$other_containers" | while IFS= read -r line; do - echo -e " ${WHITE}$line${NC}" - done - echo -e " ${RED}────────────────────────────────────────────────────────────────${NC}" - echo "" - fi - - # Подтверждение 1 - local yn - echo -e " ${RED}Это действие НЕОБРАТИМО.${NC}" - read -p " Вы точно уверены? (y/N): " yn - if [ "$yn" != "y" ] && [ "$yn" != "Y" ]; then - echo -e " ${GREEN}Отменено.${NC}" - read -p " Нажмите Enter..." - return - fi - - # Подтверждение 2 — случайное слово - local words=("УНИЧТОЖИТЬ" "ПОЛНЫЙ-СБРОС" "СТЕРЕТЬ-ВСЁ" "ПОДТВЕРЖДАЮ" "DOCKER-УДАЛИТЬ" "ТОЧНО-ДА") - local confirm_word="${words[$((RANDOM % ${#words[@]}))]}" - echo "" - echo -e " ${RED}████████████████████████████████████████████████████████████${NC}" - echo -e " ${RED}██${NC} Для подтверждения введите: ${WHITE}${confirm_word}${NC}" - echo -e " ${RED}████████████████████████████████████████████████████████████${NC}" - local input_word - read -p " >>> " input_word - if [ "$input_word" != "$confirm_word" ]; then - echo -e " ${GREEN}Слово не совпало. Удаление отменено.${NC}" - read -p " Нажмите Enter..." - return - fi - - echo "" - # Удаление MTProxy - spinner_start "Удаление контейнера MTProxy..." - docker stop "$CONTAINER_NAME" &>/dev/null - docker rm "$CONTAINER_NAME" &>/dev/null - spinner_stop - echo -e " ${GREEN}✓${NC} Контейнер MTProxy удалён" - - # Удаление бота - spinner_start "Удаление Telegram-бота..." - systemctl stop "$SERVICE_NAME" 2>/dev/null - systemctl disable "$SERVICE_NAME" 2>/dev/null - rm -f "/etc/systemd/system/${SERVICE_NAME}.service" - systemctl daemon-reload 2>/dev/null - rm -rf "$BOT_DIR" - spinner_stop - echo -e " ${GREEN}✓${NC} Telegram-бот удалён" - - # Удаление всех контейнеров Docker - spinner_start "Остановка всех контейнеров Docker..." - docker stop $(docker ps -aq) &>/dev/null - docker rm $(docker ps -aq) &>/dev/null - spinner_stop - echo -e " ${GREEN}✓${NC} Все контейнеры остановлены и удалены" - - # Удаление Docker - spinner_start "Удаление Docker Engine..." - systemctl stop docker 2>/dev/null - if command -v apt-get &>/dev/null; then - apt-get purge -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>/dev/null - apt-get autoremove -y -qq 2>/dev/null - elif command -v dnf &>/dev/null; then - dnf remove -y docker-ce docker-ce-cli containerd.io 2>/dev/null - elif command -v yum &>/dev/null; then - yum remove -y docker-ce docker-ce-cli containerd.io 2>/dev/null - fi - rm -rf /var/lib/docker /var/lib/containerd /etc/docker - spinner_stop - echo -e " ${GREEN}✓${NC} Docker полностью удалён" - - # Удаление скрипта - rm -f /usr/local/bin/gotelegram - echo -e " ${GREEN}✓${NC} Скрипт gotelegram удалён" - - echo "" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - echo -e "${GREEN} Полное удаление завершено.${NC}" - echo -e "${GREEN}══════════════════════════════════════════════════${NC}" - echo -e " Для повторной установки используйте команду curl." - read -p " Нажмите Enter для выхода..." - exit 0 -} - -# ── Выход ──────────────────────────────────────────────────────────────────── -show_exit() { - clear - show_config - echo "" - echo -e "${MAGENTA}Поддержка автора (CloudTips):${NC}" - echo -e " $TIP_LINK" - echo -e " YouTube: https://www.youtube.com/@antenkaru" - if command -v qrencode &>/dev/null; then - qrencode -t ANSIUTF8 "$TIP_LINK" - fi - exit 0 -} - -# ══════════════════════════════════════════════════════════════════════════════ -# ██ СТАРТ СКРИПТА -# ══════════════════════════════════════════════════════════════════════════════ - -install_base_deps - -# Копируем себя в /usr/local/bin/gotelegram (если запущены из другого места) -SELF="$(realpath "$0")" -if [ "$SELF" != "/usr/local/bin/gotelegram" ]; then - cp "$SELF" /usr/local/bin/gotelegram && chmod +x /usr/local/bin/gotelegram -fi - -show_promo - -# ── Главное меню (цикл) ───────────────────────────────────────────────────── -while true; do - echo "" - echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${MAGENTA}║ GoTelegram Manager (by anten-ka) ║${NC}" - echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════════╝${NC}" - - # Статус прокси - if proxy_is_running; then - echo -e " Прокси: ${GREEN}работает${NC}" - else - echo -e " Прокси: ${RED}не запущен${NC}" - fi - # Статус бота - if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then - echo -e " Telegram-бот: ${GREEN}работает${NC}" - else - echo -e " Telegram-бот: ${YELLOW}не настроен${NC}" - fi - echo "" - echo -e " ${GREEN}1)${NC} Установить / Обновить прокси" - echo -e " ${GREEN}2)${NC} Показать данные подключения" - echo -e " ${CYAN}3)${NC} Настроить Telegram-бот" - echo -e " ${GREEN}4)${NC} Перезапустить прокси" - echo -e " ${GREEN}5)${NC} Логи прокси" - echo -e " ${YELLOW}6)${NC} Показать PROMO" - echo -e " ${RED}7)${NC} Удалить (полное меню удаления)" - echo -e " ${WHITE}0)${NC} Выход" - echo "" - read -p " Пункт: " m_idx - case $m_idx in - 1) menu_install ;; - 2) clear; show_config; read -p "Нажмите Enter..." ;; - 3) menu_setup_bot ;; - 4) - if proxy_is_running; then - docker restart "$CONTAINER_NAME" && echo -e "${GREEN}Перезапущен.${NC}" || echo -e "${RED}Ошибка.${NC}" - else - echo -e "${RED}Прокси не запущен.${NC}" - fi - read -p "Нажмите Enter..." - ;; - 5) - if proxy_is_running; then - docker logs --tail 50 "$CONTAINER_NAME" 2>&1 - else - echo -e "${RED}Прокси не запущен.${NC}" - fi - read -p "Нажмите Enter..." - ;; - 6) show_promo ;; - 7) menu_remove ;; - 0) show_exit ;; - *) echo -e "${RED}Неверный ввод.${NC}" ;; - esac -done +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# GoTelegram v2.2 — MTProxy на ядре telemt (Rust + Tokio) +# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance +# +# Установка: +# curl -sL URL/install.sh | sudo bash +# ══════════════════════════════════════════════════════════════════════════════ + +set -uo pipefail + +# Путь к скрипту и библиотекам +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" + +# Загружаем библиотеки +source "$LIB_DIR/common.sh" +source "$LIB_DIR/telemt.sh" +source "$LIB_DIR/telemt_config.sh" +source "$LIB_DIR/website.sh" +source "$LIB_DIR/templates_catalog.sh" +source "$LIB_DIR/backup.sh" + +# ── Главное меню ───────────────────────────────────────────────────────────── +show_main_menu() { + local status + status=$(telemt_status) + + local status_badge + case "$status" in + running) status_badge="${GREEN}● Работает${NC}" ;; + stopped) status_badge="${YELLOW}○ Остановлен${NC}" ;; + *) status_badge="${RED}✗ Не установлен${NC}" ;; + esac + + echo "" + echo -e " ${BOLD}${WHITE}Главное меню${NC} │ ${status_badge}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN} 1)${NC} 🔧 Установить / Обновить прокси" + echo -e " ${CYAN} 2)${NC} 📊 Статус" + echo -e " ${CYAN} 3)${NC} 🔗 Ссылка для подключения" + echo -e " ${CYAN} 4)${NC} 📤 Поделиться ключом" + echo -e " ${CYAN} 5)${NC} 🔄 Перезапуск" + echo -e " ${CYAN} 6)${NC} 📋 Логи" + echo -e " ${CYAN} 7)${NC} 🎭 Сменить режим / шаблон" + echo -e " ${CYAN} 8)${NC} 💾 Бекап конфигурации" + echo -e " ${CYAN} 9)${NC} 📦 Восстановить из бекапа" + echo -e " ${CYAN}10)${NC} ⬆️ Обновить telemt" + echo -e " ${CYAN}11)${NC} 🌐 Управление сайтом (SSL)" + echo -e " ${CYAN}12)${NC} 🗑 Удалить прокси" + echo -e " ${CYAN}13)${NC} 🏷 Промо" + echo -e " ${CYAN} 0)${NC} 🚪 Выход" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}Выбор:${NC} " +} + +# ── Установка: выбор режима ────────────────────────────────────────────────── +menu_install() { + # Проверяем v1 + if detect_v1_installation; then + echo "" + echo -e " ${YELLOW}⚠️ Обнаружена установка GoTelegram v1 (mtg)${NC}" + echo -e " ${DIM}Контейнер: ${V1_CONTAINER_NAME}${NC}" + echo "" + if ! migrate_v1_to_v2; then + return + fi + fi + + echo "" + echo -e " ${BOLD}${WHITE}🎭 Выберите режим маскировки:${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN}1)${NC} ${GREEN}⚡ Quick${NC} — маскировка под популярный сайт" + echo -e " ${DIM}Быстро, без домена. telemt маскирует трафик${NC}" + echo -e " ${DIM}под выбранный сайт (google.com и т.д.)${NC}" + echo "" + echo -e " ${CYAN}2)${NC} ${MAGENTA}🛡 Stealth${NC} — свой сайт + полная маскировка" + echo -e " ${DIM}nginx + SSL + HTML-шаблон + telemt.${NC}" + echo -e " ${DIM}DPI видит реальный сайт с реальным сертификатом.${NC}" + echo -e " ${DIM}Требует: домен, направленный на этот сервер.${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}Выбор (1/2):${NC} " + read -r mode_choice + mode_choice="${mode_choice:-}" + + case "$mode_choice" in + 1) install_quick_mode ;; + 2) install_stealth_mode ;; + *) log_error "Неверный выбор: ${mode_choice:-<пусто>}" ;; + esac +} + +# ── Quick-режим ────────────────────────────────────────────────────────────── +install_quick_mode() { + log_step "Установка Quick-режима" + + # Выбор домена + local domain + domain=$(select_quick_domain) + [ $? -ne 0 ] && return + + # Выбор порта + local port + port=$(select_port) + [ $? -ne 0 ] && return + + # Генерация секрета + local secret + secret=$(generate_hex 32) + + # Подтверждение + local ip + ip=$(get_server_ip) + echo "" + echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}" + echo -e " IP: ${CYAN}${ip}${NC}" + echo -e " Порт: ${CYAN}${port}${NC}" + echo -e " Маскировка: ${CYAN}${domain}${NC}" + echo -e " Режим: ${GREEN}Quick${NC}" + echo "" + + if ! confirm "Установить прокси?"; then + return + fi + + # Установка + ensure_deps + install_telemt_full || return + + # Генерируем конфиг telemt + generate_telemt_toml "$secret" "$port" "quick" "$domain" "443" + + # Валидация + validate_telemt_config || return + + # Запуск + start_telemt || return + + # Сохраняем GoTelegram конфиг + save_gotelegram_config "telemt" "quick" "$port" "$secret" "$domain" "" "" + + # Благодарности + show_credits + + # Результат + show_proxy_info + log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Quick-режим)" +} + +# ── Stealth-режим ──────────────────────────────────────────────────────────── +install_stealth_mode() { + log_step "Установка Stealth-режима" + + # Ввод домена + echo "" + echo -ne " ${WHITE}Введите ваш домен (например, example.com):${NC} " + read -r user_domain + + if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then + log_error "Некорректный домен: ${user_domain:-<пусто>}" + return + fi + + # Проверяем DNS + local resolved_ip server_ip + resolved_ip=$(dig +short "$user_domain" A 2>/dev/null | head -1) + server_ip=$(get_server_ip) + + if [ -n "$resolved_ip" ] && [ "$resolved_ip" != "$server_ip" ]; then + log_warning "Домен $user_domain указывает на $resolved_ip, а не на $server_ip" + if ! confirm "Продолжить всё равно?"; then + return + fi + fi + + # Email для Let's Encrypt + echo -ne " ${WHITE}Email для SSL (Enter = без email):${NC} " + read -r ssl_email + + # Выбор шаблона + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + + # Выбор порта для telemt (внутренний) + local telemt_port=8443 + echo "" + echo -e " ${DIM}telemt будет слушать на порту $telemt_port (внутренний)${NC}" + echo -e " ${DIM}nginx будет на 443 (внешний) и проксировать трафик${NC}" + + # Генерация секрета + local secret + secret=$(generate_hex 32) + + # Подтверждение + echo "" + echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}" + echo -e " Домен: ${CYAN}${user_domain}${NC}" + echo -e " Порт: ${CYAN}443 (nginx) → $telemt_port (telemt)${NC}" + echo -e " Режим: ${MAGENTA}Stealth${NC}" + echo "" + + if ! confirm "Установить прокси + сайт?"; then + return + fi + + # Установка + ensure_deps + install_telemt_full || return + + # Конфиг telemt: маскировка на localhost (nginx) + generate_telemt_toml "$secret" "$telemt_port" "stealth" "127.0.0.1" "443" + + # Настройка сайта (nginx + certbot + шаблон) + setup_stealth_mode "$user_domain" "$template_dir" "$telemt_port" "$ssl_email" || return + + # Запуск telemt + start_telemt || return + + # Сохраняем конфиг + local tpl_id + tpl_id=$(basename "$template_dir") + save_gotelegram_config "telemt" "stealth" "443" "$secret" "127.0.0.1" "$user_domain" "$tpl_id" + + # Результат + show_proxy_info + echo -e " ${WHITE}Сайт:${NC} ${GREEN}https://${user_domain}${NC}" + log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Stealth-режим)" +} + +# ── Статус ─────────────────────────────────────────────────────────────────── +menu_status() { + show_proxy_info + + # Дополнительно для stealth + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "stealth" ]; then + local domain + domain=$(config_get domain 2>/dev/null) + if [ -n "$domain" ]; then + local ssl_expiry + ssl_expiry=$(get_ssl_expiry "$domain") + local nginx_st + nginx_st=$(nginx_status) + echo -e " ${WHITE}nginx:${NC} ${nginx_st}" + echo -e " ${WHITE}SSL до:${NC} ${ssl_expiry}" + echo -e " ${WHITE}Сайт:${NC} https://${domain}" + echo "" + fi + fi +} + +# ── Ссылка ─────────────────────────────────────────────────────────────────── +menu_link() { + local secret port ip link + secret=$(get_config_value secret) + port=$(get_config_value port) + ip=$(get_server_ip) + link=$(generate_proxy_link "$ip" "$port" "$secret") + + echo "" + echo -e " ${BOLD}${WHITE}🔗 Ссылка для подключения:${NC}" + echo "" + echo -e " ${GREEN}${link}${NC}" + echo "" + + if command -v qrencode &>/dev/null; then + qrencode -t UTF8 -m 2 "$link" 2>/dev/null + fi +} + +# ── Поделиться ─────────────────────────────────────────────────────────────── +menu_share() { + local secret port ip link + secret=$(get_config_value secret) + port=$(get_config_value port) + ip=$(get_server_ip) + link=$(generate_proxy_link "$ip" "$port" "$secret") + + echo "" + echo -e " ${BOLD}📤 Перешлите это сообщение:${NC}" + echo "" + echo "🔐 MTProxy для Telegram (GoTelegram v${GOTELEGRAM_VERSION})" + echo "" + echo "🌍 Сервер: $ip" + echo "🔌 Порт: $port" + echo "" + echo "👉 Подключиться одним нажатием:" + echo "$link" + echo "" + echo "Просто нажмите на ссылку или настройте вручную." + echo "" +} + +# ── Перезапуск ─────────────────────────────────────────────────────────────── +menu_restart() { + restart_telemt + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "stealth" ]; then + restart_nginx + fi +} + +# ── Логи ───────────────────────────────────────────────────────────────────── +menu_logs() { + echo "" + echo -e " ${BOLD}${WHITE}📋 Логи telemt (последние 40 строк):${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + telemt_logs 40 + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" +} + +# ── Смена режима / шаблона ─────────────────────────────────────────────────── +menu_change_mode() { + local current_mode + current_mode=$(config_get mode 2>/dev/null) + echo "" + echo -e " ${WHITE}Текущий режим:${NC} ${CYAN}${current_mode}${NC}" + echo "" + echo -e " ${CYAN}1)${NC} Сменить шаблон сайта (только stealth)" + echo -e " ${CYAN}2)${NC} Переключить режим (quick ↔ stealth)" + echo -e " ${CYAN}0)${NC} Назад" + echo -ne " ${WHITE}Выбор:${NC} " + read -r ch + + case "$ch" in + 1) + if [ "$current_mode" != "stealth" ]; then + log_error "Смена шаблона доступна только в stealth-режиме" + return + fi + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + switch_template "$template_dir" + ;; + 2) + log_warning "Переключение режима требует переустановки." + if confirm "Переустановить прокси?"; then + menu_install + fi + ;; + esac +} + +# ── Управление сайтом ─────────────────────────────────────────────────────── +menu_website() { + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" != "stealth" ]; then + log_info "Управление сайтом доступно только в stealth-режиме" + return + fi + + local domain + domain=$(config_get domain 2>/dev/null) + + echo "" + echo -e " ${BOLD}${WHITE}🌐 Управление сайтом${NC}" + echo -e " Домен: ${CYAN}${domain}${NC}" + echo -e " SSL до: $(get_ssl_expiry "$domain")" + echo "" + echo -e " ${CYAN}1)${NC} Обновить SSL сертификат" + echo -e " ${CYAN}2)${NC} Перезапустить nginx" + echo -e " ${CYAN}3)${NC} Сменить шаблон" + echo -e " ${CYAN}0)${NC} Назад" + echo -ne " ${WHITE}Выбор:${NC} " + read -r ch + + case "$ch" in + 1) renew_ssl_certificate ;; + 2) restart_nginx ;; + 3) + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + switch_template "$template_dir" + ;; + esac +} + +# ── Удаление ───────────────────────────────────────────────────────────────── +menu_remove() { + echo "" + log_warning "Это удалит прокси и все настройки." + if ! confirm "Вы уверены?"; then + return + fi + + # Предлагаем бекап + if confirm "Сделать бекап перед удалением?"; then + interactive_backup + fi + + remove_telemt + + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "stealth" ]; then + remove_stealth_mode + fi + + rm -f "$GOTELEGRAM_CONFIG" + log_success "GoTelegram полностью удалён" +} + +# ── Промо ──────────────────────────────────────────────────────────────────── +menu_promo() { + echo "" + echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}" + echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ СО СКИДКОЙ ДО -60%${NC} ${YELLOW}║${NC}" + echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/ct29NQ${NC} ${YELLOW}║${NC}" + echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" + echo -e " ${YELLOW}║${NC} Промокоды: OFF60, antenka20, antenka6, antenka12 ${YELLOW}║${NC}" + echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" + echo -e " ${YELLOW}║${NC} Донат: ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC} ${YELLOW}║${NC}" + echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# ── Точка входа ────────────────────────────────────────────────────────────── +main() { + check_root + init_dirs + show_banner + + # Pre-flight + check_os + check_disk_space 500 + + while true; do + show_main_menu + read -r choice + case "$choice" in + 1) menu_install ;; + 2) menu_status ;; + 3) menu_link ;; + 4) menu_share ;; + 5) menu_restart ;; + 6) menu_logs ;; + 7) menu_change_mode ;; + 8) interactive_backup ;; + 9) interactive_restore ;; + 10) update_telemt ;; + 11) menu_website ;; + 12) menu_remove ;; + 13) menu_promo ;; + 0|q|exit) echo ""; log_info "До встречи! 👋"; exit 0 ;; + *) log_error "Неверный выбор" ;; + esac + + echo "" + echo -ne " ${DIM}Нажмите Enter для возврата в меню...${NC}" + read -r + done +} + +main "$@" diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh index 8960fae..c5d4306 100644 --- a/install_gotelegram_bot.sh +++ b/install_gotelegram_bot.sh @@ -1,139 +1,132 @@ -#!/bin/bash -# Установка GoTelegram MTProxy Bot (по образцу gokaskad). -# Запуск: curl -sL URL/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh -# Или с токеном GitHub (приватный репо): curl -sL -H "Authorization: token YOUR_GITHUB_TOKEN" URL -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh - -set -e -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}Запустите скрипт с sudo.${NC}" - exit 1 -fi - -BOT_DIR="/opt/gotelegram-bot" -SERVICE_NAME="gotelegram-bot" -REPO_URL="${GOTETELEGRAM_BOT_REPO:-https://github.com/anten-ka/gotelegram_pro}" -# Для приватного репо задайте GITHUB_TOKEN перед запуском или в одной команде: -# GITHUB_TOKEN=ghp_xxx curl -sL -H "Authorization: token $GITHUB_TOKEN" ... - -echo -e "${GREEN}[*] Установка GoTelegram Bot...${NC}" - -# Зависимости -if ! command -v python3 &>/dev/null; then - if command -v apt-get &>/dev/null; then - apt-get update && apt-get install -y python3 python3-pip python3-venv - elif command -v dnf &>/dev/null; then - dnf install -y python3 python3-pip python3-virtualenv 2>/dev/null || dnf install -y python3 python3-pip - elif command -v yum &>/dev/null; then - yum install -y python3 python3-pip - else - echo -e "${RED}Установите python3 вручную.${NC}" - exit 1 - fi -fi -if ! command -v docker &>/dev/null; then - echo -e "${YELLOW}[!] Docker не найден. Установите Docker для работы /install.${NC}" -fi - -# Каталог бота -mkdir -p "$BOT_DIR" -cd "$BOT_DIR" - -# Откуда брать файлы: локальная папка (клонированный репо) или скачивание -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then - echo -e "${GREEN}[*] Копирование файлов из $SCRIPT_DIR/gotelegram-bot${NC}" - cp -r "$SCRIPT_DIR/gotelegram-bot"/* "$BOT_DIR/" -else - # Приватный репо: клонируем по токену (raw URL с токеном для приватного не работает) - if [ -n "$GITHUB_TOKEN" ] && command -v git &>/dev/null; then - echo -e "${GREEN}[*] Клонирование приватного репозитория (HTTPS + токен)...${NC}" - CLONE_DIR="/tmp/gotelegram_pro_fetch_$$" - trap "rm -rf '$CLONE_DIR'" EXIT - REPO_PATH="${REPO_URL#https://}" - REPO_PATH="${REPO_PATH#http://}" - git clone --depth 1 --branch "${GIT_BRANCH:-main}" "https://${GITHUB_TOKEN}@${REPO_PATH}" "$CLONE_DIR" 2>/dev/null || { - echo -e "${RED}Клонирование не удалось. Проверьте GITHUB_TOKEN и доступ к репо.${NC}" - exit 1 - } - if [ -f "$CLONE_DIR/gotelegram-bot/bot.py" ]; then - cp -r "$CLONE_DIR/gotelegram-bot"/* "$BOT_DIR/" - fi - rm -rf "$CLONE_DIR" - trap - EXIT - fi - # Если файлов всё ещё нет — пробуем raw (только для публичного репо) - if [ ! -f "$BOT_DIR/bot.py" ]; then - echo -e "${YELLOW}[*] Скачивание файлов из репозитория (публичный доступ)...${NC}" - for f in bot.py requirements.txt config.example.env; do - if [ -n "$GITHUB_TOKEN" ]; then - curl -sL -f -H "Authorization: token $GITHUB_TOKEN" \ - "$REPO_URL/raw/main/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || true - fi - [ ! -f "$BOT_DIR/$f" ] && curl -sL -f "$REPO_URL/raw/main/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || true - done - fi - if [ ! -f "$BOT_DIR/bot.py" ]; then - echo -e "${RED}Не удалось получить файлы бота.${NC}" - echo -e " Для приватного репо: задайте GITHUB_TOKEN и убедитесь, что установлен git." - echo -e " Или клонируйте репо вручную и запустите: sudo ./install_gotelegram_bot.sh" - exit 1 - fi -fi - -# venv и зависимости -if [ ! -d "$BOT_DIR/venv" ]; then - python3 -m venv "$BOT_DIR/venv" -fi -"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q - -# Конфиг -if [ ! -f "$BOT_DIR/.env" ]; then - echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" - TOKEN="" - while [ -z "$TOKEN" ]; do - read -r TOKEN - TOKEN=$(echo "$TOKEN" | tr -d '[:space:]') - [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым. Повторите:${NC}" - done - { - echo "BOT_TOKEN=$TOKEN" - [ -f "$BOT_DIR/config.example.env" ] && grep -v "^BOT_TOKEN=" "$BOT_DIR/config.example.env" | grep -v "^#" - } > "$BOT_DIR/.env" - chmod 600 "$BOT_DIR/.env" - echo -e "${GREEN}[*] .env создан.${NC}" -else - echo -e "${GREEN}[*] .env уже есть.${NC}" -fi - -# systemd -cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF -[Unit] -Description=GoTelegram MTProxy Bot -After=network.target docker.service - -[Service] -Type=simple -WorkingDirectory=$BOT_DIR -ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py -Restart=always -RestartSec=5 -Environment=PATH=$BOT_DIR/venv/bin:/usr/bin - -[Install] -WantedBy=multi-user.target -EOF - -systemctl daemon-reload -systemctl enable "$SERVICE_NAME" -systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" -echo -e "${GREEN}[*] Сервис $SERVICE_NAME установлен и запущен.${NC}" -echo -e "Проверка: systemctl status $SERVICE_NAME" -echo -e "Логи: journalctl -u $SERVICE_NAME -f" -echo -e "Настройки: $BOT_DIR/.env (BOT_TOKEN, ALLOWED_IDS)" -exit 0 +#!/bin/bash +# GoTelegram v2.2 — Установка Telegram-бота +# Создаёт venv, ставит зависимости, настраивает systemd + +set -e +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +BOT_DIR="/opt/gotelegram-bot" +SERVICE_NAME="gotelegram-bot" +GOTELEGRAM_DIR="/opt/gotelegram" + +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Запустите с sudo.${NC}" + exit 1 +fi + +echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║${NC} ${GREEN}GoTelegram v2.2 — Установка бота${NC} ${CYAN}║${NC}" +echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}" +echo "" + +# ── Python ─────────────────────────────────────────────────────────────────── +if ! command -v python3 &>/dev/null; then + echo -e "${YELLOW}[*] Установка python3...${NC}" + if command -v apt-get &>/dev/null; then + apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv + elif command -v dnf &>/dev/null; then + dnf install -y -q python3 python3-pip + elif command -v yum &>/dev/null; then + yum install -y -q python3 python3-pip + fi +fi + +# ── Каталог бота ───────────────────────────────────────────────────────────── +mkdir -p "$BOT_DIR" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then + echo -e "${GREEN}[*] Копирование файлов бота...${NC}" + cp "$SCRIPT_DIR/gotelegram-bot/bot.py" "$BOT_DIR/" + cp "$SCRIPT_DIR/gotelegram-bot/requirements.txt" "$BOT_DIR/" + [ -f "$SCRIPT_DIR/gotelegram-bot/config.example.env" ] && cp "$SCRIPT_DIR/gotelegram-bot/config.example.env" "$BOT_DIR/" +else + echo -e "${RED}Файлы бота не найдены в $SCRIPT_DIR/gotelegram-bot/${NC}" + exit 1 +fi + +# Копируем каталог шаблонов +if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/" + echo -e "${GREEN}[*] Каталог шаблонов скопирован${NC}" +fi + +# ── Virtual environment ────────────────────────────────────────────────────── +if [ ! -d "$BOT_DIR/venv" ]; then + echo -e "${GREEN}[*] Создание виртуального окружения...${NC}" + python3 -m venv "$BOT_DIR/venv" +fi + +echo -e "${GREEN}[*] Установка зависимостей...${NC}" +"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q + +# ── Конфигурация ───────────────────────────────────────────────────────────── +if [ ! -f "$BOT_DIR/.env" ]; then + echo "" + echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" + TOKEN="" + while [ -z "$TOKEN" ]; do + read -r TOKEN + TOKEN=$(echo "$TOKEN" | tr -d '[:space:]') + [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}" + done + + echo -ne "${YELLOW}ID администратора (Enter = доступ для всех):${NC} " + read -r ADMIN_ID + + { + echo "BOT_TOKEN=$TOKEN" + [ -n "$ADMIN_ID" ] && echo "ALLOWED_IDS=$ADMIN_ID" + } > "$BOT_DIR/.env" + + chmod 600 "$BOT_DIR/.env" + echo -e "${GREEN}[*] .env создан${NC}" +else + echo -e "${GREEN}[*] .env уже существует${NC}" +fi + +# ── Systemd ────────────────────────────────────────────────────────────────── +cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF +[Unit] +Description=GoTelegram v2.2 Telegram Bot +After=network.target + +[Service] +Type=simple +WorkingDirectory=$BOT_DIR +ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py +Restart=always +RestartSec=5 +Environment=PATH=$BOT_DIR/venv/bin:/usr/bin + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" + +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✅ Бот установлен и запущен! ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════╝${NC}" +echo "" +echo -e "Проверка: ${CYAN}systemctl status $SERVICE_NAME${NC}" +echo -e "Логи: ${CYAN}journalctl -u $SERVICE_NAME -f${NC}" +echo -e "Настройки: ${CYAN}$BOT_DIR/.env${NC}" +echo "" + +# Благодарности +echo -e "${CYAN}─────────────────────────────────────────────${NC}" +echo -e "💜 Спасибо авторам открытых проектов:" +echo -e " ${CYAN}telemt${NC} — MTProxy engine (Rust)" +echo -e " ${CYAN}HTML5 UP${NC} — шаблоны сайтов (CC BY 3.0)" +echo -e " ${CYAN}learning-zone${NC} — 150+ HTML5 шаблонов" +echo -e " ${CYAN}Start Bootstrap${NC} — Bootstrap шаблоны (MIT)" +echo -e "${CYAN}─────────────────────────────────────────────${NC}" diff --git a/lib/backup.sh b/lib/backup.sh new file mode 100644 index 0000000..de60d8a --- /dev/null +++ b/lib/backup.sh @@ -0,0 +1,341 @@ +#!/bin/bash +# GoTelegram v2.2 — Бекап и восстановление конфигурации + +# ── Создание бекапа ────────────────────────────────────────────────────────── +create_backup() { + local password="$1" + local output_dir="${2:-$BACKUP_DIR}" + local timestamp + timestamp=$(date +%Y%m%d_%H%M%S) + local backup_name="gotelegram_backup_${timestamp}" + local tmp_dir="/tmp/${backup_name}" + + mkdir -p "$tmp_dir" "$output_dir" + + # Собираем файлы + log_info "Собираю конфигурацию..." + + # telemt конфиг + if [ -f "$TELEMT_CONFIG" ]; then + cp "$TELEMT_CONFIG" "$tmp_dir/config.toml" + fi + + # GoTelegram конфиг + if [ -f "$GOTELEGRAM_CONFIG" ]; then + cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json" + fi + + # nginx конфиг (stealth mode) + if [ -f "$NGINX_SITE_CONF" ]; then + cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf" + fi + + # SSL сертификаты + local domain + domain=$(config_get domain 2>/dev/null) + 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 сертификаты включены" + fi + + # Шаблон сайта (если есть) + if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then + mkdir -p "$tmp_dir/site" + cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/" + log_dim "Шаблон сайта включён" + fi + + # Метаданные + local ip mode engine + ip=$(get_server_ip) + mode=$(config_get mode 2>/dev/null || echo "unknown") + engine=$(config_get engine 2>/dev/null || echo "telemt") + + cat > "$tmp_dir/metadata.json" << EOMETA +{ + "backup_version": "1.0", + "gotelegram_version": "$GOTELEGRAM_VERSION", + "created_at": "$(date -Iseconds)", + "hostname": "$(hostname)", + "ip": "$ip", + "engine": "$engine", + "mode": "$mode", + "port": $(config_get port 2>/dev/null || echo "443"), + "domain": "$(config_get domain 2>/dev/null)" +} +EOMETA + + # Архивируем + local tar_file="/tmp/${backup_name}.tar.gz" + if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then + log_error "Ошибка создания архива" + rm -rf "$tmp_dir" + rm -f "$tar_file" + return 1 + fi + + if [ ! -f "$tar_file" ]; then + log_error "Архив не создан" + rm -rf "$tmp_dir" + return 1 + fi + + # Шифруем если задан пароль + 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 + log_error "Ошибка шифрования" + rm -f "$tar_file" + rm -rf "$tmp_dir" + return 1 + fi + rm -f "$tar_file" + log_success "Бекап зашифрован (AES-256-CBC)" + else + final_file="${output_dir}/${backup_name}.tar.gz" + mv "$tar_file" "$final_file" + fi + + # SHA256 подпись + sha256sum "$final_file" > "${final_file}.sha256" 2>/dev/null + + # Очистка + rm -rf "$tmp_dir" + + local size + size=$(du -h "$final_file" | cut -f1) + log_success "Бекап создан: $final_file ($size)" + echo "$final_file" + return 0 +} + +# ── Восстановление из бекапа ──────────────────────────────────────────────── +restore_backup() { + local backup_file="$1" + local password="$2" + + if [ ! -f "$backup_file" ]; then + log_error "Файл не найден: $backup_file" + return 1 + fi + + local tmp_dir="/tmp/gotelegram_restore_$$" + mkdir -p "$tmp_dir" + + # Расшифровываем если нужно + local tar_file="" + if echo "$backup_file" | grep -q '\.enc$'; then + if [ -z "$password" ]; then + echo -ne " Введите пароль от бекапа: " + read -rs password + echo "" + 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 + log_error "Неверный пароль или повреждённый файл" + rm -rf "$tmp_dir" "$tar_file" + return 1 + fi + else + tar_file="$backup_file" + fi + + # Распаковываем + tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null + if [ $? -ne 0 ]; then + log_error "Ошибка распаковки архива" + rm -rf "$tmp_dir" + return 1 + fi + + # Находим папку бекапа + local backup_dir + backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1) + [ -z "$backup_dir" ] && backup_dir="$tmp_dir" + + # Проверяем метаданные + if [ -f "$backup_dir/metadata.json" ]; then + local bk_version bk_mode bk_ip + bk_version=$(jq -r '.gotelegram_version // "unknown"' "$backup_dir/metadata.json") + bk_mode=$(jq -r '.mode // "unknown"' "$backup_dir/metadata.json") + bk_ip=$(jq -r '.ip // "unknown"' "$backup_dir/metadata.json") + echo "" + echo -e " ${BOLD}${WHITE}📦 Бекап:${NC}" + echo -e " Версия: $bk_version | Режим: $bk_mode | IP: $bk_ip" + echo -e " Дата: $(jq -r '.created_at' "$backup_dir/metadata.json")" + echo "" + fi + + if ! confirm "Восстановить конфигурацию? Текущие настройки будут перезаписаны."; then + rm -rf "$tmp_dir" + return 0 + fi + + # Останавливаем сервисы + stop_telemt 2>/dev/null + systemctl stop nginx 2>/dev/null + + # Восстанавливаем telemt конфиг + if [ -f "$backup_dir/config.toml" ]; then + mkdir -p /etc/telemt + cp "$backup_dir/config.toml" "$TELEMT_CONFIG" + chmod 600 "$TELEMT_CONFIG" + log_success "telemt конфиг восстановлен" + fi + + # Восстанавливаем GoTelegram конфиг + if [ -f "$backup_dir/gotelegram.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG" + log_success "GoTelegram конфиг восстановлен" + fi + + # Восстанавливаем nginx конфиг + if [ -f "$backup_dir/nginx.conf" ]; then + mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled + cp "$backup_dir/nginx.conf" "$NGINX_SITE_CONF" + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + log_success "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 "SSL сертификаты восстановлены" + fi + fi + + # Восстанавливаем шаблон сайта + if [ -d "$backup_dir/site" ]; then + mkdir -p "$WEBSITE_ROOT" + cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/" + chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null + log_success "Шаблон сайта восстановлен" + fi + + # Запускаем сервисы + if is_telemt_installed; then + start_telemt + fi + systemctl start nginx 2>/dev/null + + # Очистка + rm -rf "$tmp_dir" + [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" + + log_success "Восстановление завершено!" + show_proxy_info + return 0 +} + +# ── Список бекапов ─────────────────────────────────────────────────────────── +list_backups() { + if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then + log_info "Бекапов нет" + return 1 + fi + + echo "" + echo -e " ${BOLD}${WHITE}📦 Доступные бекапы:${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + + local i=1 + for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do + [ -f "$f" ] || continue + [[ "$f" == *.sha256 ]] && continue + local size date_str name + 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="" + [[ "$f" == *.enc ]] && encrypted=" 🔒" + echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}" + ((i++)) + done + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" +} + +# ── Очистка старых бекапов ─────────────────────────────────────────────────── +cleanup_old_backups() { + local keep="${1:-5}" + local count + count=$(find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | wc -l) + + if [ "$count" -gt "$keep" ]; then + local to_delete=$((count - keep)) + find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do + rm -f "$f" "${f}.sha256" + done + log_dim "Удалено $to_delete старых бекапов (оставлено $keep)" + fi +} + +# ── Интерактивный бекап ────────────────────────────────────────────────────── +interactive_backup() { + echo "" + echo -e " ${BOLD}${WHITE}💾 Создание бекапа${NC}" + echo -ne " Зашифровать бекап паролем? [Y/n]: " + read -r use_pass + + local password="" + if [[ ! "$use_pass" =~ ^[Nn] ]]; then + echo -ne " Введите пароль: " + read -rs password + echo "" + echo -ne " Повторите пароль: " + read -rs password2 + echo "" + if [ "$password" != "$password2" ]; then + log_error "Пароли не совпадают" + return 1 + fi + if [ ${#password} -lt 6 ]; then + log_error "Пароль слишком короткий (минимум 6 символов)" + return 1 + fi + fi + + create_backup "$password" + cleanup_old_backups +} + +# ── Интерактивное восстановление ───────────────────────────────────────────── +interactive_restore() { + list_backups || return 1 + + echo -ne " Номер бекапа (или путь к файлу): " + read -r choice + + local backup_file="" + if [[ "$choice" =~ ^[0-9]+$ ]]; then + local i=1 + for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do + [ -f "$f" ] || continue + [[ "$f" == *.sha256 ]] && continue + if [ "$i" -eq "$choice" ]; then + backup_file="$f" + break + fi + ((i++)) + done + elif [ -f "$choice" ]; then + backup_file="$choice" + fi + + if [ -z "$backup_file" ]; then + log_error "Бекап не найден" + return 1 + fi + + restore_backup "$backup_file" +} diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..2804498 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,456 @@ +#!/bin/bash +# GoTelegram v2.2 — Общие утилиты +# Цвета, логирование, спиннер, системные функции, совместимость с v1 + +# ── Версия ──────────────────────────────────────────────────────────────────── +GOTELEGRAM_VERSION="2.2.0" +GOTELEGRAM_NAME="GoTelegram" + +# ── Пути ────────────────────────────────────────────────────────────────────── +GOTELEGRAM_DIR="/opt/gotelegram" +GOTELEGRAM_CONFIG="$GOTELEGRAM_DIR/config.json" +TELEMT_CONFIG="/etc/telemt/config.toml" +TELEMT_BIN="/usr/local/bin/telemt" +TELEMT_SERVICE="telemt" +NGINX_SITE_CONF="/etc/nginx/sites-available/gotelegram" +NGINX_SITE_LINK="/etc/nginx/sites-enabled/gotelegram" +WEBSITE_ROOT="/var/www/gotelegram-site" +BACKUP_DIR="$GOTELEGRAM_DIR/backups" +LOG_FILE="/var/log/gotelegram.log" +BOT_DIR="/opt/gotelegram-bot" + +# ── V1 совместимость ───────────────────────────────────────────────────────── +V1_CONTAINER_NAME="mtproto-proxy" +V1_CONFIG_FILE="/opt/gotelegram-bot/proxy.json" +V1_SERVICE_NAME="gotelegram-bot" + +# ── Цвета ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +MAGENTA='\033[0;35m' +BLUE='\033[0;34m' +WHITE='\033[1;37m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +# ── Логирование ────────────────────────────────────────────────────────────── +log_info() { echo -e " ${CYAN}ℹ${NC} $*"; } +log_success() { echo -e " ${GREEN}✓${NC} $*"; } +log_warning() { echo -e " ${YELLOW}⚠${NC} $*"; } +log_error() { echo -e " ${RED}✗${NC} $*"; } +log_step() { echo -e "\n${BOLD}${WHITE} $*${NC}"; } +log_dim() { echo -e " ${DIM}$*${NC}"; } + +log_to_file() { + local ts; ts=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null +} + +# ── Спиннер ────────────────────────────────────────────────────────────────── +_spin_pid="" +spinner_start() { + local msg="${1:-Подождите...}" + ( + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while true; do + printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2 + i=$(( (i+1) % ${#frames[@]} )) + sleep 0.1 + done + ) & + _spin_pid=$! +} + +spinner_stop() { + [ -n "$_spin_pid" ] && kill "$_spin_pid" 2>/dev/null && wait "$_spin_pid" 2>/dev/null + _spin_pid="" + printf "\r\033[K" >&2 +} + +# ── Прогресс-бар ───────────────────────────────────────────────────────────── +progress_bar() { + local current="$1" total="$2" label="${3:-}" + local pct=$(( current * 100 / total )) + local filled=$(( pct / 2 )) + local empty=$(( 50 - filled )) + local bar="" + for ((i=0; i&2 + [ "$current" -eq "$total" ] && echo "" >&2 +} + +# ── Выполнение с индикатором ───────────────────────────────────────────────── +run_with_spinner() { + local label="$1"; shift + local err_file="/tmp/.gotelegram_spinner_err_$$" + spinner_start "$label" + "$@" >/dev/null 2>"$err_file" + local rc=$? + spinner_stop + if [ $rc -eq 0 ]; then + log_success "$label" + else + log_error "$label ${RED}(ошибка, код: $rc)${NC}" + if [ -s "$err_file" ]; then + log_dim " $(head -3 "$err_file")" + fi + fi + rm -f "$err_file" + return $rc +} + +# ── Баннер ─────────────────────────────────────────────────────────────────── +show_banner() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║${NC} ${BOLD}${WHITE}🚀 GoTelegram v${GOTELEGRAM_VERSION}${NC} ${CYAN}║${NC}" + echo -e "${CYAN}║${NC} ${DIM}MTProxy на ядре telemt (Rust + Tokio)${NC} ${CYAN}║${NC}" + echo -e "${CYAN}║${NC} ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC} ${CYAN}║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# ── Благодарности ──────────────────────────────────────────────────────────── +show_credits() { + echo "" + echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${MAGENTA}║${NC} ${BOLD}Благодарности / Credits${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}╟──────────────────────────────────────────────────────────╢${NC}" + echo -e "${MAGENTA}║${NC} ${WHITE}telemt${NC} — MTProxy engine (Rust) ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${DIM}github.com/telemt/telemt${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${WHITE}HTML5 UP${NC} — адаптивные HTML/CSS шаблоны ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${WHITE}learning-zone${NC} — 150+ HTML5 шаблонов ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${DIM}github.com/learning-zone/website-templates${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${WHITE}Start Bootstrap${NC} — MIT лицензия ${MAGENTA}║${NC}" + echo -e "${MAGENTA}║${NC} ${DIM}startbootstrap.com${NC} ${MAGENTA}║${NC}" + echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +# ── Системные утилиты ──────────────────────────────────────────────────────── +_valid_ip() { + # Validate that each octet is 0-255 + local ip="$1" + local IFS='.' + read -ra octets <<< "$ip" + [ ${#octets[@]} -ne 4 ] && return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] || return 1 + [ "$octet" -gt 255 ] && return 1 + done + return 0 +} + +get_server_ip() { + local ip raw + for url in "https://api.ipify.org" "https://icanhazip.com" "https://ifconfig.me"; do + raw=$(curl -s -4 --max-time 5 "$url" 2>/dev/null) + ip=$(echo "$raw" | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) + if [ -n "$ip" ] && _valid_ip "$ip"; then + echo "$ip" + return 0 + fi + done + echo "0.0.0.0" + return 1 +} + +check_root() { + if [ "$EUID" -ne 0 ]; then + log_error "Запустите скрипт с sudo / от root" + exit 1 + fi +} + +check_os() { + if [ ! -f /etc/os-release ]; then + log_error "Не удалось определить ОС. Требуется Linux." + return 1 + fi + # Validate os-release before sourcing (reject command injection: ;, backticks, $()) + if grep -qE '(;|`|\$\(|\$\{)' /etc/os-release 2>/dev/null; then + log_warning "/etc/os-release содержит подозрительные строки, пропускаем" + return 0 + fi + . /etc/os-release + case "$ID" in + ubuntu|debian|centos|rocky|almalinux|fedora|rhel) + log_dim "ОС: $PRETTY_NAME" + return 0 + ;; + *) + log_warning "ОС $ID может быть несовместима. Поддерживаются: Ubuntu, Debian, CentOS, Rocky." + return 0 + ;; + esac +} + +get_arch() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7*|armhf) echo "armv7" ;; + *) echo "$arch" ;; + esac +} + +get_pkg_manager() { + if command -v apt-get &>/dev/null; then echo "apt" + elif command -v dnf &>/dev/null; then echo "dnf" + elif command -v yum &>/dev/null; then echo "yum" + else echo "unknown" + fi +} + +install_pkg() { + local pkg="$1" + case "$(get_pkg_manager)" in + apt) apt-get install -y -qq "$pkg" ;; + dnf) dnf install -y -q "$pkg" ;; + yum) yum install -y -q "$pkg" ;; + *) log_error "Неизвестный пакетный менеджер"; return 1 ;; + esac +} + +ensure_deps() { + local missing=() + for cmd in curl jq openssl git; do + if ! command -v "$cmd" &>/dev/null; then + missing+=("$cmd") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + log_step "Установка зависимостей: ${missing[*]}" + 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 + fi +} + +check_port() { + local port="$1" + local line + line=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) + [ -z "$line" ] && line=$(netstat -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) + if [ -n "$line" ]; then + echo "$line" + return 0 # порт занят + fi + return 1 # свободен +} + +check_disk_space() { + local min_mb="${1:-500}" + local avail_mb + avail_mb=$(df -m / | awk 'NR==2 {print $4}') + if [ "$avail_mb" -lt "$min_mb" ]; then + log_error "Мало места на диске: ${avail_mb}MB (нужно ${min_mb}MB+)" + return 1 + fi + return 0 +} + +# ── Конфигурация GoTelegram (JSON) ────────────────────────────────────────── +save_gotelegram_config() { + mkdir -p "$(dirname "$GOTELEGRAM_CONFIG")" + cat > "$GOTELEGRAM_CONFIG" << EOJSON +{ + "version": "$GOTELEGRAM_VERSION", + "engine": "${1:-telemt}", + "mode": "${2:-quick}", + "port": ${3:-443}, + "secret": "${4:-}", + "mask_host": "${5:-google.com}", + "domain": "${6:-}", + "template_id": "${7:-}", + "installed_at": "$(date -Iseconds)", + "updated_at": "$(date -Iseconds)" +} +EOJSON + chmod 600 "$GOTELEGRAM_CONFIG" +} + +load_gotelegram_config() { + if [ -f "$GOTELEGRAM_CONFIG" ]; then + cat "$GOTELEGRAM_CONFIG" + return 0 + fi + echo "{}" + return 1 +} + +config_get() { + local key="$1" + if [ ! -f "$GOTELEGRAM_CONFIG" ]; then + log_dim "Конфиг не найден: $GOTELEGRAM_CONFIG" >&2 + return 2 # file missing + fi + local val + val=$(jq -r ".$key // empty" "$GOTELEGRAM_CONFIG" 2>/dev/null) + if [ $? -ne 0 ]; then + log_dim "Ошибка чтения JSON: $GOTELEGRAM_CONFIG" >&2 + return 3 # invalid JSON + fi + if [ -z "$val" ]; then + return 1 # key missing or empty + fi + echo "$val" + return 0 +} + +# ── V1 совместимость ───────────────────────────────────────────────────────── +detect_v1_installation() { + # Проверяем наличие mtg Docker контейнера (v1) + if command -v docker &>/dev/null; then + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${V1_CONTAINER_NAME}$"; then + return 0 # v1 обнаружена + fi + fi + # Проверяем наличие конфига v1 + if [ -f "$V1_CONFIG_FILE" ]; then + return 0 + fi + return 1 +} + +get_v1_config() { + # Извлекаем данные из работающего v1 контейнера + if ! command -v docker &>/dev/null; then + echo "{}" + return 1 + fi + + local running + running=$(docker ps --format '{{.Names}}' 2>/dev/null | grep "^${V1_CONTAINER_NAME}$") + + if [ -z "$running" ]; then + # Пробуем из сохранённого конфига + if [ -f "$V1_CONFIG_FILE" ]; then + cat "$V1_CONFIG_FILE" + return 0 + fi + echo "{}" + return 1 + fi + + # Достаём из Docker + local cmd_str port secret ip + cmd_str=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range .Config.Cmd}}{{.}} {{end}}' 2>/dev/null) + secret=$(echo "$cmd_str" | awk '{print $NF}') + port=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}' 2>/dev/null) + ip=$(get_server_ip) + + jq -n \ + --arg secret "$secret" \ + --arg port "${port:-443}" \ + --arg ip "$ip" \ + '{secret: $secret, port: ($port | tonumber), ip: $ip, engine: "mtg"}' +} + +migrate_v1_to_v2() { + log_step "Миграция с v1 (mtg) на v2 (telemt)" + + local v1_config + v1_config=$(get_v1_config) + + local old_port old_secret + old_port=$(echo "$v1_config" | jq -r '.port // 443') + old_secret=$(echo "$v1_config" | jq -r '.secret // empty') + + if [ -z "$old_secret" ]; then + log_warning "Не удалось извлечь secret из v1. Будет создан новый." + return 1 + fi + + echo "" + echo -e " ${WHITE}Найдена установка v1 (mtg):${NC}" + echo -e " Порт: ${CYAN}${old_port}${NC}" + echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}" + echo "" + echo -e " ${YELLOW}Внимание:${NC} секрет mtg НЕ совместим с telemt напрямую." + echo -e " Клиентам потребуется новая ссылка." + echo "" + echo -ne " Остановить v1 контейнер и перейти на v2? [Y/n]: " + read -r ans + if [[ "$ans" =~ ^[Nn] ]]; then + log_info "Миграция отменена. v1 оставлен без изменений." + return 1 + fi + + # Останавливаем v1 + log_info "Остановка v1 контейнера..." + docker stop "$V1_CONTAINER_NAME" 2>/dev/null + docker rm "$V1_CONTAINER_NAME" 2>/dev/null + + # Бекапим v1 конфиг + if [ -f "$V1_CONFIG_FILE" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$V1_CONFIG_FILE" "$GOTELEGRAM_DIR/v1_backup_proxy.json" 2>/dev/null + log_success "Конфиг v1 сохранён в $GOTELEGRAM_DIR/v1_backup_proxy.json" + fi + + log_success "v1 остановлен. Порт $old_port освобождён." + return 0 +} + +# ── Подтверждение ──────────────────────────────────────────────────────────── +confirm() { + local msg="${1:-Продолжить?}" + echo -ne " ${msg} [Y/n]: " + read -r ans + [[ ! "$ans" =~ ^[Nn] ]] +} + +# ── Выбор из списка ────────────────────────────────────────────────────────── +select_option() { + local title="$1" + shift + local options=("$@") + + echo "" + echo -e " ${BOLD}${WHITE}${title}${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + local i=1 + for opt in "${options[@]}"; do + echo -e " ${CYAN}${i})${NC} ${opt}" + ((i++)) + done + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -ne " ${WHITE}Выбор:${NC} " + read -r choice + echo "$choice" +} + +# ── Генерация случайного hex ───────────────────────────────────────────────── +generate_hex() { + local len="${1:-32}" + openssl rand -hex "$((len/2))" 2>/dev/null || head -c "$((len/2))" /dev/urandom | xxd -p | tr -d '\n' +} + +# ── Проверка домена ────────────────────────────────────────────────────────── +validate_domain() { + local domain="$1" + if echo "$domain" | grep -qE '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'; then + return 0 + fi + return 1 +} + +# ── Init: создание директорий ──────────────────────────────────────────────── +init_dirs() { + mkdir -p "$GOTELEGRAM_DIR" "$BACKUP_DIR" /etc/telemt 2>/dev/null + touch "$LOG_FILE" 2>/dev/null +} diff --git a/lib/telemt.sh b/lib/telemt.sh new file mode 100644 index 0000000..7cb1a95 --- /dev/null +++ b/lib/telemt.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# GoTelegram v2.2 — Управление telemt binary +# Скачивание, обновление, запуск, остановка через systemd + +TELEMT_GITHUB="telemt/telemt" +TELEMT_RELEASE_API="https://api.github.com/repos/${TELEMT_GITHUB}/releases/latest" +TELEMT_USER="telemt" +TELEMT_GROUP="telemt" + +# ── Получение последней версии ─────────────────────────────────────────────── +get_latest_telemt_version() { + local resp + resp=$(curl -s --max-time 10 "$TELEMT_RELEASE_API" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$resp" ]; then + log_error "Не удалось получить информацию о релизах telemt" + return 1 + fi + echo "$resp" | jq -r '.tag_name // empty' +} + +get_telemt_download_url() { + local 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 + + local pattern + case "$arch" in + amd64) pattern="linux.*amd64\|linux.*x86_64" ;; + arm64) pattern="linux.*arm64\|linux.*aarch64" ;; + armv7) pattern="linux.*armv7\|linux.*arm" ;; + *) pattern="linux.*${arch}" ;; + esac + + echo "$resp" | jq -r ".assets[].browser_download_url" 2>/dev/null | grep -i "$pattern" | head -1 +} + +# ── Установленная версия ───────────────────────────────────────────────────── +get_installed_telemt_version() { + if [ -x "$TELEMT_BIN" ]; then + "$TELEMT_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 + else + echo "" + fi +} + +is_telemt_installed() { + [ -x "$TELEMT_BIN" ] +} + +# ── Скачивание и установка ─────────────────────────────────────────────────── +download_telemt() { + local url + url=$(get_telemt_download_url) + if [ -z "$url" ]; then + log_error "Не найден бинарник telemt для архитектуры $(get_arch)" + return 1 + fi + + local tmp_file="/tmp/telemt_download_$$" + log_info "Скачивание: $url" + + if ! curl -L -s --max-time 120 -o "$tmp_file" "$url"; then + log_error "Ошибка скачивания telemt" + rm -f "$tmp_file" + return 1 + fi + + # Определяем тип файла и распаковываем + local mime + mime=$(file -b --mime-type "$tmp_file" 2>/dev/null) + + case "$mime" in + application/gzip|application/x-gzip) + tar xzf "$tmp_file" -C /tmp/ 2>/dev/null + local extracted + extracted=$(find /tmp -maxdepth 2 -name "telemt" -type f -newer "$tmp_file" 2>/dev/null | head -1) + if [ -z "$extracted" ]; then + # Может быть просто gzip без tar + gunzip -c "$tmp_file" > /tmp/telemt_bin_$$ 2>/dev/null + extracted="/tmp/telemt_bin_$$" + fi + ;; + application/x-tar) + tar xf "$tmp_file" -C /tmp/ 2>/dev/null + extracted=$(find /tmp -maxdepth 2 -name "telemt" -type f -newer "$tmp_file" 2>/dev/null | head -1) + ;; + application/zip) + unzip -o "$tmp_file" -d /tmp/telemt_extract_$$ 2>/dev/null + extracted=$(find /tmp/telemt_extract_$$ -name "telemt" -type f 2>/dev/null | head -1) + ;; + application/octet-stream|application/x-executable) + # Уже бинарник + extracted="$tmp_file" + ;; + *) + # Пробуем как бинарник + extracted="$tmp_file" + ;; + esac + + if [ -z "$extracted" ] || [ ! -f "$extracted" ]; then + log_error "Не удалось извлечь бинарник telemt" + rm -f "$tmp_file" + return 1 + fi + + # Устанавливаем + cp "$extracted" "$TELEMT_BIN" + chmod 755 "$TELEMT_BIN" + rm -f "$tmp_file" + rm -rf /tmp/telemt_extract_$$ /tmp/telemt_bin_$$ + + # Проверяем + if "$TELEMT_BIN" --version &>/dev/null; then + log_success "telemt $(get_installed_telemt_version) установлен в $TELEMT_BIN" + return 0 + else + log_error "Бинарник telemt не запускается" + return 1 + fi +} + +# ── Системный пользователь ─────────────────────────────────────────────────── +create_telemt_user() { + if ! id "$TELEMT_USER" &>/dev/null; then + useradd -r -s /usr/sbin/nologin -d /etc/telemt "$TELEMT_USER" 2>/dev/null + log_dim "Создан системный пользователь: $TELEMT_USER" + fi +} + +# ── Systemd сервис ─────────────────────────────────────────────────────────── +install_telemt_service() { + local config_path="${1:-$TELEMT_CONFIG}" + + cat > "/etc/systemd/system/${TELEMT_SERVICE}.service" << EOF +[Unit] +Description=GoTelegram MTProxy (telemt engine) +Documentation=https://github.com/telemt/telemt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +ExecStart=$TELEMT_BIN run $config_path +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# Безопасность +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/etc/telemt /var/log +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +EOF + + systemctl daemon-reload + log_success "Systemd сервис $TELEMT_SERVICE создан" +} + +# ── Управление сервисом ────────────────────────────────────────────────────── +start_telemt() { + systemctl start "$TELEMT_SERVICE" 2>/dev/null + sleep 2 + if systemctl is-active --quiet "$TELEMT_SERVICE"; then + log_success "telemt запущен" + return 0 + else + log_error "telemt не запустился" + journalctl -u "$TELEMT_SERVICE" --no-pager -n 10 2>/dev/null + return 1 + fi +} + +stop_telemt() { + if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + systemctl stop "$TELEMT_SERVICE" + log_success "telemt остановлен" + else + log_dim "telemt уже остановлен" + fi +} + +restart_telemt() { + systemctl restart "$TELEMT_SERVICE" 2>/dev/null + sleep 2 + if systemctl is-active --quiet "$TELEMT_SERVICE"; then + log_success "telemt перезапущен" + return 0 + else + log_error "telemt не перезапустился" + return 1 + fi +} + +enable_telemt() { + systemctl enable "$TELEMT_SERVICE" 2>/dev/null +} + +telemt_status() { + if ! is_telemt_installed; then + echo "not_installed" + return + fi + if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + echo "running" + elif systemctl is-enabled --quiet "$TELEMT_SERVICE" 2>/dev/null; then + echo "stopped" + else + echo "disabled" + fi +} + +telemt_logs() { + local lines="${1:-40}" + journalctl -u "$TELEMT_SERVICE" --no-pager -n "$lines" 2>/dev/null +} + +telemt_uptime() { + local started + started=$(systemctl show "$TELEMT_SERVICE" --property=ActiveEnterTimestamp --value 2>/dev/null) + if [ -n "$started" ] && [ "$started" != "" ]; then + echo "$started" + else + echo "N/A" + fi +} + +# ── Обновление ─────────────────────────────────────────────────────────────── +check_telemt_update() { + local current latest + current=$(get_installed_telemt_version) + latest=$(get_latest_telemt_version) + + if [ -z "$current" ] || [ -z "$latest" ]; then + return 1 + fi + + if [ "$current" != "$latest" ]; then + echo "$latest" + return 0 # есть обновление + fi + return 1 # актуально +} + +update_telemt() { + local latest + latest=$(check_telemt_update) + if [ $? -ne 0 ]; then + log_info "telemt уже последней версии ($(get_installed_telemt_version))" + return 0 + fi + + log_info "Доступно обновление: $(get_installed_telemt_version) → $latest" + if ! confirm "Обновить telemt?"; then + return 0 + fi + + stop_telemt + if download_telemt; then + start_telemt + log_success "telemt обновлён до $latest" + else + start_telemt # запускаем старую версию обратно + log_error "Обновление не удалось" + return 1 + fi +} + +# ── Полная установка telemt ────────────────────────────────────────────────── +install_telemt_full() { + log_step "Установка telemt" + + # Создаём директории + mkdir -p /etc/telemt + + # Скачиваем бинарник + run_with_spinner "Скачивание telemt" download_telemt || return 1 + + # Устанавливаем systemd сервис + install_telemt_service + + # Включаем автозапуск + enable_telemt + + log_success "telemt готов к работе" + return 0 +} + +# ── Удаление telemt ────────────────────────────────────────────────────────── +remove_telemt() { + stop_telemt + systemctl disable "$TELEMT_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${TELEMT_SERVICE}.service" + systemctl daemon-reload + rm -f "$TELEMT_BIN" + rm -rf /etc/telemt + log_success "telemt полностью удалён" +} diff --git a/lib/telemt_config.sh b/lib/telemt_config.sh new file mode 100644 index 0000000..142f271 --- /dev/null +++ b/lib/telemt_config.sh @@ -0,0 +1,282 @@ +#!/bin/bash +# GoTelegram v2.2 — Генерация TOML конфигурации для telemt + +# ── Популярные домены (не заблокированные в РФ) ────────────────────────────── +QUICK_DOMAINS=( + "google.com" + "microsoft.com" + "cloudflare.com" + "apple.com" + "amazon.com" + "github.com" + "stackoverflow.com" + "medium.com" + "wikipedia.org" + "coursera.org" + "udemy.com" + "habr.com" + "stepik.org" + "duolingo.com" + "khanacademy.org" + "bbc.com" + "reuters.com" + "nytimes.com" + "ted.com" + "zoom.us" +) + +# ── Генерация TOML конфига ─────────────────────────────────────────────────── +generate_telemt_toml() { + local secret="$1" + local port="${2:-443}" + local mask_mode="${3:-quick}" # quick | stealth + local mask_host="${4:-google.com}" + local mask_port="${5:-443}" + local output="${6:-$TELEMT_CONFIG}" + + mkdir -p "$(dirname "$output")" + + cat > "$output" << EOTOML +# GoTelegram v${GOTELEGRAM_VERSION} — telemt configuration +# Сгенерировано: $(date -Iseconds) +# Режим: ${mask_mode} + +# ── Основные настройки ─────────────────────────────────────────────────────── +[stats] + statsd_address = "" + +# ── Секреты ────────────────────────────────────────────────────────────────── +[[users]] + name = "main" + secret = "${secret}" + +# ── Привязка ───────────────────────────────────────────────────────────────── +[listen] + bind_to = "0.0.0.0:${port}" + +# ── TLS маскировка ─────────────────────────────────────────────────────────── +[security] + # Маскировочный хост — куда перенаправлять неопознанные подключения + # quick: внешний сайт | stealth: локальный nginx + host = "${mask_host}:${mask_port}" + +EOTOML + + chmod 600 "$output" + log_success "Конфиг telemt записан: $output" + log_dim "Режим: $mask_mode, маскировка: $mask_host:$mask_port" +} + +# ── Добавление дополнительного секрета ─────────────────────────────────────── +add_secret_to_config() { + local name="$1" + local secret="$2" + local config="${3:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then + log_error "Конфиг не найден: $config" + return 1 + fi + + # Добавляем новый блок [[users]] + cat >> "$config" << EOSECRET + +[[users]] + name = "${name}" + secret = "${secret}" +EOSECRET + + log_success "Добавлен секрет: $name" +} + +# ── Чтение текущего конфига ────────────────────────────────────────────────── +get_config_value() { + local key="$1" + local config="${2:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then return 1; fi + + case "$key" in + secret) + grep -m1 'secret\s*=' "$config" | sed 's/.*=\s*"\(.*\)"/\1/' | tr -d ' ' + ;; + port) + grep 'bind_to\s*=' "$config" | sed 's/.*:\([0-9]*\)".*/\1/' + ;; + mask_host) + grep -A10 '\[security\]' "$config" | grep 'host\s*=' | sed 's/.*=\s*"\(.*\)".*/\1/' + ;; + *) + grep "$key" "$config" | head -1 | sed 's/.*=\s*"\?\(.*\)"\?/\1/' | tr -d ' "' + ;; + esac +} + +# ── Валидация конфига ──────────────────────────────────────────────────────── +validate_telemt_config() { + local config="${1:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then + log_error "Конфиг не найден: $config" + return 1 + fi + + # Проверяем обязательные поля + local secret port host + secret=$(get_config_value secret "$config") + port=$(get_config_value port "$config") + host=$(get_config_value mask_host "$config") + + local errors=0 + + if [ -z "$secret" ]; then + log_error "Не задан secret" + ((errors++)) + elif [ ${#secret} -lt 32 ]; then + log_warning "Secret слишком короткий (${#secret} символов, рекомендуется 32+)" + fi + + if [ -z "$port" ]; then + log_error "Не задан порт (bind_to)" + ((errors++)) + elif [ "$port" -lt 1 ] || [ "$port" -gt 65535 ] 2>/dev/null; then + log_error "Порт вне диапазона: $port" + ((errors++)) + fi + + if [ -z "$host" ]; then + log_error "Не задан маскировочный хост (security.host)" + ((errors++)) + fi + + if [ $errors -gt 0 ]; then + log_error "Найдено ошибок: $errors" + return 1 + fi + + log_success "Конфиг валиден" + return 0 +} + +# ── Выбор домена (интерактивный) ───────────────────────────────────────────── +select_quick_domain() { + echo "" + echo -e " ${BOLD}${WHITE}🌐 Выберите домен для маскировки (Fake TLS):${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + + local i=1 + local row="" + for d in "${QUICK_DOMAINS[@]}"; do + printf " ${CYAN}%2d)${NC} %-25s" "$i" "$d" + if (( i % 2 == 0 )); then + echo "" + fi + ((i++)) + done + if (( (i-1) % 2 != 0 )); then echo ""; fi + + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -ne " ${WHITE}Выбор (1-${#QUICK_DOMAINS[@]}):${NC} " + read -r choice + + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#QUICK_DOMAINS[@]} ]; then + echo "${QUICK_DOMAINS[$((choice-1))]}" + return 0 + fi + + log_error "Неверный выбор" + return 1 +} + +# ── Выбор порта (интерактивный) ────────────────────────────────────────────── +select_port() { + echo "" + echo -e " ${BOLD}${WHITE}🔌 Выберите порт:${NC}" + + # Проверяем стандартные порты + local busy_443 busy_8443 + busy_443=$(check_port 443) + busy_8443=$(check_port 8443) + + local label_443="443 (рекомендуется)" + local label_8443="8443" + [ -n "$busy_443" ] && label_443="443 ⚠️ занят" + [ -n "$busy_8443" ] && label_8443="8443 ⚠️ занят" + + echo -e " ${CYAN}1)${NC} $label_443" + echo -e " ${CYAN}2)${NC} $label_8443" + echo -e " ${CYAN}3)${NC} Свой порт" + + if [ -n "$busy_443" ]; then + echo -e " ${DIM} ⚠ Порт 443 занят: $(echo "$busy_443" | head -c 60)${NC}" + fi + + echo -ne " ${WHITE}Выбор:${NC} " + read -r choice + + case "$choice" in + 1) echo "443" ;; + 2) echo "8443" ;; + 3) + echo -ne " Введите порт (1-65535): " + read -r custom_port + if [[ "$custom_port" =~ ^[0-9]+$ ]] && [ "$custom_port" -ge 1 ] && [ "$custom_port" -le 65535 ]; then + echo "$custom_port" + else + log_error "Неверный порт" + return 1 + fi + ;; + *) echo "443" ;; + esac +} + +# ── Генерация ссылки tg://proxy ────────────────────────────────────────────── +generate_proxy_link() { + local ip="${1:-$(get_server_ip)}" + local port="${2:-443}" + local secret="$3" + echo "tg://proxy?server=${ip}&port=${port}&secret=${secret}" +} + +# ── Вывод информации о прокси ──────────────────────────────────────────────── +show_proxy_info() { + local config="${1:-$TELEMT_CONFIG}" + local secret port mask_host ip link status + + secret=$(get_config_value secret "$config") + port=$(get_config_value port "$config") + mask_host=$(get_config_value mask_host "$config") + ip=$(get_server_ip) + link=$(generate_proxy_link "$ip" "$port" "$secret") + status=$(telemt_status) + + local mode + mode=$(config_get mode 2>/dev/null || echo "quick") + + local status_icon status_text + case "$status" in + running) status_icon="✅"; status_text="Работает" ;; + stopped) status_icon="⏸️"; status_text="Остановлен" ;; + *) status_icon="❌"; status_text="Не установлен" ;; + esac + + echo "" + echo -e " ${BOLD}${WHITE}${status_icon} Статус прокси: ${status_text}${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -e " ${WHITE}Ядро:${NC} telemt (Rust)" + echo -e " ${WHITE}IP:${NC} ${CYAN}${ip}${NC}" + 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 " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -e " ${WHITE}Ссылка:${NC}" + echo -e " ${GREEN}${link}${NC}" + echo "" + + # QR если доступен + if command -v qrencode &>/dev/null; then + qrencode -t UTF8 -m 2 "$link" 2>/dev/null + fi +} diff --git a/lib/templates_catalog.sh b/lib/templates_catalog.sh new file mode 100644 index 0000000..157ac2b --- /dev/null +++ b/lib/templates_catalog.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# GoTelegram v2.2 — Каталог шаблонов сайтов +# Выбор из ~200 шаблонов, превью-ссылки, скачивание через git sparse-checkout + +CATALOG_FILE="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")/templates_catalog.json" +TEMPLATES_CACHE="/tmp/gotelegram_templates" + +# ── Загрузка каталога ──────────────────────────────────────────────────────── +load_catalog() { + if [ ! -f "$CATALOG_FILE" ]; then + log_error "Каталог шаблонов не найден: $CATALOG_FILE" + return 1 + fi + return 0 +} + +# ── Категории ──────────────────────────────────────────────────────────────── +get_categories() { + jq -r '.categories[] | "\(.id)|\(.name)|\(.icon)|\(.templates | length)"' "$CATALOG_FILE" 2>/dev/null +} + +get_category_name() { + local cat_id="$1" + jq -r ".categories[] | select(.id == \"$cat_id\") | .name" "$CATALOG_FILE" 2>/dev/null +} + +# ── Шаблоны по категории ──────────────────────────────────────────────────── +get_templates_by_category() { + local cat_id="$1" + jq -r ".categories[] | select(.id == \"$cat_id\") | .templates[] | \"\(.id)|\(.name)|\(.source)|\(.preview_url)\"" "$CATALOG_FILE" 2>/dev/null +} + +# ── Информация о шаблоне ──────────────────────────────────────────────────── +get_template_info() { + local tpl_id="$1" + jq ".categories[].templates[] | select(.id == \"$tpl_id\")" "$CATALOG_FILE" 2>/dev/null +} + +get_template_field() { + local tpl_id="$1" + local field="$2" + jq -r ".categories[].templates[] | select(.id == \"$tpl_id\") | .$field" "$CATALOG_FILE" 2>/dev/null +} + +# ── Интерактивный выбор категории ──────────────────────────────────────────── +select_category() { + load_catalog || return 1 + + echo "" + echo -e " ${BOLD}${WHITE}📂 Категории шаблонов сайтов:${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + + local cats=() + local i=1 + while IFS='|' read -r id name icon count; do + [ "$count" -eq 0 ] && continue + local emoji + case "$icon" in + briefcase) emoji="🏢" ;; + shopping-cart) emoji="🛒" ;; + heart) emoji="🏥" ;; + book) emoji="🎓" ;; + palette) emoji="📸" ;; + home) emoji="🏠" ;; + utensils) emoji="🍕" ;; + rocket) emoji="🎨" ;; + chart-bar) emoji="🔧" ;; + *) emoji="📄" ;; + esac + printf " ${CYAN}%2d)${NC} ${emoji} %-30s ${DIM}(%d шаблонов)${NC}\n" "$i" "$name" "$count" + cats+=("$id") + ((i++)) + done < <(get_categories) + + printf " ${CYAN}%2d)${NC} 🎲 Случайный шаблон\n" "$i" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}Выбор:${NC} " + read -r choice + + # Случайный + if [ "$choice" -eq "$i" ] 2>/dev/null; then + local random_cat="${cats[$((RANDOM % ${#cats[@]}))]}" + echo "$random_cat" + return 0 + fi + + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then + echo "${cats[$((choice-1))]}" + return 0 + fi + + log_error "Неверный выбор" + return 1 +} + +# ── Интерактивный выбор шаблона ────────────────────────────────────────────── +select_template() { + local cat_id="$1" + local cat_name + cat_name=$(get_category_name "$cat_id") + + echo "" + echo -e " ${BOLD}${WHITE}📋 $cat_name — доступные шаблоны:${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + + local tpls=() + local i=1 + while IFS='|' read -r id name source preview; do + printf " ${CYAN}%2d)${NC} %-30s ${DIM}[%s]${NC}\n" "$i" "$name" "$source" + tpls+=("$id") + ((i++)) + done < <(get_templates_by_category "$cat_id") + + if [ ${#tpls[@]} -eq 0 ]; then + log_info "В этой категории нет шаблонов" + return 1 + fi + + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + echo -ne " ${WHITE}Выбор (1-$((i-1))):${NC} " + read -r choice + + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then + local selected_id="${tpls[$((choice-1))]}" + + # Показываем превью + show_template_preview "$selected_id" + + echo "$selected_id" + return 0 + fi + + log_error "Неверный выбор" + return 1 +} + +# ── Показ превью шаблона ──────────────────────────────────────────────────── +show_template_preview() { + local tpl_id="$1" + local info + info=$(get_template_info "$tpl_id") + + local name source preview_url repo_url description + name=$(echo "$info" | jq -r '.name') + source=$(echo "$info" | jq -r '.source') + preview_url=$(echo "$info" | jq -r '.preview_url // empty') + repo_url=$(echo "$info" | jq -r '.repo_url // empty') + description=$(echo "$info" | jq -r '.description // "—"') + + echo "" + echo -e " ${BOLD}${WHITE}🔍 Превью шаблона:${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${WHITE}Название:${NC} $name" + echo -e " ${WHITE}Источник:${NC} $source" + echo -e " ${WHITE}Описание:${NC} $description" + + if [ -n "$preview_url" ]; then + echo "" + echo -e " ${GREEN}👁 Превью:${NC} ${CYAN}${preview_url}${NC}" + echo -e " ${DIM}Откройте ссылку в браузере для просмотра шаблона${NC}" + fi + + if [ -n "$repo_url" ]; then + echo -e " ${DIM}📦 Репо: ${repo_url}${NC}" + fi + + # Благодарность автору + echo "" + echo -e " ${MAGENTA}💜 Спасибо авторам ${source} за открытый код!${NC}" + + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo "" + + if ! confirm "Установить этот шаблон?"; then + return 1 + fi + return 0 +} + +# ── Скачивание шаблона ─────────────────────────────────────────────────────── +download_template() { + local tpl_id="$1" + local output_dir="${2:-$TEMPLATES_CACHE}" + local info + info=$(get_template_info "$tpl_id") + + local repo_url sparse_path source name + repo_url=$(echo "$info" | jq -r '.repo_url') + sparse_path=$(echo "$info" | jq -r '.sparse_path') + source=$(echo "$info" | jq -r '.source') + name=$(echo "$info" | jq -r '.name') + + local clone_dir="$output_dir/${tpl_id}" + rm -rf "$clone_dir" + mkdir -p "$clone_dir" + + log_info "Скачивание шаблона \"$name\"..." + + # Для HTML5 UP — отдельный репо с папками + if [ "$source" = "html5up" ]; then + local tmp_clone="/tmp/html5up_clone_$$" + rm -rf "$tmp_clone" + + # Sparse checkout + git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null + if [ $? -ne 0 ]; then + # Fallback: полный clone + git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null + fi + + if [ -d "$tmp_clone" ]; then + cd "$tmp_clone" && git sparse-checkout set "$sparse_path" 2>/dev/null + if [ -d "$tmp_clone/$sparse_path" ]; then + cp -r "$tmp_clone/$sparse_path"/* "$clone_dir/" + fi + cd - >/dev/null + fi + rm -rf "$tmp_clone" + + # Для learning-zone — один большой репо + elif [ "$source" = "learning-zone" ]; then + local tmp_clone="/tmp/lz_clone_$$" + rm -rf "$tmp_clone" + + git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null + if [ $? -ne 0 ]; then + git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null + fi + + if [ -d "$tmp_clone" ]; then + cd "$tmp_clone" && git sparse-checkout set "$sparse_path" 2>/dev/null + if [ -d "$tmp_clone/$sparse_path" ]; then + cp -r "$tmp_clone/$sparse_path"/* "$clone_dir/" + fi + cd - >/dev/null + fi + rm -rf "$tmp_clone" + + # Для StartBootstrap — каждый шаблон в своём репо + elif [ "$source" = "startbootstrap" ]; then + git clone --depth 1 "$repo_url" "$clone_dir" 2>/dev/null + # Убираем .git + rm -rf "$clone_dir/.git" + fi + + # Проверяем результат + if [ -f "$clone_dir/index.html" ]; then + log_success "Шаблон \"$name\" скачан" + echo "$clone_dir" + return 0 + else + log_error "Шаблон не содержит index.html" + log_dim "Путь: $clone_dir" + ls -la "$clone_dir" 2>/dev/null + return 1 + fi +} + +# ── Полный интерактивный процесс выбора ────────────────────────────────────── +interactive_template_selection() { + load_catalog || return 1 + + # Выбор категории + local cat_id + cat_id=$(select_category) + [ $? -ne 0 ] && return 1 + + # Выбор шаблона + local tpl_id + tpl_id=$(select_template "$cat_id") + [ $? -ne 0 ] && return 1 + + # Скачивание + local template_dir + template_dir=$(download_template "$tpl_id") + [ $? -ne 0 ] && return 1 + + echo "$template_dir" + return 0 +} diff --git a/lib/website.sh b/lib/website.sh new file mode 100644 index 0000000..5d0ab5f --- /dev/null +++ b/lib/website.sh @@ -0,0 +1,340 @@ +#!/bin/bash +# GoTelegram v2.2 — Управление сайтом (nginx + certbot + шаблоны) + +# ── Установка nginx ────────────────────────────────────────────────────────── +install_nginx() { + if command -v nginx &>/dev/null; then + log_dim "nginx уже установлен" + return 0 + 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 ;; + esac + systemctl enable nginx 2>/dev/null +} + +# ── Установка certbot ──────────────────────────────────────────────────────── +install_certbot() { + if command -v certbot &>/dev/null; then + log_dim "certbot уже установлен" + return 0 + 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 ;; + esac +} + +# ── Генерация nginx конфига ────────────────────────────────────────────────── +generate_nginx_config() { + local domain="$1" + local proxy_port="${2:-443}" + local use_ssl="${3:-true}" + + mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled + + cat > "$NGINX_SITE_CONF" << 'EONGINX' +# GoTelegram v2.2 — nginx config +# Обслуживает сайт-маскировку для telemt stealth mode + +server { + listen 80; + listen [::]:80; + server_name DOMAIN_PLACEHOLDER; + + # Let's Encrypt ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + # Редирект на HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen SSL_PORT_PLACEHOLDER ssl http2; + listen [::]:SSL_PORT_PLACEHOLDER ssl http2; + server_name DOMAIN_PLACEHOLDER; + + # SSL сертификаты + ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem; + + # Современные TLS настройки + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Корень сайта + root /var/www/gotelegram-site; + index index.html; + + location / { + try_files $uri $uri/ =404; + expires 30d; + } + + # Кеширование статики + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Скрываем служебные файлы + location ~ /\. { deny all; } + location = /robots.txt { allow all; log_not_found off; access_log off; } + location = /favicon.ico { log_not_found off; access_log off; } +} +EONGINX + + # Подставляем значения (используем | как разделитель, чтобы / в домене не ломал sed) + local escaped_domain + escaped_domain=$(printf '%s\n' "$domain" | sed 's/[&/\]/\\&/g') + sed -i "s|DOMAIN_PLACEHOLDER|${escaped_domain}|g" "$NGINX_SITE_CONF" + sed -i "s|SSL_PORT_PLACEHOLDER|443|g" "$NGINX_SITE_CONF" + + # Активируем сайт + rm -f /etc/nginx/sites-enabled/default 2>/dev/null + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + + log_success "nginx конфиг создан для $domain" +} + +# ── Временный конфиг (до получения SSL) ────────────────────────────────────── +generate_nginx_temp_config() { + local domain="$1" + + cat > "$NGINX_SITE_CONF" << EONGINX_TEMP +# GoTelegram — временный конфиг (до получения SSL) +server { + listen 80; + listen [::]:80; + server_name ${domain}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + root /var/www/gotelegram-site; + index index.html; + + location / { + try_files \$uri \$uri/ =404; + } +} +EONGINX_TEMP + + rm -f /etc/nginx/sites-enabled/default 2>/dev/null + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + mkdir -p /var/www/certbot +} + +# ── Получение SSL сертификата ──────────────────────────────────────────────── +obtain_ssl_certificate() { + local domain="$1" + local email="${2:-}" + + if [ ! -d "/etc/letsencrypt/live/$domain" ]; then + log_info "Получение SSL сертификата для $domain..." + + # Временный конфиг для ACME challenge + generate_nginx_temp_config "$domain" + systemctl restart nginx 2>/dev/null + + local certbot_args=( + certonly + --webroot + -w /var/www/certbot + -d "$domain" + --non-interactive + --agree-tos + ) + + if [ -n "$email" ]; then + certbot_args+=(--email "$email") + else + certbot_args+=(--register-unsafely-without-email) + fi + + if certbot "${certbot_args[@]}" 2>/dev/null; then + log_success "SSL сертификат получен для $domain" + return 0 + else + log_error "Не удалось получить SSL сертификат" + log_dim "Убедитесь что домен $domain направлен на IP этого сервера" + log_dim "и порт 80 открыт в файрволе." + return 1 + fi + else + log_dim "SSL сертификат уже существует для $domain" + return 0 + fi +} + +# ── Авто-обновление сертификата ────────────────────────────────────────────── +setup_ssl_auto_renewal() { + # Certbot systemd timer (предпочтительно) + if [ -f /etc/systemd/system/certbot.timer ] || [ -f /lib/systemd/system/certbot.timer ]; then + systemctl enable certbot.timer 2>/dev/null + systemctl start certbot.timer 2>/dev/null + log_success "Авто-обновление SSL через systemd timer" + return 0 + fi + + # Fallback: cron + if ! crontab -l 2>/dev/null | grep -q "certbot renew"; then + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --post-hook 'systemctl reload nginx'") | crontab - + log_success "Авто-обновление SSL через cron (3:00 ежедневно)" + fi +} + +# ── Обновление сертификата вручную ─────────────────────────────────────────── +renew_ssl_certificate() { + log_info "Обновление SSL сертификата..." + if certbot renew --quiet --post-hook "systemctl reload nginx" 2>/dev/null; then + log_success "Сертификат обновлён" + return 0 + else + log_error "Ошибка обновления сертификата" + return 1 + fi +} + +# ── Дата истечения SSL ─────────────────────────────────────────────────────── +get_ssl_expiry() { + local domain="$1" + local cert="/etc/letsencrypt/live/$domain/fullchain.pem" + if [ -f "$cert" ]; then + openssl x509 -enddate -noout -in "$cert" 2>/dev/null | sed 's/notAfter=//' + else + echo "N/A" + fi +} + +# ── Деплой шаблона сайта ───────────────────────────────────────────────────── +deploy_template_to_nginx() { + local template_dir="$1" + + if [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then + log_error "Шаблон не содержит index.html: $template_dir" + return 1 + fi + + # Бекапим старый сайт + if [ -d "$WEBSITE_ROOT" ] && [ "$(ls -A "$WEBSITE_ROOT" 2>/dev/null)" ]; then + local backup_name="site_backup_$(date +%Y%m%d_%H%M%S)" + mv "$WEBSITE_ROOT" "/tmp/$backup_name" 2>/dev/null + log_dim "Старый сайт сохранён в /tmp/$backup_name" + fi + + mkdir -p "$WEBSITE_ROOT" + cp -r "$template_dir"/* "$WEBSITE_ROOT/" + chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null || chown -R nginx:nginx "$WEBSITE_ROOT" 2>/dev/null + chmod -R 755 "$WEBSITE_ROOT" + + log_success "Шаблон развёрнут в $WEBSITE_ROOT" +} + +# ── Полная установка stealth-режима ────────────────────────────────────────── +setup_stealth_mode() { + local domain="$1" + local template_dir="$2" + local proxy_port="${3:-443}" + local email="${4:-}" + + log_step "Настройка stealth-режима" + + # 1. Устанавливаем nginx + run_with_spinner "Установка nginx" install_nginx || return 1 + + # 2. Устанавливаем certbot + run_with_spinner "Установка certbot" install_certbot || return 1 + + # 3. Деплоим шаблон сайта + deploy_template_to_nginx "$template_dir" || return 1 + + # 4. Получаем SSL + obtain_ssl_certificate "$domain" "$email" || return 1 + + # 5. Генерируем полный nginx конфиг с SSL + generate_nginx_config "$domain" "$proxy_port" + + # 6. Тестируем и перезапускаем nginx + if nginx -t 2>/dev/null; then + systemctl restart nginx + log_success "nginx запущен с SSL" + else + log_error "Ошибка в конфигурации nginx" + nginx -t + return 1 + fi + + # 7. Настраиваем авто-обновление SSL + setup_ssl_auto_renewal + + # 8. Показываем благодарности авторам шаблонов + show_credits + + log_success "Stealth-режим настроен: https://${domain}" + return 0 +} + +# ── Управление nginx ──────────────────────────────────────────────────────── +nginx_status() { + if systemctl is-active --quiet nginx 2>/dev/null; then + echo "running" + else + echo "stopped" + fi +} + +restart_nginx() { + if nginx -t 2>/dev/null; then + systemctl restart nginx 2>/dev/null + log_success "nginx перезапущен" + else + log_error "Ошибка конфигурации nginx" + nginx -t + return 1 + fi +} + +# ── Удаление stealth-режима ────────────────────────────────────────────────── +remove_stealth_mode() { + log_info "Удаление stealth-режима..." + rm -f "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + rm -rf "$WEBSITE_ROOT" + systemctl restart nginx 2>/dev/null + log_success "Stealth-режим удалён (nginx оставлен)" +} + +# ── Смена шаблона ──────────────────────────────────────────────────────────── +switch_template() { + local new_template_dir="$1" + deploy_template_to_nginx "$new_template_dir" + # nginx не требует перезапуска — статика обновилась на месте + log_success "Шаблон сайта обновлён" +} diff --git a/templates_catalog.json b/templates_catalog.json new file mode 100644 index 0000000..fc06cbd --- /dev/null +++ b/templates_catalog.json @@ -0,0 +1,1139 @@ +{ + "version": "1.0", + "categories": [ + { + "id": "landing", + "name": "Лендинг / Креатив", + "icon": "rocket", + "templates": [ + { + "id": "h5up_big_picture", + "name": "Big Picture", + "source": "html5up", + "description": "Одностраничный лендинг с полноэкранными изображениями", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "big-picture", + "preview_url": "https://html5up.net/big-picture/" + }, + { + "id": "h5up_eventually", + "name": "Eventually", + "source": "html5up", + "description": "Лендинг со счётчиком запуска", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "eventually", + "preview_url": "https://html5up.net/eventually/" + }, + { + "id": "h5up_escape_velocity", + "name": "Escape Velocity", + "source": "html5up", + "description": "Креативный лендинг с необычным дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "escape-velocity", + "preview_url": "https://html5up.net/escape-velocity/" + }, + { + "id": "lz_mobile_app_landing", + "name": "Mobile App Landing Page", + "source": "learning-zone", + "description": "Лендинг для мобильного приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "mobile-app-free-one-page-responsive-html5-landing-page", + "preview_url": "https://learning-zone.github.io/website-templates/mobile-app-free-one-page-responsive-html5-landing-page/" + }, + { + "id": "lz_smartapp_landing", + "name": "SmartApp Landing", + "source": "learning-zone", + "description": "Современный лендинг приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "smartapp-free-html5-landing-page", + "preview_url": "https://learning-zone.github.io/website-templates/smartapp-free-html5-landing-page/" + }, + { + "id": "lz_brand_app_landing", + "name": "Brand App Landing", + "source": "learning-zone", + "description": "Лендинг для брендового приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "brand-html5-app-landing-page-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/brand-html5-app-landing-page-responsive-web-template/" + }, + { + "id": "lz_line_app_landing", + "name": "Line App Landing", + "source": "learning-zone", + "description": "Минималистичный лендинг приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "line-free-app-landing-page-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/line-free-app-landing-page-responsive-web-template/" + }, + { + "id": "sb_landing_page", + "name": "StartBootstrap Landing Page", + "source": "startbootstrap", + "description": "Универсальный адаптивный лендинг", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-landing-page", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-landing-page/" + } + ] + }, + { + "id": "business", + "name": "Бизнес / Корпоративный", + "icon": "briefcase", + "templates": [ + { + "id": "h5up_aerial", + "name": "Aerial", + "source": "html5up", + "description": "Корпоративный сайт с минималистичным дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "aerial", + "preview_url": "https://html5up.net/aerial/" + }, + { + "id": "h5up_alpha", + "name": "Alpha", + "source": "html5up", + "description": "Профессиональный корпоративный шаблон", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "alpha", + "preview_url": "https://html5up.net/alpha/" + }, + { + "id": "h5up_arcana", + "name": "Arcana", + "source": "html5up", + "description": "Стильный деловой сайт с эффектами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "arcana", + "preview_url": "https://html5up.net/arcana/" + }, + { + "id": "h5up_directive", + "name": "Directive", + "source": "html5up", + "description": "Компактный корпоративный шаблон", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "directive", + "preview_url": "https://html5up.net/directive/" + }, + { + "id": "h5up_dimension", + "name": "Dimension", + "source": "html5up", + "description": "Элегантный деловой портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "dimension", + "preview_url": "https://html5up.net/dimension/" + }, + { + "id": "h5up_editorial", + "name": "Editorial", + "source": "html5up", + "description": "Деловой журнальный сайт", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "editorial", + "preview_url": "https://html5up.net/editorial/" + }, + { + "id": "h5up_helios", + "name": "Helios", + "source": "html5up", + "description": "Современный бизнес-сайт", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "helios", + "preview_url": "https://html5up.net/helios/" + }, + { + "id": "h5up_identity", + "name": "Identity", + "source": "html5up", + "description": "Профильная карточка компании", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "identity", + "preview_url": "https://html5up.net/identity/" + }, + { + "id": "lz_atlanta_business", + "name": "Atlanta Business", + "source": "learning-zone", + "description": "Шаблон для бизнес-консультаций", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "atlanta-free-business-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/atlanta-free-business-bootstrap-template/" + }, + { + "id": "lz_creative_bee", + "name": "Creative Bee Corporate", + "source": "learning-zone", + "description": "Креативное корпоративное агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "creative-bee-corporate-free-html5-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/creative-bee-corporate-free-html5-web-template/" + }, + { + "id": "lz_frames_corporate", + "name": "Frames Corporate", + "source": "learning-zone", + "description": "Корпоративный сайт с фреймами", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "frames-corporate-bootstrap-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/frames-corporate-bootstrap-free-html5-template/" + }, + { + "id": "lz_ninja_consulting", + "name": "Ninja Business Consulting", + "source": "learning-zone", + "description": "Сайт бизнес-консультаций", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "ninja-business-consulting-html-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/ninja-business-consulting-html-responsive-web-template/" + }, + { + "id": "lz_vone_business", + "name": "Vone Business", + "source": "learning-zone", + "description": "Адаптивный бизнес-шаблон", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "vone-free-business-html5-responsive-website", + "preview_url": "https://learning-zone.github.io/website-templates/vone-free-business-html5-responsive-website/" + }, + { + "id": "lz_kavin_corporate", + "name": "Kavin Corporate", + "source": "learning-zone", + "description": "Элегантный корпоративный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "kavin-corporate-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/kavin-corporate-bootstrap-responsive-web-template/" + }, + { + "id": "lz_swifty_business", + "name": "Swifty Business", + "source": "learning-zone", + "description": "Быстрый и стильный бизнес-сайт", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "swifty-business-html5-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/swifty-business-html5-website-template/" + }, + { + "id": "lz_techking_corporate", + "name": "TechKing Corporate", + "source": "learning-zone", + "description": "IT-компании и технологический бизнес", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "techking-free-html5-template-for-corporate-business", + "preview_url": "https://learning-zone.github.io/website-templates/techking-free-html5-template-for-corporate-business/" + }, + { + "id": "lz_everest_corporate", + "name": "Everest Corporate", + "source": "learning-zone", + "description": "Профессиональный корпоративный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "everest-corporate-business-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/everest-corporate-business-bootstrap-template/" + }, + { + "id": "sb_agency", + "name": "StartBootstrap Agency", + "source": "startbootstrap", + "description": "Сайт креативного агентства", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-agency", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-agency/" + }, + { + "id": "sb_modern_business", + "name": "StartBootstrap Modern Business", + "source": "startbootstrap", + "description": "Современный шаблон для бизнеса", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-modern-business", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-modern-business/" + } + ] + }, + { + "id": "blog", + "name": "Блог / Медиа", + "icon": "newspaper", + "templates": [ + { + "id": "h5up_dopetrope", + "name": "Dopetrope", + "source": "html5up", + "description": "Шаблон для медиа-блога", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "dopetrope", + "preview_url": "https://html5up.net/dopetrope/" + }, + { + "id": "h5up_massively", + "name": "Massively", + "source": "html5up", + "description": "Блог с выделенными постами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "massively", + "preview_url": "https://html5up.net/massively/" + }, + { + "id": "h5up_story", + "name": "Story", + "source": "html5up", + "description": "Шаблон для рассказов и статей", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "story", + "preview_url": "https://html5up.net/story/" + }, + { + "id": "sb_clean_blog", + "name": "StartBootstrap Clean Blog", + "source": "startbootstrap", + "description": "Чистый и простой блог", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-clean-blog", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-clean-blog/" + } + ] + }, + { + "id": "portfolio", + "name": "Портфолио / Фото", + "icon": "image", + "templates": [ + { + "id": "h5up_astral", + "name": "Astral", + "source": "html5up", + "description": "Портфолио с галереей", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "astral", + "preview_url": "https://html5up.net/astral/" + }, + { + "id": "h5up_ethereal", + "name": "Ethereal", + "source": "html5up", + "description": "Портфолио с эффектами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "ethereal", + "preview_url": "https://html5up.net/ethereal/" + }, + { + "id": "h5up_forty", + "name": "Forty", + "source": "html5up", + "description": "Минималистичное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "forty", + "preview_url": "https://html5up.net/forty/" + }, + { + "id": "h5up_fractal", + "name": "Fractal", + "source": "html5up", + "description": "Портфолио с геометрическим дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "fractal", + "preview_url": "https://html5up.net/fractal/" + }, + { + "id": "h5up_halcyonic", + "name": "Halcyonic", + "source": "html5up", + "description": "Яркое портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "halcyonic", + "preview_url": "https://html5up.net/halcyonic/" + }, + { + "id": "h5up_highlights", + "name": "Highlights", + "source": "html5up", + "description": "Портфолио с выделением работ", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "highlights", + "preview_url": "https://html5up.net/highlights/" + }, + { + "id": "h5up_lens", + "name": "Lens", + "source": "html5up", + "description": "Фотографический портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "lens", + "preview_url": "https://html5up.net/lens/" + }, + { + "id": "h5up_paradigm_shift", + "name": "Paradigm Shift", + "source": "html5up", + "description": "Современное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "paradigm-shift", + "preview_url": "https://html5up.net/paradigm-shift/" + }, + { + "id": "h5up_phantom", + "name": "Phantom", + "source": "html5up", + "description": "Портфолио с темной темой", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "phantom", + "preview_url": "https://html5up.net/phantom/" + }, + { + "id": "h5up_photon", + "name": "Photon", + "source": "html5up", + "description": "Фото-портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "photon", + "preview_url": "https://html5up.net/photon/" + }, + { + "id": "h5up_strata", + "name": "Strata", + "source": "html5up", + "description": "Компактное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "strata", + "preview_url": "https://html5up.net/strata/" + }, + { + "id": "h5up_striped", + "name": "Striped", + "source": "html5up", + "description": "Портфолио в полоску", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "striped", + "preview_url": "https://html5up.net/striped/" + }, + { + "id": "lz_iclick_photography", + "name": "iClick Photography", + "source": "learning-zone", + "description": "Портфолио фотографа", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "iclick-photography-bootstrap-free-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/iclick-photography-bootstrap-free-website-template/" + }, + { + "id": "lz_amaze_photography", + "name": "Amaze Photography", + "source": "learning-zone", + "description": "Студия фотографии", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "amaze-photography-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/amaze-photography-bootstrap-html5-template/" + }, + { + "id": "lz_html5_portfolio", + "name": "HTML5 Portfolio", + "source": "learning-zone", + "description": "Универсальный портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "html5-portfolio", + "preview_url": "https://learning-zone.github.io/website-templates/html5-portfolio/" + }, + { + "id": "lz_wow_portfolio", + "name": "WOW Portfolio", + "source": "learning-zone", + "description": "Многофункциональное портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "wow-portfolio-multi-purpose-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/wow-portfolio-multi-purpose-html5-template/" + }, + { + "id": "lz_me_portfolio", + "name": "ME Portfolio", + "source": "learning-zone", + "description": "Личный портфолио и резюме", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "me-resume-personal-portfolio-responsive-template", + "preview_url": "https://learning-zone.github.io/website-templates/me-resume-personal-portfolio-responsive-template/" + }, + { + "id": "lz_johndoe_portfolio", + "name": "JohnDoe Portfolio", + "source": "learning-zone", + "description": "Портфолио с резюме", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "johndoe-portfolio-resume-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/johndoe-portfolio-resume-bootstrap-template/" + }, + { + "id": "lz_free_portfolio_sam", + "name": "Free Portfolio", + "source": "learning-zone", + "description": "Адаптивное портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-portfolio-html5-responsive-website-sam", + "preview_url": "https://learning-zone.github.io/website-templates/free-portfolio-html5-responsive-website-sam/" + } + ] + }, + { + "id": "restaurant", + "name": "Ресторан / Кафе", + "icon": "utensils", + "templates": [ + { + "id": "lz_coffee_shop", + "name": "Coffee Shop", + "source": "learning-zone", + "description": "Сайт кофейни", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "coffee-shop-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/coffee-shop-free-html5-template/" + }, + { + "id": "lz_golden_hotel_restaurant", + "name": "Golden Hotel Restaurant", + "source": "learning-zone", + "description": "Ресторан отеля", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "golden-hotel-free-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/golden-hotel-free-html5-bootstrap-web-template/" + }, + { + "id": "lz_bestro_restaurant", + "name": "Bestro Restaurant", + "source": "learning-zone", + "description": "Шаблон ресторана на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "bestro-restaurant-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/bestro-restaurant-bootstrap-html5-template/" + }, + { + "id": "lz_eat_restaurant", + "name": "Eat Restaurant", + "source": "learning-zone", + "description": "Сайт ресторана с меню", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "eat-restaurant-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/eat-restaurant-bootstrap-html5-template/" + } + ] + }, + { + "id": "medical", + "name": "Медицина / Здоровье", + "icon": "heartbeat", + "templates": [ + { + "id": "lz_medplus_medical", + "name": "MedPlus Medical", + "source": "learning-zone", + "description": "Сайт медицинской клиники", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "medplus-medical", + "preview_url": "https://learning-zone.github.io/website-templates/medplus-medical/" + }, + { + "id": "lz_vcare_hospital", + "name": "VCare Hospital", + "source": "learning-zone", + "description": "Шаблон для больницы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "vcare-free-html5-template-hospital-website", + "preview_url": "https://learning-zone.github.io/website-templates/vcare-free-html5-template-hospital-website/" + }, + { + "id": "lz_touch_hospital", + "name": "Touch Hospital", + "source": "learning-zone", + "description": "Медицинский центр на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "touch-hospital-medical-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/touch-hospital-medical-bootstrap-html5-template/" + } + ] + }, + { + "id": "beauty", + "name": "Красота / Салон", + "icon": "spa", + "templates": [ + { + "id": "lz_beauty_salon", + "name": "Beauty Salon", + "source": "learning-zone", + "description": "Салон красоты", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "beauty-salon-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/beauty-salon-bootstrap-html5-template/" + } + ] + }, + { + "id": "education", + "name": "Образование", + "icon": "graduation-cap", + "templates": [ + { + "id": "lz_bschool_education", + "name": "B-School Education", + "source": "learning-zone", + "description": "Сайт образовательного учреждения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "b-school-free-education-html5-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/b-school-free-education-html5-website-template/" + }, + { + "id": "lz_learn_education", + "name": "Learn Education", + "source": "learning-zone", + "description": "Платформа онлайн-обучения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "learn-educational-free-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/learn-educational-free-responsive-web-template/" + }, + { + "id": "lz_school_education", + "name": "School Education", + "source": "learning-zone", + "description": "Сайт школы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "school-educational-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/school-educational-html5-template/" + }, + { + "id": "lz_victory_education", + "name": "Victory Education", + "source": "learning-zone", + "description": "Образовательный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "victory-educational-institution-free-html5-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/victory-educational-institution-free-html5-bootstrap-template/" + } + ] + }, + { + "id": "fitness", + "name": "Фитнес / Спорт", + "icon": "dumbbell", + "templates": [ + { + "id": "lz_fit_healthy_fitness", + "name": "Fit Healthy Fitness", + "source": "learning-zone", + "description": "Фитнес-клуб и спортзал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fit-healthy-fitness-and-gym-html5-bootstrap-theme", + "preview_url": "https://learning-zone.github.io/website-templates/fit-healthy-fitness-and-gym-html5-bootstrap-theme/" + }, + { + "id": "lz_fitness_zone", + "name": "Fitness Zone", + "source": "learning-zone", + "description": "Зона фитнеса и тренировок", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fitness-zone-html5-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/fitness-zone-html5-bootstrap-responsive-web-template/" + } + ] + }, + { + "id": "realestate", + "name": "Недвижимость", + "icon": "home", + "templates": [ + { + "id": "lz_aerosky_realestate", + "name": "Aerosky Real Estate", + "source": "learning-zone", + "description": "Агентство недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "aerosky-real-estate-html-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/aerosky-real-estate-html-responsive-website-template/" + }, + { + "id": "lz_bootstrap_realestate", + "name": "Bootstrap Real Estate", + "source": "learning-zone", + "description": "Портал продажи недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-bootstrap-template-real-estate-my-home", + "preview_url": "https://learning-zone.github.io/website-templates/free-bootstrap-template-real-estate-my-home/" + }, + { + "id": "lz_park_city_realestate", + "name": "Park City Real Estate", + "source": "learning-zone", + "description": "Недвижимость в городе", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "park-city-bootstrap-html-real-estate-responsive-template", + "preview_url": "https://learning-zone.github.io/website-templates/park-city-bootstrap-html-real-estate-responsive-template/" + }, + { + "id": "lz_icon_realestate", + "name": "Icon Real Estate", + "source": "learning-zone", + "description": "Застройщики недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "icon-real-estate-developers-free-responsive-html-template", + "preview_url": "https://learning-zone.github.io/website-templates/icon-real-estate-developers-free-responsive-html-template/" + }, + { + "id": "lz_real_estate_builders", + "name": "Real Estate Builders", + "source": "learning-zone", + "description": "Строители и девелоперы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "real-estate-builders-free-responsive-website-templates-adesign", + "preview_url": "https://learning-zone.github.io/website-templates/real-estate-builders-free-responsive-website-templates-adesign/" + } + ] + }, + { + "id": "wedding", + "name": "Свадьба / События", + "icon": "heart", + "templates": [ + { + "id": "lz_lovely_wedding", + "name": "Lovely Wedding", + "source": "learning-zone", + "description": "Свадебное агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "lovely-wedding-bootstrap-free-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/lovely-wedding-bootstrap-free-website-template/" + }, + { + "id": "lz_best_wedding", + "name": "Best Wedding", + "source": "learning-zone", + "description": "Портал свадебных услуг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "the-best-wedding-free-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/the-best-wedding-free-bootstrap-template/" + }, + { + "id": "lz_wedding_bells", + "name": "Wedding Bells", + "source": "learning-zone", + "description": "Звон свадебных колоколов", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "wedding-bells-free-responsive-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/wedding-bells-free-responsive-html5-template/" + } + ] + }, + { + "id": "auto", + "name": "Авто / Транспорт", + "icon": "car", + "templates": [ + { + "id": "lz_car_zone", + "name": "Car Zone", + "source": "learning-zone", + "description": "Автосалон и продажа машин", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "car-zone-automobile-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/car-zone-automobile-bootstrap-responsive-web-template/" + }, + { + "id": "lz_car_care", + "name": "Car Care", + "source": "learning-zone", + "description": "Автосервис и уход за авто", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "car-care-auto-mobile-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/car-care-auto-mobile-html5-bootstrap-web-template/" + } + ] + }, + { + "id": "hosting", + "name": "Хостинг / Технологии", + "icon": "server", + "templates": [ + { + "id": "h5up_hyperspace", + "name": "Hyperspace", + "source": "html5up", + "description": "Сайт облачного хостинга", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "hyperspace", + "preview_url": "https://html5up.net/hyperspace/" + }, + { + "id": "h5up_future_imperfect", + "name": "Future Imperfect", + "source": "html5up", + "description": "Блог с боковой панелью", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "future-imperfect", + "preview_url": "https://html5up.net/future-imperfect/" + }, + { + "id": "lz_cloud_hosting", + "name": "Cloud Hosting", + "source": "learning-zone", + "description": "Облачный хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "cloud-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/cloud-hosting-free-bootstrap-responsive-website-template/" + }, + { + "id": "lz_fiber_hosting", + "name": "Fiber Hosting", + "source": "learning-zone", + "description": "Высокоскоростной хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fiber-hosting-bootstrap-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/fiber-hosting-bootstrap-website-template/" + }, + { + "id": "lz_speed_hosting", + "name": "Speed Hosting", + "source": "learning-zone", + "description": "Быстрый веб-хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "speed-hosting-bootstrap-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/speed-hosting-bootstrap-free-html5-template/" + }, + { + "id": "lz_idata_hosting", + "name": "IData Hosting", + "source": "learning-zone", + "description": "Хостинг и облачные услуги", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "idata-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/idata-hosting-free-bootstrap-responsive-website-template/" + } + ] + }, + { + "id": "interior", + "name": "Интерьер / Дизайн", + "icon": "palette", + "templates": [ + { + "id": "h5up_landed", + "name": "Landed", + "source": "html5up", + "description": "Сайт дизайн-студии", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "landed", + "preview_url": "https://html5up.net/landed/" + }, + { + "id": "h5up_strongly_typed", + "name": "Strongly Typed", + "source": "html5up", + "description": "Портфолио типографики", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "strongly-typed", + "preview_url": "https://html5up.net/strongly-typed/" + }, + { + "id": "lz_ideal_interior", + "name": "Ideal Interior Design", + "source": "learning-zone", + "description": "Дизайн интерьера", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "ideal-interior-design-free-bootstrap-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/ideal-interior-design-free-bootstrap-website-template/" + }, + { + "id": "lz_smart_interior", + "name": "Smart Interior Designs", + "source": "learning-zone", + "description": "Интерьерный проект", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "smart-interior-designs-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/smart-interior-designs-html5-bootstrap-web-template/" + }, + { + "id": "lz_relax_interior", + "name": "Relax Interior", + "source": "learning-zone", + "description": "Дизайн жилых помещений", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "relax-interior-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/relax-interior-free-bootstrap-responsive-website-template/" + } + ] + }, + { + "id": "travel", + "name": "Путешествия", + "icon": "map-marker", + "templates": [ + { + "id": "h5up_prologue", + "name": "Prologue", + "source": "html5up", + "description": "Одностраничный портал путешествий", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "prologue", + "preview_url": "https://html5up.net/prologue/" + }, + { + "id": "h5up_twenty", + "name": "Twenty", + "source": "html5up", + "description": "Туристический портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "twenty", + "preview_url": "https://html5up.net/twenty/" + }, + { + "id": "lz_traveller", + "name": "Traveller", + "source": "learning-zone", + "description": "Туристическое агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "traveller-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/traveller-bootstrap-responsive-web-template/" + }, + { + "id": "sb_grayscale", + "name": "StartBootstrap Grayscale", + "source": "startbootstrap", + "description": "Портал путешествий", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-grayscale", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-grayscale/" + } + ] + }, + { + "id": "ecommerce", + "name": "Магазин / E-commerce", + "icon": "shopping-cart", + "templates": [ + { + "id": "h5up_overflow", + "name": "Overflow", + "source": "html5up", + "description": "Портал онлайн-магазина", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "overflow", + "preview_url": "https://html5up.net/overflow/" + }, + { + "id": "h5up_minimaxing", + "name": "Minimaxing", + "source": "html5up", + "description": "Минималистичный магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "minimaxing", + "preview_url": "https://html5up.net/minimaxing/" + }, + { + "id": "h5up_multiverse", + "name": "Multiverse", + "source": "html5up", + "description": "Мультифункциональный магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "multiverse", + "preview_url": "https://html5up.net/multiverse/" + }, + { + "id": "h5up_telephasic", + "name": "Telephasic", + "source": "html5up", + "description": "Современный интернет-магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "telephasic", + "preview_url": "https://html5up.net/telephasic/" + }, + { + "id": "h5up_miniport", + "name": "Miniport", + "source": "html5up", + "description": "Компактный каталог товаров", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "miniport", + "preview_url": "https://html5up.net/miniport/" + } + ] + }, + { + "id": "admin", + "name": "Админ-панель", + "icon": "cog", + "templates": [ + { + "id": "h5up_solid_state", + "name": "Solid State", + "source": "html5up", + "description": "Панель управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "solid-state", + "preview_url": "https://html5up.net/solid-state/" + }, + { + "id": "h5up_spectral", + "name": "Spectral", + "source": "html5up", + "description": "Администраторская панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "spectral", + "preview_url": "https://html5up.net/spectral/" + }, + { + "id": "h5up_stellar", + "name": "Stellar", + "source": "html5up", + "description": "Интерфейс администратора", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "stellar", + "preview_url": "https://html5up.net/stellar/" + }, + { + "id": "h5up_tessellate", + "name": "Tessellate", + "source": "html5up", + "description": "Модульная админ-панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "tessellate", + "preview_url": "https://html5up.net/tessellate/" + }, + { + "id": "h5up_txt", + "name": "TXT", + "source": "html5up", + "description": "Текстовый интерфейс управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "txt", + "preview_url": "https://html5up.net/txt/" + }, + { + "id": "h5up_verti", + "name": "Verti", + "source": "html5up", + "description": "Вертикальная панель управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "verti", + "preview_url": "https://html5up.net/verti/" + }, + { + "id": "h5up_zerofour", + "name": "ZeroFour", + "source": "html5up", + "description": "Современная админ-панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "zerofour", + "preview_url": "https://html5up.net/zerofour/" + }, + { + "id": "h5up_parallelism", + "name": "Parallelism", + "source": "html5up", + "description": "Дашборд управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "parallelism", + "preview_url": "https://html5up.net/parallelism/" + }, + { + "id": "lz_dream_admin", + "name": "Dream Admin", + "source": "learning-zone", + "description": "Админ-панель на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-bootstrap-admin-template-dream", + "preview_url": "https://learning-zone.github.io/website-templates/free-bootstrap-admin-template-dream/" + }, + { + "id": "lz_sb_admin", + "name": "SB Admin", + "source": "learning-zone", + "description": "Простая админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "sb-admin", + "preview_url": "https://learning-zone.github.io/website-templates/sb-admin/" + }, + { + "id": "lz_sb_admin_2", + "name": "SB Admin 2", + "source": "learning-zone", + "description": "Продвинутая админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "sb-admin-2", + "preview_url": "https://learning-zone.github.io/website-templates/sb-admin-2/" + }, + { + "id": "lz_insight_admin", + "name": "Insight Admin", + "source": "learning-zone", + "description": "Аналитическая панель управления", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "insight-free-bootstrap-html5-admin-template", + "preview_url": "https://learning-zone.github.io/website-templates/insight-free-bootstrap-html5-admin-template/" + }, + { + "id": "lz_hybrid_admin", + "name": "Hybrid Admin", + "source": "learning-zone", + "description": "Гибридная админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "hybrid-bootstrap-admin-template", + "preview_url": "https://learning-zone.github.io/website-templates/hybrid-bootstrap-admin-template/" + }, + { + "id": "sb_admin_panel", + "name": "StartBootstrap SB Admin", + "source": "startbootstrap", + "description": "Универсальная админ-панель", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-sb-admin", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-sb-admin/" + } + ] + }, + { + "id": "creative", + "name": "Креатив / Агентство", + "icon": "star", + "templates": [ + { + "id": "h5up_read_only", + "name": "Read Only", + "source": "html5up", + "description": "Портфолио творческого агентства", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "read-only", + "preview_url": "https://html5up.net/read-only/" + }, + { + "id": "h5up_future", + "name": "Future", + "source": "html5up", + "description": "Сайт креативного студии", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "future-imperfect", + "preview_url": "https://html5up.net/future-imperfect/" + }, + { + "id": "sb_creative", + "name": "StartBootstrap Creative", + "source": "startbootstrap", + "description": "Креативное агентство", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-creative", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-creative/" + }, + { + "id": "sb_freelancer", + "name": "StartBootstrap Freelancer", + "source": "startbootstrap", + "description": "Портфолио фрилансера", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-freelancer", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-freelancer/" + } + ] + } + ] +}