Files
mypointvpn/src/App/models.py
T

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