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'])