Библиотеки: - миграция с aiogram 2.25.2 (EOL) на aiogram 3.31: aiohttp обновился с 3.8.6 до 3.14.3, так как aiogram 2 жёстко требовал aiohttp<3.9 - самодельный словарь состояний заменён на FSM aiogram (MemoryStorage); это убирает падение на user_state.startswith(), когда состояния нет - из requirements убраны неиспользуемые пакеты (Babel 2.9.1, humanize, aiofiles, yookassa, YooMoney); оставлены только прямые зависимости Хардкод: - пути вычисляются от расположения файлов бота (awg/config.py), больше нет привязки к /root/amnezia-bot и к запуску из конкретного каталога - вместо 'python3.11' используется sys.executable; awg-decode.py переименован в awg_decode.py и импортируется напрямую вместо запуска через subprocess - newclient.sh и removeclient.sh определяют каталог по своему расположению, а не по cwd - install.sh: каталог, репозиторий, ветка и Python настраиваются переменными окружения, зависимости ставятся из requirements.txt - секреты можно задавать переменными окружения вместо config.json Оплата (встроенные платежи Telegram, провайдер ЮKassa): - счёт, pre_checkout_query и successful_payment; публичный HTTPS-адрес и вебхук не нужны - обработчик платежей вынесен в отдельный роутер и подключён первым: successful_payment приходит без текста и иначе перехватывался бы обработчиком, ожидающим ввода в FSM - выдача ключа идемпотентна по id платежа, повторная доставка апдейта не создаёт второй ключ - если после оплаты ключ создать не удалось, уведомляются администраторы - промокоды разделены на validate/consume: использование засчитывается только после фактической выдачи ключа, а не при проверке кода - поддержаны скидочные промокоды, уменьшающие сумму счёта - опциональные чеки по 54-ФЗ через provider_data Исправленные ошибки: - db.root_add и db.deactive_user_db передавали скриптам 2 аргумента вместо 4, из-за чего создание и удаление клиентов всегда падало; публичный ключ для удаления теперь вычисляется из конфига клиента - в create_backup не был импортирован zipfile (NameError при бэкапе) - планировщик запускался без задач, подписки никогда не истекали; добавлена ежечасная проверка с отключением доступа и уведомлением - callback_data вида renew_period_<имя>_<период> разбирался неверно для имён с подчёркиваниями; разделителем стало двоеточие - статус клиента брался из status.json, который никто не создавал; теперь используется wg show latest-handshakes - продление подписки сбрасывало остаток, теперь период добавляется к нему - Markdown-разметка ломалась на именах с подчёркиваниями; используется HTML с экранированием - vpn:// ключ уходит отдельным сообщением, а не в подписи к файлу, где мог не поместиться в лимит 1024 символа - блокирующие вызовы docker и скриптов вынесены в отдельные потоки - при удалении клиента связь с Telegram ID удаляется, а не пишется None Удалён мёртвый код: awg/wg.py (нигде не импортировался, содержал подсеть 10.0.0.0/24 вместо используемой 10.8.1.0/24) и handlers/add_client.py (импортировал несуществующий модуль loader).
103 строки
3.7 KiB
Python
103 строки
3.7 KiB
Python
"""Операции с клиентами VPN: создание, удаление, выдача подписок.
|
|
|
|
Все блокирующие вызовы (docker, скрипты, DNS) выполняются в отдельном потоке,
|
|
чтобы не блокировать цикл событий бота.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytz
|
|
|
|
import awg_decode
|
|
import config
|
|
import db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def client_conf_path(username):
|
|
"""Путь к файлу конфигурации клиента."""
|
|
return config.USERS_DIR / username / f'{username}.conf'
|
|
|
|
|
|
def _encode_conf(conf_text):
|
|
return awg_decode.encode(awg_decode.process_conf_data(conf_text))
|
|
|
|
|
|
async def generate_vpn_key(conf_path):
|
|
"""Преобразует конфиг клиента в ссылку vpn:// для приложения AmneziaVPN."""
|
|
try:
|
|
conf_text = conf_path.read_text(encoding='utf-8')
|
|
# process_conf_data резолвит DNS, поэтому выносим в поток.
|
|
return await asyncio.to_thread(_encode_conf, conf_text)
|
|
except Exception as e:
|
|
logger.error(f"Ошибка генерации vpn:// для {conf_path}: {e}")
|
|
return ''
|
|
|
|
|
|
def make_username(telegram_id):
|
|
"""Генерирует уникальное имя клиента для пользователя Telegram."""
|
|
return f'user_{telegram_id}_{uuid.uuid4().hex[:8]}'
|
|
|
|
|
|
async def create_client(username, settings):
|
|
"""Создаёт клиента WireGuard."""
|
|
return await asyncio.to_thread(
|
|
db.root_add, username, settings.endpoint,
|
|
settings.wg_config_file, settings.docker_container,
|
|
)
|
|
|
|
|
|
async def delete_client(username, settings):
|
|
"""Удаляет клиента WireGuard и все связанные с ним данные."""
|
|
removed = await asyncio.to_thread(
|
|
db.deactive_user_db, username,
|
|
settings.wg_config_file, settings.docker_container,
|
|
)
|
|
if not removed:
|
|
return False
|
|
shutil.rmtree(config.USERS_DIR / username, ignore_errors=True)
|
|
db.remove_user_expiration(username)
|
|
db.remove_user_telegram_id(username)
|
|
return True
|
|
|
|
|
|
async def active_clients(settings):
|
|
"""Возвращает {имя клиента: время последнего handshake}."""
|
|
return await asyncio.to_thread(
|
|
db.get_active_list, settings.wg_config_file, settings.docker_container,
|
|
)
|
|
|
|
|
|
def expiration_for(period, since=None):
|
|
"""Вычисляет дату окончания подписки для периода."""
|
|
months = config.PERIODS.get(period, 1)
|
|
return (since or datetime.now(pytz.utc)) + timedelta(days=30 * months)
|
|
|
|
|
|
async def issue_subscription(telegram_id, period, settings):
|
|
"""Создаёт клиента и выдаёт ему подписку. Возвращает имя клиента или None."""
|
|
username = make_username(telegram_id)
|
|
if not await create_client(username, settings):
|
|
return None
|
|
db.set_user_expiration(username, expiration_for(period))
|
|
db.set_user_telegram_id(username, telegram_id)
|
|
return username
|
|
|
|
|
|
def extend_subscription(username, period):
|
|
"""Продлевает подписку клиента, отсчитывая от текущей даты окончания.
|
|
|
|
Если подписка ещё активна, время не сгорает — новый период добавляется
|
|
к остатку.
|
|
"""
|
|
current = db.get_user_expiration(username)
|
|
now = datetime.now(pytz.utc)
|
|
since = current if current and current > now else now
|
|
expiration = expiration_for(period, since=since)
|
|
db.set_user_expiration(username, expiration)
|
|
return expiration
|