├── .gitignore ├── README.md ├── bot.py ├── db ├── __init__.py ├── db_connector.py └── models.py ├── expiration_notifier ├── __init__.py ├── base.py ├── manager.py └── notifier.py ├── handlers ├── __init__.py ├── buy_service.py ├── callback_factories.py ├── create_bill.py ├── introduction.py ├── profile.py └── user_agreement.py ├── helpers ├── __init__.py ├── bot_answers_shortcuts.py └── verbose_numbers.py ├── images ├── img.png └── logo.png ├── manager.py ├── nginx.conf ├── payment ├── __init__.py ├── base.py └── qiwi_payment.py ├── requirements.txt ├── server_manager ├── __init__.py ├── configuration │ ├── __init__.py │ ├── base.py │ └── manager.py ├── managers │ ├── __init__.py │ ├── base.py │ ├── config_manager.py │ ├── payment_control.py │ └── vpn_control_manager.py └── server │ ├── __init__.py │ ├── base.py │ ├── connection.py │ └── types.py ├── settings.py └── templates └── user_agreement.html /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | test* 3 | .idea 4 | *.drawio* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Axo-VPN / Telegram бот 2 | 3 | ![Python](https://img.shields.io/badge/python-3.10+-blue.svg) 4 | [![Code style: black](https://img.shields.io/badge/code_style-black-black.svg)](https://github.com/psf/black) 5 | 6 | [@axo_vpn_bot](https://t.me/axo_vpn_bot) 7 | 8 | 9 | 10 | Полностью асинхронный бот для покупки VPN через телеграм 11 | 12 | Написан с использованием: 13 | * aiogram 14 | * asyncio 15 | * aiohttp 16 | * asyncssh 17 | * aiomysql 18 | * sqlalchemy 19 | 20 | ![img.png](images/img.png) 21 | 22 | 23 | ### Для работы необходимо указать следующие переменные окружения 24 | 25 | # for database 26 | MYSQL_DATABASE = vpn_bot 27 | MYSQL_HOST = localhost 28 | MYSQL_LOGIN = root 29 | MYSQL_PASSWORD = password 30 | 31 | QIWI_TOKEN = aabb... 32 | TG_BOT_TOKEN = 0011... 33 | 34 | # for webhook 35 | BASE_URL = https://... 36 | PUBLIC_IP = 123.123.123.123 37 | CERTIFICATE_PATH = /absolute/path/to/webhook_cert.pem 38 | 39 | ## Старт 40 | 41 | Настраиваем Nginx: 42 | 43 | ```nginx configuration 44 | server { 45 | listen 443 ssl; 46 | 47 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 48 | server_name ; 49 | 50 | ssl on; 51 | ssl_certificate /absolute/path/to/webhook_cert.pem; 52 | ssl_certificate_key /absolute/path/to/webhook_pkey.pem; 53 | 54 | location /webhook/bot { 55 | include proxy_params; 56 | proxy_pass http://127.0.0.1:8888/webhook/bot; 57 | } 58 | } 59 | ``` 60 | 61 | Запуск бота: 62 | ```shell 63 | python bot.py 64 | ``` 65 | 66 | Запуск менеджера: 67 | ```shell 68 | python manager.py 69 | ``` -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aiogram.dispatcher.webhook.aiohttp_server import ( 5 | SimpleRequestHandler, 6 | setup_application, 7 | ) 8 | from aiohttp import web 9 | from aiogram import Bot, Dispatcher 10 | from aiogram.types.input_file import FSInputFile 11 | from aiogram.client.session.aiohttp import AiohttpSession 12 | 13 | import settings 14 | from handlers import introduction, buy_service, create_bill, profile, user_agreement 15 | from expiration_notifier.manager import ExpirationManager 16 | from expiration_notifier.notifier import TgBotNotifier 17 | 18 | 19 | async def notify(bot: Bot): 20 | try: 21 | notifiers = [TgBotNotifier(bot)] 22 | await ExpirationManager(notifiers).run() 23 | except Exception as exc: 24 | logging.error("Ошибка ExpirationManager", exc_info=exc) 25 | 26 | 27 | async def on_startup(dispatcher: Dispatcher, bot: Bot): 28 | await bot.set_webhook( 29 | f"{settings.BASE_URL}{settings.BOT_PATH}", 30 | certificate=FSInputFile(settings.CERTIFICATE_PATH), 31 | ip_address=settings.PUBLIC_IP, 32 | drop_pending_updates=True, 33 | ) 34 | webhook = await bot.get_webhook_info() 35 | print("======== SET WEBHOOK ========") 36 | print(webhook) 37 | 38 | # Запускаем проверку подключений и отправку уведомлений 39 | asyncio.get_event_loop().create_task(notify(bot)) 40 | 41 | 42 | async def on_shutdown(dispatcher: Dispatcher, bot: Bot): 43 | webhook = await bot.delete_webhook() 44 | print("======== DELETE WEBHOOK ======== ->", webhook) 45 | 46 | 47 | def add_routes(dispatcher: Dispatcher): 48 | dispatcher.include_router(introduction.router) 49 | dispatcher.include_router(buy_service.router) 50 | dispatcher.include_router(profile.router) 51 | dispatcher.include_router(create_bill.router) 52 | dispatcher.include_router(user_agreement.router) 53 | 54 | 55 | def main(): 56 | session = AiohttpSession() 57 | bot_settings = {"session": session, "parse_mode": "HTML"} 58 | 59 | bot = Bot(token=settings.TOKEN, **bot_settings) 60 | dp = Dispatcher() 61 | dp.startup.register(on_startup) 62 | dp.shutdown.register(on_shutdown) 63 | 64 | # Добавляем роуты 65 | add_routes(dp) 66 | 67 | app = web.Application() 68 | SimpleRequestHandler(dispatcher=dp, bot=bot).register(app, path=settings.BOT_PATH) 69 | setup_application(app, dp, bot=bot) 70 | 71 | web.run_app(app, host=settings.WEB_SERVER_HOST, port=settings.WEB_SERVER_PORT) 72 | 73 | 74 | if __name__ == "__main__": 75 | logging.basicConfig(level=logging.INFO) 76 | main() 77 | -------------------------------------------------------------------------------- /db/__init__.py: -------------------------------------------------------------------------------- 1 | from .db_connector import async_db_session 2 | from .models import Server, VPNConnection, User, ActiveBills 3 | -------------------------------------------------------------------------------- /db/db_connector.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession 4 | from sqlalchemy.orm import DeclarativeBase 5 | 6 | 7 | class Base(DeclarativeBase): 8 | pass 9 | 10 | 11 | class AsyncDatabaseSession: 12 | def __init__(self): 13 | login = os.getenv("MYSQL_LOGIN") 14 | password = os.getenv("MYSQL_PASSWORD") 15 | database = os.getenv("MYSQL_DATABASE") 16 | host = os.getenv("MYSQL_HOST") 17 | 18 | self._engine = create_async_engine( 19 | f"mysql+aiomysql://{login}:{password}@{host}/{database}?charset=utf8mb4" 20 | ) 21 | self._session = async_sessionmaker( 22 | self._engine, 23 | expire_on_commit=False, 24 | class_=AsyncSession, 25 | ) 26 | 27 | def __call__(self): 28 | return self._session() 29 | 30 | def __getattr__(self, name): 31 | return getattr(self._session, name) 32 | 33 | async def create_all(self): 34 | async with self._engine.begin() as conn: 35 | await conn.run_sync(Base.metadata.create_all) 36 | await self._engine.dispose() 37 | 38 | 39 | async_db_session = AsyncDatabaseSession() 40 | -------------------------------------------------------------------------------- /db/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TypeVar, Generic, Sequence 3 | 4 | import flag 5 | from sqlalchemy.exc import NoResultFound 6 | from sqlalchemy.schema import ForeignKey, Column, Table 7 | from sqlalchemy.types import String, DateTime, Text, Integer 8 | from sqlalchemy.sql import select, update as sqlalchemy_update 9 | from sqlalchemy.orm import Mapped, mapped_column, relationship 10 | from sqlalchemy.orm.strategy_options import load_only, selectinload 11 | 12 | from .db_connector import Base, async_db_session 13 | 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | class ModelAdmin(Generic[T]): 19 | class DoesNotExists(Exception): 20 | pass 21 | 22 | @classmethod 23 | async def create(cls, **kwargs) -> T: 24 | """ 25 | # Создает новый объект и возвращает его. 26 | :param kwargs: Поля и значения для объекта. 27 | :return: Созданный объект. 28 | """ 29 | 30 | async with async_db_session() as session: 31 | obj = cls(**kwargs) 32 | session.add(obj) 33 | await session.commit() 34 | await session.refresh(obj) 35 | return obj 36 | 37 | @classmethod 38 | async def add(cls, **kwargs) -> None: 39 | """ 40 | # Создает новый объект. 41 | :param kwargs: Поля и значения для объекта. 42 | """ 43 | 44 | async with async_db_session() as session: 45 | session.add(cls(**kwargs)) 46 | await session.commit() 47 | 48 | async def update(self, **kwargs) -> None: 49 | """ 50 | # Обновляет текущий объект. 51 | :param kwargs: Поля и значения, которые надо поменять. 52 | """ 53 | 54 | async with async_db_session() as session: 55 | await session.execute( 56 | sqlalchemy_update(self.__class__), [{"id": self.id, **kwargs}] 57 | ) 58 | await session.commit() 59 | 60 | async def delete(self) -> None: 61 | """ 62 | # Удаляет объект. 63 | """ 64 | async with async_db_session() as session: 65 | await session.delete(self) 66 | await session.commit() 67 | 68 | @classmethod 69 | async def get(cls, select_in_load: str | None = None, **kwargs) -> T: 70 | """ 71 | # Возвращает одну запись, которая удовлетворяет введенным параметрам. 72 | 73 | :param select_in_load: Загрузить сразу связанную модель. 74 | :param kwargs: Поля и значения. 75 | :return: Объект или вызовет исключение DoesNotExists. 76 | """ 77 | 78 | params = [getattr(cls, key) == val for key, val in kwargs.items()] 79 | query = select(cls).where(*params) 80 | 81 | if select_in_load: 82 | query.options(selectinload(getattr(cls, select_in_load))) 83 | 84 | try: 85 | async with async_db_session() as session: 86 | results = await session.execute(query) 87 | (result,) = results.one() 88 | return result 89 | except NoResultFound: 90 | raise cls.DoesNotExists 91 | 92 | @classmethod 93 | async def filter(cls, select_in_load: str | None = None, **kwargs) -> Sequence[T]: 94 | """ 95 | # Возвращает все записи, которые удовлетворяют фильтру. 96 | 97 | :param select_in_load: Загрузить сразу связанную модель. 98 | :param kwargs: Поля и значения. 99 | :return: Перечень записей. 100 | """ 101 | 102 | params = [getattr(cls, key) == val for key, val in kwargs.items()] 103 | query = select(cls).where(*params) 104 | 105 | if select_in_load: 106 | query.options(selectinload(getattr(cls, select_in_load))) 107 | 108 | try: 109 | async with async_db_session() as session: 110 | results = await session.execute(query) 111 | return results.scalars().all() 112 | except NoResultFound: 113 | return () 114 | 115 | @classmethod 116 | async def all( 117 | cls, select_in_load: str = None, values: list[str] = None 118 | ) -> Sequence[T]: 119 | """ 120 | # Получает все записи. 121 | 122 | :param select_in_load: Загрузить сразу связанную модель. 123 | :param values: Список полей, которые надо вернуть, если нет, то все (default None). 124 | """ 125 | 126 | if values and isinstance(values, list): 127 | # Определенные поля 128 | values = [getattr(cls, val) for val in values if isinstance(val, str)] 129 | query = select(cls).options(load_only(*values)) 130 | else: 131 | # Все поля 132 | query = select(cls) 133 | 134 | if select_in_load: 135 | query.options(selectinload(getattr(cls, select_in_load))) 136 | 137 | async with async_db_session() as session: 138 | result = await session.execute(query) 139 | return result.scalars().all() 140 | 141 | 142 | class User(Base, ModelAdmin): 143 | __tablename__ = "users" 144 | 145 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 146 | tg_id: Mapped[int] 147 | 148 | active_bills: Mapped[list["ActiveBills"]] = relationship() 149 | 150 | @classmethod 151 | async def get_or_create(cls, tg_id: int) -> "User": 152 | try: 153 | user = await User.get(tg_id=tg_id) 154 | except cls.DoesNotExists: 155 | user = await User.create(tg_id=tg_id) 156 | return user 157 | 158 | async def get_connections(self) -> Sequence["VPNConnection"]: 159 | async with async_db_session() as session: 160 | query = select(VPNConnection).where( 161 | VPNConnection.user_id == self.id, 162 | VPNConnection.available_to != None, 163 | ) 164 | connections = await session.execute(query) 165 | 166 | return connections.scalars().all() 167 | 168 | async def get_active_bills(self) -> Sequence["ActiveBills"]: 169 | async with async_db_session() as session: 170 | query = ( 171 | select(User) 172 | .where(User.tg_id == self.tg_id) 173 | .options( 174 | selectinload(User.active_bills).selectinload( 175 | ActiveBills.vpn_connections 176 | ) 177 | ) 178 | ) 179 | user = await session.execute(query) 180 | 181 | return user.scalars().one().active_bills 182 | 183 | 184 | class Server(Base, ModelAdmin): 185 | __tablename__ = "servers" 186 | 187 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 188 | name: Mapped[str] = mapped_column(String(50), unique=True) 189 | ip: Mapped[str] = mapped_column(String(45), unique=True) 190 | port: Mapped[str] = mapped_column(Integer(), default=22) 191 | login: Mapped[str] = mapped_column(String(50)) 192 | password: Mapped[str] = mapped_column(String(50)) 193 | location: Mapped[str] = mapped_column(String(100)) 194 | country_code: Mapped[str] = mapped_column(String(6)) 195 | vpn_connections: Mapped[list["VPNConnection"]] = relationship(lazy="subquery") 196 | 197 | @property 198 | def verbose_location(self) -> str: 199 | return f"{flag.flag(self.country_code)} {self.location}" 200 | 201 | 202 | class VPNConnection(Base, ModelAdmin): 203 | __tablename__ = "vpn_connections" 204 | 205 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 206 | server_id: Mapped[int] = mapped_column(ForeignKey("servers.id")) 207 | user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True) 208 | available: Mapped[bool] 209 | local_ip: Mapped[str] = mapped_column(String(15)) 210 | available_to: Mapped[datetime] = mapped_column(nullable=True) 211 | config: Mapped[str] = mapped_column(Text()) 212 | client_name: Mapped[str] = mapped_column(String(30)) 213 | 214 | @staticmethod 215 | async def get_free(server_id: int, limit: int): 216 | async with async_db_session() as session: 217 | query = ( 218 | select(VPNConnection) 219 | .where(VPNConnection.server_id == server_id) 220 | .where(VPNConnection.user_id.is_(None)) 221 | .options(load_only(VPNConnection.id)) 222 | .limit(limit) 223 | ) 224 | res = await session.execute(query) 225 | return res.scalars() 226 | 227 | 228 | bills_vpn_connections_association_table = Table( 229 | "bills_vpn_connections", 230 | Base.metadata, 231 | Column("bill_id", ForeignKey("active_bills.id")), 232 | Column("vpn_conn_id", ForeignKey("vpn_connections.id")), 233 | ) 234 | 235 | 236 | class ActiveBills(Base, ModelAdmin): 237 | __tablename__ = "active_bills" 238 | 239 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 240 | bill_id: Mapped[str] = mapped_column(String(255)) 241 | user: Mapped[int] = mapped_column(ForeignKey("users.id")) 242 | vpn_connections: Mapped[list["VPNConnection"]] = relationship( 243 | secondary=bills_vpn_connections_association_table, 244 | backref="active_bills", 245 | lazy="subquery", 246 | ) 247 | available_to: Mapped[datetime] = mapped_column( 248 | DateTime(), nullable=True, default=None 249 | ) 250 | type: Mapped[str] = mapped_column(String(50)) 251 | rent_month: Mapped[int] 252 | pay_url: Mapped[str] = mapped_column(String(255)) 253 | 254 | async def delete(self) -> None: 255 | """ 256 | # Удаляет объект. 257 | """ 258 | async with async_db_session() as session: 259 | active_bill = await session.get(ActiveBills, self.id) 260 | for conn in active_bill.vpn_connections: 261 | active_bill.vpn_connections.remove(conn) 262 | await session.delete(active_bill) 263 | await session.commit() 264 | -------------------------------------------------------------------------------- /expiration_notifier/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/expiration_notifier/__init__.py -------------------------------------------------------------------------------- /expiration_notifier/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from db import VPNConnection, User, Server 4 | 5 | 6 | class AbstractNotifier(ABC): 7 | 8 | @abstractmethod 9 | async def notify_connection_expired( 10 | self, user: User, server: Server, connection: VPNConnection, days_left: int 11 | ): 12 | pass 13 | 14 | @abstractmethod 15 | async def notify_soon_expired( 16 | self, user: User, server: Server, connection: VPNConnection, days_left: int 17 | ): 18 | pass 19 | -------------------------------------------------------------------------------- /expiration_notifier/manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime, timedelta, time, date 3 | from typing import Literal 4 | 5 | from db import VPNConnection, User, Server 6 | from db.models import ModelAdmin 7 | from .base import AbstractNotifier 8 | 9 | 10 | class ExpirationManager: 11 | expiration_limit_timedelta = timedelta(days=5) 12 | notifier_time = time(hour=19, minute=31, second=0) 13 | 14 | def __init__(self, notifiers: list[AbstractNotifier]): 15 | self._notifiers = notifiers 16 | self._last_day_checked: date = date.today() - timedelta(days=1) 17 | 18 | async def run(self): 19 | while True: 20 | if self.is_time_to_check(): 21 | await self._check_vpn_connections() 22 | self._last_day_checked = date.today() 23 | await asyncio.sleep(60 * 2) 24 | 25 | def is_time_to_check(self) -> bool: 26 | if self._last_day_checked == date.today(): 27 | return False 28 | return ( 29 | (datetime.now() - timedelta(minutes=5)).time() 30 | <= self.notifier_time 31 | <= (datetime.now() + timedelta(minutes=5)).time() 32 | ) 33 | 34 | async def _check_vpn_connections(self): 35 | for conn in await VPNConnection.all(): 36 | conn: VPNConnection 37 | 38 | if not conn.user_id or not conn.available_to: 39 | continue 40 | 41 | if not conn.available: 42 | # Если подключение уже недоступно, но еще принадлежит пользователю 43 | days_to_delete = ( 44 | (conn.available_to + self.expiration_limit_timedelta) 45 | - datetime.now() 46 | ).days 47 | 48 | await self._notify("connection_expired", conn, days_to_delete) 49 | 50 | elif conn.available_to <= datetime.now() + self.expiration_limit_timedelta: 51 | # Если подключение скоро истечет 52 | days_left = (conn.available_to - datetime.now()).days 53 | 54 | await self._notify("soon_expired", conn, days_left) 55 | 56 | @staticmethod 57 | async def get_user_and_server(conn: VPNConnection) -> tuple[User, Server]: 58 | user: User = await User.get(id=conn.user_id) 59 | server: Server = await Server.get(id=conn.server_id) 60 | 61 | return user, server 62 | 63 | async def _notify( 64 | self, 65 | mode: Literal["connection_expired", "soon_expired"], 66 | conn: VPNConnection, 67 | days_to_delete, 68 | ): 69 | try: 70 | user, server = await self.get_user_and_server(conn) 71 | except ModelAdmin.DoesNotExists: 72 | return 73 | 74 | else: 75 | for notifier in self._notifiers: 76 | await getattr(notifier, f"notify_{mode}")( 77 | user, server, conn, days_to_delete 78 | ) 79 | -------------------------------------------------------------------------------- /expiration_notifier/notifier.py: -------------------------------------------------------------------------------- 1 | from db import VPNConnection, User, Server 2 | from .base import AbstractNotifier 3 | from helpers.verbose_numbers import days_verbose 4 | 5 | 6 | class TgBotNotifier(AbstractNotifier): 7 | def __init__(self, bot): 8 | self.bot = bot 9 | 10 | async def notify_connection_expired( 11 | self, user: User, server: Server, connection: VPNConnection, days_left: int 12 | ): 13 | if days_left == 0: 14 | days_left_string = "Удалится завтра" 15 | else: 16 | days_left_string = f"Удалится через {days_left} {days_verbose(days_left)}" 17 | 18 | await self.bot.send_message( 19 | chat_id=user.tg_id, 20 | text=f"Срок аренды вышел!\n" 21 | f"Подключение {connection.local_ip}\n{server.name}{server.verbose_location}\n" 22 | f"{days_left_string}", 23 | ) 24 | 25 | async def notify_soon_expired( 26 | self, user: User, server: Server, connection: VPNConnection, days_left: int 27 | ): 28 | if days_left == 0: 29 | days_left_string = "Завтра" 30 | else: 31 | days_left_string = f"Через {days_left} {days_verbose(days_left)}" 32 | 33 | await self.bot.send_message( 34 | chat_id=user.tg_id, 35 | text=f"{days_left_string} закончится аренда подключения!" 36 | f" {connection.local_ip}\n{server.name}{server.verbose_location}", 37 | ) 38 | -------------------------------------------------------------------------------- /handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/handlers/__init__.py -------------------------------------------------------------------------------- /handlers/buy_service.py: -------------------------------------------------------------------------------- 1 | import flag 2 | from aiogram import Router 3 | from aiogram.types import InlineKeyboardButton, CallbackQuery 4 | from aiogram.utils.keyboard import InlineKeyboardBuilder 5 | 6 | from db import Server 7 | from helpers.verbose_numbers import month_verbose 8 | from .callback_factories import ( 9 | DeviceCountCallbackFactory as DevCountCF, 10 | BuyCallbackFactory as BuyCF, 11 | ConfirmPaymentCallbackFactory as ConfirmPaymentCF, 12 | ExtendRentCallbackFactory as ExtendRentCF, 13 | ) 14 | 15 | 16 | router = Router() 17 | 18 | 19 | @router.callback_query(text="choose_location") 20 | async def choose_location(callback: CallbackQuery): 21 | keyboard = InlineKeyboardBuilder() 22 | # Смотрим список VPN серверов 23 | for server in await Server.all(values=["country_code", "location"]): 24 | # Добавляем флаг страны и местоположение VPN сервера 25 | text = flag.flagize( 26 | f":{server.country_code}: {server.location}\n", subregions=True 27 | ) 28 | keyboard.add( 29 | InlineKeyboardButton( 30 | text=text, callback_data=DevCountCF(count=1, server_id=server.id).pack() 31 | ) 32 | ) 33 | await callback.message.edit_text( 34 | text="Выберите VPN сервер", reply_markup=keyboard.as_markup() 35 | ) 36 | await callback.answer() 37 | 38 | 39 | def add_period_keyboard(keyboard: InlineKeyboardBuilder, callback_data): 40 | print(callback_data) 41 | if isinstance(callback_data, DevCountCF): 42 | base_cost = 50 + 100 * callback_data.count 43 | end_data = {"count": callback_data.count} 44 | type_ = "new" 45 | else: 46 | base_cost = 150 47 | type_ = "extend" 48 | end_data = {"connection_id": callback_data.connection_id} 49 | 50 | # Кнопки выбора периода оплаты 51 | # 1, 2 МЕСЯЦА 52 | keyboard.row( 53 | InlineKeyboardButton( 54 | text=f"1️⃣ месяц - {base_cost} ₽", 55 | callback_data=BuyCF( 56 | type_=type_, 57 | month=1, 58 | cost=base_cost, 59 | server_id=callback_data.server_id, 60 | **end_data, 61 | ).pack(), 62 | ), 63 | InlineKeyboardButton( 64 | text=f"2️⃣ месяца - {round(base_cost * 2)} ₽", 65 | callback_data=BuyCF( 66 | type_=type_, 67 | month=2, 68 | cost=round(base_cost * 2), 69 | server_id=callback_data.server_id, 70 | **end_data, 71 | ).pack(), 72 | ), 73 | ) 74 | 75 | # 3, 4 МЕСЯЦА 76 | keyboard.row( 77 | InlineKeyboardButton( 78 | text=f"3️⃣ месяца - {round(base_cost * 3)} ₽", 79 | callback_data=BuyCF( 80 | type_=type_, 81 | month=3, 82 | cost=round(base_cost * 3), 83 | server_id=callback_data.server_id, 84 | **end_data, 85 | ).pack(), 86 | ), 87 | InlineKeyboardButton( 88 | text=f"4️⃣ месяца - {round(base_cost * 4)} ₽", 89 | callback_data=BuyCF( 90 | type_=type_, 91 | month=4, 92 | cost=round(base_cost * 4), 93 | server_id=callback_data.server_id, 94 | **end_data, 95 | ).pack(), 96 | ), 97 | ) 98 | 99 | # 5, 6 МЕСЯЦЕВ 100 | keyboard.row( 101 | InlineKeyboardButton( 102 | text=f"5️⃣ месяцев - {round(base_cost * 5)} ₽", 103 | callback_data=BuyCF( 104 | type_=type_, 105 | month=5, 106 | cost=round(base_cost * 5), 107 | server_id=callback_data.server_id, 108 | **end_data, 109 | ).pack(), 110 | ) 111 | ) 112 | 113 | keyboard.row( 114 | InlineKeyboardButton( 115 | text=f"6️⃣ месяцев 🔸 -20% 🔸 - {round(base_cost * 6 * 0.8)} ₽", 116 | callback_data=BuyCF( 117 | type_=type_, 118 | month=6, 119 | cost=round(base_cost * 6 * 0.8), 120 | server_id=callback_data.server_id, 121 | **end_data, 122 | ).pack(), 123 | ) 124 | ) 125 | 126 | # 1 ГОД 127 | keyboard.row( 128 | InlineKeyboardButton( 129 | text=f"1️⃣ год 🔹 -30% 🔹 - {round(base_cost * 12 * 0.7)} ₽", 130 | callback_data=BuyCF( 131 | type_=type_, 132 | month=12, 133 | cost=round(base_cost * 12 * 0.7), 134 | server_id=callback_data.server_id, 135 | **end_data, 136 | ).pack(), 137 | ) 138 | ) 139 | 140 | 141 | @router.callback_query(DevCountCF.filter()) 142 | async def show_prices(callback: CallbackQuery, callback_data: DevCountCF): 143 | """ 144 | Выбор кол-ва подключений и периода 145 | """ 146 | 147 | keyboard = InlineKeyboardBuilder() 148 | if callback_data.count > 1: 149 | keyboard.row( 150 | InlineKeyboardButton( 151 | text="➖ Убрать одно устройство", 152 | callback_data=DevCountCF( 153 | count=callback_data.count - 1, server_id=callback_data.server_id 154 | ).pack(), 155 | ) 156 | ) 157 | if callback_data.count < 4: 158 | keyboard.row( 159 | InlineKeyboardButton( 160 | text="➕ Добавить еще одно устройство", 161 | callback_data=DevCountCF( 162 | count=callback_data.count + 1, server_id=callback_data.server_id 163 | ).pack(), 164 | ) 165 | ) 166 | 167 | add_period_keyboard(keyboard, callback_data) 168 | 169 | keyboard.row(InlineKeyboardButton(text="🔝 На главную", callback_data="start")) 170 | 171 | await callback.message.edit_text( 172 | text=f"Подключений: {callback_data.count}\n" f"Выберите период аренды", 173 | reply_markup=keyboard.as_markup(), 174 | ) 175 | await callback.answer() 176 | 177 | 178 | # ПРОДЛИТЬ АРЕНДУ 179 | @router.callback_query(ExtendRentCF.filter()) 180 | async def extend_rent(callback: CallbackQuery, callback_data: ExtendRentCF): 181 | keyboard = InlineKeyboardBuilder() 182 | 183 | add_period_keyboard(keyboard, callback_data) 184 | 185 | keyboard.add(InlineKeyboardButton(text="✖️Отмена", callback_data=f"show_profile")) 186 | await callback.message.edit_text( 187 | text="Выберите период аренды", reply_markup=keyboard.as_markup() 188 | ) 189 | await callback.answer() 190 | 191 | 192 | # СОГЛАСИЕ НА ПОКУПКУ 193 | @router.callback_query(BuyCF.filter()) 194 | async def confirm_payment(callback: CallbackQuery, callback_data: BuyCF): 195 | """ 196 | Подтверждение покупки 197 | """ 198 | 199 | keyboard = InlineKeyboardBuilder().row( 200 | InlineKeyboardButton( 201 | text="Пользовательское соглашение", callback_data="user_agreement" 202 | ) 203 | ) 204 | 205 | if callback_data.type_ == "extend": 206 | # Если это продление аренды 207 | confirm_callback = ConfirmPaymentCF( 208 | type_="extend", 209 | cost=callback_data.cost, 210 | count=1, 211 | month=callback_data.month, 212 | connection_id=callback_data.connection_id, 213 | server_id=callback_data.server_id, 214 | ) 215 | 216 | text = ( 217 | f"Продление аренды ⏩\n" 218 | f"Нажимая кнопку оплатить, Вы соглашаетесь с пользовательским соглашением \n" 219 | ) 220 | keyboard.row( 221 | InlineKeyboardButton( 222 | text=f"Оплатить {callback_data.cost} ₽", 223 | callback_data=confirm_callback.pack(), 224 | ), 225 | InlineKeyboardButton(text="✖️Отмена", callback_data="show_profile"), 226 | ) 227 | 228 | elif callback_data.type_ == "new": 229 | # Новое подключение 230 | confirm_callback = ConfirmPaymentCF( 231 | type_="new", 232 | cost=callback_data.cost, 233 | count=callback_data.count, 234 | month=callback_data.month, 235 | server_id=callback_data.server_id, 236 | ) 237 | # Новая покупка 238 | text = ( 239 | f"Ваше количество устройств: {callback_data.count} 📲\n" 240 | f"Длительность аренды: {callback_data.month} {month_verbose(callback_data.month)}\n" 241 | f"Нажимая кнопку оплатить, Вы соглашаетесь с пользовательским соглашением \n" 242 | ) 243 | keyboard.row( 244 | InlineKeyboardButton( 245 | text=f"Оплатить {callback_data.cost} ₽", 246 | callback_data=confirm_callback.pack(), 247 | ), 248 | InlineKeyboardButton(text="✖️Отмена", callback_data="start"), 249 | ) 250 | 251 | else: 252 | text = "❗Неверные данные❗" 253 | keyboard = InlineKeyboardBuilder().add( 254 | InlineKeyboardButton(text="✖️Отмена", callback_data="start") 255 | ) 256 | 257 | await callback.message.edit_text(text, reply_markup=keyboard.as_markup()) 258 | await callback.answer() 259 | -------------------------------------------------------------------------------- /handlers/callback_factories.py: -------------------------------------------------------------------------------- 1 | """ 2 | Описывает классы фабрик callback запросов при нажатии кнопок. 3 | """ 4 | 5 | 6 | from aiogram.dispatcher.filters.callback_data import CallbackData 7 | 8 | 9 | class DeviceCountCallbackFactory(CallbackData, prefix="devcount"): 10 | """ 11 | Используется для выбора кол-ва новых VPN подключений к конкретному серверу. 12 | 13 | Класс содержит атрибуты: 14 | - count (int) - количество устройств. 15 | - server_id (int) - идентификатор VPN сервера. 16 | """ 17 | 18 | count: int 19 | server_id: int 20 | 21 | 22 | class ExtendRentCallbackFactory(CallbackData, prefix="extend_rent"): 23 | """ 24 | Используется для продления текущего VPN подключения к конкретному серверу. 25 | 26 | Класс содержит атрибуты: 27 | - connection_id (int) - идентификатор VPN подключения. 28 | - server_id (int) - идентификатор сервера. 29 | """ 30 | 31 | connection_id: int 32 | server_id: int 33 | 34 | 35 | class BuyCallbackFactory(CallbackData, prefix="buy"): 36 | """ 37 | Используется для формирования покупки: 38 | указания выбора кол-ва VPN подключений к серверу, период аренды в зависимости 39 | от типа аренды (новая, либо продление существующей). 40 | 41 | Класс содержит атрибуты: 42 | - type_ (str) - тип аренды ("new", "extend") - новое, либо продление. 43 | - month (int) - кол-во месяцев. 44 | - cost (int) - стоимость аренды. 45 | - server_id (int) - идентификатор сервера VPN. 46 | - count (int) - опционально, кол-во устройств для аренды (если аренда новых). 47 | - connection_id (int) - опционально, идентификатор существующего подключения. 48 | """ 49 | type_: str 50 | month: int 51 | cost: int 52 | server_id: int 53 | count: int | None 54 | connection_id: int | None 55 | 56 | 57 | class ConfirmPaymentCallbackFactory(CallbackData, prefix="submit_buy"): 58 | """ 59 | Используется для подтвержденного выбора VPN аренды и покупки. 60 | 61 | Класс содержит атрибуты: 62 | - type_ (str) - тип аренды (`new`, `extend`) - новое, либо продление. 63 | - cost (int) - стоимость аренды. 64 | - count (int) - кол-во устройств для аренды. 65 | - month (int) - кол-во месяцев. 66 | - server_id (int) - опционально, идентификатор сервера VPN (если продление существующего подключения). 67 | - connection_id (int) - опционально, идентификатор существующего подключения. 68 | """ 69 | type_: str 70 | cost: int 71 | count: int 72 | month: int 73 | server_id: int | None 74 | connection_id: int | None 75 | 76 | 77 | class GetConfigCallbackFactory(CallbackData, prefix="config"): 78 | """ 79 | Используется для указания идентификатора VPN подключения. 80 | 81 | Класс содержит один атрибут: 82 | - connection_id 83 | """ 84 | connection_id: int 85 | -------------------------------------------------------------------------------- /handlers/create_bill.py: -------------------------------------------------------------------------------- 1 | from magic_filter import F 2 | from aiogram import Router 3 | from aiogram.types import InlineKeyboardButton, CallbackQuery 4 | from aiogram.utils.keyboard import InlineKeyboardBuilder 5 | 6 | from sqlalchemy import update 7 | 8 | from helpers.bot_answers_shortcuts import send_technical_error 9 | from payment.qiwi_payment import QIWIPayment 10 | from .callback_factories import ConfirmPaymentCallbackFactory as ConfirmPaymentCF 11 | from db import VPNConnection, async_db_session, ActiveBills, User 12 | 13 | router = Router() 14 | 15 | 16 | async def payment_answer(callback: CallbackQuery, data: dict): 17 | keyboard = InlineKeyboardBuilder() 18 | keyboard.row(InlineKeyboardButton(text="Оплатить", url=data["payUrl"])) 19 | keyboard.row(InlineKeyboardButton(text="🔝 Назад", callback_data="start")) 20 | 21 | text = ( 22 | "Ссылка на оплату через платежную систему Qiwi доступна в течение 10 минут!\n\n" 23 | "Реквизиты банковской карты и регистрационные данные передаются по защищенным протоколам и не " 24 | "попадут в интернет-магазин и третьим лицам.\nПлатежи обрабатываются на защищенной странице процессинга " 25 | 'по стандарту ' 26 | "PCI DSS – Payment Card Industry Data Security Standard." 27 | ) 28 | 29 | await callback.message.edit_text(text, reply_markup=keyboard.as_markup()) 30 | await callback.answer() 31 | 32 | 33 | @router.callback_query(ConfirmPaymentCF.filter(F.type_ == "new")) 34 | async def create_bill_for_new_rent( 35 | callback: CallbackQuery, callback_data: ConfirmPaymentCF 36 | ): 37 | """ 38 | СОЗДАНИЕ ФОРМЫ ОПЛАТЫ НА QIWI | КУПИТЬ НОВЫЕ ПОДКЛЮЧЕНИЯ 39 | """ 40 | 41 | keyboard = InlineKeyboardBuilder() 42 | keyboard.add(InlineKeyboardButton(text="🔝 Назад", callback_data="start")) 43 | 44 | user = await User.get_or_create(tg_id=callback.from_user.id) 45 | 46 | if not callback_data.server_id: 47 | await callback.message.edit_text( 48 | "❗️Вы не выбрали VPN сервер для подключения❗️", 49 | reply_markup=keyboard.as_markup(), 50 | ) 51 | await callback.answer() 52 | return 53 | 54 | # Получаем свободные подключения на данном сервере в необходимом кол-ве. 55 | free_connection = list( 56 | await VPNConnection.get_free( 57 | server_id=callback_data.server_id, limit=callback_data.count 58 | ) 59 | ) 60 | 61 | # Если нет свободных подключений на этом сервере 62 | if not free_connection: 63 | await callback.message.edit_text( 64 | "☹️ Извините, на данном сервере подключения закончились, пожалуйста, выберите другой", 65 | reply_markup=keyboard.as_markup(), 66 | ) 67 | await callback.answer() 68 | return 69 | if len(free_connection) < callback_data.count: 70 | await callback.message.edit_text( 71 | f"☹️ Извините, на выбранном сервере недостаточно свободных подключений," 72 | f' осталось: "{len(free_connection)}"\n', 73 | reply_markup=keyboard.as_markup(), 74 | ) 75 | await callback.answer() 76 | return 77 | 78 | # На время оплаты указанное пользователем кол-во устройств необходимо заморозить, чтобы не заняли другие 79 | async with async_db_session() as session: 80 | await session.execute( 81 | update(VPNConnection), 82 | [ 83 | {"id": conn.id, "user_id": user.id, "available": False} 84 | for conn in free_connection 85 | ], 86 | ) 87 | await session.commit() 88 | 89 | # Пользователь 90 | user = await User.get_or_create(tg_id=callback.from_user.id) 91 | 92 | qiwi_payment = QIWIPayment() 93 | if data := await qiwi_payment.create_bill(value=callback_data.cost): 94 | # Добавляем счет об оплате 95 | await ActiveBills.add( 96 | bill_id=data["billId"], 97 | user=user.id, 98 | available_to=qiwi_payment.available_to, 99 | type="new", 100 | rent_month=callback_data.month, 101 | pay_url=data["payUrl"], 102 | vpn_connections=free_connection, 103 | ) 104 | 105 | # Формируем ответ по оплате 106 | await payment_answer(callback, data) 107 | 108 | else: 109 | # Если не удалось создать форму оплаты, тогда освобождаем забронированные устройства 110 | async with async_db_session() as session: 111 | await session.execute( 112 | update(VPNConnection), 113 | [ 114 | {"id": conn.id, "user_id": None, "available": False} 115 | for conn in free_connection 116 | ], 117 | ) 118 | await session.commit() 119 | 120 | await send_technical_error(callback) 121 | 122 | 123 | @router.callback_query(ConfirmPaymentCF.filter(F.type_ == "extend")) 124 | async def create_bill_for_exist_rent( 125 | callback: CallbackQuery, callback_data: ConfirmPaymentCF 126 | ): 127 | """ 128 | СОЗДАНИЕ ФОРМЫ ОПЛАТЫ НА QIWI | ПРОДЛИТЬ АРЕНДУ ПОДКЛЮЧЕНИЯ 129 | """ 130 | 131 | keyboard = InlineKeyboardBuilder() 132 | keyboard.add( 133 | InlineKeyboardButton( 134 | text="🔝 Вернуться в профиль", callback_data=f"show_profile" 135 | ) 136 | ) 137 | 138 | user = await User.get_or_create(tg_id=callback.from_user.id) 139 | 140 | if not callback_data.connection_id: 141 | await callback.message.edit_text( 142 | f"❗️Вы не выбрали подключение❗️", 143 | reply_markup=keyboard.as_markup(), 144 | ) 145 | await callback.answer() 146 | return 147 | 148 | qiwi_payment = QIWIPayment() 149 | if data := await qiwi_payment.create_bill(value=callback_data.cost): 150 | # Добавляем счет об оплате 151 | 152 | try: 153 | connection = await VPNConnection.get(id=callback_data.connection_id) 154 | except VPNConnection.DoesNotExists: 155 | await send_technical_error(callback, text="Подключение не было найдено! ☹️") 156 | return 157 | 158 | await ActiveBills.add( 159 | bill_id=data["billId"], 160 | user=user.id, 161 | available_to=qiwi_payment.available_to, 162 | type="extend", 163 | rent_month=callback_data.month, 164 | pay_url=data["payUrl"], 165 | vpn_connections=[connection], 166 | ) 167 | 168 | # Формируем ответ по оплате 169 | await payment_answer(callback, data) 170 | 171 | else: 172 | # Если не удалось создать форму оплаты 173 | await send_technical_error(callback) 174 | -------------------------------------------------------------------------------- /handlers/introduction.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.types import Message, InlineKeyboardButton, CallbackQuery 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from db import Server 6 | 7 | router = Router() 8 | 9 | 10 | @router.callback_query(text="start") 11 | async def home(callback: CallbackQuery): 12 | await welcome(callback.message.edit_text) 13 | await callback.answer() 14 | 15 | 16 | @router.message(commands="start") 17 | async def home(message: Message): 18 | await welcome(message.answer) 19 | 20 | 21 | async def welcome(message_type): 22 | text = f""" 23 | 👋🏽 Добро пожаловать 24 | 25 | 🌐 AXO VPN 🌐 26 | 27 | 🔥 У нас самые низкие цены на рынке! 28 | 29 | Мы используем Wireguard 30 | 31 | ❎ Блокируем: ❎ 32 | 33 | Запросы на рекламу на всех устройствах💻📱🖥 34 | 35 | 🪲 Запросы на вредоносные сайты 36 | 37 | 📊 Аналитические запросы 38 | 39 | 🚰 Защищаем от утечки DNS ⛓ 40 | 41 | 📝 Не пишем логи 🗑 42 | """ 43 | keyboard = InlineKeyboardBuilder() 44 | keyboard.row( 45 | InlineKeyboardButton(text="🌍 Доступные страны", callback_data="show_countries"), 46 | InlineKeyboardButton(text="❔ Как пользоваться", callback_data="how_to_use"), 47 | ) 48 | keyboard.row( 49 | InlineKeyboardButton( 50 | text="💱 Купить подключение", callback_data="choose_location" 51 | ) 52 | ) 53 | keyboard.row(InlineKeyboardButton(text="🔹 Профиль 🔹", callback_data="show_profile")) 54 | await message_type(text, reply_markup=keyboard.as_markup()) 55 | 56 | 57 | @router.callback_query(text="how_to_use") 58 | async def how_to_use(call: CallbackQuery): 59 | text = f""" 60 | 1️⃣ Скачиваем клиент Wireguard: 61 | 62 | 📱 Android: [PlayStore] [F-Droid] 63 | 64 | 📱 iOS: [AppStore] 65 | 66 | 💻 Windows: [С официального сайта] 67 | 68 | 💻 Linux: [На сайте] 69 | 70 | 2️⃣ Покупаем подключение, скачиваем файл для подключения в Профиле 71 | 72 | 3️⃣ Открываем приложение и добавляем скачанный файл 73 | 74 | """ 75 | keyboard = InlineKeyboardBuilder() 76 | keyboard.add(InlineKeyboardButton(text="🔙 Назад", callback_data="start")) 77 | await call.message.edit_text(text, reply_markup=keyboard.as_markup()) 78 | 79 | 80 | @router.callback_query(text="show_countries") 81 | async def show_countries(callback: CallbackQuery): 82 | """ 83 | Список доступных стран 84 | """ 85 | 86 | countries = "" 87 | # Смотрим список VPN серверов 88 | for server in await Server.all(values=["country_code", "location"]): 89 | # Добавляем флаг страны и местоположение VPN сервера 90 | countries += server.verbose_location + "\n" 91 | 92 | keyboard = InlineKeyboardBuilder( 93 | [[InlineKeyboardButton(text="🔙 Назад", callback_data="start")]] 94 | ) 95 | await callback.message.edit_text( 96 | text="Список стран\n" + countries, 97 | reply_markup=keyboard.as_markup(), 98 | ) 99 | await callback.answer() 100 | -------------------------------------------------------------------------------- /handlers/profile.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.types import InlineKeyboardButton, CallbackQuery, BufferedInputFile 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from helpers.bot_answers_shortcuts import send_technical_error 6 | from .callback_factories import ExtendRentCallbackFactory as ExtendRentCF 7 | from db import VPNConnection, ActiveBills, User, Server 8 | from .buy_service import month_verbose 9 | from .callback_factories import GetConfigCallbackFactory as GetConfigCF 10 | 11 | router = Router() 12 | 13 | 14 | class UserProfile: 15 | """ 16 | Для управления пользовательским профилем и просмотром состояния его подключений. 17 | """ 18 | 19 | def __init__(self, user: User): 20 | self._user = user 21 | 22 | # Доступные пользователю VPN подключения 23 | self._vpn_connections: list[VPNConnection] = [] 24 | 25 | # Текущие неоплаченные счета 26 | self._active_bills: list[ActiveBills] = [] 27 | 28 | self._keyboard = InlineKeyboardBuilder() 29 | self._text_lines = [] 30 | 31 | async def collect_vpn_connections(self): 32 | self._vpn_connections = await self._user.get_connections() 33 | 34 | async def collect_active_bills(self): 35 | self._active_bills = await self._user.get_active_bills() 36 | 37 | def get_keyboard(self) -> InlineKeyboardBuilder: 38 | """ 39 | Возвращает набор кнопок для отправки. 40 | :return: 41 | """ 42 | return self._keyboard 43 | 44 | def get_text(self) -> str: 45 | """ 46 | Возвращает текст профиля для отправки. 47 | """ 48 | return "\n".join(self._text_lines) 49 | 50 | async def create_profile(self) -> None: 51 | """ 52 | Создаем информацию о профиле пользователя. 53 | """ 54 | if self._user_has_no_data(): 55 | self._create_empty_user_profile() 56 | 57 | else: 58 | await self._create_new_active_bills_info_text() 59 | await self._create_text_and_add_buttons_for_users_connections() 60 | self._add_button_to_start() 61 | 62 | def _user_has_no_data(self) -> bool: 63 | return not len(self._vpn_connections) and not len(self._active_bills) 64 | 65 | def _create_empty_user_profile(self) -> None: 66 | self._keyboard.row( 67 | InlineKeyboardButton(text="Купить", callback_data="choose_location") 68 | ) 69 | self._add_button_to_start() 70 | self._text_lines = ["🟠 У вас нет доступных подключений"] 71 | 72 | def _add_button_to_start(self): 73 | self._keyboard.row(InlineKeyboardButton(text="🔙 Назад", callback_data="start")) 74 | 75 | async def _create_new_active_bills_info_text(self) -> None: 76 | """ 77 | Создает описание для новых счетов ожидающих подключение. 78 | """ 79 | for bill in self._active_bills: 80 | if bill.type == "new": 81 | try: 82 | server = await Server.get(id=bill.vpn_connections[0].server_id) 83 | except Server.DoesNotExists: 84 | continue 85 | 86 | self._text_lines.append( 87 | f"⏳ Ожидается оплата:\n" 88 | f"На длительность {bill.rent_month} {month_verbose(bill.rent_month)} " 89 | f"{server.verbose_location}\n" 90 | f"Количество устройств: {len(bill.vpn_connections)}\n" 91 | f'Форма оплаты' 92 | f' доступна до {bill.available_to.strftime("%H:%M:%S")}\n' 93 | ) 94 | 95 | async def _create_text_and_add_buttons_for_users_connections(self) -> None: 96 | """ 97 | Создает информацию обо всех подключениях имеющихся у пользователя. 98 | """ 99 | self._text_lines.append( 100 | f"\nУ вас имеется: {len(self._vpn_connections)} подключений\n" 101 | ) 102 | 103 | # Смотрим по очереди подключения 104 | for i, connection in enumerate(self._vpn_connections, 1): 105 | buttons_row: list[InlineKeyboardButton] = [] 106 | 107 | await self._create_info_for_connection( 108 | connection, conn_number=i, buttons_row=buttons_row 109 | ) 110 | 111 | self._create_text_for_extended_connection( 112 | connection, conn_number=i, buttons_row=buttons_row 113 | ) 114 | 115 | if buttons_row: 116 | # Формируем кнопки для данного подключения 117 | self._keyboard.row(*buttons_row) 118 | 119 | async def _create_info_for_connection( 120 | self, 121 | connection: VPNConnection, 122 | conn_number: int, 123 | buttons_row: list[InlineKeyboardButton], 124 | ) -> None: 125 | """ 126 | Добавляет информацию об одном подключении. 127 | :param connection: Объект подключения. 128 | :param conn_number: Номер в списке по порядку. 129 | :param buttons_row: Список для вставки кнопки. 130 | """ 131 | 132 | # Определяем местоположение подключения 133 | try: 134 | server = await Server.get(id=connection.server_id) 135 | except Server.DoesNotExists: 136 | return 137 | 138 | # Информация подключения (состояние) 139 | self._text_lines.append( 140 | f"# {conn_number}: {'🟢' if connection.available else '🔴'} {server.verbose_location}\n" 141 | f"{connection.local_ip}" 142 | ) 143 | if connection.available: 144 | self._text_lines.append( 145 | f"Доступно до {connection.available_to.strftime('%Y.%m.%d %H:%M')}" 146 | ) 147 | buttons_row.append( 148 | InlineKeyboardButton( 149 | text=f"# {conn_number} - ⚙️ Конфиг", 150 | callback_data=GetConfigCF(connection_id=connection.id).pack(), 151 | ) 152 | ) 153 | self._text_lines.append("\n") 154 | 155 | def _create_text_for_extended_connection( 156 | self, 157 | connection: VPNConnection, 158 | conn_number: int, 159 | buttons_row: list[InlineKeyboardButton], 160 | ) -> None: 161 | # Имеется ли информация о продлении данного подключения 162 | for bill in self._active_bills: 163 | conn_ids = [conn.id for conn in bill.vpn_connections] 164 | if bill.type == "extend" and connection.id in conn_ids: 165 | self._text_lines.append( 166 | f"Вы уже запросили продление услуги на {bill.rent_month} {month_verbose(bill.rent_month)}\n" 167 | f' Форма оплаты' 168 | f' доступна до {bill.available_to.strftime("%H:%M:%S")}' 169 | ) 170 | break 171 | else: 172 | self._create_button_for_extend( 173 | connection, conn_number=conn_number, buttons_row=buttons_row 174 | ) 175 | 176 | def _create_button_for_extend( 177 | self, 178 | connection: VPNConnection, 179 | conn_number: int, 180 | buttons_row: list[InlineKeyboardButton], 181 | ) -> None: 182 | if not len(self._active_bills): 183 | # Формируем callback data для продления услуги VPN 184 | extend_rent_callback = ExtendRentCF( 185 | connection_id=connection.id, 186 | server_id=connection.server_id, 187 | ) 188 | 189 | # Если нет зарегистрированных форм оплаты для данного подключения, 190 | # то добавляем кнопку продления 191 | buttons_row.append( 192 | InlineKeyboardButton( 193 | text=f"# {conn_number} - продлить", 194 | callback_data=extend_rent_callback.pack(), 195 | ) 196 | ) 197 | 198 | 199 | @router.callback_query(text="show_profile") 200 | async def show_profile(callback: CallbackQuery): 201 | user = await User.get_or_create(tg_id=callback.from_user.id) 202 | 203 | user_profile = UserProfile(user) 204 | await user_profile.collect_vpn_connections() 205 | await user_profile.collect_active_bills() 206 | await user_profile.create_profile() 207 | 208 | await callback.message.edit_text( 209 | text=user_profile.get_text(), 210 | reply_markup=user_profile.get_keyboard().as_markup(), 211 | ) 212 | await callback.answer() 213 | 214 | 215 | @router.callback_query(GetConfigCF.filter()) 216 | async def get_user_config(callback: CallbackQuery, callback_data: GetConfigCF): 217 | try: 218 | user = await User.get(tg_id=callback.from_user.id) 219 | except User.DoesNotExists: 220 | # Не существует пользователя 221 | await send_technical_error(callback, "❗️У вас нет доступных конфигурации❗️") 222 | return 223 | 224 | try: 225 | # Смотрим запрашиваемое подключение 226 | connection: VPNConnection = await VPNConnection.get( 227 | id=callback_data.connection_id, user_id=user.id 228 | ) 229 | if not connection.available or not connection.available_to: 230 | await send_technical_error( 231 | callback, "❗️Данное подключение вам недоступно❗️" 232 | ) 233 | return 234 | except VPNConnection.DoesNotExists: 235 | await send_technical_error(callback, "❗Неверная конфигурация❗️") 236 | return 237 | 238 | try: 239 | server = await Server.get(id=connection.server_id) 240 | except Server.DoesNotExists: 241 | await send_technical_error(callback, "❗Сервер больше не существует❗️") 242 | return 243 | 244 | # Конфигурация пользователя найдена. 245 | # Формируем текст конфигурации и её имя как название сервера. 246 | config = connection.config.encode() 247 | file_name = server.name + ".conf" 248 | 249 | # Удаляем предыдущее сообщение от бота. 250 | # await callback.message.delete() 251 | 252 | # Отправляем конфигурационный файл пользователю. 253 | await callback.message.answer_document( 254 | BufferedInputFile(bytes(config), filename=file_name), 255 | caption=f"Не изменяйте содержимое файла, во избежание нестабильной работы", 256 | ) 257 | keyboard = InlineKeyboardBuilder() 258 | keyboard.row(InlineKeyboardButton(text="🔙 Назад", callback_data="show_profile")) 259 | await callback.message.answer( 260 | text=f"Вернуться в профиль", reply_markup=keyboard.as_markup() 261 | ) 262 | await callback.answer() 263 | -------------------------------------------------------------------------------- /handlers/user_agreement.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.dispatcher.filters import Text 3 | from aiogram.types import CallbackQuery 4 | 5 | from settings import TEMPLATE_DIR 6 | 7 | router = Router() 8 | 9 | 10 | @router.callback_query(Text(text="user_agreement")) 11 | async def user_agreement(callback: CallbackQuery): 12 | """ 13 | Отправляем пользовательское соглашение. 14 | """ 15 | with open(TEMPLATE_DIR / "user_agreement.html", encoding="utf-8") as file: 16 | text = file.read() 17 | 18 | for text_chunk in text.split("
"): 19 | await callback.message.answer(text=text_chunk) 20 | 21 | await callback.message.reply("Продолжим") 22 | await callback.answer() 23 | -------------------------------------------------------------------------------- /helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/helpers/__init__.py -------------------------------------------------------------------------------- /helpers/bot_answers_shortcuts.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery, InlineKeyboardButton 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | 4 | 5 | async def send_technical_error( 6 | callback: CallbackQuery, 7 | text: str = "Технические неполадки, проносим свои извинения ☹️", 8 | ): 9 | keyboard = InlineKeyboardBuilder() 10 | keyboard.add(InlineKeyboardButton(text="🔝 Назад", callback_data="start")) 11 | await callback.message.edit_text( 12 | text, 13 | reply_markup=keyboard.as_markup(), 14 | ) 15 | await callback.answer() 16 | -------------------------------------------------------------------------------- /helpers/verbose_numbers.py: -------------------------------------------------------------------------------- 1 | def days_verbose(days: int) -> str: 2 | if days > 20: 3 | days %= 10 4 | if days == 1: 5 | return "день" 6 | elif days <= 4: 7 | return "дня" 8 | return "дней" 9 | 10 | 11 | def month_verbose(month: int) -> str: 12 | if month > 20: 13 | month %= 10 14 | 15 | if month == 1: 16 | return "месяц" 17 | elif month <= 4: 18 | return "месяца" 19 | return "месяцев" 20 | -------------------------------------------------------------------------------- /images/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/images/img.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/images/logo.png -------------------------------------------------------------------------------- /manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from db import async_db_session 5 | from server_manager.managers import ConfigManager, PaymentManager, VPNControlManager 6 | 7 | 8 | async def main(): 9 | await asyncio.gather( 10 | asyncio.Task(async_db_session.create_all(), name="create_db_tables"), 11 | asyncio.Task(ConfigManager().run(), name="config_manager"), 12 | asyncio.Task(VPNControlManager().run(), name="vpn_connections_manager"), 13 | asyncio.Task(PaymentManager().run(), name="payment_manager"), 14 | ) 15 | 16 | 17 | if __name__ == "__main__": 18 | logging.basicConfig( 19 | level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" 20 | ) 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | 4 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 5 | server_name ; 6 | 7 | ssl on; 8 | ssl_certificate ; 9 | ssl_certificate_key ; 10 | 11 | location /webhook/bot { 12 | include proxy_params; 13 | proxy_pass http://127.0.0.1:8888/webhook/bot; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /payment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ig-rudenko/axo_vpn_bot/1c953cf86952e9b6bbb363bf1fabac7a22949cfc/payment/__init__.py -------------------------------------------------------------------------------- /payment/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractPayment(ABC): 5 | @abstractmethod 6 | def create_bill(self, *args, **kwargs): 7 | pass 8 | 9 | @abstractmethod 10 | def check_bill_status(self, *args, **kwargs): 11 | pass 12 | -------------------------------------------------------------------------------- /payment/qiwi_payment.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from datetime import datetime, timedelta, timezone 4 | 5 | import aiohttp 6 | 7 | from .base import AbstractPayment 8 | 9 | 10 | class QIWIPayment(AbstractPayment): 11 | _token = os.getenv("QIWI_TOKEN") 12 | 13 | def __init__(self, currency="RUB", expiration_minutes=10): 14 | self.currency = currency 15 | offset = timedelta(hours=3) 16 | current_time = datetime.now().astimezone(timezone(offset)) 17 | self.available_to = current_time + timedelta(minutes=expiration_minutes) 18 | 19 | async def create_bill(self, value: int) -> dict: 20 | async with aiohttp.ClientSession() as session: 21 | response = await session.put( 22 | url=f"https://api.qiwi.com/partner/bill/v1/bills/{uuid.uuid4()}", 23 | headers={ 24 | "accept": "application/json", 25 | "Authorization": f"Bearer {self._token}", 26 | }, 27 | json={ 28 | "amount": {"currency": self.currency, "value": value}, 29 | "comment": "Axo VPN", 30 | "expirationDateTime": f"{self.available_to.strftime('%Y-%m-%dT%H:%M:%S+03:00')}", 31 | }, 32 | ) 33 | if response.status == 200: 34 | return await response.json() 35 | 36 | return {} 37 | 38 | async def check_bill_status(self, bill_id: str) -> str | None: 39 | async with aiohttp.ClientSession() as session: 40 | response = await session.get( 41 | url=f"https://api.qiwi.com/partner/bill/v1/bills/{bill_id}", 42 | headers={ 43 | "accept": "application/json", 44 | "Authorization": f"Bearer {self._token}", 45 | }, 46 | ) 47 | if response.status == 200: 48 | result = await response.json() 49 | return result["status"]["value"] 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sqlalchemy==2.0.0 2 | mysqlclient 3 | aiomysql 4 | aiogram==3.0.0b3 5 | aiohttp 6 | asyncssh 7 | emoji-country-flag -------------------------------------------------------------------------------- /server_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import ServerConnection 2 | from .configuration import ConfigBuilder 3 | -------------------------------------------------------------------------------- /server_manager/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import ConfigBuilder 2 | -------------------------------------------------------------------------------- /server_manager/configuration/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | from abc import ABC, abstractmethod 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class Config: 8 | rows: list[str] 9 | name: str = "" 10 | client_name: str = "" 11 | client_ip_v4: str = "" 12 | client_ip_v6: str = "" 13 | endpoint_ip: str = "" 14 | endpoint_port: str = "" 15 | dns: list[str] = field(default_factory=list) 16 | 17 | @property 18 | def config_text(self): 19 | return "\n".join(self.rows) 20 | 21 | 22 | class BaseConfigBuilder(ABC): 23 | default_allowed_ips = [ 24 | "64.0.0.0/2", 25 | "32.0.0.0/3", 26 | "128.0.0.0/3", 27 | "16.0.0.0/4", 28 | "176.0.0.0/4", 29 | "208.0.0.0/4", 30 | "0.0.0.0/5", 31 | "160.0.0.0/5", 32 | "200.0.0.0/5", 33 | "12.0.0.0/6", 34 | "168.0.0.0/6", 35 | "196.0.0.0/6", 36 | "8.0.0.0/7", 37 | "174.0.0.0/7", 38 | "194.0.0.0/7", 39 | "11.0.0.0/8", 40 | "173.0.0.0/8", 41 | "193.0.0.0/8", 42 | "172.128.0.0/9", 43 | "192.0.0.0/9", 44 | "172.64.0.0/10", 45 | "192.192.0.0/10", 46 | "172.32.0.0/11", 47 | "192.128.0.0/11", 48 | "172.0.0.0/12", 49 | "192.176.0.0/12", 50 | "192.160.0.0/13", 51 | "192.172.0.0/14", 52 | "192.170.0.0/15", 53 | "192.169.0.0/16", 54 | "10.66.66.1/32", 55 | "::/0", 56 | ] 57 | 58 | def __init__(self, config: str, name: str = ""): 59 | self.config = self._parse_config(config, name) 60 | 61 | @staticmethod 62 | def _parse_config(config_text: str, name: str) -> Config: 63 | rows = config_text.split("\n") 64 | config = Config(name=name, rows=rows) 65 | if match := re.match(r"wg0-client-(\d+?)\.conf", name): 66 | config.client_name = match.group(1) 67 | 68 | for line in config.rows: 69 | if line.startswith("Address = "): 70 | if match := re.match(r"Address = (\S+)/32,(\S+)/128", line): 71 | config.client_ip_v4 = match.group(1) 72 | config.client_ip_v6 = match.group(2) 73 | if line.startswith("Endpoint = "): 74 | if match := re.match(r"Endpoint = (\S+):(\S+)", line): 75 | config.endpoint_ip = match.group(1) 76 | config.endpoint_port = match.group(2) 77 | if line.startswith("DNS = "): 78 | if match := re.match(r"DNS = (\S+)", line): 79 | config.dns = match.group(1).split(",") 80 | 81 | return config 82 | 83 | @abstractmethod 84 | def create_config(self) -> str: 85 | pass 86 | 87 | @abstractmethod 88 | def add_allowed_ips(self, allowed_ips: list[str]): 89 | pass 90 | -------------------------------------------------------------------------------- /server_manager/configuration/manager.py: -------------------------------------------------------------------------------- 1 | from .base import BaseConfigBuilder 2 | 3 | 4 | class ConfigBuilder(BaseConfigBuilder): 5 | 6 | @property 7 | def raw_config(self): 8 | return "\n".join(self.config) 9 | 10 | def create_config(self) -> str: 11 | config = "" 12 | for line in self.config.rows: 13 | if line.startswith("Address"): 14 | config += f"Address = {self.config.client_ip_v4}/32,{self.config.client_ip_v6}/128\n" 15 | 16 | elif line.startswith("DNS"): 17 | config += "DNS = " + ",".join(self.config.dns) + "\n" 18 | 19 | elif line.startswith("AllowedIPs"): 20 | config += "AllowedIPs = " + ", ".join(self.default_allowed_ips) + "\n" 21 | 22 | else: 23 | config += line + "\n" 24 | 25 | return config.strip() 26 | 27 | def add_allowed_ips(self, allowed_ips: list[str]): 28 | self.default_allowed_ips += allowed_ips 29 | -------------------------------------------------------------------------------- /server_manager/managers/__init__.py: -------------------------------------------------------------------------------- 1 | from .config_manager import ConfigManager 2 | from .payment_control import PaymentManager 3 | from .vpn_control_manager import VPNControlManager 4 | -------------------------------------------------------------------------------- /server_manager/managers/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | 4 | 5 | class BaseManager(ABC): 6 | timeout: int = 60 7 | 8 | def __init__(self): 9 | self.logger = logging.getLogger(self.__class__.__name__) 10 | 11 | @abstractmethod 12 | async def task(self): 13 | pass 14 | 15 | @abstractmethod 16 | async def run(self): 17 | pass 18 | -------------------------------------------------------------------------------- /server_manager/managers/config_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from db import Server, VPNConnection 4 | from .base import BaseManager 5 | from ..configuration.base import BaseConfigBuilder 6 | from ..server import ServerConnection 7 | 8 | 9 | class ConfigManager(BaseManager): 10 | """Сборщик VPN конфигураций""" 11 | 12 | timeout = 60 * 10 13 | 14 | async def run(self): 15 | print("=== Запущен сборщик VPN конфигураций ===") 16 | while True: 17 | await self.task() 18 | await asyncio.sleep(self.timeout) 19 | 20 | async def task(self): 21 | for server in await Server.all(): 22 | self.logger.info(f"Смотрим сервер {server.name} {server.location}") 23 | try: 24 | config_files = await self._get_server_config_files(server) 25 | 26 | for config_manager in config_files: 27 | if not config_manager.config.client_ip_v4: 28 | continue 29 | 30 | # Пытаемся найти в базе текущую конфигурацию 31 | try: 32 | connection = await VPNConnection.get( 33 | server_id=server.id, 34 | local_ip=config_manager.config.client_ip_v4, 35 | ) 36 | # Если нашли конфигурацию, то проверяем её с текущей на сервере. 37 | if connection.config != config_manager.create_config(): 38 | await self._update_connection( 39 | server, connection, config_manager 40 | ) 41 | 42 | except VPNConnection.DoesNotExists: 43 | await self._create_new_connection(server, config_manager) 44 | 45 | except Exception as exc: 46 | self.logger.error( 47 | f"Сборщик VPN конфигураций | Сервер {server.name} | Ошибка {exc}", 48 | exc_info=exc, 49 | ) 50 | 51 | @staticmethod 52 | async def _get_server_config_files(server: Server) -> list[BaseConfigBuilder]: 53 | sc = ServerConnection(server) 54 | await sc.connect() 55 | 56 | # Собираем с сервера конфигурации 57 | await sc.collect_configs(folder="/root") 58 | return sc.config_files 59 | 60 | async def _create_new_connection( 61 | self, server: Server, config_manager: BaseConfigBuilder 62 | ): 63 | # Если в базе нет такой конфигурации, то добавляем 64 | self.logger.info( 65 | f"# Север: {server.name:<15} | " 66 | f"Добавляем конфигурацию для {config_manager.config.client_ip_v4}" 67 | ) 68 | await VPNConnection.add( 69 | server_id=server.id, 70 | user_id=None, 71 | available=False, 72 | local_ip=config_manager.config.client_ip_v4, 73 | available_to=None, 74 | config=config_manager.create_config(), 75 | client_name=config_manager.config.name, 76 | ) 77 | 78 | async def _update_connection( 79 | self, server: Server, conn: VPNConnection, config_manager: BaseConfigBuilder 80 | ): 81 | self.logger.info( 82 | f"# Север: {server.name:<15} | " 83 | f"Изменяем конфигурацию для {config_manager.config.client_ip_v4}" 84 | ) 85 | # Если они отличаются, значит надо изменить конфиг в базе. 86 | await conn.update(config=config_manager.create_config()) 87 | -------------------------------------------------------------------------------- /server_manager/managers/payment_control.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime, timedelta 3 | 4 | from payment.base import AbstractPayment 5 | from payment.qiwi_payment import QIWIPayment 6 | from db import ActiveBills, VPNConnection, Server 7 | from ..server import ServerConnection 8 | from .base import BaseManager 9 | 10 | 11 | class PaymentManager(BaseManager): 12 | timeout = 10 13 | payment_class: AbstractPayment = QIWIPayment 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self._qiwi = self.payment_class(currency="RUB") 18 | 19 | async def run(self): 20 | print("=== Запущен обработчик QIWI платежей ===") 21 | while True: 22 | await self.task() 23 | await asyncio.sleep(self.timeout) 24 | 25 | async def task(self): 26 | all_bills: list[ActiveBills] = await ActiveBills.all( 27 | select_in_load="vpn_connections" 28 | ) 29 | 30 | for bill in all_bills: 31 | try: 32 | await self._processing_bill(bill) 33 | except Exception as exc: 34 | self.logger.error( 35 | f"Обработчик QIWI платежей | Счет {bill} | Ошибка {exc}", 36 | exc_info=exc, 37 | ) 38 | await asyncio.sleep(5) 39 | 40 | async def _processing_bill(self, bill: ActiveBills): 41 | status = await self._qiwi.check_bill_status(bill.bill_id) 42 | print(status) 43 | 44 | if status is None: 45 | # Слишком много запросов на QIWI 46 | await asyncio.sleep(10) 47 | 48 | # Счет отклонен или истек срок действия формы и это новое подключение. 49 | elif status in ["REJECTED", "EXPIRED"]: 50 | if bill.type == "new": 51 | await self._reject_bill(bill) 52 | else: 53 | await bill.delete() 54 | 55 | # Счет был оплачен 56 | elif status == "PAID": 57 | await self._activate_connections(bill) 58 | await bill.delete() 59 | 60 | # Задержка перед запросами на QIWI 61 | await asyncio.sleep(3) 62 | 63 | async def _reject_bill(self, bill: ActiveBills): 64 | self.logger.info(f"# Пользователь {bill.user:<5} | Счет отклонен") 65 | # Забронированные за пользователем подключения надо освободить. 66 | for conn in bill.vpn_connections: 67 | conn: VPNConnection 68 | await conn.update(user_id=None, available_to=None, available=False) 69 | 70 | await bill.delete() 71 | 72 | async def _activate_connections(self, bill: ActiveBills): 73 | # Активируем подключения 74 | self.logger.info(f"# Пользователь {bill.user:<5} | Счет был оплачен") 75 | 76 | for conn in bill.vpn_connections: 77 | conn: VPNConnection 78 | 79 | # Выбираем сервер, на котором необходимо активировать подключения 80 | try: 81 | sc = ServerConnection(await Server.get(id=conn.server_id)) 82 | except Server.DoesNotExists: 83 | continue 84 | 85 | await sc.connect() 86 | # Размораживаем подключение на сервере 87 | await sc.unfreeze_connection(conn.local_ip) 88 | 89 | if bill.type == "new": 90 | # Если новое подключение 91 | rent_type = "Новое подключение" 92 | 93 | elif bill.type == "extend": 94 | # Добавляем к текущему времени 95 | rent_type = "Продление подключения" 96 | 97 | else: 98 | continue 99 | 100 | # Либо продление, либо новое 101 | rent_time_from = conn.available_to or datetime.now() 102 | 103 | # Новое время окончания аренды 104 | new_rent_to = rent_time_from + timedelta(days=31 * bill.rent_month) 105 | 106 | self.logger.info( 107 | f"# Пользователь {bill.user:<5} | {rent_type}" 108 | f" {conn.local_ip} на {bill.rent_month} мес. до {new_rent_to}" 109 | ) 110 | 111 | await conn.update( 112 | available=True, 113 | user_id=bill.user, 114 | available_to=new_rent_to, 115 | ) 116 | -------------------------------------------------------------------------------- /server_manager/managers/vpn_control_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Данный код представляет собой менеджер для обработки аренды VPN подключений. Он выполняет следующие задачи: 3 | 4 | 1. В бесконечном цикле получает все VPN подключения из базы данных без поля конфигурации. 5 | 6 | 2. Для каждого подключения вызывает метод _process_connection(), который обрабатывает данное подключение. 7 | 8 | 3. В методе _process_connection() проверяется, активно ли подключение. 9 | Если нет, то происходит одно из следующих действий: 10 | 11 | - Если время окончания аренды подключения меньше текущего времени на 5 дней, подключение пересоздается и 12 | удаляется у пользователя. 13 | - Если время окончания аренды подключения прошло, подключение замораживается. 14 | 15 | 4. Метод _recreate_connection() пересоздает подключение, если время окончания аренды подключения меньше 16 | текущего времени на 5 дней. Он выполняет следующие действия: 17 | 18 | - Получает объект сервера по его идентификатору из базы данных. 19 | - Создает подключение к серверу. 20 | - Замораживает текущее подключение. 21 | - Получает объект VPN подключения со всеми полями из базы данных. 22 | - Пересоздает конфигурацию подключения. 23 | - Обновляет конфигурацию в базе данных. 24 | 25 | 5. Метод _freeze_connection() замораживает подключение, если время окончания аренды подключения прошло. 26 | Он выполняет следующие действия: 27 | 28 | - Получает объект сервера по его идентификатору из базы данных. 29 | - Создает подключение к серверу. 30 | - Замораживает текущее подключение. 31 | - Обновляет статус доступности подключения в базе данных. 32 | """ 33 | 34 | import asyncio 35 | from datetime import datetime, timedelta 36 | 37 | from asyncssh import ProcessError 38 | 39 | from db import VPNConnection, Server 40 | from .base import BaseManager 41 | from .. import ServerConnection, ConfigBuilder 42 | 43 | 44 | class VPNControlManager(BaseManager): 45 | timeout = 60 * 10 46 | 47 | async def run(self): 48 | print("=== Запущен обработчик аренды VPN подключений ===") 49 | while True: 50 | await self.task() 51 | await asyncio.sleep(self.timeout) 52 | 53 | async def task(self): 54 | # Вытягиваем из базы все VPN подключения, но без поля конфигурации. 55 | all_connections = await VPNConnection.all( 56 | values=[ 57 | "server_id", 58 | "user_id", 59 | "available", 60 | "local_ip", 61 | "available_to", 62 | "client_name", 63 | ] 64 | ) 65 | 66 | for connection in all_connections: 67 | await self._process_connection(connection) 68 | 69 | async def _process_connection(self, connection: VPNConnection): 70 | try: 71 | if not connection.available_to or connection.available_to > datetime.now(): 72 | # Подключение не назначено или еще активно. 73 | return 74 | 75 | # Если поле available_to меньше текущего времени на 5 дней 76 | if connection.available_to < datetime.now() - timedelta(days=5): 77 | # Необходимо пересоздать подключение и удалить его у пользователя 78 | await self._recreate_connection(connection) 79 | 80 | elif connection.available_to < datetime.now(): 81 | # Вышел строк аренды подключения - замораживаем. 82 | await self._freeze_connection(connection) 83 | 84 | except Exception as exc: 85 | self.logger.error( 86 | f"Обработчик аренды VPN подключений |" 87 | f" Подключение: {connection.local_ip} | Ошибка: {exc}", 88 | exc_info=exc, 89 | ) 90 | 91 | async def _recreate_connection(self, connection: VPNConnection): 92 | try: 93 | server = await Server.get(id=connection.server_id) 94 | except Server.DoesNotExists: 95 | return 96 | 97 | sc = ServerConnection(server) 98 | await sc.connect() 99 | 100 | self.logger.info( 101 | f"# Сервер: {server.name:<15} | " 102 | f"Подключение {connection.local_ip} необходимо пересоздать" 103 | ) 104 | 105 | try: 106 | # Замораживаем подключение, на всякий случай. 107 | await sc.freeze_connection(connection.local_ip) 108 | # Вытягиваем из базы объект VPN подключения со всеми полями. 109 | try: 110 | config_obj: VPNConnection = await VPNConnection.get(id=connection.id) 111 | except VPNConnection.DoesNotExists: 112 | return 113 | 114 | print(config_obj.id, config_obj.client_name, config_obj.config) 115 | 116 | # Пересоздаем конфигурацию. 117 | new_config = await sc.regenerate_config( 118 | ConfigBuilder(config=config_obj.config, name=config_obj.client_name) 119 | ) 120 | # Обновляем конфигурацию в базе. 121 | await config_obj.update(config=new_config.create_config()) 122 | 123 | config_obj: VPNConnection = await VPNConnection.get(id=connection.id) 124 | print(config_obj.id, config_obj.client_name, config_obj.config) 125 | 126 | except (ConnectionError, ProcessError) as exc: 127 | exc: ProcessError 128 | # В случае ошибки на стороне сервера, будет попытка на следующей итерации. 129 | self.logger.error( 130 | f"# Сервер: {server.name:<15} | " 131 | f"Подключение: {connection.local_ip} | Ошибка: {exc.stderr}", 132 | exc_info=exc, 133 | ) 134 | 135 | else: 136 | # Освобождаем подключение от пользователя. 137 | await connection.update(user_id=None, available_to=None, available=False) 138 | 139 | async def _freeze_connection(self, connection: VPNConnection): 140 | try: 141 | server = await Server.get(id=connection.server_id) 142 | except Server.DoesNotExists: 143 | return 144 | 145 | sc = ServerConnection(server) 146 | await sc.connect() 147 | 148 | self.logger.info( 149 | f"# Сервер: {server.name:<15} | " 150 | f"Подключение {connection.local_ip} необходимо заморозить" 151 | ) 152 | 153 | await sc.freeze_connection(connection.local_ip) 154 | # Подключение недоступно. 155 | await connection.update(available=False) 156 | -------------------------------------------------------------------------------- /server_manager/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .connection import ServerConnection 2 | -------------------------------------------------------------------------------- /server_manager/server/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | 4 | import asyncssh 5 | from asyncssh import SSHClientConnection 6 | from asyncssh.logging import logger 7 | 8 | from db.models import Server 9 | from ..configuration.base import BaseConfigBuilder 10 | 11 | logger.setLevel(level=logging.ERROR) 12 | 13 | 14 | class ServerConnectionBase(ABC): 15 | config_file_prefix = "wg0-client" 16 | config_builder = None 17 | 18 | def __init__(self, server: Server): 19 | self.auth = { 20 | "host": server.ip, 21 | "port": server.port, 22 | "username": server.login, 23 | "password": server.password, 24 | "known_hosts": None, 25 | } 26 | self._configs: list[BaseConfigBuilder] = [] 27 | 28 | self._conn: SSHClientConnection | None = None 29 | 30 | async def connect(self): 31 | self._conn = await asyncssh.connect(**self.auth) 32 | 33 | @property 34 | def config_files(self): 35 | return self._configs 36 | 37 | @abstractmethod 38 | async def collect_configs(self, folder="/root"): 39 | pass 40 | 41 | @abstractmethod 42 | async def unfreeze_connection(self, connection_ip: str): 43 | pass 44 | 45 | @abstractmethod 46 | async def freeze_connection(self, connection_ip: str): 47 | pass 48 | 49 | @abstractmethod 50 | async def regenerate_config( 51 | self, config_manager: BaseConfigBuilder 52 | ) -> BaseConfigBuilder: 53 | pass 54 | 55 | def __del__(self, **kwargs): 56 | self._conn.close() 57 | del self 58 | -------------------------------------------------------------------------------- /server_manager/server/connection.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from asyncssh import ProcessError 4 | 5 | from ..configuration.base import Config 6 | from ..configuration.manager import ConfigBuilder 7 | from ..server.base import ServerConnectionBase 8 | from ..server.types import WGParams, KeyPair 9 | 10 | 11 | class ServerConnection(ServerConnectionBase): 12 | config_builder = ConfigBuilder 13 | 14 | @property 15 | def config_files(self): 16 | return self._configs 17 | 18 | async def collect_configs(self, folder="/root"): 19 | list_config_cmd = rf"ls -l {folder} | grep {self.config_file_prefix}" 20 | 21 | result = await self._conn.run(list_config_cmd, timeout=3) 22 | config_files_names = re.findall(r"(wg0-client-\d+?\.conf)", result.stdout) 23 | for file_name in config_files_names: 24 | config = await self._conn.run(f"cat {folder}/{file_name}", timeout=3) 25 | config_manager = self.config_builder(config.stdout, name=file_name) 26 | self._configs.append(config_manager) 27 | 28 | async def unfreeze_connection(self, connection_ip: str): 29 | try: 30 | await self._conn.run( 31 | f"ip route del {connection_ip} via 127.0.0.1", check=True, timeout=3 32 | ) 33 | except ProcessError as exc: 34 | # Если уже разморожено 35 | if exc.exit_status != 2: 36 | raise exc 37 | 38 | async def freeze_connection(self, connection_ip: str): 39 | try: 40 | await self._conn.run( 41 | f"ip route add {connection_ip} via 127.0.0.1", check=True, timeout=3 42 | ) 43 | except ProcessError as exc: 44 | # Если уже заморожено 45 | if exc.exit_status != 2: 46 | raise exc 47 | 48 | async def regenerate_config(self, config_manager: config_builder) -> config_builder: 49 | config = config_manager.config 50 | wg_params = await self._get_wg_params() 51 | key_pair = await self._get_keypair() 52 | 53 | await self._remove_client(config, wg_params) 54 | await self._restart_wireguard(wg_params) 55 | 56 | # Новая конфигурация 57 | new_config_manager = await self._create_and_get_new_config_file_manager( 58 | config=config, wg_params=wg_params, key_pair=key_pair 59 | ) 60 | 61 | await self._add_client( 62 | config=new_config_manager.config, wg_params=wg_params, key_pair=key_pair 63 | ) 64 | await self._restart_wireguard(wg_params) 65 | 66 | return new_config_manager 67 | 68 | async def _get_wg_params(self) -> WGParams: 69 | result = await self._conn.run( 70 | "cat /etc/wireguard/params", check=True, timeout=3 71 | ) 72 | file_lines = re.findall(r"([A-Z\d_]+)=(.+)\n?", result.stdout) 73 | return WGParams(**{key: value for key, value in file_lines}) 74 | 75 | async def _get_keypair(self) -> KeyPair: 76 | """Generate key pair for the client""" 77 | 78 | result = await self._conn.run(r"wg genkey", check=True, timeout=3) 79 | client_private_key = result.stdout.strip() 80 | result = await self._conn.run( 81 | rf'echo "{client_private_key}" | wg pubkey', check=True, timeout=3 82 | ) 83 | client_public_key = result.stdout.strip() 84 | result = await self._conn.run(r"wg genpsk", check=True, timeout=3) 85 | client_pre_shared_key = result.stdout.strip() 86 | return KeyPair( 87 | public_key=client_public_key, 88 | private_key=client_private_key, 89 | pre_shared_key=client_pre_shared_key, 90 | ) 91 | 92 | async def _create_and_get_new_config_file_manager( 93 | self, config: Config, wg_params: WGParams, key_pair: KeyPair 94 | ) -> config_builder: 95 | # Новая конфигурация 96 | new_config = f"""[Interface] 97 | PrivateKey = {key_pair.private_key} 98 | Address = {config.client_ip_v4}/32,{config.client_ip_v6}/128 99 | DNS = {wg_params.CLIENT_DNS_1},{wg_params.CLIENT_DNS_2} 100 | 101 | [Peer] 102 | PublicKey = {wg_params.SERVER_PUB_KEY} 103 | PresharedKey = {key_pair.pre_shared_key} 104 | Endpoint = {wg_params.endpoint} 105 | AllowedIPs = 0.0.0.0/0,::/0""" 106 | 107 | # Create client file and add the server as a peer 108 | await self._conn.run( 109 | rf'''echo "{new_config}" >>"/root/{config.name}"''', check=True, timeout=3 110 | ) 111 | 112 | return self.config_builder(config=new_config, name=config.name) 113 | 114 | async def _add_client(self, config: Config, wg_params: WGParams, key_pair: KeyPair): 115 | """Add the client as a peer to the server""" 116 | 117 | await self._conn.run( 118 | rf'''echo -e "\n### Client {config.client_name} 119 | [Peer] 120 | PublicKey = {key_pair.public_key} 121 | PresharedKey = {key_pair.pre_shared_key} 122 | AllowedIPs = {config.client_ip_v4}/32,{config.client_ip_v6}/128" >>"/etc/wireguard/{wg_params.SERVER_WG_NIC}.conf"''', 123 | check=True, 124 | timeout=3, 125 | ) 126 | 127 | async def _remove_client(self, config: Config, wg_params: WGParams): 128 | """ 129 | remove [Peer] block matching `config.client_name`. 130 | 131 | remove generated client file. 132 | """ 133 | 134 | await self._conn.run( 135 | rf'sed -i "/^### Client {config.client_name}\$/,/^$/d" "/etc/wireguard/{wg_params.SERVER_WG_NIC}.conf"', 136 | timeout=3, 137 | check=True, 138 | ) 139 | await self._conn.run(rf'rm -f "/root/{config.name}"', timeout=3) 140 | 141 | async def _restart_wireguard(self, wg_params: WGParams): 142 | """Restart wireguard to apply changes""" 143 | await self._conn.run( 144 | rf'wg syncconf "{wg_params.SERVER_WG_NIC}" <(wg-quick strip "{wg_params.SERVER_WG_NIC}")', 145 | check=True, 146 | timeout=3, 147 | ) 148 | -------------------------------------------------------------------------------- /server_manager/server/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class WGParams: 6 | """ 7 | Параметры wireguard на сервере. 8 | """ 9 | 10 | SERVER_PUB_IP: str 11 | SERVER_PUB_NIC: str 12 | SERVER_WG_NIC: str 13 | SERVER_WG_IPV4: str 14 | SERVER_WG_IPV6: str 15 | SERVER_PORT: str 16 | SERVER_PRIV_KEY: str 17 | SERVER_PUB_KEY: str 18 | CLIENT_DNS_1: str 19 | CLIENT_DNS_2: str 20 | 21 | @property 22 | def endpoint(self): 23 | return self.SERVER_PUB_IP + ":" + self.SERVER_PORT 24 | 25 | 26 | @dataclass 27 | class KeyPair: 28 | private_key: str 29 | public_key: str 30 | pre_shared_key: str 31 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from os import getenv 3 | 4 | TOKEN = getenv("TG_BOT_TOKEN") 5 | BASE_URL = getenv("BASE_URL") 6 | PUBLIC_IP = getenv("PUBLIC_IP") 7 | 8 | CERTIFICATE_PATH = getenv("CERTIFICATE_PATH") 9 | 10 | WEB_SERVER_HOST = "127.0.0.1" 11 | WEB_SERVER_PORT = 8888 12 | 13 | BOT_PATH = f"/webhook/bot/{TOKEN[:23]}" 14 | 15 | BASE_DIR = pathlib.Path(__file__).parent 16 | 17 | TEMPLATE_DIR = BASE_DIR / "templates" 18 | -------------------------------------------------------------------------------- /templates/user_agreement.html: -------------------------------------------------------------------------------- 1 | 2 | Настоящее Пользовательское соглашение (далее — Соглашение) регулирует отношения между 3 | владельцем интернет-сервиса "Axo-VPN" (далее — Сервис) и лицом, использующим Сервис (далее — Пользователь). 4 | 5 | 6 | Общие положения. 7 | 8 | 1.1. Сервис предоставляет Пользователю возможность подключаться к виртуальной частной сети (VPN) для обеспечения анонимности, 9 | безопасности и свободы доступа к интернет-ресурсам. 10 | 11 | 1.2. Сервис предоставляется Пользователю согласно тарифному плану. 12 | 13 | 1.3. Для использования Сервиса Пользователь должен установить на свое устройство специальное программное 14 | обеспечение (ПО), предоставляемое Сервисом. 15 | 16 | 1.4. Пользователь соглашается с тем, что Сервис НЕ гарантирует ПОЛНУЮ анонимность, безопасность и свободу 17 | доступа к интернет-ресурсам, а также не несет ответственности за любые последствия, связанные с использованием Сервиса. 18 | 19 | 1.5. Пользователь соглашается с тем, что он использует Сервис на свой страх и риск и самостоятельно несет ответственность 20 | за соблюдение законодательства страны, в которой он находится, а также за уважение прав и интересов третьих 21 | лиц при использовании Сервиса. 22 | 23 | 24 |
25 | 26 | 27 | Права и обязанности сторон. 28 | 29 | 2.1. Сервис обязуется: 30 | 31 | 2.1.1. Предоставлять Пользователю доступ к Сервису в соответствии с условиями настоящего Соглашения. 32 | 33 | 2.1.2. Обеспечивать работоспособность и доступность Сервиса в максимально возможной степени, но не гарантирует 34 | отсутствие сбоев, ошибок или перерывов в работе Сервиса. 35 | 36 | 2.1.3. Не разглашать персональные данные Пользователя без его согласия, за исключением случаев, предусмотренных 37 | законодательством или настоящим Соглашением. 38 | 39 | 40 |
41 | 42 | 43 | 2.2. Сервис имеет право: 44 | 45 | 2.2.1. Изменять условия настоящего Соглашения по своему усмотрению с уведомлением Пользователя путем размещения 46 | новой редакции Соглашения на сайте Сервиса или в ПО. 47 | 48 | 2.2.2. Изменять функциональность, внешний вид, параметры и настройки Сервиса по своему усмотрению без уведомления Пользователя. 49 | 50 | 2.2.3. Ограничивать или прекращать доступ Пользователя к Сервису в случае нарушения Пользователем условий настоящего 51 | Соглашения или законодательства, а также по любым другим причинам по своему усмотрению без уведомления Пользователя. 52 | 53 | 2.2.4. Размещать рекламные материалы на сайте Сервиса или в ПО, а также направлять Пользователю рекламные сообщения 54 | по электронной почте или иными способами с согласия Пользователя. 55 | 56 | 57 |
58 | 59 | 60 | 2.3. Пользователь обязуется: 61 | 62 | 2.3.1. Соблюдать условия настоящего Соглашения и законодательство страны, в которой он находится, при использовании Сервиса. 63 | 64 | 2.3.2. Не использовать Сервис для совершения противоправных действий, нарушения прав и интересов третьих лиц, 65 | распространения запрещенной информации и материалов, а также для любых других целей, не соответствующих назначению Сервиса. 66 | 67 | 2.3.3. Не предпринимать действий, направленных на нарушение работоспособности или доступности Сервиса, 68 | а также не пытаться получить несанкционированный доступ к Сервису или его компонентам. 69 | 70 | 2.3.4. Не передавать свои учетные данные для доступа к Сервису третьим лицам и несет ответственность за их сохранность 71 | и конфиденциальность. 72 | 73 | 2.4. Пользователь имеет право: 74 | 75 | 2.4.1. Использовать Сервис в соответствии с условиями настоящего Соглашения и законодательством страны, в которой он находится. 76 | 77 | 2.4.2. Запрашивать у Сервиса информацию о работе Сервиса и получать ответы в разумные сроки. 78 | 79 | 2.4.3. Отказаться от использования Сервиса в любое время путем удаления ПО со своего устройства. 80 | 81 |
82 | 83 | 84 | Ответственность сторон. 85 | 86 | 3.1. Стороны несут ответственность за неисполнение или ненадлежащее исполнение своих обязательств по настоящему 87 | Соглашению в соответствии с действующим законодательством. 88 | 89 | 3.2. Сервис не несет ответственности за качество, скорость, надежность и доступность интернет-соединения Пользователя, 90 | а также за любые убытки или ущерб, причиненные Пользователю или третьим лицам в результате использования или 91 | невозможности использования Сервиса. 92 | 93 | 3.3. Пользователь несет ответственность за любые действия, совершенные им при использовании Сервиса, а также за 94 | любые последствия, связанные с такими действиями, включая возможное нарушение прав и интересов третьих лиц или законодательства. 95 | 96 |
97 | 98 | 99 | Прочие условия. 100 | 101 | 4.1. Настоящее Соглашение вступает в силу с момента начала использования Пользователем Сервиса и действует до 102 | момента прекращения такого использования. 103 | 104 | 4.2. Настоящее Соглашение подлежит применению и толкованию в соответствии с законодательством Российской Федерации. 105 | 106 | 4.3. Если какое-либо положение настоящего Соглашения признается недействительным или не подлежащим применению по 107 | какой-либо причине, это не влияет на действительность и применимость остальных положений Соглашения. 108 | 109 | 4.4. Сервис оставляет за собой право в любое время изменять или дополнять настоящее Соглашение по своему 110 | усмотрению без предварительного уведомления Пользователя. Пользователь обязан самостоятельно следить за изменениями 111 | Соглашения на сайте Сервиса или в ПО. Продолжение использования Сервиса после внесения изменений или дополнений 112 | означает принятие Пользователем таких изменений или дополнений. 113 | 114 | 4.5. Пользователь подтверждает, что ознакомился со всеми положениями настоящего Соглашения и безоговорочно принимает их. --------------------------------------------------------------------------------