chore: переезд на serWebhook

This commit is contained in:
protokey committed 2026-09-26 18:20:35 +04:00
1 parent 45e7a1d22f
commit f1414e5ace
42 files changed
+1391 -224

No files matched your search

+4
View File
@@ -6,6 +6,7 @@ Django приложение связывающее все части сайта
-[x] Модель данных для поддержки интерфейса и функционала
-[ ] Настройки домена и прокси у готовой точки
-[x] Клиенты и статистика через API панели 3x-ui
-[x] Супервизор долгих задач и ежедневная проверка точек
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -33,6 +34,9 @@ Django приложение связывающее все части сайта
- `fields.py` - `EncryptedTextField`: секреты в базе зашифрованы Fernet ключами `FIELD_ENCRYPTION_KEYS` из `.env`.
- `management/commands/serve_ssh.py` - ручной вход на сервер: `make serve_ssh ip=<ip>`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- Долгая задача запускается через `tasks.start(actor, point, status, chat, *args)`: точка получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в точке (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `Point.INTERRUPTED`, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
- `check_point` - ежедневная проверка точки (`services.check_point`): своя цепочка у каждой точки, запускается после установки и командой `check_points` (ее выполняет воркер в docker при старте). Сутки проходятся перескоками по 15 минут: RabbitMQ не держит неподтвержденное отложенное сообщение дольше `consumer_timeout`.
- `backups.py` и команды `backup`, `restore` - бэкап базы `pg_dump`/`pg_restore`, `generate_secrets` - секреты для `.env`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты.
+28 -2
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
from django.utils.translation import gettext_lazy as _
from .models import Point, Server, User
from .models import Key, Point, Server, User
class UserCreationForm(AdminUserCreationForm):
@@ -48,7 +48,10 @@ class PointAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'status', 'domain', 'created_at')
list_filter = ('status',)
search_fields = ('name', 'servers__address')
readonly_fields = ('owner', 'name', 'status', 'error', 'created_at')
readonly_fields = (
'owner', 'name', 'status', 'error', 'health_error', 'checked_at', 'task_started_at', 'task_heartbeat',
'created_at',
)
inlines = [ServerInline]
@@ -70,3 +73,26 @@ class ServerAdmin(admin.ModelAdmin):
for server in queryset:
server.reset_fingerprint()
self.message_user(request, f'Сброшено отпечатков: {len(queryset)}')
@admin.register(Key)
class KeyAdmin(admin.ModelAdmin):
"""Только просмотр: ключи выпускаются и отзываются через панель точки (services), иначе база и панель разойдутся.
uuid и ссылка подписки не показываются: это доступ в сеть клиента.
"""
list_display = ('name', 'point', 'created_at')
list_filter = ('point',)
search_fields = ('name', 'point__name')
fields = ('name', 'point', 'created_at')
readonly_fields = fields
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
+57
View File
@@ -0,0 +1,57 @@
"""Бэкап базы для команд backup и restore: pg_dump и pg_restore той же major-версии, что и сервер Postgres.
Дамп берется из базы как есть, поэтому секреты (App.fields) в нем зашифрованы. Восстановленная база читается
только с тем же FIELD_ENCRYPTION_KEYS, check_keys это проверяет.
"""
import os
import subprocess
from pathlib import Path
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.management.base import CommandError
SUFFIX = '.dump'
def database() -> dict:
database = settings.DATABASES['default']
if 'postgresql' not in database['ENGINE']:
raise CommandError('бэкап умеет только PostgreSQL: задайте DB_NAME и остальные DB_* в .env')
return database
def run(args: list[str], database: dict, **kwargs):
"""Запускает утилиту postgres с доступом к базе из переменных окружения: пароль не виден в списке процессов."""
env = {
**os.environ,
'PGHOST': database.get('HOST') or '127.0.0.1',
'PGPORT': str(database.get('PORT') or 5432),
'PGUSER': database.get('USER', ''),
'PGPASSWORD': database.get('PASSWORD', ''),
'PGDATABASE': database['NAME'],
}
try:
result = subprocess.run(args, env=env, stderr=subprocess.PIPE, text=True, **kwargs)
except FileNotFoundError:
raise CommandError(f'{args[0]} не найден: поставьте postgresql-client той же версии, что и сервер')
if result.returncode:
raise CommandError(f'{args[0]}: {result.stderr.strip()}')
def check_keys():
"""Секреты в базе расшифровываются текущим FIELD_ENCRYPTION_KEYS: иначе с базы нет толку."""
from .models import Server
try:
# поля расшифровываются при чтении из базы
list(Server.objects.all())
except ImproperlyConfigured:
raise CommandError('FIELD_ENCRYPTION_KEYS не подходит к секретам в базе: нужен ключ, с которым они шифровались')
def rotate(directory: Path, keep: int) -> list[Path]:
"""Удаляет из directory бэкапы, кроме keep последних. Возвращает удаленные."""
dumps = sorted(directory.glob(f'*{SUFFIX}'), key=lambda path: path.stat().st_mtime, reverse=True)
for old in dumps[keep:]:
old.unlink()
return dumps[keep:]
+4 -7
View File
@@ -186,7 +186,7 @@ class MyPointInterface(Interface):
domain=draft.get('domain', ''),
outbound_proxy=draft.get('proxy', ''),
)
tasks.install_point.send(point.pk, ctx.chat.pk)
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
@@ -243,8 +243,7 @@ class MyPointInterface(Interface):
point = self.idle_point(ctx)
if point is None:
return
point.set_status(Point.Status.RELEASING)
tasks.release_point.send(point.pk, ctx.chat.pk)
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
@@ -273,8 +272,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.set_status(Point.Status.INSTALLING)
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
@@ -347,8 +345,7 @@ class MyPointInterface(Interface):
self.start_change(ctx, point, tasks.set_outbound_proxy, fields.encrypt(proxy) if proxy else '')
def start_change(self, ctx, point: Point, actor, value: str):
point.set_status(Point.Status.UPDATING)
actor.send(point.pk, ctx.chat.pk, value)
tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value)
ctx.reset()
ctx.reply(texts.CHANGE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
+17 -4
View File
@@ -151,6 +151,8 @@ def point_card(point: Point) -> str:
]
if point.error:
lines.append(f'Ошибка: {escape(point.error)}')
if point.health_error:
lines.append(f'Проверка: {escape(point.health_error)}')
return '\n'.join(lines)
@@ -214,12 +216,11 @@ def key_card(key: Key, traffic: dict | None) -> str:
def proxy_label(proxy: str) -> str:
"""Прокси для показа в чате: без пароля."""
"""Прокси для показа в чате: без логина и пароля."""
parts = urlsplit(proxy)
if not parts.password:
if '@' not in parts.netloc:
return proxy
host = parts.netloc.rpartition('@')[2]
return urlunsplit(parts._replace(netloc=f'{parts.username}:***@{host}'))
return urlunsplit(parts._replace(netloc='***@' + parts.netloc.rpartition('@')[2]))
def domain_prompt(point: Point) -> str:
@@ -264,3 +265,15 @@ def change_failed(point: Point, error: Exception) -> str:
f'Не удалось применить настройку <code>{escape(point.name)}</code>:\n{escape(str(error))}\n'
'Точка работает с прежними настройками.'
)
def task_interrupted(point: Point) -> str:
return f'<code>{escape(point.name)}</code>: {escape(point.error)}.'
def health_problem(point: Point, problem: str) -> str:
return f'Проверка <code>{escape(point.name)}</code>: {escape(problem)}.'
def health_restored(point: Point) -> str:
return f'Проверка <code>{escape(point.name)}</code>: снова все в порядке.'
+47
View File
@@ -0,0 +1,47 @@
import datetime
import os
import subprocess
import tempfile
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from App import backups
class Command(BaseCommand):
help = (
'Бэкап базы: pg_dump в формате custom, согласованный снимок без остановки сайта. '
'Секреты в бэкапе остаются зашифрованными, ключ FIELD_ENCRYPTION_KEYS в него не входит'
)
def add_arguments(self, parser):
parser.add_argument('--output', type=Path, help='файл бэкапа, по умолчанию в BACKUP_DIR')
parser.add_argument('--keep', type=int, default=0, help='оставить в BACKUP_DIR столько последних бэкапов, 0 - все')
def handle(self, output, keep, **options):
database = backups.database()
backups.check_keys()
directory = Path(settings.BACKUP_DIR)
if output is None:
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
output = directory / f"{database['NAME']}-{stamp}{backups.SUFFIX}"
output.parent.mkdir(parents=True, exist_ok=True)
# пишем во временный файл рядом: оборванный дамп не должен выглядеть как бэкап
descriptor, temporary = tempfile.mkstemp(dir=output.parent, prefix='.backup-')
os.close(descriptor)
temporary = Path(temporary)
try:
backups.run(['pg_dump', '--format=custom', f'--file={temporary}'], database)
# архив читается - значит, дописан до конца
backups.run(['pg_restore', '--list', str(temporary)], database, stdout=subprocess.DEVNULL)
temporary.chmod(0o600)
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
self.stdout.write(f'бэкап: {output} ({output.stat().st_size // 1024} КБ)')
if keep:
for old in backups.rotate(directory, keep):
self.stdout.write(f'удален старый бэкап: {old}')
self.stdout.write('Ключ FIELD_ENCRYPTION_KEYS в бэкап не входит: храните его отдельно.')
@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand
from App import tasks
from App.models import Point
class Command(BaseCommand):
help = (
'Запускает ежедневные проверки всех точек заново: прежние цепочки проверок останавливаются. '
'Нужен воркер. Запускается при старте воркера в docker, так цепочки переживают потерю очереди'
)
def handle(self, **options):
points = list(Point.objects.all())
for point in points:
tasks.schedule_checks(point)
self.stdout.write(f'проверки запущены: {len(points)}')
@@ -0,0 +1,66 @@
import secrets
from pathlib import Path
from cryptography.fernet import Fernet
from django.core.management.base import BaseCommand
# FIELD_ENCRYPTION_KEYS не перезаписывается никогда: с новым ключом зашифрованные секреты в базе не прочитать.
# Только [A-Za-z0-9_=-]: .env читают make (# - комментарий, $ - переменная) и docker compose ($ - подстановка)
GENERATORS = {
'SECRET_KEY': lambda: secrets.token_urlsafe(50),
'FIELD_ENCRYPTION_KEYS': lambda: Fernet.generate_key().decode(),
'DB_PASSWD': lambda: secrets.token_urlsafe(24),
'RABBITMQ_PASSWORD': lambda: secrets.token_urlsafe(24),
'RABBITMQ_ERLANG_COOKIE': lambda: secrets.token_hex(32),
}
def fill_env(path: Path) -> list[str]:
"""Дописывает в env-файл секреты, которых там нет или которые пустые. Заданные не трогает. Возвращает имена."""
lines = path.read_text().splitlines() if path.exists() else []
filled = []
present = set()
for index, line in enumerate(lines):
key, sep, value = line.partition('=')
key = key.strip()
if not sep or key not in GENERATORS:
continue
present.add(key)
if not value.strip():
lines[index] = f'{key}={GENERATORS[key]()}'
filled.append(key)
for key, generate in GENERATORS.items():
if key not in present:
lines.append(f'{key}={generate()}')
filled.append(key)
if filled:
path.write_text('\n'.join(lines) + '\n')
path.chmod(0o600)
return filled
class Command(BaseCommand):
help = (
'Секреты для .env: SECRET_KEY, FIELD_ENCRYPTION_KEYS, пароли базы и RabbitMQ. '
'Без --env-file печатает их, с ним - дописывает только отсутствующие и пустые'
)
def add_arguments(self, parser):
parser.add_argument('--env-file', type=Path, help='env-файл, например ../.env')
def handle(self, env_file, **options):
if env_file is None:
for key, generate in GENERATORS.items():
self.stdout.write(f'{key}={generate()}')
return
filled = fill_env(env_file)
if not filled:
self.stdout.write('все секреты уже заданы, ничего не менял')
return
self.stdout.write(f"{env_file}: сгенерированы {', '.join(filled)}")
if {'DB_PASSWD', 'RABBITMQ_PASSWORD', 'RABBITMQ_ERLANG_COOKIE'} & set(filled):
self.stdout.write(
'Пароли базы и RabbitMQ применяются только при первом запуске контейнеров с пустыми томами.'
)
if 'FIELD_ENCRYPTION_KEYS' in filled:
self.stdout.write('Сохраните FIELD_ENCRYPTION_KEYS отдельно от бэкапов базы: без него секреты не расшифровать.')
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import connections
from App import backups
class Command(BaseCommand):
help = (
'Восстановление базы из бэкапа manage.py backup: текущие данные заменяются целиком, в одной транзакции. '
'Перед запуском остановите сайт и воркер'
)
def add_arguments(self, parser):
parser.add_argument('path', type=Path, help='файл бэкапа')
parser.add_argument('--yes', action='store_true', help='не спрашивать подтверждение')
def handle(self, path, yes, **options):
database = backups.database()
if not path.is_file():
raise CommandError(f'{path}: нет такого файла')
if not yes:
answer = input(f"База {database['NAME']} будет заменена данными из {path}. Продолжить? [yes/no] ")
if answer.strip().lower() != 'yes':
raise CommandError('отменено')
# свое соединение с базой держало бы блокировки, мешая --clean
connections.close_all()
backups.run([
'pg_restore', '--clean', '--if-exists', '--no-owner', '--no-privileges',
'--single-transaction', '--exit-on-error', f"--dbname={database['NAME']}", str(path),
], database)
self.stdout.write(f'восстановлено из {path}')
backups.check_keys()
self.stdout.write('Если бэкап старше кода, выполните manage.py migrate.')
@@ -0,0 +1,50 @@
# Generated by Django 6.1.1 on 2026-09-26 13:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('App', '0008_point_status_updating'),
('Telegram', '0003_bot_webhook'),
]
operations = [
migrations.AddField(
model_name='point',
name='check_token',
field=models.CharField(blank=True, editable=False, max_length=32),
),
migrations.AddField(
model_name='point',
name='checked_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='point',
name='health_error',
field=models.TextField(blank=True, editable=False, help_text='Что не так по последней проверке'),
),
migrations.AddField(
model_name='point',
name='task_chat',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='Telegram.chat'),
),
migrations.AddField(
model_name='point',
name='task_heartbeat',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='point',
name='task_started_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='point',
name='task_token',
field=models.CharField(blank=True, editable=False, max_length=32),
),
]
+68 -1
View File
@@ -5,6 +5,7 @@ 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 django.utils import timezone
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
from .fields import EncryptedTextField
@@ -85,6 +86,9 @@ class PointQuerySet(models.QuerySet):
def is_name_taken(self, name: str) -> bool:
return self.filter(name=name).exists()
def busy(self) -> 'PointQuerySet':
return self.filter(status__in=Point.BUSY_STATUSES)
class PointManager(models.Manager.from_queryset(PointQuerySet)):
def create_point(
@@ -113,6 +117,46 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
Server.objects.create_server(point, Server.Role.MASK, mask)
return point
# долгая задача точки: token связывает точку, задачу и ее супервизора (App.tasks)
def start_task(self, point: 'Point', status: str, token: str, chat=None):
"""Точка занята задачей token. chat - куда сообщить, если задача прервется."""
point.status = status
point.error = ''
point.task_token = token
point.task_chat = chat
point.task_started_at = None
point.task_heartbeat = timezone.now()
point.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
def claim_task(self, pk, token: str) -> bool:
"""Задача token начала работу. False - точка уже не ее или это повтор после падения воркера."""
now = timezone.now()
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
.update(task_started_at=now, task_heartbeat=now))
def beat(self, pk, token: str):
"""Задача token жива."""
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
def finish_task(self, pk, token: str):
"""Задача token закончилась, статус она выставила сама."""
self.filter(pk=pk, task_token=token).update(**Point.NO_TASK)
def abort_task(self, pk, token: str) -> bool:
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
with transaction.atomic():
point = self.select_for_update().filter(pk=pk, task_token=token).first()
if point is None:
return False
if point.status not in Point.INTERRUPTED:
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
self.filter(pk=pk).update(**Point.NO_TASK)
return False
status, error = Point.INTERRUPTED[point.status]
self.filter(pk=pk).update(status=status, error=error, **Point.NO_TASK)
return True
def _unique_name(self) -> str:
while True:
name = f'point-{secrets.token_hex(3)}'
@@ -137,6 +181,16 @@ class Point(models.Model):
ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается'
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
INTERRUPTED = {
Status.INSTALLING: (Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
# домен и прокси serverus применяет в самом конце, прежние настройки почти наверняка на месте
Status.UPDATING: (Status.READY, 'смена настройки прервалась, настройки прежние'),
Status.RELEASING: (Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
}
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
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)
@@ -154,6 +208,19 @@ class Point(models.Model):
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
# долгая задача (установка, смена настройки, возврат) и ее супервизор, см. App.tasks
task_token = models.CharField(max_length=32, blank=True, editable=False)
task_chat = models.ForeignKey(
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
)
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
# ежедневная проверка, см. App.tasks.check_point
check_token = models.CharField(max_length=32, blank=True, editable=False)
checked_at = models.DateTimeField(null=True, blank=True, editable=False)
health_error = models.TextField(blank=True, editable=False, help_text='Что не так по последней проверке')
created_at = models.DateTimeField(auto_now_add=True)
objects = PointManager()
@@ -167,7 +234,7 @@ class Point(models.Model):
@property
def is_busy(self) -> bool:
return self.status in (self.Status.INSTALLING, self.Status.UPDATING, self.Status.RELEASING)
return self.status in self.BUSY_STATUSES
@property
def panel_host(self) -> str:
+37
View File
@@ -1,4 +1,7 @@
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
import socket
import ssl
import time
from contextlib import contextmanager
import serverus
@@ -122,6 +125,40 @@ def restore_keys(point: Point):
api.add_client(point.panel_inbound_id, key.name, uuid=key.uuid, sub_id=key.sub_id)
def check_point(point: Point) -> str:
"""Проверка готовой точки: панель отвечает, Xray работает, сертификат подписки не истекает.
Ходит так же, как клиенты: по домену через маскировку, без домена - на ip панели. Возвращает, что не так, или ''.
"""
try:
status = xui_api(point).status()
except serverus.XuiApiError as error:
return f'панель не отвечает: {error}'
xray = status.get('xray', {}).get('state')
if xray and xray != 'running':
return f'Xray не работает: {xray}'
try:
days = _certificate_days_left(point.panel_host, point.sub_port)
except (OSError, ssl.SSLError) as error:
return f'подписка не отвечает: {error}'
if days < CERTIFICATE_WARN_DAYS:
return f'сертификат истекает через {days:.1f} дн., не продлился'
return ''
# сертификат на ip живет ~6 дней, acme.sh продлевает его заранее
CERTIFICATE_WARN_DAYS = 2
def _certificate_days_left(host: str, port: int) -> float:
ca = local_ca()
context = ssl.create_default_context(cafile=str(ca.cert_path) if ca else None)
with socket.create_connection((host, port), timeout=15) as sock:
with context.wrap_socket(sock, server_hostname=host) as tls:
not_after = ssl.cert_time_to_seconds(tls.getpeercert()['notAfter'])
return (not_after - time.time()) / 86400
def local_ca() -> serverus.LocalCA | None:
"""Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local)."""
if settings.SERVERUS_CERTIFICATES == 'local':
+157 -30
View File
@@ -1,7 +1,21 @@
"""Фоновые задачи: долгие операции с серверами и уведомление пользователя о результате."""
"""Фоновые задачи: долгие операции с серверами, их супервизор и ежедневная проверка точек.
Долгая задача запускается через start(): точка получает статус и token задачи, вместе с задачей запускается
супервизор. Пока задача работает, она раз в HEARTBEAT_SECONDS отмечается в точке. Супервизор просыпается
раз в минуту, пока задача не закончится, и снимает ее, если отметки прекратились: воркер упал, его убили,
сообщение потерялось. Точка получает статус по Point.INTERRUPTED, пользователь - уведомление.
Повторная доставка сообщения (RabbitMQ возвращает в очередь сообщение упавшего воркера) задачу не повторяет,
а снимает: повторять половину работы с серверами опасно, пользователь сам решит, переустанавливать ли.
"""
import datetime
import logging
import secrets
import threading
import dramatiq
from django.db import connection
from django.utils import timezone
from Telegram.messages import send
from Telegram.models import Chat
@@ -13,13 +27,87 @@ logger = logging.getLogger(__name__)
# установка обновляет систему и ставит панель, это долго
TIME_LIMIT_MS = 60 * 60 * 1000
HEARTBEAT_SECONDS = 30
SUPERVISE_DELAY_MS = 60 * 1000
# без отметок дольше - задача мертва
TASK_STALE = datetime.timedelta(minutes=3)
# задача ждет свободный поток воркера; дольше - сообщение потерялось
QUEUE_TIMEOUT = datetime.timedelta(hours=1)
CHECK_INTERVAL = datetime.timedelta(days=1)
# сутки проходятся перескоками: отложенное сообщение воркер держит неподтвержденным,
# а RabbitMQ закрывает канал, если сообщение не подтверждено дольше consumer_timeout (30 минут)
CHECK_HOP_MS = 15 * 60 * 1000
def _notify(chat_id: int, text: str, keyboard=None):
def _notify(chat_id: int | None, text: str, keyboard=None):
if chat_id is None:
return
send(Chat.objects.get(pk=chat_id), text, keyboard=keyboard, parse_mode='HTML')
def _run_installation(point: Point, chat_id: int, operation, *args):
# долгие задачи и супервизор
def start(actor, point: Point, status: str, chat, *args):
"""Запускает долгую задачу точки вместе с супервизором. chat - куда сообщить о результате."""
token = secrets.token_hex(16)
Point.objects.start_task(point, status, token, chat)
actor.send(point.pk, token, *args)
supervise.send_with_options(args=(point.pk, token), delay=SUPERVISE_DELAY_MS)
@dramatiq.actor(max_retries=3)
def supervise(point_id: int, token: str):
point = Point.objects.filter(pk=point_id, task_token=token).first()
if point is None:
# задача закончилась, супервизор больше не нужен
return
limit = TASK_STALE if point.task_started_at else QUEUE_TIMEOUT
if timezone.now() - point.task_heartbeat > limit:
logger.error('Point %s task is stuck: no heartbeat since %s', point, point.task_heartbeat)
_abort(point_id, token)
return
supervise.send_with_options(args=(point_id, token), delay=SUPERVISE_DELAY_MS)
def _run(point_id: int, token: str, work):
"""Выполняет work(point) как задачу token: с отметками для супервизора и снятием, если она прервется."""
if not Point.objects.claim_task(point_id, token):
# точка удалена, задачу уже снял супервизор или это повторная доставка после падения воркера
_abort(point_id, token)
return
stop = threading.Event()
heartbeat = threading.Thread(target=_heartbeat, args=(point_id, token, stop), daemon=True)
heartbeat.start()
try:
work(Point.objects.get(pk=point_id))
except BaseException:
# лимит времени dramatiq и остановка воркера - не Exception, сценарий их не ловит
logger.exception('Point %s task interrupted', point_id)
_abort(point_id, token)
raise
finally:
stop.set()
heartbeat.join()
Point.objects.finish_task(point_id, token)
def _heartbeat(point_id: int, token: str, stop: threading.Event):
try:
while not stop.wait(HEARTBEAT_SECONDS):
Point.objects.beat(point_id, token)
finally:
connection.close()
def _abort(point_id: int, token: str):
chat_id = Point.objects.filter(pk=point_id).values_list('task_chat', flat=True).first()
if Point.objects.abort_task(point_id, token):
_notify(chat_id, texts.task_interrupted(Point.objects.get(pk=point_id)), keyboard=[[texts.SERVER_MENU]])
def _install(point: Point, operation, *args):
chat_id = point.task_chat_id
try:
operation(point, *args)
except Exception as error:
@@ -27,46 +115,85 @@ def _run_installation(point: Point, chat_id: int, operation, *args):
_notify(chat_id, texts.install_failed(point, error), keyboard=[[texts.SERVER_MENU]])
return
_notify(chat_id, texts.installed(point), keyboard=[[texts.SERVER_MENU]])
schedule_checks(point)
# повтор не нужен: пользователь сам решает, переустанавливать ли после ошибки
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def install_point(point_id: int, chat_id: int):
_run_installation(Point.objects.get(pk=point_id), chat_id, services.install_point)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def reinstall_point(point_id: int, chat_id: int, address: str | None = None, password: str | None = None):
_run_installation(Point.objects.get(pk=point_id), chat_id, services.reinstall, address, password)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def release_point(point_id: int, chat_id: int):
point = Point.objects.get(pk=point_id)
name = point.name
passwords, errors = services.release_point(point)
_notify(chat_id, texts.released(name, passwords, errors))
def _run_change(point: Point, chat_id: int, operation, value: str, done: str):
def _change(point: Point, operation, value: str, done):
chat_id = point.task_chat_id
try:
operation(point, value)
except Exception as error:
logger.exception('Point %s change failed', point)
_notify(chat_id, texts.change_failed(point, error), keyboard=[[texts.SERVER_MENU]])
return
_notify(chat_id, done, keyboard=[[texts.SERVER_MENU]])
_notify(chat_id, done(point, value), keyboard=[[texts.SERVER_MENU]])
def _release(point: Point):
chat_id, name = point.task_chat_id, point.name
passwords, errors = services.release_point(point)
_notify(chat_id, texts.released(name, passwords, errors))
# повтор не нужен: пользователь сам решает, переустанавливать ли после ошибки
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def install_point(point_id: int, token: str):
_run(point_id, token, lambda point: _install(point, services.install_point))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_domain(point_id: int, chat_id: int, domain: str):
point = Point.objects.get(pk=point_id)
_run_change(point, chat_id, services.set_domain, domain, texts.domain_changed(point, domain))
def reinstall_point(point_id: int, token: str, address: str | None = None, password: str | None = None):
_run(point_id, token, lambda point: _install(point, services.reinstall, address, password))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_outbound_proxy(point_id: int, chat_id: int, encrypted_proxy: str):
def release_point(point_id: int, token: str):
_run(point_id, token, _release)
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_domain(point_id: int, token: str, domain: str):
_run(point_id, token, lambda point: _change(point, services.set_domain, domain, texts.domain_changed))
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
def set_outbound_proxy(point_id: int, token: str, encrypted_proxy: str):
"""encrypted_proxy - fields.encrypt(proxy): в прокси логин и пароль, а сообщения брокера лежат на диске."""
point = Point.objects.get(pk=point_id)
proxy = fields.decrypt(encrypted_proxy) if encrypted_proxy else ''
_run_change(point, chat_id, services.set_outbound_proxy, proxy, texts.proxy_changed(point, proxy))
_run(point_id, token, lambda point: _change(point, services.set_outbound_proxy, proxy, texts.proxy_changed))
# ежедневная проверка: своя цепочка у каждой точки, живет, пока жива точка
def schedule_checks(point: Point):
"""(Пере)запускает цепочку проверок точки. Прежняя цепочка остановится: у нее другой token."""
token = secrets.token_hex(16)
Point.objects.filter(pk=point.pk).update(check_token=token)
check_point.send(point.pk, token)
@dramatiq.actor(max_retries=3)
def check_point(point_id: int, token: str):
point = Point.objects.filter(pk=point_id, check_token=token).first()
if point is None:
# точка удалена или цепочку сменила новая
return
# следующий перескок сначала: ошибка проверки не должна обрывать цепочку
check_point.send_with_options(args=(point_id, token), delay=CHECK_HOP_MS)
due = point.checked_at is None or timezone.now() - point.checked_at >= CHECK_INTERVAL
if point.status == Point.Status.READY and due:
_check(point)
def _check(point: Point):
problem = services.check_point(point)
previous = point.health_error
Point.objects.filter(pk=point.pk).update(checked_at=timezone.now(), health_error=problem)
if problem == previous:
return
text = texts.health_problem(point, problem) if problem else texts.health_restored(point)
_notify(_owner_chat(point), text, keyboard=[[texts.SERVER_MENU]])
def _owner_chat(point: Point) -> int | None:
return Chat.objects.filter(user=point.owner, deleted_at__isnull=True).order_by('-pk').values_list('pk', flat=True).first()
+186 -13
View File
@@ -1,11 +1,14 @@
import datetime
from unittest import mock
from django.test import TestCase
from django.utils import timezone
from dramatiq.middleware.time_limit import TimeLimitExceeded
from Telegram import interfaces
from Telegram.models import Bot, Chat, User as TelegramUser
from Telegram.testing import Conversation, FakeApi
from . import fields, tasks
from . import fields, services, tasks
from .bot import texts
from .bot.interface import MyPointInterface, is_proxy_url, parse_credentials
from .models import Credentials, Point, Server
@@ -83,7 +86,9 @@ class AddServerTests(BotTestCase):
self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw'))
self.assertEqual((point.domain, point.outbound_proxy), ('example.com', 'cf_warp'))
self.assertEqual(point.owner, TelegramUser.objects.get())
send.assert_called_once_with(point.pk, Chat.objects.get().pk)
send.assert_called_once_with(point.pk, mock.ANY)
point.refresh_from_db()
self.assertEqual((point.task_chat, point.task_token), (Chat.objects.get(), send.call_args.args[1]))
# сообщения с паролями удалены из чата
self.assertEqual(len(self.api.deleted), 2)
self.assertEqual(Chat.objects.get().state, '')
@@ -158,7 +163,7 @@ class PointMenuTests(BotTestCase):
self.assertIn(self.point.name, self.say(texts.NO))
self.assertIn('Продолжить?', self.say(texts.RELEASE))
self.assertEqual(self.say(texts.YES), texts.RELEASE_STARTED)
send.assert_called_once_with(self.point.pk, Chat.objects.get(user__tg_id=7).pk)
send.assert_called_once_with(self.point.pk, mock.ANY)
self.point.refresh_from_db()
self.assertEqual(self.point.status, Point.Status.RELEASING)
@@ -311,8 +316,9 @@ class PointSettingsTests(BotTestCase):
self.point.outbound_proxy = 'socks5://user:secret@proxy.example:1080'
self.point.save()
card = self.say(texts.SERVER_MENU)
self.assertIn('socks5://user:***@proxy.example:1080', card)
self.assertIn('socks5://***@proxy.example:1080', card)
self.assertNotIn('secret', card)
self.assertNotIn('user', card)
class TasksTests(TestCase):
@@ -323,13 +329,21 @@ class TasksTests(TestCase):
self.point = create_point(owner)
self.api = FakeApi()
def run_task(self, actor, *args, runner=None):
with patch_runner(runner or FakeRunner()), mock.patch.object(Bot, 'api', return_value=self.api):
actor.fn(*args)
def run_task(self, actor, *args, status=Point.Status.INSTALLING, runner=None):
"""Запускает задачу, как бот, и выполняет ее сообщение."""
self.point.refresh_from_db()
with mock.patch.object(actor, 'send') as send:
tasks.start(actor, self.point, status, self.chat, *args)
with (
patch_runner(runner or FakeRunner()),
mock.patch.object(Bot, 'api', return_value=self.api),
mock.patch.object(tasks.check_point, 'send'),
):
actor.fn(*send.call_args.args)
return self.api.last_text
def test_install_notifies_success(self):
text = self.run_task(tasks.install_point, self.point.pk, self.chat.pk)
text = self.run_task(tasks.install_point)
self.assertIn(self.point.name, text)
self.assertIn('10.0.0.2', text)
self.assertIn('готов к работе', text)
@@ -337,12 +351,12 @@ class TasksTests(TestCase):
def test_install_notifies_failure(self):
with self.assertLogs('App.tasks', 'ERROR'):
text = self.run_task(tasks.install_point, self.point.pk, self.chat.pk, runner=FakeRunner(fail='xui'))
text = self.run_task(tasks.install_point, runner=FakeRunner(fail='xui'))
self.assertIn('Не удалось', text)
self.assertIn('boom', text)
def test_release_notifies_passwords(self):
text = self.run_task(tasks.release_point, self.point.pk, self.chat.pk)
text = self.run_task(tasks.release_point, status=Point.Status.RELEASING)
self.assertIn('10.0.0.1 - пароль root', text)
self.assertFalse(Point.objects.exists())
@@ -351,10 +365,169 @@ class TasksTests(TestCase):
self.point.status = Point.Status.READY
self.point.save()
proxy = fields.encrypt('socks5://user:secret@proxy.example:1080')
text = self.run_task(tasks.set_outbound_proxy, self.point.pk, self.chat.pk, proxy)
self.assertIn('user:***@proxy.example', text)
text = self.run_task(tasks.set_outbound_proxy, proxy, status=Point.Status.UPDATING)
self.assertIn('socks5://***@proxy.example', text)
with self.assertLogs('App.tasks', 'ERROR'):
text = self.run_task(tasks.set_domain, self.point.pk, self.chat.pk, 'vpn.example.com', runner=FakeRunner(fail='xui_cert'))
text = self.run_task(
tasks.set_domain, 'vpn.example.com', status=Point.Status.UPDATING, runner=FakeRunner(fail='xui_cert'),
)
self.assertIn('прежними настройками', text)
self.point.refresh_from_db()
self.assertEqual((self.point.status, self.point.domain), (Point.Status.READY, ''))
class SupervisorTests(TestCase):
"""Задача, которая прервалась, не оставляет точку занятой навсегда."""
def setUp(self):
bot = Bot.objects.create(token='1:test')
owner = TelegramUser.objects.create(tg_id=1)
self.chat = Chat.objects.create(bot=bot, chat_id=1, type='private', user=owner)
self.point = create_point(owner)
self.api = FakeApi()
patcher = mock.patch.object(Bot, 'api', return_value=self.api)
patcher.start()
self.addCleanup(patcher.stop)
def start(self, actor=tasks.install_point, status=Point.Status.INSTALLING, *args):
with mock.patch.object(actor, 'send') as send, mock.patch.object(tasks.supervise, 'send_with_options') as sup:
tasks.start(actor, self.point, status, self.chat, *args)
sup.assert_called_once_with(args=(self.point.pk, send.call_args.args[1]), delay=tasks.SUPERVISE_DELAY_MS)
return send.call_args.args
def supervise(self, token):
with mock.patch.object(tasks.supervise, 'send_with_options') as again:
tasks.supervise.fn(self.point.pk, token)
self.point.refresh_from_db()
return again.called
def age_heartbeat(self, delta):
Point.objects.filter(pk=self.point.pk).update(task_heartbeat=timezone.now() - delta)
def test_stuck_task_is_aborted(self):
_, token = self.start()
Point.objects.claim_task(self.point.pk, token)
self.assertTrue(self.supervise(token))
self.age_heartbeat(tasks.TASK_STALE + datetime.timedelta(seconds=1))
self.assertFalse(self.supervise(token))
self.assertEqual(self.point.status, Point.Status.ERROR)
self.assertIn('Переустановить', self.point.error)
self.assertEqual(self.point.task_token, '')
self.assertIn('установка прервалась', self.api.last_text)
def test_queued_task_waits_longer(self):
_, token = self.start()
self.age_heartbeat(tasks.TASK_STALE * 2)
self.assertTrue(self.supervise(token))
self.age_heartbeat(tasks.QUEUE_TIMEOUT + datetime.timedelta(seconds=1))
self.assertFalse(self.supervise(token))
self.assertEqual(self.point.status, Point.Status.ERROR)
# сообщение все же дошло: задача снята, работы нет
runner = FakeRunner()
with patch_runner(runner):
tasks.install_point.fn(self.point.pk, token)
self.assertEqual(runner.calls, [])
def test_interrupted_update_keeps_point_ready(self):
token = self.start(tasks.set_domain, Point.Status.UPDATING, 'vpn.example.com')[1]
Point.objects.claim_task(self.point.pk, token)
self.age_heartbeat(tasks.TASK_STALE * 2)
self.supervise(token)
self.assertEqual((self.point.status, self.point.domain), (Point.Status.READY, ''))
self.assertIn('прервалась', self.point.error)
def test_supervisor_stops_after_task(self):
args = self.start()
with patch_runner(FakeRunner()), mock.patch.object(tasks.check_point, 'send'):
tasks.install_point.fn(*args)
self.assertFalse(self.supervise(args[1]))
self.assertEqual((self.point.status, self.point.task_token), (Point.Status.READY, ''))
def test_redelivered_message_is_not_repeated(self):
# воркер упал посреди установки, RabbitMQ доставил сообщение снова
args = self.start()
Point.objects.claim_task(*args)
runner = FakeRunner()
with patch_runner(runner):
tasks.install_point.fn(*args)
self.assertEqual(runner.calls, [])
self.point.refresh_from_db()
self.assertEqual(self.point.status, Point.Status.ERROR)
def test_time_limit_aborts_task(self):
args = self.start()
with (
mock.patch.object(services, 'install_point', side_effect=TimeLimitExceeded),
self.assertRaises(TimeLimitExceeded),
self.assertLogs('App.tasks', 'ERROR'),
):
tasks.install_point.fn(*args)
self.point.refresh_from_db()
self.assertEqual((self.point.status, self.point.task_token), (Point.Status.ERROR, ''))
def test_failed_notification_keeps_result(self):
args = self.start()
with (
patch_runner(FakeRunner()),
mock.patch.object(tasks, 'schedule_checks', side_effect=RuntimeError('boom')),
self.assertRaises(RuntimeError),
self.assertLogs('App.tasks', 'ERROR'),
):
tasks.install_point.fn(*args)
self.point.refresh_from_db()
self.assertEqual((self.point.status, self.point.task_token), (Point.Status.READY, ''))
class CheckTests(TestCase):
def setUp(self):
bot = Bot.objects.create(token='1:test')
owner = TelegramUser.objects.create(tg_id=1)
Chat.objects.create(bot=bot, chat_id=1, type='private', user=owner)
self.point = create_point(owner)
self.point.status = Point.Status.READY
self.point.save()
self.api = FakeApi()
patcher = mock.patch.object(Bot, 'api', return_value=self.api)
patcher.start()
self.addCleanup(patcher.stop)
with mock.patch.object(tasks.check_point, 'send') as send:
tasks.schedule_checks(self.point)
self.token = send.call_args.args[1]
def check(self, problem='', token=None):
with (
mock.patch.object(services, 'check_point', return_value=problem) as check,
mock.patch.object(tasks.check_point, 'send_with_options') as again,
):
tasks.check_point.fn(self.point.pk, token or self.token)
self.point.refresh_from_db()
return check.called, again.called
def test_checks_once_a_day_and_reports_changes(self):
self.assertEqual(self.check('панель не отвечает'), (True, True))
self.assertEqual(self.point.health_error, 'панель не отвечает')
self.assertIn('панель не отвечает', self.api.last_text)
self.assertIn('Проверка: панель не отвечает', texts.point_card(self.point))
# до следующих суток не проверяет, но цепочка живет
self.assertEqual(self.check(), (False, True))
Point.objects.filter(pk=self.point.pk).update(checked_at=timezone.now() - tasks.CHECK_INTERVAL)
sent = len(self.api.sent)
self.check('панель не отвечает')
self.assertEqual(len(self.api.sent), sent)
Point.objects.filter(pk=self.point.pk).update(checked_at=timezone.now() - tasks.CHECK_INTERVAL)
self.check()
self.assertIn('снова все в порядке', self.api.last_text)
def test_busy_point_is_not_checked(self):
self.point.set_status(Point.Status.INSTALLING)
self.assertEqual(self.check(), (False, True))
def test_old_chain_stops(self):
with mock.patch.object(tasks.check_point, 'send'):
tasks.schedule_checks(self.point)
self.assertEqual(self.check(token=self.token), (False, False))
point_id = self.point.pk
self.point.delete()
with mock.patch.object(tasks.check_point, 'send_with_options') as again:
tasks.check_point.fn(point_id, self.token)
again.assert_not_called()
+136
View File
@@ -0,0 +1,136 @@
import subprocess
import tempfile
from io import StringIO
from pathlib import Path
from unittest import mock
from cryptography.fernet import Fernet
from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings
from . import backups, tasks
from .management.commands.generate_secrets import GENERATORS, fill_env
from .tests_services import create_point
DATABASE = {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'vpn', 'USER': 'vpn', 'PASSWORD': 'db-secret', 'HOST': 'db'}
class GenerateSecretsTests(TestCase):
def test_prints_all_secrets(self):
out = StringIO()
call_command('generate_secrets', stdout=out)
lines = dict(line.split('=', 1) for line in out.getvalue().splitlines())
self.assertEqual(set(lines), set(GENERATORS))
Fernet(lines['FIELD_ENCRYPTION_KEYS'])
for value in lines.values():
self.assertRegex(value, r'^[A-Za-z0-9_=-]+$')
def test_fills_only_missing_and_empty(self):
with tempfile.TemporaryDirectory() as directory:
env = Path(directory) / '.env'
env.write_text('# база\nDB_NAME=vpn\nDB_PASSWD=\nFIELD_ENCRYPTION_KEYS=keep-me\n')
filled = fill_env(env)
text = env.read_text()
self.assertNotIn('FIELD_ENCRYPTION_KEYS', filled)
self.assertIn('FIELD_ENCRYPTION_KEYS=keep-me\n', text)
self.assertIn('# база\nDB_NAME=vpn\n', text)
self.assertNotIn('DB_PASSWD=\n', text)
self.assertEqual(set(filled), set(GENERATORS) - {'FIELD_ENCRYPTION_KEYS'})
self.assertEqual(env.stat().st_mode & 0o777, 0o600)
self.assertEqual(fill_env(env), [])
class FakePostgres:
"""Подменяет pg_dump и pg_restore."""
def __init__(self, fail: str | None = None):
self.calls = []
self.fail = fail
def __call__(self, args, env, **kwargs):
self.calls.append((args, env))
if args[0] == self.fail:
return subprocess.CompletedProcess(args, 1, stderr='connection refused')
if args[0] == 'pg_dump':
Path(args[-1].removeprefix('--file=')).write_bytes(b'PGDMP')
return subprocess.CompletedProcess(args, 0, stderr='')
class BackupTests(TestCase):
def setUp(self):
create_point(mask=None)
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.directory = Path(directory.name)
patcher = mock.patch.object(backups, 'database', return_value=DATABASE)
patcher.start()
self.addCleanup(patcher.stop)
settings = override_settings(BACKUP_DIR=str(self.directory))
settings.enable()
self.addCleanup(settings.disable)
def backup(self, postgres, *args):
with mock.patch('subprocess.run', postgres):
call_command('backup', *args, stdout=StringIO())
def test_backup(self):
postgres = FakePostgres()
self.backup(postgres)
[dump] = self.directory.glob('vpn-*.dump')
self.assertEqual(dump.stat().st_mode & 0o777, 0o600)
(dump_args, env), (list_args, _) = postgres.calls
self.assertEqual(dump_args[:2], ['pg_dump', '--format=custom'])
self.assertEqual(list_args[:2], ['pg_restore', '--list'])
# пароль базы только в окружении, не в аргументах
self.assertEqual((env['PGPASSWORD'], env['PGHOST'], env['PGDATABASE']), ('db-secret', 'db', 'vpn'))
self.assertNotIn('db-secret', ' '.join(dump_args))
def test_failed_dump_leaves_nothing(self):
with self.assertRaisesMessage(CommandError, 'connection refused'):
self.backup(FakePostgres(fail='pg_dump'))
self.assertEqual(list(self.directory.iterdir()), [])
def test_rotation(self):
for name in ('vpn-1.dump', 'vpn-2.dump', 'other.txt'):
(self.directory / name).write_text('old')
self.backup(FakePostgres(), '--keep', '2')
self.assertEqual(len(list(self.directory.glob('*.dump'))), 2)
self.assertTrue((self.directory / 'other.txt').exists())
def test_wrong_key_is_reported(self):
with override_settings(FIELD_ENCRYPTION_KEYS=[Fernet.generate_key().decode()]):
with self.assertRaisesMessage(CommandError, 'FIELD_ENCRYPTION_KEYS'):
self.backup(FakePostgres())
def test_restore(self):
dump = self.directory / 'vpn-1.dump'
dump.write_bytes(b'PGDMP')
postgres = FakePostgres()
with mock.patch('subprocess.run', postgres):
call_command('restore', str(dump), '--yes', stdout=StringIO())
[(args, _)] = postgres.calls
self.assertIn('--single-transaction', args)
self.assertIn('--clean', args)
self.assertEqual(args[-1], str(dump))
def test_restore_asks_confirmation(self):
dump = self.directory / 'vpn-1.dump'
dump.write_bytes(b'PGDMP')
with mock.patch('builtins.input', return_value='no'), self.assertRaisesMessage(CommandError, 'отменено'):
call_command('restore', str(dump))
class BackupDatabaseTests(TestCase):
def test_postgres_only(self):
# тесты идут на SQLite
with self.assertRaisesMessage(CommandError, 'PostgreSQL'):
call_command('backup')
class CheckPointsCommandTests(TestCase):
def test_schedules_every_point(self):
create_point(mask=None)
with mock.patch.object(tasks.check_point, 'send') as send:
call_command('check_points', stdout=StringIO())
send.assert_called_once()
+33
View File
@@ -48,6 +48,7 @@ class FakeXuiApi:
self.clients = {}
self.down = False
self.added = []
self.xray_state = 'running'
def _check(self):
if self.down:
@@ -76,6 +77,11 @@ class FakeXuiApi:
self.get_client(email)
return {'up': 1024, 'down': 3 * 1024 * 1024, 'lastOnline': 0}
def status(self):
self._check()
return {'xray': {'state': self.xray_state}}
def patch_xui_api(api):
return mock.patch('App.services.xui_api', lambda point: api)
@@ -289,6 +295,33 @@ class ReadyPointTests(TestCase):
self.assertEqual(runner.calls, [])
class CheckPointTests(TestCase):
def setUp(self):
self.point = create_point(mask=None)
self.panel_api = FakeXuiApi()
patcher = patch_xui_api(self.panel_api)
patcher.start()
self.addCleanup(patcher.stop)
def check(self, days=60):
with mock.patch.object(services, '_certificate_days_left', return_value=days):
return services.check_point(self.point)
def test_healthy(self):
self.assertEqual(self.check(), '')
def test_problems(self):
self.assertIn('истекает', self.check(days=1))
self.panel_api.xray_state = 'stop'
self.assertIn('Xray не работает', self.check())
self.panel_api.down = True
self.assertIn('панель не отвечает', self.check())
def test_subscription_unreachable(self):
with mock.patch.object(services, '_certificate_days_left', side_effect=ConnectionRefusedError('refused')):
self.assertIn('подписка не отвечает', services.check_point(self.point))
class KeyTests(TestCase):
def setUp(self):
self.point = create_point(mask=None)