Files
viktor 11b52cf5ad Обновление зависимостей, устранение хардкода, оплата через Telegram
Библиотеки:
- миграция с 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).
2026-09-04 14:44:26 +00:00

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