#!/usr/bin/env python3 """ SwiftGram MTProxy — Telegram-бот для управления MTProxy на сервере. Возможности: статус BBR, проверка IPv6, фикс звонков (UDP), управление контейнером. Без рекламы и промокодов. """ import asyncio import html import json import os import re from pathlib import Path # ── Загрузка настроек из .env ──────────────────────────────────────────────── _env_path = Path(__file__).resolve().parent / ".env" if not _env_path.exists(): _env_path = Path("/opt/swiftgram/.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 = os.environ.get("CONTAINER_NAME", "swiftgram-proxy") CONFIG_FILE = Path(os.environ.get("CONFIG_PATH", "/opt/swiftgram/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", ] # ── Утилиты ────────────────────────────────────────────────────────────────── 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_ip4() -> 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 get_ip6() -> str: code, out, _ = await sh("curl", "-s", "-6", "--max-time", "5", "https://api6.ipify.org", timeout=8) if code == 0 and out: return out.strip() return "" async def check_bbr() -> bool: code, out, _ = await sh("sysctl", "net.ipv4.tcp_congestion_control", timeout=5) return "bbr" in out.lower() 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: 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 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: 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" ip4 = await get_ip4() ip6 = await get_ip6() cfg = load_config() return { "ip4": ip4, "ip6": ip6, "port": port, "secret": secret, "domain": cfg.get("domain", "—"), "link4": f"tg://proxy?server={ip4}&port={port}&secret={secret}", "link6": f"tg://proxy?server={ip6}&port={port}&secret={secret}" if ip6 else None } # ── Меню ───────────────────────────────────────────────────────────────────── 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")], ]) HELP_TEXT = ( "🚀 SwiftGram MTProxy Manager\n\n" "Управление прокси на сервере.\n" "• TCP + UDP (звонки) активны\n" "• IPv6 поддержка включена\n" "• BBR оптимизация стека\n\n" "Используйте кнопки ниже или команды:\n" "/install /status /link /share /restart /logs /remove" ) async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return msg = update.message or (update.callback_query and update.callback_query.message) 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: if not update.effective_user or not _ok(update.effective_user.id): return info = await proxy_info() if not info: text = "❌ Прокси не запущен.\nИспользуйте команду /install" else: bbr = "✅ Активен" if await check_bbr() else "❌ Выключен" text = ( "✅ SwiftGram работает\n\n" f"🌐 IPv4: {info['ip4']}\n" f"🌐 IPv6: {info['ip6'] or 'не найден'}\n" f"🔌 Порт: {info['port']}\n" f"🎭 Домен: {html.escape(info['domain'])}\n" f"🚀 BBR: {bbr}\n" f"📞 Звонки: ✅ UDP открыт\n\n" f"Secret: {info['secret']}" ) 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 update.message.reply_text(text, parse_mode="HTML", reply_markup=kb) async def cmd_link(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return info = await proxy_info() if not info: text = "❌ Прокси не запущен." else: text = f"Ваша ссылка:\n{info['link4']}" if info['link6']: text += f"\n\nIPv6 ссылка:\n{info['link6']}" 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 update.message.reply_text(text, parse_mode="HTML", reply_markup=kb) async def cmd_share(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return info = await proxy_info() if not info: return share_text = ( f"🔐 MTProxy для Telegram\n\n" f"🌍 Сервер: {info['ip4']}\n" f"🔌 Порт: {info['port']}\n" f"🔑 Secret: {info['secret']}\n\n" f"👉 Подключиться:\n{info['link4']}" ) kb = InlineKeyboardMarkup([ [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=info['link4'])], [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 update.message.reply_text(share_text, parse_mode="HTML", reply_markup=kb) async def cmd_restart(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return if update.callback_query: await update.callback_query.answer("Перезапуск...") code, _, err = await sh("docker", "restart", CONTAINER_NAME, timeout=30) text = "✅ Контейнер перезапущен." if code == 0 else f"❌ Ошибка: {err}" kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) if update.callback_query: await update.callback_query.edit_message_text(text, reply_markup=kb) else: await update.message.reply_text(text, reply_markup=kb) async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return code, out, err = await sh("docker", "logs", "--tail", "30", CONTAINER_NAME, timeout=15) text = f"
{html.escape(out or err or 'Логов нет.')}
" 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 update.message.reply_text(text, parse_mode="HTML", reply_markup=kb) async def cmd_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return if update.callback_query: await update.callback_query.edit_message_text("⏳ Удаление контейнера...") await sh("docker", "stop", CONTAINER_NAME) await sh("docker", "rm", CONTAINER_NAME) 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 update.message.reply_text(text, reply_markup=kb) # ── Установка ──────────────────────────────────────────────────────────────── async def install_step_domain(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_user or not _ok(update.effective_user.id): return buttons = [] row = [] for i, d in enumerate(DOMAINS[:10]): # Показываем первые 10 для компактности row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}")) if len(row) == 2: buttons.append(row); row = [] buttons.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]) text = "🌐 Выберите домен маскировки (Fake TLS):" if update.callback_query: await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) else: await update.message.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) async def do_install(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: domain = ctx.user_data.get("install_domain", "google.com") # Бот делегирует установку системному вызову или запускает через docker напрямую # Для безопасности в этой модульной версии мы просто дергаем docker run port = "443" if await check_port(443): port = "8443" msg = update.callback_query.message if update.callback_query else update.message await msg.reply_text(f"⏳ Начинаю установку SwiftGram на порт {port}...") # Генерация секрета _, s_out, _ = await sh("docker", "run", "--rm", "nineseconds/mtg:2", "generate-secret", "--hex", domain) secret = s_out.strip().split()[-1] if s_out else "" if not secret: await msg.reply_text("❌ Ошибка генерации секрета.") return await sh("docker", "stop", CONTAINER_NAME) await sh("docker", "rm", CONTAINER_NAME) 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 ) if code == 0: save_config({"domain": domain, "port": port, "secret": secret}) await msg.reply_text(f"✅ SwiftGram установлен!\nПорт: {port}\nДомен: {domain}", parse_mode="HTML") await cmd_status(update, ctx) else: await msg.reply_text(f"❌ Ошибка: {err}") # ── Обработчики ────────────────────────────────────────────────────────────── async def callback_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() data = query.data 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.startswith("dom_"): idx = int(data[4:]) ctx.user_data["install_domain"] = DOMAINS[idx] await do_install(update, ctx) 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("install", install_step_domain)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("restart", cmd_restart)) app.add_handler(CommandHandler("remove", cmd_remove)) app.add_handler(CallbackQueryHandler(callback_handler)) app.run_polling() if __name__ == "__main__": main()