chore: улучшен интерфейс работы с моделями
This commit is contained in:
1 parent
6cfb00a847
commit
adab286884
6 files changed
+175
-89
No files matched your search
+10
-1
@@ -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`.
|
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
|
||||||
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
||||||
- `bot/texts.py` - кнопки и тексты.
|
- `bot/texts.py` - кнопки и тексты.
|
||||||
|
|||||||
+15
-17
@@ -5,7 +5,7 @@ from urllib.parse import urlsplit
|
|||||||
from Telegram.interfaces import Interface, command, register, state, text
|
from Telegram.interfaces import Interface, command, register, state, text
|
||||||
|
|
||||||
from .. import services, tasks
|
from .. import services, tasks
|
||||||
from ..models import Point, Server
|
from ..models import Credentials, Point, Server
|
||||||
from . import texts
|
from . import texts
|
||||||
|
|
||||||
# состояния диалога
|
# состояния диалога
|
||||||
@@ -26,14 +26,14 @@ PROXY_SCHEMES = ('http', 'https', 'socks5')
|
|||||||
HTML = 'HTML'
|
HTML = 'HTML'
|
||||||
|
|
||||||
|
|
||||||
def parse_credentials(value: str) -> services.Credentials | None:
|
def parse_credentials(value: str) -> Credentials | None:
|
||||||
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
|
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
|
||||||
address, _, password = value.strip().partition(':')
|
address, _, password = value.strip().partition(':')
|
||||||
try:
|
try:
|
||||||
address = str(ipaddress.ip_address(address))
|
address = str(ipaddress.ip_address(address))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
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:
|
def is_domain(value: str) -> bool:
|
||||||
@@ -58,7 +58,7 @@ class MyPointInterface(Interface):
|
|||||||
@text(texts.MAIN_MENU, texts.CANCEL)
|
@text(texts.MAIN_MENU, texts.CANCEL)
|
||||||
def main_menu(self, ctx):
|
def main_menu(self, ctx):
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
points = list(ctx.user.points.order_by('created_at'))
|
points = list(Point.objects.owned_by(ctx.user))
|
||||||
if points:
|
if points:
|
||||||
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
|
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
|
||||||
else:
|
else:
|
||||||
@@ -136,7 +136,7 @@ class MyPointInterface(Interface):
|
|||||||
other = draft.get('mask' if key == 'server' else 'server', {})
|
other = draft.get('mask' if key == 'server' else 'server', {})
|
||||||
if other.get('address') == credentials.address:
|
if other.get('address') == credentials.address:
|
||||||
return ctx.reply(texts.ADDRESS_DUPLICATE)
|
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))
|
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
|
||||||
draft[key] = {'address': credentials.address, 'password': credentials.password}
|
draft[key] = {'address': credentials.address, 'password': credentials.password}
|
||||||
self.show_draft(ctx)
|
self.show_draft(ctx)
|
||||||
@@ -195,10 +195,10 @@ class MyPointInterface(Interface):
|
|||||||
return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
|
return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
|
||||||
if draft.get('cloudflare') and not draft.get('domain'):
|
if draft.get('cloudflare') and not draft.get('domain'):
|
||||||
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
|
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
|
||||||
point = services.create_point(
|
point = Point.objects.create_point(
|
||||||
ctx.user,
|
ctx.user,
|
||||||
panel=services.Credentials(**draft['server']),
|
panel=Credentials(**draft['server']),
|
||||||
mask=services.Credentials(**draft['mask']) if draft.get('mask') else None,
|
mask=Credentials(**draft['mask']) if draft.get('mask') else None,
|
||||||
domain=draft.get('domain', ''),
|
domain=draft.get('domain', ''),
|
||||||
cloudflare=draft.get('cloudflare', False),
|
cloudflare=draft.get('cloudflare', False),
|
||||||
outbound_proxy=draft.get('proxy', ''),
|
outbound_proxy=draft.get('proxy', ''),
|
||||||
@@ -212,12 +212,12 @@ class MyPointInterface(Interface):
|
|||||||
@text(texts.MANAGE)
|
@text(texts.MANAGE)
|
||||||
def ask_point(self, ctx):
|
def ask_point(self, ctx):
|
||||||
ctx.reset()
|
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)}')
|
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
|
||||||
|
|
||||||
@state(SELECT)
|
@state(SELECT)
|
||||||
def select_point(self, ctx):
|
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:
|
if point is None:
|
||||||
return ctx.reply(texts.POINT_NOT_FOUND)
|
return ctx.reply(texts.POINT_NOT_FOUND)
|
||||||
self.show_point(ctx, point)
|
self.show_point(ctx, point)
|
||||||
@@ -225,14 +225,14 @@ class MyPointInterface(Interface):
|
|||||||
@text(texts.SERVER_MENU)
|
@text(texts.SERVER_MENU)
|
||||||
def server_menu(self, ctx):
|
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:
|
if point is None:
|
||||||
return self.main_menu(ctx)
|
return self.main_menu(ctx)
|
||||||
self.show_point(ctx, point)
|
self.show_point(ctx, point)
|
||||||
|
|
||||||
def current_point(self, ctx) -> Point | None:
|
def current_point(self, ctx) -> Point | None:
|
||||||
point_id = ctx.data.get('point')
|
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):
|
def show_point(self, ctx, point: Point):
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
@@ -260,8 +260,7 @@ class MyPointInterface(Interface):
|
|||||||
point = self.idle_point(ctx)
|
point = self.idle_point(ctx)
|
||||||
if point is None:
|
if point is None:
|
||||||
return
|
return
|
||||||
point.status = Point.Status.RELEASING
|
point.set_status(Point.Status.RELEASING)
|
||||||
point.save(update_fields=['status'])
|
|
||||||
tasks.release_point.send(point.pk, ctx.chat.pk)
|
tasks.release_point.send(point.pk, ctx.chat.pk)
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||||
@@ -280,7 +279,7 @@ class MyPointInterface(Interface):
|
|||||||
point = self.idle_point(ctx)
|
point = self.idle_point(ctx)
|
||||||
if point is None:
|
if point is None:
|
||||||
return
|
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))
|
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
|
||||||
self.start_reinstall(ctx, point, credentials.address, credentials.password)
|
self.start_reinstall(ctx, point, credentials.address, credentials.password)
|
||||||
|
|
||||||
@@ -291,8 +290,7 @@ class MyPointInterface(Interface):
|
|||||||
self.start_reinstall(ctx, point)
|
self.start_reinstall(ctx, point)
|
||||||
|
|
||||||
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
|
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
|
||||||
point.status = Point.Status.INSTALLING
|
point.set_status(Point.Status.INSTALLING)
|
||||||
point.save(update_fields=['status'])
|
|
||||||
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
|
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||||
|
|||||||
+116
-5
@@ -1,7 +1,13 @@
|
|||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.contrib.auth.base_user import BaseUserManager
|
from django.contrib.auth.base_user import BaseUserManager
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from serverus import Host
|
from serverus import Host, generate_password
|
||||||
|
|
||||||
|
PANEL_PORT_RANGE = (10000, 20000)
|
||||||
|
"""Не пересекается с диапазоном ssh портов serverus."""
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
@@ -50,10 +56,76 @@ class User(AbstractUser):
|
|||||||
raise NotImplementedError('User has no email')
|
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):
|
class Point(models.Model):
|
||||||
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
|
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
|
||||||
|
|
||||||
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
||||||
|
|
||||||
|
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CF_WARP = 'cf_warp'
|
CF_WARP = 'cf_warp'
|
||||||
@@ -83,6 +155,8 @@ class Point(models.Model):
|
|||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
objects = PointManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'точка доступа'
|
verbose_name = 'точка доступа'
|
||||||
verbose_name_plural = 'точки доступа'
|
verbose_name_plural = 'точки доступа'
|
||||||
@@ -94,16 +168,27 @@ class Point(models.Model):
|
|||||||
def is_busy(self) -> bool:
|
def is_busy(self) -> bool:
|
||||||
return self.status in (self.Status.INSTALLING, self.Status.RELEASING)
|
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()
|
return self.servers.filter(role=role).first()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def panel(self) -> 'Server':
|
def panel(self) -> 'Server':
|
||||||
|
"""Сервер панели. Есть у каждой точки: create_point создает его вместе с точкой."""
|
||||||
return self.server(Server.Role.PANEL)
|
return self.server(Server.Role.PANEL)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mask(self) -> 'Server | None':
|
def mask(self) -> 'Server | None':
|
||||||
return self.server(Server.Role.MASK)
|
"""Сервер маскировки, если он есть."""
|
||||||
|
return self.find_server(Server.Role.MASK)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entry(self) -> 'Server':
|
def entry(self) -> 'Server':
|
||||||
@@ -111,8 +196,32 @@ class Point(models.Model):
|
|||||||
return self.mask or self.panel
|
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):
|
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):
|
class Role(models.TextChoices):
|
||||||
PANEL = 'panel', 'панель'
|
PANEL = 'panel', 'панель'
|
||||||
@@ -133,6 +242,8 @@ class Server(models.Model):
|
|||||||
known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')
|
known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')
|
||||||
uname = models.CharField(max_length=512, blank=True)
|
uname = models.CharField(max_length=512, blank=True)
|
||||||
|
|
||||||
|
objects = ServerManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'сервер'
|
verbose_name = 'сервер'
|
||||||
verbose_name_plural = 'серверы'
|
verbose_name_plural = 'серверы'
|
||||||
|
|||||||
+5
-58
@@ -1,56 +1,15 @@
|
|||||||
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
|
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
|
||||||
import secrets
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import serverus
|
import serverus
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from .models import Point, Server
|
from .models import Point, Server
|
||||||
|
|
||||||
PANEL_PORT_RANGE = (10000, 20000)
|
|
||||||
"""Не пересекается с диапазоном ssh портов serverus."""
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisionError(Exception):
|
class ProvisionError(Exception):
|
||||||
pass
|
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):
|
def install_point(point: Point):
|
||||||
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
|
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
|
||||||
with _installing(point):
|
with _installing(point):
|
||||||
@@ -64,7 +23,7 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
|
|||||||
address - переустановленный сервер точки, без него переустанавливаются все.
|
address - переустановленный сервер точки, без него переустанавливаются все.
|
||||||
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
|
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):
|
with _installing(point):
|
||||||
for server in servers:
|
for server in servers:
|
||||||
server.known_hosts = ''
|
server.known_hosts = ''
|
||||||
@@ -90,7 +49,7 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
|
|||||||
else:
|
else:
|
||||||
errors[server.address] = result.error
|
errors[server.address] = result.error
|
||||||
if errors:
|
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:
|
else:
|
||||||
point.delete()
|
point.delete()
|
||||||
return passwords, errors
|
return passwords, errors
|
||||||
@@ -150,23 +109,11 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def _installing(point: Point):
|
def _installing(point: Point):
|
||||||
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
|
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
|
||||||
_set_status(point, Point.Status.INSTALLING)
|
point.set_status(Point.Status.INSTALLING)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_set_status(point, Point.Status.ERROR, str(error))
|
point.set_status(Point.Status.ERROR, str(error))
|
||||||
raise
|
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
|
|
||||||
@@ -143,8 +143,7 @@ class PointMenuTests(BotTestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
self.say('/start')
|
self.say('/start')
|
||||||
self.point = create_point(TelegramUser.objects.get())
|
self.point = create_point(TelegramUser.objects.get())
|
||||||
self.point.status = Point.Status.READY
|
self.point.set_status(Point.Status.READY)
|
||||||
self.point.save()
|
|
||||||
|
|
||||||
def open_point(self):
|
def open_point(self):
|
||||||
self.say(texts.MANAGE)
|
self.say(texts.MANAGE)
|
||||||
@@ -175,8 +174,7 @@ class PointMenuTests(BotTestCase):
|
|||||||
|
|
||||||
@mock.patch.object(tasks.release_point, 'send')
|
@mock.patch.object(tasks.release_point, 'send')
|
||||||
def test_busy_point(self, send):
|
def test_busy_point(self, send):
|
||||||
self.point.status = Point.Status.INSTALLING
|
self.point.set_status(Point.Status.INSTALLING)
|
||||||
self.point.save()
|
|
||||||
self.open_point()
|
self.open_point()
|
||||||
self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается'))
|
self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается'))
|
||||||
send.assert_not_called()
|
send.assert_not_called()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.test import TestCase
|
|||||||
from Telegram.models import User as TelegramUser
|
from Telegram.models import User as TelegramUser
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from .models import Point, Server
|
from .models import PANEL_PORT_RANGE, Credentials, Point, Server
|
||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
class FakeRunner:
|
||||||
@@ -34,12 +34,12 @@ def patch_runner(runner):
|
|||||||
return mock.patch('serverus.runner.run_playbook', 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):
|
def create_point(owner=None, mask=MASK, **kwargs):
|
||||||
owner = owner or TelegramUser.objects.create(tg_id=1)
|
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):
|
class CreatePointTests(TestCase):
|
||||||
@@ -48,7 +48,7 @@ class CreatePointTests(TestCase):
|
|||||||
self.assertEqual(point.status, Point.Status.INSTALLING)
|
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.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'))
|
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(low <= point.panel_port < high)
|
||||||
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
|
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
|
||||||
self.assertEqual(point.panel.default_root_password, 'panel-pw')
|
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(list(point.servers.all()), [point.panel])
|
||||||
self.assertEqual(point.entry, 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):
|
class InstallTests(TestCase):
|
||||||
def test_install_point(self):
|
def test_install_point(self):
|
||||||
|
|||||||
Reference in new issue
Block a user