├── .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 | 
4 | [](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 | 
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. Пользователь подтверждает, что ознакомился со всеми положениями настоящего Соглашения и безоговорочно принимает их.
--------------------------------------------------------------------------------