From adab286884d37bf94091923f982cf950a8866c2e Mon Sep 17 00:00:00 2001 From: protokey Date: Fri, 25 Sep 2026 16:24:03 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App/README.md | 11 +++- src/App/bot/interface.py | 32 +++++----- src/App/models.py | 121 ++++++++++++++++++++++++++++++++++++-- src/App/services.py | 63 ++------------------ src/App/tests_bot.py | 6 +- src/App/tests_services.py | 31 ++++++++-- 6 files changed, 175 insertions(+), 89 deletions(-) diff --git a/src/App/README.md b/src/App/README.md index da88577..f34bec3 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -16,9 +16,18 @@ Django приложение связывающее все части сайта С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен. +Работа с записями идет через менеджеры и методы моделей, а не через `filter`/`create` напрямую: + +- создание: `Point.objects.create_point(owner, panel, mask=None, *, domain, cloudflare, outbound_proxy)` создает точку вместе с серверами, имя и доступ к панели генерирует сама. `Server.objects.create_server(point, role, credentials)`. `Credentials` - адрес и пароль root, которые прислал пользователь +- поиск: `Point.objects.owned_by(owner)`, дальше `find_by_name`, `find_by_pk`, `find_last`. У серверов `find_by_address` и `is_address_taken`, в том числе в пределах точки через `point.servers` +- изменение: `point.set_status(status, error)`, `server.reset_fingerprint()` +- удаление: `point.delete()`, серверы удаляются вместе с точкой + +Соглашение для поиска: `get_*` и `point.server(role)` бросают `DoesNotExist`, если записи нет, а `find_*` возвращают `None`, и это видно по типу. `point.panel` всегда есть, потому что панель создается вместе с точкой, а `point.mask` может быть `None`. + ## Модули -- `services.py` - сценарии поверх serverus: `create_point`, `install_point`, `reinstall`, `release_point`. +- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`. - `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`. - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/texts.py` - кнопки и тексты. diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py index 2ed3763..92e3086 100644 --- a/src/App/bot/interface.py +++ b/src/App/bot/interface.py @@ -5,7 +5,7 @@ from urllib.parse import urlsplit from Telegram.interfaces import Interface, command, register, state, text from .. import services, tasks -from ..models import Point, Server +from ..models import Credentials, Point, Server from . import texts # состояния диалога @@ -26,14 +26,14 @@ PROXY_SCHEMES = ('http', 'https', 'socks5') HTML = 'HTML' -def parse_credentials(value: str) -> services.Credentials | None: +def parse_credentials(value: str) -> Credentials | None: """'ip:password' -> Credentials. Пароль может содержать двоеточия.""" address, _, password = value.strip().partition(':') try: address = str(ipaddress.ip_address(address)) except ValueError: return None - return services.Credentials(address, password) if password else None + return Credentials(address, password) if password else None def is_domain(value: str) -> bool: @@ -58,7 +58,7 @@ class MyPointInterface(Interface): @text(texts.MAIN_MENU, texts.CANCEL) def main_menu(self, ctx): ctx.reset() - points = list(ctx.user.points.order_by('created_at')) + points = list(Point.objects.owned_by(ctx.user)) if points: ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML) else: @@ -136,7 +136,7 @@ class MyPointInterface(Interface): other = draft.get('mask' if key == 'server' else 'server', {}) if other.get('address') == credentials.address: return ctx.reply(texts.ADDRESS_DUPLICATE) - if Server.objects.filter(address=credentials.address).exists(): + if Server.objects.is_address_taken(credentials.address): return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address)) draft[key] = {'address': credentials.address, 'password': credentials.password} self.show_draft(ctx) @@ -195,10 +195,10 @@ class MyPointInterface(Interface): return ctx.reply(texts.MASK_WITHOUT_DOMAIN) if draft.get('cloudflare') and not draft.get('domain'): return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN) - point = services.create_point( + point = Point.objects.create_point( ctx.user, - panel=services.Credentials(**draft['server']), - mask=services.Credentials(**draft['mask']) if draft.get('mask') else None, + panel=Credentials(**draft['server']), + mask=Credentials(**draft['mask']) if draft.get('mask') else None, domain=draft.get('domain', ''), cloudflare=draft.get('cloudflare', False), outbound_proxy=draft.get('proxy', ''), @@ -212,12 +212,12 @@ class MyPointInterface(Interface): @text(texts.MANAGE) def ask_point(self, ctx): ctx.reset() - points = ctx.user.points.order_by('created_at') + points = Point.objects.owned_by(ctx.user) self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}') @state(SELECT) def select_point(self, ctx): - point = ctx.user.points.filter(name=ctx.text.strip()).first() + point = Point.objects.owned_by(ctx.user).find_by_name(ctx.text.strip()) if point is None: return ctx.reply(texts.POINT_NOT_FOUND) self.show_point(ctx, point) @@ -225,14 +225,14 @@ class MyPointInterface(Interface): @text(texts.SERVER_MENU) def server_menu(self, ctx): """Кнопка из уведомления: последняя открытая точка или последняя созданная.""" - point = self.current_point(ctx) or ctx.user.points.order_by('-created_at').first() + point = self.current_point(ctx) or Point.objects.owned_by(ctx.user).find_last() if point is None: return self.main_menu(ctx) self.show_point(ctx, point) def current_point(self, ctx) -> Point | None: point_id = ctx.data.get('point') - return ctx.user.points.filter(pk=point_id).first() if point_id else None + return Point.objects.owned_by(ctx.user).find_by_pk(point_id) if point_id else None def show_point(self, ctx, point: Point): ctx.reset() @@ -260,8 +260,7 @@ class MyPointInterface(Interface): point = self.idle_point(ctx) if point is None: return - point.status = Point.Status.RELEASING - point.save(update_fields=['status']) + point.set_status(Point.Status.RELEASING) tasks.release_point.send(point.pk, ctx.chat.pk) ctx.reset() ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS) @@ -280,7 +279,7 @@ class MyPointInterface(Interface): point = self.idle_point(ctx) if point is None: return - if not point.servers.filter(address=credentials.address).exists(): + if point.servers.find_by_address(credentials.address) is None: return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address)) self.start_reinstall(ctx, point, credentials.address, credentials.password) @@ -291,8 +290,7 @@ class MyPointInterface(Interface): self.start_reinstall(ctx, point) def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None): - point.status = Point.Status.INSTALLING - point.save(update_fields=['status']) + point.set_status(Point.Status.INSTALLING) tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password) ctx.reset() ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS) diff --git a/src/App/models.py b/src/App/models.py index 2fe2b04..e01a77a 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -1,7 +1,13 @@ +import secrets +from dataclasses import dataclass + from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser -from django.db import models -from serverus import Host +from django.db import models, transaction +from serverus import Host, generate_password + +PANEL_PORT_RANGE = (10000, 20000) +"""Не пересекается с диапазоном ssh портов serverus.""" class UserManager(BaseUserManager): @@ -50,10 +56,76 @@ class User(AbstractUser): raise NotImplementedError('User has no email') +@dataclass(frozen=True) +class Credentials: + """Доступ к серверу, который прислал пользователь: адрес и пароль root от провайдера.""" + address: str + password: str + + +# Соглашение для поиска: get_* и server() бросают DoesNotExist, если записи нет, +# find_* возвращают None, и это отражено в типе. Вызывающая сторона видит по имени и типу, нужна ли проверка на None. + + +class PointQuerySet(models.QuerySet): + def owned_by(self, owner) -> 'PointQuerySet': + """Точки владельца от старых к новым.""" + return self.filter(owner=owner).order_by('created_at') + + def find_by_pk(self, pk) -> 'Point | None': + return self.filter(pk=pk).first() + + def find_by_name(self, name: str) -> 'Point | None': + return self.filter(name=name).first() + + def find_last(self) -> 'Point | None': + """Последняя созданная точка.""" + return self.order_by('created_at').last() + + def is_name_taken(self, name: str) -> bool: + return self.filter(name=name).exists() + + +class PointManager(models.Manager.from_queryset(PointQuerySet)): + def create_point( + self, owner, panel: Credentials, mask: Credentials | None = None, *, domain: str = '', + cloudflare: bool = False, outbound_proxy: str = '', + ) -> 'Point': + """Создает точку вместе с ее серверами. Вызывать через Point.objects. + + Точка без сервера панели не существует, поэтому панель обязательна. Имя точки и доступ к панели генерируются. + """ + low, high = PANEL_PORT_RANGE + with transaction.atomic(): + point = self.create( + owner=owner, + name=self._unique_name(), + domain=domain, + cloudflare=cloudflare, + outbound_proxy=outbound_proxy, + panel_username=generate_password(12), + panel_password=generate_password(), + panel_port=low + secrets.randbelow(high - low), + panel_path=generate_password(18), + ) + Server.objects.create_server(point, Server.Role.PANEL, panel) + if mask: + Server.objects.create_server(point, Server.Role.MASK, mask) + return point + + def _unique_name(self) -> str: + while True: + name = f'point-{secrets.token_hex(3)}' + if not self.is_name_taken(name): + return name + + class Point(models.Model): """Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним. С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. + + Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы. """ CF_WARP = 'cf_warp' @@ -83,6 +155,8 @@ class Point(models.Model): created_at = models.DateTimeField(auto_now_add=True) + objects = PointManager() + class Meta: verbose_name = 'точка доступа' verbose_name_plural = 'точки доступа' @@ -94,16 +168,27 @@ class Point(models.Model): def is_busy(self) -> bool: return self.status in (self.Status.INSTALLING, self.Status.RELEASING) - def server(self, role: str) -> 'Server | None': + def set_status(self, status: str, error: str = ''): + self.status = status + self.error = error + self.save(update_fields=['status', 'error']) + + def server(self, role: str) -> 'Server': + """Сервер с ролью role. Бросает Server.DoesNotExist, если его нет.""" + return self.servers.get(role=role) + + def find_server(self, role: str) -> 'Server | None': return self.servers.filter(role=role).first() @property def panel(self) -> 'Server': + """Сервер панели. Есть у каждой точки: create_point создает его вместе с точкой.""" return self.server(Server.Role.PANEL) @property def mask(self) -> 'Server | None': - return self.server(Server.Role.MASK) + """Сервер маскировки, если он есть.""" + return self.find_server(Server.Role.MASK) @property def entry(self) -> 'Server': @@ -111,8 +196,32 @@ class Point(models.Model): return self.mask or self.panel +class ServerQuerySet(models.QuerySet): + def find_by_address(self, address: str) -> 'Server | None': + return self.filter(address=address).first() + + def is_address_taken(self, address: str) -> bool: + return self.filter(address=address).exists() + + +class ServerManager(models.Manager.from_queryset(ServerQuerySet)): + def create_server(self, point: Point, role: str, credentials: Credentials) -> 'Server': + """Добавляет сервер в точку. Вызывать через Server.objects. Присланный пароль запоминается как пароль по умолчанию.""" + return self.create( + point=point, + role=role, + address=credentials.address, + root_password=credentials.password, + default_root_password=credentials.password, + ) + + class Server(models.Model): - """Сервер под управлением системы. Поля ssh отражают текущее состояние сервера.""" + """Сервер под управлением системы. Поля ssh отражают текущее состояние сервера. + + Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet, + в том числе в пределах точки: point.servers.find_by_address(address). + """ class Role(models.TextChoices): PANEL = 'panel', 'панель' @@ -133,6 +242,8 @@ class Server(models.Model): known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС') uname = models.CharField(max_length=512, blank=True) + objects = ServerManager() + class Meta: verbose_name = 'сервер' verbose_name_plural = 'серверы' diff --git a/src/App/services.py b/src/App/services.py index de81fb4..3aa1063 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -1,56 +1,15 @@ """Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов.""" -import secrets from contextlib import contextmanager -from dataclasses import dataclass import serverus -from django.db import transaction from .models import Point, Server -PANEL_PORT_RANGE = (10000, 20000) -"""Не пересекается с диапазоном ssh портов serverus.""" - class ProvisionError(Exception): pass -@dataclass(frozen=True) -class Credentials: - address: str - password: str - - -def create_point( - owner, panel: Credentials, mask: Credentials | None = None, domain: str = '', cloudflare: bool = False, - outbound_proxy: str = '', -) -> Point: - """Создает точку. mask - необязательный сервер маскировки.""" - low, high = PANEL_PORT_RANGE - with transaction.atomic(): - point = Point.objects.create( - owner=owner, - name=_unique_name(), - domain=domain, - cloudflare=cloudflare, - outbound_proxy=outbound_proxy, - panel_username=serverus.generate_password(12), - panel_password=serverus.generate_password(), - panel_port=low + secrets.randbelow(high - low), - panel_path=serverus.generate_password(18), - ) - for role, credentials in ((Server.Role.PANEL, panel), (Server.Role.MASK, mask)): - if credentials: - point.servers.create( - role=role, - address=credentials.address, - root_password=credentials.password, - default_root_password=credentials.password, - ) - return point - - def install_point(point: Point): """Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели.""" with _installing(point): @@ -64,7 +23,7 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N address - переустановленный сервер точки, без него переустанавливаются все. password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера. """ - servers = point.servers.filter(address=address) if address else point.servers.all() + servers = [point.servers.get(address=address)] if address else point.servers.all() with _installing(point): for server in servers: server.known_hosts = '' @@ -90,7 +49,7 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]: else: errors[server.address] = result.error if errors: - _set_status(point, Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items())) + point.set_status(Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items())) else: point.delete() return passwords, errors @@ -150,23 +109,11 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result: @contextmanager def _installing(point: Point): """Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки.""" - _set_status(point, Point.Status.INSTALLING) + point.set_status(Point.Status.INSTALLING) try: yield except Exception as error: - _set_status(point, Point.Status.ERROR, str(error)) + point.set_status(Point.Status.ERROR, str(error)) raise - _set_status(point, Point.Status.READY) + point.set_status(Point.Status.READY) - -def _set_status(point: Point, status: str, error: str = ''): - point.status = status - point.error = error - point.save(update_fields=['status', 'error']) - - -def _unique_name() -> str: - while True: - name = f'point-{secrets.token_hex(3)}' - if not Point.objects.filter(name=name).exists(): - return name diff --git a/src/App/tests_bot.py b/src/App/tests_bot.py index 0fbc2f1..7403f7b 100644 --- a/src/App/tests_bot.py +++ b/src/App/tests_bot.py @@ -143,8 +143,7 @@ class PointMenuTests(BotTestCase): super().setUp() self.say('/start') self.point = create_point(TelegramUser.objects.get()) - self.point.status = Point.Status.READY - self.point.save() + self.point.set_status(Point.Status.READY) def open_point(self): self.say(texts.MANAGE) @@ -175,8 +174,7 @@ class PointMenuTests(BotTestCase): @mock.patch.object(tasks.release_point, 'send') def test_busy_point(self, send): - self.point.status = Point.Status.INSTALLING - self.point.save() + self.point.set_status(Point.Status.INSTALLING) self.open_point() self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается')) send.assert_not_called() diff --git a/src/App/tests_services.py b/src/App/tests_services.py index 58b49a9..5e7e640 100644 --- a/src/App/tests_services.py +++ b/src/App/tests_services.py @@ -5,7 +5,7 @@ from django.test import TestCase from Telegram.models import User as TelegramUser from . import services -from .models import Point, Server +from .models import PANEL_PORT_RANGE, Credentials, Point, Server class FakeRunner: @@ -34,12 +34,12 @@ def patch_runner(runner): return mock.patch('serverus.runner.run_playbook', runner) -MASK = services.Credentials('10.0.0.2', 'mask-pw') +MASK = Credentials('10.0.0.2', 'mask-pw') def create_point(owner=None, mask=MASK, **kwargs): owner = owner or TelegramUser.objects.create(tg_id=1) - return services.create_point(owner, services.Credentials('10.0.0.1', 'panel-pw'), mask, **kwargs) + return Point.objects.create_point(owner, Credentials('10.0.0.1', 'panel-pw'), mask, **kwargs) class CreatePointTests(TestCase): @@ -48,7 +48,7 @@ class CreatePointTests(TestCase): self.assertEqual(point.status, Point.Status.INSTALLING) self.assertEqual((point.panel.address, point.panel.root_password), ('10.0.0.1', 'panel-pw')) self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw')) - low, high = services.PANEL_PORT_RANGE + low, high = PANEL_PORT_RANGE self.assertTrue(low <= point.panel_port < high) self.assertTrue(point.panel_username and point.panel_password and point.panel_path) self.assertEqual(point.panel.default_root_password, 'panel-pw') @@ -60,6 +60,29 @@ class CreatePointTests(TestCase): self.assertEqual(list(point.servers.all()), [point.panel]) self.assertEqual(point.entry, point.panel) + def test_panel_is_required(self): + point = create_point(mask=None) + point.panel.delete() + with self.assertRaises(Server.DoesNotExist): + point.panel + self.assertIsNone(point.find_server(Server.Role.PANEL)) + + def test_search(self): + point = create_point() + owner = point.owner + stranger = TelegramUser.objects.create(tg_id=2) + self.assertEqual(Point.objects.owned_by(owner).find_by_name(point.name), point) + self.assertIsNone(Point.objects.owned_by(stranger).find_by_name(point.name)) + self.assertIsNone(Point.objects.owned_by(stranger).find_by_pk(point.pk)) + self.assertEqual(Point.objects.owned_by(owner).find_last(), point) + self.assertTrue(Server.objects.is_address_taken('10.0.0.2')) + self.assertEqual(point.servers.find_by_address('10.0.0.2'), point.mask) + self.assertIsNone(point.servers.find_by_address('10.0.0.3')) + + def test_delete_removes_servers(self): + create_point().delete() + self.assertFalse(Server.objects.exists()) + class InstallTests(TestCase): def test_install_point(self):