261 lines
9.9 KiB
Python
261 lines
9.9 KiB
Python
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, transaction
|
|
from serverus import Host, generate_password
|
|
|
|
PANEL_PORT_RANGE = (10000, 20000)
|
|
"""Не пересекается с диапазоном ssh портов serverus."""
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
"""Менеджер пользователей без email: только username и password."""
|
|
|
|
use_in_migrations = True
|
|
|
|
def _create_user(self, username, password, **extra_fields):
|
|
if not username:
|
|
raise ValueError('The given username must be set')
|
|
username = self.model.normalize_username(username)
|
|
user = self.model(username=username, **extra_fields)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_user(self, username, password=None, **extra_fields):
|
|
extra_fields.setdefault('is_staff', False)
|
|
extra_fields.setdefault('is_superuser', False)
|
|
return self._create_user(username, password, **extra_fields)
|
|
|
|
def create_superuser(self, username, password=None, **extra_fields):
|
|
extra_fields.setdefault('is_staff', True)
|
|
extra_fields.setdefault('is_superuser', True)
|
|
if extra_fields.get('is_staff') is not True:
|
|
raise ValueError('Superuser must have is_staff=True.')
|
|
if extra_fields.get('is_superuser') is not True:
|
|
raise ValueError('Superuser must have is_superuser=True.')
|
|
return self._create_user(username, password, **extra_fields)
|
|
|
|
|
|
class User(AbstractUser):
|
|
"""Пользователь без email. Регистрация только по username и password."""
|
|
|
|
email = None
|
|
EMAIL_FIELD = None
|
|
REQUIRED_FIELDS = []
|
|
|
|
objects = UserManager()
|
|
|
|
def clean(self):
|
|
# AbstractUser.clean() нормализует email, которого у нас нет
|
|
pass
|
|
|
|
def email_user(self, *args, **kwargs):
|
|
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'
|
|
|
|
class Status(models.TextChoices):
|
|
INSTALLING = 'installing', 'устанавливается'
|
|
READY = 'ready', 'активен'
|
|
ERROR = 'error', 'ошибка'
|
|
RELEASING = 'releasing', 'возвращается'
|
|
|
|
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='points')
|
|
name = models.CharField(max_length=64, unique=True)
|
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
|
|
error = models.TextField(blank=True)
|
|
|
|
domain = models.CharField(max_length=253, blank=True)
|
|
cloudflare = models.BooleanField(default=False, help_text='Домен спрятан за Cloudflare')
|
|
outbound_proxy = models.CharField(
|
|
max_length=512, blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}',
|
|
)
|
|
|
|
panel_username = models.CharField(max_length=64)
|
|
panel_password = models.CharField(max_length=128)
|
|
panel_port = models.PositiveIntegerField()
|
|
panel_path = models.CharField(max_length=64)
|
|
panel_api_token = models.CharField(max_length=255, blank=True)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
objects = PointManager()
|
|
|
|
class Meta:
|
|
verbose_name = 'точка доступа'
|
|
verbose_name_plural = 'точки доступа'
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@property
|
|
def is_busy(self) -> bool:
|
|
return self.status in (self.Status.INSTALLING, self.Status.RELEASING)
|
|
|
|
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.find_server(Server.Role.MASK)
|
|
|
|
@property
|
|
def entry(self) -> 'Server':
|
|
"""Сервер, к которому подключаются клиенты."""
|
|
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 отражают текущее состояние сервера.
|
|
|
|
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet,
|
|
в том числе в пределах точки: point.servers.find_by_address(address).
|
|
"""
|
|
|
|
class Role(models.TextChoices):
|
|
PANEL = 'panel', 'панель'
|
|
MASK = 'mask', 'маскировка'
|
|
|
|
point = models.ForeignKey(Point, on_delete=models.CASCADE, related_name='servers')
|
|
role = models.CharField(max_length=8, choices=Role.choices)
|
|
address = models.GenericIPAddressField(unique=True)
|
|
ssh_port = models.PositiveIntegerField(default=22)
|
|
default_root_password = models.CharField(
|
|
max_length=128,
|
|
help_text='Пароль root, который пользователь прислал при добавлении сервера. '
|
|
'После сброса сервера у провайдера он снова может быть актуальным',
|
|
)
|
|
root_password = models.CharField(max_length=128, help_text='Текущий пароль root')
|
|
private_key = models.TextField(blank=True)
|
|
public_key = models.TextField(blank=True)
|
|
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 = 'серверы'
|
|
constraints = [models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique')]
|
|
|
|
def __str__(self):
|
|
return f'{self.address} ({self.get_role_display()})'
|
|
|
|
def host(self) -> Host:
|
|
return Host(self.address, port=self.ssh_port, private_key=self.private_key or None, known_hosts=self.known_hosts)
|
|
|
|
def reset_fingerprint(self):
|
|
self.known_hosts = ''
|
|
self.save(update_fields=['known_hosts'])
|