chore: интерфесы бота
This commit is contained in:
1 parent
5f53cadf89
commit
6d322f36a9
42 files changed
+1731
-111
No files matched your search
+28
-1
@@ -3,4 +3,31 @@
|
||||
Django приложение связывающее все части сайта в одном месте. Никто не импортирует App, кроме MyPointVPN, а App может импортировать все.
|
||||
|
||||
-[x] Реализует кастомный менеджер модели и кастомную модель пользователя с отключенным email.
|
||||
-[ ] Модель данных для поддержки интерфейса и функционала
|
||||
-[x] Модель данных для поддержки интерфейса и функционала
|
||||
-[ ] Настройки домена, прокси и cloudflare у готовой точки
|
||||
-[ ] Клиенты и статистика через API панели 3x-ui
|
||||
|
||||
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
|
||||
|
||||
## Модель данных
|
||||
|
||||
- `Point` - точка доступа: кластер из сервера с панелью 3x-ui и сервера маскировки. Владелец - `Telegram.User`. Хранит домен, cloudflare, прокси на выходе и доступ к панели.
|
||||
- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh.
|
||||
|
||||
Клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
||||
|
||||
## Модули
|
||||
|
||||
- `services.py` - сценарии поверх serverus: `create_point`, `install_point`, `reinstall`, `release_point`.
|
||||
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
|
||||
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
||||
- `bot/texts.py` - кнопки и тексты.
|
||||
|
||||
## Установка точки
|
||||
|
||||
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
|
||||
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App
|
||||
3. маскировка: перенаправление трафика на адрес панели
|
||||
4. facts: `uname -a` для карточки сервера
|
||||
|
||||
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку. Отпечаток можно сбросить и в админке.
|
||||
+41
-1
@@ -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 User
|
||||
from .models import Point, Server, User
|
||||
|
||||
|
||||
class UserCreationForm(AdminUserCreationForm):
|
||||
@@ -28,3 +28,43 @@ class UserAdmin(BaseUserAdmin):
|
||||
)
|
||||
list_display = ('username', 'first_name', 'last_name', 'is_staff')
|
||||
search_fields = ('username', 'first_name', 'last_name')
|
||||
|
||||
|
||||
class ServerInline(admin.StackedInline):
|
||||
model = Server
|
||||
extra = 0
|
||||
fields = ('role', 'address', 'ssh_port', 'root_password', 'public_key', 'known_hosts', 'uname')
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(Point)
|
||||
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')
|
||||
inlines = [ServerInline]
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(admin.ModelAdmin):
|
||||
list_display = ('address', 'role', 'point', 'ssh_port', 'has_fingerprint')
|
||||
list_filter = ('role',)
|
||||
search_fields = ('address', 'point__name')
|
||||
exclude = ('private_key',)
|
||||
readonly_fields = ('point', 'role', 'public_key', 'uname')
|
||||
actions = ('reset_fingerprint',)
|
||||
|
||||
@admin.display(boolean=True, description='отпечаток')
|
||||
def has_fingerprint(self, server):
|
||||
return bool(server.known_hosts)
|
||||
|
||||
@admin.action(description='Сбросить отпечаток ssh (после переустановки ОС)')
|
||||
def reset_fingerprint(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.reset_fingerprint()
|
||||
self.message_user(request, f'Сброшено отпечатков: {len(queryset)}')
|
||||
+1
-1
@@ -5,4 +5,4 @@ class AppConfig(AppConfig):
|
||||
name = 'App'
|
||||
|
||||
def ready(self):
|
||||
from . import bot # noqa: F401 регистрирует интерфейс бота
|
||||
from .bot import interface # noqa: F401 регистрирует интерфейс бота
|
||||
@@ -1,39 +0,0 @@
|
||||
from Telegram.interfaces import Interface, command, register, text
|
||||
|
||||
MAIN_MENU = 'главное меню'
|
||||
CANCEL = 'отмена'
|
||||
FAQ = 'ФАК'
|
||||
ADD_SERVER = 'добавить сервер'
|
||||
HELP = 'помощь'
|
||||
|
||||
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
|
||||
|
||||
|
||||
@register
|
||||
class MyPointInterface(Interface):
|
||||
title = 'MyPoint VPN'
|
||||
|
||||
@command('start')
|
||||
@text(MAIN_MENU, CANCEL)
|
||||
def main_menu(self, ctx):
|
||||
ctx.reset()
|
||||
# TODO: перечислить серверы пользователя и добавить кнопку "управление сервером", когда появится модель данных
|
||||
ctx.reply('привет. выбери действие', keyboard=MAIN_KEYBOARD)
|
||||
|
||||
@text(FAQ)
|
||||
def faq(self, ctx):
|
||||
# TODO: текст FAQ
|
||||
ctx.reply('FAQ пока пуст')
|
||||
|
||||
@text(HELP)
|
||||
def help(self, ctx):
|
||||
# TODO: текст помощи
|
||||
ctx.reply('Помощь пока пуста')
|
||||
|
||||
@text(ADD_SERVER)
|
||||
def add_server(self, ctx):
|
||||
# TODO: меню добавления сервера, когда появится модель данных App
|
||||
ctx.reply('Добавление сервера пока не реализовано')
|
||||
|
||||
def fallback(self, ctx):
|
||||
ctx.reply('Не понял. Воспользуйся кнопками меню.', keyboard=MAIN_KEYBOARD)
|
||||
Whitespace-only changes.
@@ -0,0 +1,308 @@
|
||||
import ipaddress
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from Telegram.interfaces import Interface, command, register, state, text
|
||||
|
||||
from .. import services, tasks
|
||||
from ..models import Point, Server
|
||||
from . import texts
|
||||
|
||||
# состояния диалога
|
||||
DRAFT = 'draft'
|
||||
DRAFT_SERVER = 'draft.server'
|
||||
DRAFT_MASK = 'draft.mask'
|
||||
DRAFT_DOMAIN = 'draft.domain'
|
||||
DRAFT_CLOUDFLARE = 'draft.cloudflare'
|
||||
DRAFT_PROXY = 'draft.proxy'
|
||||
SELECT = 'select'
|
||||
POINT = 'point'
|
||||
POINT_RELEASE = 'point.release'
|
||||
POINT_REINSTALL = 'point.reinstall'
|
||||
|
||||
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
|
||||
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
|
||||
PROXY_SCHEMES = ('http', 'https', 'socks5')
|
||||
HTML = 'HTML'
|
||||
|
||||
|
||||
def parse_credentials(value: str) -> services.Credentials | None:
|
||||
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
|
||||
address, _, password = value.strip().partition(':')
|
||||
try:
|
||||
address = str(ipaddress.ip_address(address))
|
||||
except ValueError:
|
||||
return None
|
||||
return services.Credentials(address, password) if password else None
|
||||
|
||||
|
||||
def is_domain(value: str) -> bool:
|
||||
return bool(DOMAIN_RE.match(value))
|
||||
|
||||
|
||||
def is_proxy_url(value: str) -> bool:
|
||||
try:
|
||||
url = urlsplit(value)
|
||||
return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@register
|
||||
class MyPointInterface(Interface):
|
||||
title = 'MyPoint VPN'
|
||||
|
||||
# главное меню
|
||||
|
||||
@command('start')
|
||||
@text(texts.MAIN_MENU, texts.CANCEL)
|
||||
def main_menu(self, ctx):
|
||||
ctx.reset()
|
||||
points = list(ctx.user.points.order_by('created_at'))
|
||||
if points:
|
||||
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
|
||||
else:
|
||||
ctx.reply(texts.GREETING, keyboard=texts.MAIN_KEYBOARD)
|
||||
|
||||
@text(texts.FAQ)
|
||||
def faq(self, ctx):
|
||||
ctx.reply(texts.FAQ_TEXT, parse_mode=HTML)
|
||||
|
||||
@text(texts.HELP)
|
||||
def help(self, ctx):
|
||||
ctx.reply(texts.HELP_TEXT, parse_mode=HTML)
|
||||
|
||||
def fallback(self, ctx):
|
||||
ctx.reply(texts.FALLBACK)
|
||||
|
||||
# добавление сервера: черновик живет в данных чата до "установить"
|
||||
|
||||
@text(texts.ADD_SERVER)
|
||||
def add_server(self, ctx):
|
||||
ctx.reset()
|
||||
ctx.data['draft'] = {}
|
||||
self.show_draft(ctx)
|
||||
|
||||
def show_draft(self, ctx):
|
||||
ctx.state = DRAFT
|
||||
ctx.reply(texts.draft_card(ctx.data['draft']), keyboard=texts.DRAFT_KEYBOARD, parse_mode=HTML)
|
||||
|
||||
@text(texts.SERVER, state=DRAFT)
|
||||
def ask_server(self, ctx):
|
||||
self.ask(ctx, DRAFT_SERVER, texts.CREDENTIALS_PROMPT)
|
||||
|
||||
@text(texts.MASK, state=DRAFT)
|
||||
def ask_mask(self, ctx):
|
||||
self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT)
|
||||
|
||||
@text(texts.DOMAIN, state=DRAFT)
|
||||
def ask_domain(self, ctx):
|
||||
self.ask(ctx, DRAFT_DOMAIN, texts.DOMAIN_PROMPT, [[texts.CANCEL, texts.DELETE]])
|
||||
|
||||
@text(texts.CLOUDFLARE, state=DRAFT)
|
||||
def ask_cloudflare(self, ctx):
|
||||
self.ask(ctx, DRAFT_CLOUDFLARE, texts.CLOUDFLARE_PROMPT, [[texts.ENABLE, texts.DISABLE, texts.CANCEL]])
|
||||
|
||||
@text(texts.PROXY, state=DRAFT)
|
||||
def ask_proxy(self, ctx):
|
||||
self.ask(ctx, DRAFT_PROXY, texts.PROXY_PROMPT, [[texts.CF_WARP, texts.CANCEL, texts.DELETE]])
|
||||
|
||||
def ask(self, ctx, new_state: str, prompt: str, keyboard=texts.CANCEL_KEYBOARD):
|
||||
ctx.state = new_state
|
||||
ctx.reply(prompt, keyboard=keyboard, parse_mode=HTML)
|
||||
|
||||
@text(texts.CANCEL, state=DRAFT_SERVER)
|
||||
@text(texts.CANCEL, state=DRAFT_MASK)
|
||||
@text(texts.CANCEL, state=DRAFT_DOMAIN)
|
||||
@text(texts.CANCEL, state=DRAFT_CLOUDFLARE)
|
||||
@text(texts.CANCEL, state=DRAFT_PROXY)
|
||||
def back_to_draft(self, ctx):
|
||||
self.show_draft(ctx)
|
||||
|
||||
@state(DRAFT_SERVER, DRAFT_MASK)
|
||||
def set_credentials(self, ctx):
|
||||
# в сообщении пароль root, в чате ему не место
|
||||
ctx.delete_message()
|
||||
credentials = parse_credentials(ctx.text)
|
||||
if credentials is None:
|
||||
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
|
||||
draft = ctx.data['draft']
|
||||
key = DRAFT_CREDENTIALS[ctx.state]
|
||||
other = draft.get('mask' if key == 'server' else 'server', {})
|
||||
if other.get('address') == credentials.address:
|
||||
return ctx.reply(texts.ADDRESS_DUPLICATE)
|
||||
if Server.objects.filter(address=credentials.address).exists():
|
||||
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
|
||||
draft[key] = {'address': credentials.address, 'password': credentials.password}
|
||||
self.show_draft(ctx)
|
||||
|
||||
@state(DRAFT_DOMAIN)
|
||||
def set_domain(self, ctx):
|
||||
domain = ctx.text.strip().lower().rstrip('.')
|
||||
if not is_domain(domain):
|
||||
return ctx.reply(texts.DOMAIN_INVALID)
|
||||
ctx.data['draft']['domain'] = domain
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.DELETE, state=DRAFT_DOMAIN)
|
||||
def delete_domain(self, ctx):
|
||||
ctx.data['draft']['domain'] = ''
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.ENABLE, state=DRAFT_CLOUDFLARE)
|
||||
def enable_cloudflare(self, ctx):
|
||||
ctx.data['draft']['cloudflare'] = True
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.DISABLE, state=DRAFT_CLOUDFLARE)
|
||||
def disable_cloudflare(self, ctx):
|
||||
ctx.data['draft']['cloudflare'] = False
|
||||
self.show_draft(ctx)
|
||||
|
||||
@state(DRAFT_CLOUDFLARE)
|
||||
def repeat_cloudflare(self, ctx):
|
||||
self.ask_cloudflare(ctx)
|
||||
|
||||
@state(DRAFT_PROXY)
|
||||
def set_proxy(self, ctx):
|
||||
proxy = ctx.text.strip()
|
||||
if not is_proxy_url(proxy):
|
||||
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
|
||||
ctx.data['draft']['proxy'] = proxy
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.CF_WARP, state=DRAFT_PROXY)
|
||||
def set_cf_warp(self, ctx):
|
||||
ctx.data['draft']['proxy'] = Point.CF_WARP
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.DELETE, state=DRAFT_PROXY)
|
||||
def delete_proxy(self, ctx):
|
||||
ctx.data['draft']['proxy'] = ''
|
||||
self.show_draft(ctx)
|
||||
|
||||
@text(texts.INSTALL, state=DRAFT)
|
||||
def install(self, ctx):
|
||||
draft = ctx.data['draft']
|
||||
if not (draft.get('server') and draft.get('mask')):
|
||||
return ctx.reply(texts.DRAFT_INCOMPLETE)
|
||||
if draft.get('cloudflare') and not draft.get('domain'):
|
||||
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
|
||||
point = services.create_point(
|
||||
ctx.user,
|
||||
panel=services.Credentials(**draft['server']),
|
||||
mask=services.Credentials(**draft['mask']),
|
||||
domain=draft.get('domain', ''),
|
||||
cloudflare=draft.get('cloudflare', False),
|
||||
outbound_proxy=draft.get('proxy', ''),
|
||||
)
|
||||
tasks.install_point.send(point.pk, ctx.chat.pk)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
# управление сервером
|
||||
|
||||
@text(texts.MANAGE)
|
||||
def ask_point(self, ctx):
|
||||
ctx.reset()
|
||||
points = ctx.user.points.order_by('created_at')
|
||||
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
|
||||
|
||||
@state(SELECT)
|
||||
def select_point(self, ctx):
|
||||
point = ctx.user.points.filter(name=ctx.text.strip()).first()
|
||||
if point is None:
|
||||
return ctx.reply(texts.POINT_NOT_FOUND)
|
||||
self.show_point(ctx, point)
|
||||
|
||||
@text(texts.SERVER_MENU)
|
||||
def server_menu(self, ctx):
|
||||
"""Кнопка из уведомления: последняя открытая точка или последняя созданная."""
|
||||
point = self.current_point(ctx) or ctx.user.points.order_by('-created_at').first()
|
||||
if point is None:
|
||||
return self.main_menu(ctx)
|
||||
self.show_point(ctx, point)
|
||||
|
||||
def current_point(self, ctx) -> Point | None:
|
||||
point_id = ctx.data.get('point')
|
||||
return ctx.user.points.filter(pk=point_id).first() if point_id else None
|
||||
|
||||
def show_point(self, ctx, point: Point):
|
||||
ctx.reset()
|
||||
ctx.state = POINT
|
||||
ctx.data['point'] = point.pk
|
||||
ctx.reply(texts.point_card(point), keyboard=texts.POINT_KEYBOARD, parse_mode=HTML)
|
||||
|
||||
def idle_point(self, ctx) -> Point | None:
|
||||
"""Текущая точка, если с ней можно работать. Иначе отвечает пользователю и возвращает None."""
|
||||
point = self.current_point(ctx)
|
||||
if point is None:
|
||||
self.main_menu(ctx)
|
||||
elif point.is_busy:
|
||||
ctx.reply(texts.POINT_BUSY.format(status=point.get_status_display()))
|
||||
return None
|
||||
return point
|
||||
|
||||
@text(texts.RELEASE, state=POINT)
|
||||
def ask_release(self, ctx):
|
||||
if self.idle_point(ctx):
|
||||
self.ask(ctx, POINT_RELEASE, texts.RELEASE_CONFIRM, [[texts.YES, texts.NO]])
|
||||
|
||||
@text(texts.YES, state=POINT_RELEASE)
|
||||
def release(self, ctx):
|
||||
point = self.idle_point(ctx)
|
||||
if point is None:
|
||||
return
|
||||
point.status = Point.Status.RELEASING
|
||||
point.save(update_fields=['status'])
|
||||
tasks.release_point.send(point.pk, ctx.chat.pk)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
@text(texts.REINSTALL, state=POINT)
|
||||
def ask_reinstall(self, ctx):
|
||||
if self.idle_point(ctx):
|
||||
self.ask(ctx, POINT_REINSTALL, texts.REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
|
||||
|
||||
@state(POINT_REINSTALL)
|
||||
def reinstall_with_password(self, ctx):
|
||||
ctx.delete_message()
|
||||
credentials = parse_credentials(ctx.text)
|
||||
if credentials is None:
|
||||
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
|
||||
point = self.idle_point(ctx)
|
||||
if point is None:
|
||||
return
|
||||
if not point.servers.filter(address=credentials.address).exists():
|
||||
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
|
||||
self.start_reinstall(ctx, point, credentials.address, credentials.password)
|
||||
|
||||
@text(texts.USE_OLD, state=POINT_REINSTALL)
|
||||
def reinstall_with_old_passwords(self, ctx):
|
||||
point = self.idle_point(ctx)
|
||||
if point:
|
||||
self.start_reinstall(ctx, point)
|
||||
|
||||
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
|
||||
point.status = Point.Status.INSTALLING
|
||||
point.save(update_fields=['status'])
|
||||
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
|
||||
ctx.reset()
|
||||
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
@text(texts.NO, state=POINT_RELEASE)
|
||||
@text(texts.CANCEL, state=POINT_RELEASE)
|
||||
@text(texts.CANCEL, state=POINT_REINSTALL)
|
||||
def back_to_point(self, ctx):
|
||||
point = self.current_point(ctx)
|
||||
if point is None:
|
||||
return self.main_menu(ctx)
|
||||
self.show_point(ctx, point)
|
||||
|
||||
@text(texts.POINT_DOMAIN, state=POINT)
|
||||
@text(texts.POINT_PROXY, state=POINT)
|
||||
@text(texts.POINT_CLOUDFLARE, state=POINT)
|
||||
@text(texts.CLIENTS, state=POINT)
|
||||
def not_implemented(self, ctx):
|
||||
# TODO: настройки домена, прокси, cloudflare и клиенты через API панели 3x-ui
|
||||
ctx.reply(texts.NOT_IMPLEMENTED)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются."""
|
||||
from html import escape
|
||||
|
||||
from ..models import Point, Server
|
||||
|
||||
# главное меню
|
||||
MAIN_MENU = 'главное меню'
|
||||
FAQ = 'ФАК'
|
||||
ADD_SERVER = 'добавить сервер'
|
||||
HELP = 'помощь'
|
||||
MANAGE = 'управление сервером'
|
||||
|
||||
# общие
|
||||
CANCEL = 'отмена'
|
||||
DELETE = 'удалить'
|
||||
YES = 'Да'
|
||||
NO = 'Нет'
|
||||
|
||||
# меню добавления сервера
|
||||
SERVER = 'сервер'
|
||||
MASK = 'маскировка'
|
||||
DOMAIN = 'домен'
|
||||
CLOUDFLARE = 'cloudflare'
|
||||
PROXY = 'прокси'
|
||||
INSTALL = 'установить'
|
||||
ENABLE = 'включить'
|
||||
DISABLE = 'выключить'
|
||||
CF_WARP = 'cf_warp'
|
||||
|
||||
# меню сервера
|
||||
SERVER_MENU = 'меню сервера'
|
||||
RELEASE = 'Вернуть'
|
||||
REINSTALL = 'Переустановить'
|
||||
POINT_DOMAIN = 'Домен'
|
||||
POINT_PROXY = 'Прокси'
|
||||
POINT_CLOUDFLARE = 'Cloudflare'
|
||||
CLIENTS = 'Клиенты'
|
||||
USE_OLD = 'Использовать старый'
|
||||
|
||||
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
|
||||
MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]]
|
||||
DRAFT_KEYBOARD = [[DOMAIN, CLOUDFLARE, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]]
|
||||
POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY, POINT_CLOUDFLARE], [CLIENTS], [MAIN_MENU]]
|
||||
CANCEL_KEYBOARD = [[CANCEL]]
|
||||
|
||||
EMPTY = '-'
|
||||
|
||||
GREETING = 'привет. выбери действие'
|
||||
|
||||
FAQ_TEXT = '''<b>Что это за бот?</b>
|
||||
Бот настраивает ваш собственный VPN на ваших серверах: панель 3x-ui с подключениями vless + tls + reality и сервер маскировки перед ней.
|
||||
|
||||
<b>Что нужно от меня?</b>
|
||||
Два арендованных сервера с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root каждого. По желанию - домен и прокси на выходе.
|
||||
|
||||
<b>Зачем два сервера?</b>
|
||||
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит.
|
||||
|
||||
<b>Что бот делает с сервером?</b>
|
||||
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
|
||||
|
||||
<b>Как забрать сервер назад?</b>
|
||||
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
|
||||
|
||||
<b>Сервер переустановили у провайдера</b>
|
||||
Меню сервера - "Переустановить" и новый пароль root.'''
|
||||
|
||||
HELP_TEXT = '''<b>Добавить сервер</b>
|
||||
1. "добавить сервер"
|
||||
2. "сервер" - пришлите <code>ip:пароль</code> сервера для панели
|
||||
3. "маскировка" - пришлите <code>ip:пароль</code> сервера маскировки
|
||||
4. по желанию "домен", "cloudflare", "прокси"
|
||||
5. "установить" - установка идет в фоне, по окончании придет уведомление
|
||||
|
||||
<b>Управлять сервером</b>
|
||||
"управление сервером" и имя сервера из главного меню.
|
||||
|
||||
"главное меню" или /start всегда возвращают в начало.'''
|
||||
|
||||
CREDENTIALS_PROMPT = 'отправь данные для подключения к серверу\n<code>ip:root_password</code>'
|
||||
CREDENTIALS_INVALID = 'Не похоже на <code>ip:root_password</code>. Попробуй еще раз.'
|
||||
ADDRESS_TAKEN = 'Сервер {address} уже подключен.'
|
||||
ADDRESS_DUPLICATE = 'Панель и маскировка должны быть разными серверами.'
|
||||
DOMAIN_PROMPT = 'Пришли доменное имя. Доменное имя лучше подходит для маскировки вашей точки доступа.'
|
||||
DOMAIN_INVALID = 'Не похоже на доменное имя. Попробуй еще раз.'
|
||||
CLOUDFLARE_PROMPT = 'Если вы спрятали домен за клаудом, то нужно включить опцию тут.'
|
||||
PROXY_PROMPT = 'Можно указать свои в формате <code>protocol://username:password@host:port</code> или включить cf_warp.'
|
||||
PROXY_INVALID = 'Не похоже на <code>protocol://username:password@host:port</code>. Попробуй еще раз.'
|
||||
DRAFT_INCOMPLETE = 'Нужны оба сервера: "сервер" и "маскировка".'
|
||||
CLOUDFLARE_WITHOUT_DOMAIN = 'Cloudflare работает только с доменом. Укажи домен или выключи cloudflare.'
|
||||
INSTALL_STARTED = 'Запускаю установку сервера. Отправлю уведомление когда все будет готово.'
|
||||
|
||||
SELECT_PROMPT = 'Пришлите server_name'
|
||||
POINT_NOT_FOUND = 'Сервер не найден. Пришлите server_name из списка.'
|
||||
POINT_BUSY = 'Сервер сейчас {status}. Дождитесь окончания.'
|
||||
RELEASE_CONFIRM = '''Сервер будет освобожден из-под моего контроля,
|
||||
будет установлен случайный пароль рута,
|
||||
мои доступы будут убраны, данные на сервере сохранены.
|
||||
Будет отправлено уведомление с новым паролем. Продолжить?'''
|
||||
RELEASE_STARTED = 'Освобождаю серверы. Пришлю новые пароли, когда закончу.'
|
||||
REINSTALL_PROMPT = '''Будет произведена повторная установка всех компонентов.
|
||||
Вызывайте эту функцию, только после того, как в панели управления
|
||||
сервером в аккаунте хостинг провайдера вызовите переустановку сервера.
|
||||
Если уверены, то пришлите <code>ip:новый_пароль</code> переустановленного сервера.
|
||||
"Использовать старый" переустановит оба сервера с последними известными паролями.'''
|
||||
REINSTALL_UNKNOWN_ADDRESS = 'Сервер {address} не входит в эту точку.'
|
||||
REINSTALL_STARTED = 'Запускаю переустановку. Отправлю уведомление когда все будет готово.'
|
||||
NOT_IMPLEMENTED = 'Пока не реализовано.'
|
||||
FALLBACK = 'Не понял. Воспользуйся кнопками меню.'
|
||||
|
||||
|
||||
def value(text: str) -> str:
|
||||
return escape(text) if text else EMPTY
|
||||
|
||||
|
||||
def draft_card(draft: dict) -> str:
|
||||
return '\n'.join([
|
||||
f"Сервер: {value(draft.get('server', {}).get('address', ''))}",
|
||||
f"Сервер маскировки: {value(draft.get('mask', {}).get('address', ''))}",
|
||||
f"Домен: {value(draft.get('domain', ''))}",
|
||||
f"Cloudflare: {'Да' if draft.get('cloudflare') else EMPTY}",
|
||||
f"Прокси: {value(draft.get('proxy', ''))}",
|
||||
])
|
||||
|
||||
|
||||
def points_list(points) -> str:
|
||||
return '\n'.join(f'<code>{escape(point.name)}</code>\t{point.get_status_display()}' for point in points)
|
||||
|
||||
|
||||
def point_card(point: Point) -> str:
|
||||
servers = {server.role: server for server in point.servers.all()}
|
||||
lines = [
|
||||
f'Сервер: <code>{escape(point.name)}</code>',
|
||||
f'Состояние: {point.get_status_display()}',
|
||||
]
|
||||
for role in Server.Role:
|
||||
server = servers.get(role)
|
||||
lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}')
|
||||
lines += [
|
||||
# TODO: онлайн/всего ключей из API панели
|
||||
f'Ключи: {EMPTY}',
|
||||
f'Домен: {value(point.domain)}',
|
||||
f"Cloudflare: {'Да' if point.cloudflare else EMPTY}",
|
||||
f'Прокси: {value(point.outbound_proxy)}',
|
||||
]
|
||||
if point.error:
|
||||
lines.append(f'Ошибка: {escape(point.error)}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def installed(point: Point) -> str:
|
||||
return f'сервер {escape(point.mask.address)} - проименован как <code>{escape(point.name)}</code>, готов к работе.'
|
||||
|
||||
|
||||
def install_failed(point: Point, error: Exception) -> str:
|
||||
return f'Не удалось настроить <code>{escape(point.name)}</code>:\n{escape(str(error))}'
|
||||
|
||||
|
||||
def released(name: str, passwords: dict[str, str], errors: dict[str, str]) -> str:
|
||||
lines = [f'Серверы <code>{escape(name)}</code> возвращены.' if not errors else f'<code>{escape(name)}</code>:']
|
||||
lines += [f'{escape(address)} - пароль root <code>{escape(password)}</code>' for address, password in passwords.items()]
|
||||
lines += [f'{escape(address)} - не удалось вернуть: {escape(error)}' for address, error in errors.items()]
|
||||
return '\n'.join(lines)
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-24 13:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('App', '0001_initial'),
|
||||
('Telegram', '0002_bot_cloud_logged_out'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Point',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
('status', models.CharField(choices=[('installing', 'устанавливается'), ('ready', 'активен'), ('error', 'ошибка'), ('releasing', 'возвращается')], default='installing', max_length=16)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('domain', models.CharField(blank=True, max_length=253)),
|
||||
('cloudflare', models.BooleanField(default=False, help_text='Домен спрятан за Cloudflare')),
|
||||
('outbound_proxy', models.CharField(blank=True, help_text='protocol://user:password@host:port или cf_warp', max_length=512)),
|
||||
('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(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='points', to='Telegram.user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'точка доступа',
|
||||
'verbose_name_plural': 'точки доступа',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Server',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('panel', 'панель'), ('mask', 'маскировка')], max_length=8)),
|
||||
('address', models.GenericIPAddressField(unique=True)),
|
||||
('ssh_port', models.PositiveIntegerField(default=22)),
|
||||
('root_password', models.CharField(help_text='Текущий пароль root', max_length=128)),
|
||||
('private_key', models.TextField(blank=True)),
|
||||
('public_key', models.TextField(blank=True)),
|
||||
('known_hosts', models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')),
|
||||
('uname', models.CharField(blank=True, max_length=512)),
|
||||
('point', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.point')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'сервер',
|
||||
'verbose_name_plural': 'серверы',
|
||||
'constraints': [models.UniqueConstraint(fields=('point', 'role'), name='app_server_role_unique')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from serverus import Host
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
@@ -46,3 +48,92 @@ class User(AbstractUser):
|
||||
|
||||
def email_user(self, *args, **kwargs):
|
||||
raise NotImplementedError('User has no email')
|
||||
|
||||
|
||||
class Point(models.Model):
|
||||
"""Точка доступа: кластер из сервера с панелью 3x-ui и сервера маскировки перед ним.
|
||||
|
||||
Клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
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 server(self, role: str) -> 'Server':
|
||||
return self.servers.get(role=role)
|
||||
|
||||
@property
|
||||
def panel(self) -> 'Server':
|
||||
return self.server(Server.Role.PANEL)
|
||||
|
||||
@property
|
||||
def mask(self) -> 'Server':
|
||||
return self.server(Server.Role.MASK)
|
||||
|
||||
|
||||
class Server(models.Model):
|
||||
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера."""
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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'])
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
|
||||
import secrets
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import serverus
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Point, Server
|
||||
|
||||
PANEL_PORT_RANGE = (10000, 20000)
|
||||
"""Не пересекается с диапазоном ssh портов serverus."""
|
||||
|
||||
|
||||
class ProvisionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Credentials:
|
||||
address: str
|
||||
password: str
|
||||
|
||||
|
||||
def create_point(
|
||||
owner, panel: Credentials, mask: Credentials, domain: str = '', cloudflare: bool = False,
|
||||
outbound_proxy: str = '',
|
||||
) -> Point:
|
||||
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)):
|
||||
point.servers.create(role=role, address=credentials.address, root_password=credentials.password)
|
||||
return point
|
||||
|
||||
|
||||
def install_point(point: Point):
|
||||
"""Настраивает оба сервера точки. Порядок не важен: маскировке нужен только адрес панели."""
|
||||
with _installing(point):
|
||||
for server in point.servers.all():
|
||||
provision(server)
|
||||
|
||||
|
||||
def reinstall(point: Point, address: str | None = None, password: str | None = None):
|
||||
"""Повторная установка после переустановки ОС у провайдера.
|
||||
|
||||
address - переустановленный сервер точки, без него переустанавливаются все.
|
||||
password - новый пароль root от провайдера, без него используется последний известный.
|
||||
"""
|
||||
servers = point.servers.filter(address=address) if address else point.servers.all()
|
||||
with _installing(point):
|
||||
for server in servers:
|
||||
server.known_hosts = ''
|
||||
server.ssh_port = 22
|
||||
if password:
|
||||
server.root_password = password
|
||||
server.save()
|
||||
provision(server)
|
||||
|
||||
|
||||
def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Возвращает серверы владельцу. Возвращает (новые пароли root, ошибки) по адресам.
|
||||
|
||||
Точка удаляется, только если освобождены все серверы.
|
||||
"""
|
||||
passwords, errors = {}, {}
|
||||
for server in point.servers.all():
|
||||
password = serverus.generate_password()
|
||||
result = serverus.Server(server.host()).release(password)
|
||||
if result.ok:
|
||||
server.root_password = password
|
||||
server.save(update_fields=['root_password'])
|
||||
passwords[server.address] = password
|
||||
else:
|
||||
errors[server.address] = result.error
|
||||
if errors:
|
||||
_set_status(point, Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
|
||||
else:
|
||||
point.delete()
|
||||
return passwords, errors
|
||||
|
||||
|
||||
def provision(server: Server):
|
||||
"""Базовая настройка сервера и специализация по его роли."""
|
||||
_bootstrap(server)
|
||||
if server.role == Server.Role.PANEL:
|
||||
_install_panel(server)
|
||||
else:
|
||||
_redirect_to_panel(server)
|
||||
server.uname = _check(server, serverus.Server(server.host()).facts()).data.get('uname', '')
|
||||
server.save(update_fields=['uname'])
|
||||
|
||||
|
||||
def _bootstrap(server: Server):
|
||||
if not server.private_key:
|
||||
keys = serverus.generate_keypair(f'mypoint-{server.address}')
|
||||
server.private_key, server.public_key = keys.private_key, keys.public_key
|
||||
server.save(update_fields=['private_key', 'public_key'])
|
||||
new_password = serverus.generate_password()
|
||||
ssh_port = serverus.generate_ssh_port()
|
||||
# при ошибке остается старый пароль, но ключ уже может быть прописан: повторный запуск войдет по ключу
|
||||
_check(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
|
||||
server.root_password = new_password
|
||||
server.ssh_port = ssh_port
|
||||
server.save(update_fields=['root_password', 'ssh_port'])
|
||||
|
||||
|
||||
def _install_panel(server: Server):
|
||||
point = server.point
|
||||
result = _check(server, serverus.XuiPanel(server.host()).install(
|
||||
username=point.panel_username,
|
||||
password=point.panel_password,
|
||||
panel_port=point.panel_port,
|
||||
web_base_path=point.panel_path,
|
||||
domain=point.domain,
|
||||
))
|
||||
point.panel_api_token = result.data.get('api_token', '')
|
||||
point.save(update_fields=['panel_api_token'])
|
||||
|
||||
|
||||
def _redirect_to_panel(server: Server):
|
||||
_check(server, serverus.RedirectProxy(server.host()).redirect(server.point.panel.address))
|
||||
|
||||
|
||||
def _check(server: Server, result: serverus.Result) -> serverus.Result:
|
||||
if result.known_hosts and result.known_hosts != server.known_hosts:
|
||||
server.known_hosts = result.known_hosts
|
||||
server.save(update_fields=['known_hosts'])
|
||||
if not result.ok:
|
||||
raise ProvisionError(f'{server.address}: {result.error}')
|
||||
return result
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _installing(point: Point):
|
||||
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
|
||||
_set_status(point, Point.Status.INSTALLING)
|
||||
try:
|
||||
yield
|
||||
except Exception as error:
|
||||
_set_status(point, Point.Status.ERROR, str(error))
|
||||
raise
|
||||
_set_status(point, 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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Фоновые задачи: долгие операции с серверами и уведомление пользователя о результате."""
|
||||
import logging
|
||||
|
||||
import dramatiq
|
||||
from Telegram.messages import send
|
||||
from Telegram.models import Chat
|
||||
|
||||
from . import services
|
||||
from .bot import texts
|
||||
from .models import Point
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# установка обновляет систему и ставит панель, это долго
|
||||
TIME_LIMIT_MS = 60 * 60 * 1000
|
||||
|
||||
|
||||
def _notify(chat_id: int, text: str, keyboard=None):
|
||||
send(Chat.objects.get(pk=chat_id), text, keyboard=keyboard, parse_mode='HTML')
|
||||
|
||||
|
||||
def _run_installation(point: Point, chat_id: int, operation, *args):
|
||||
try:
|
||||
operation(point, *args)
|
||||
except Exception as error:
|
||||
logger.exception('Point %s installation failed', point)
|
||||
_notify(chat_id, texts.install_failed(point, error), keyboard=[[texts.SERVER_MENU]])
|
||||
return
|
||||
_notify(chat_id, texts.installed(point), keyboard=[[texts.SERVER_MENU]])
|
||||
|
||||
|
||||
# повтор не нужен: пользователь сам решает, переустанавливать ли после ошибки
|
||||
@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))
|
||||
+209
-12
@@ -1,18 +1,215 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from Telegram import interfaces
|
||||
from Telegram.dispatcher import handle_update
|
||||
from Telegram.models import Bot
|
||||
from Telegram.tests import FakeApi, message_update
|
||||
from Telegram.models import Bot, Chat, User as TelegramUser
|
||||
from Telegram.testing import Conversation, FakeApi
|
||||
|
||||
from .bot import MAIN_KEYBOARD, MyPointInterface
|
||||
from . import tasks
|
||||
from .bot import texts
|
||||
from .bot.interface import MyPointInterface, is_proxy_url, parse_credentials
|
||||
from .models import Point
|
||||
from .tests_services import FakeRunner, create_point, patch_runner
|
||||
|
||||
|
||||
class MyPointInterfaceTests(TestCase):
|
||||
class ParsersTests(TestCase):
|
||||
def test_credentials(self):
|
||||
self.assertEqual(parse_credentials('1.2.3.4:pa:ss').password, 'pa:ss')
|
||||
self.assertIsNone(parse_credentials('1.2.3.4:'))
|
||||
self.assertIsNone(parse_credentials('host:pw'))
|
||||
self.assertIsNone(parse_credentials('1.2.3.400:pw'))
|
||||
|
||||
def test_proxy_url(self):
|
||||
self.assertTrue(is_proxy_url('http://127.0.0.1:9050'))
|
||||
self.assertTrue(is_proxy_url('socks5://user:pw@proxy.example.com:1080'))
|
||||
self.assertFalse(is_proxy_url('http://127.0.0.1'))
|
||||
self.assertFalse(is_proxy_url('ftp://127.0.0.1:21'))
|
||||
|
||||
|
||||
class BotTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(MyPointInterface))
|
||||
self.chat = Conversation(self.bot)
|
||||
self.api = self.chat.api
|
||||
|
||||
def say(self, text):
|
||||
return self.chat.say(text)
|
||||
|
||||
@property
|
||||
def keyboard(self):
|
||||
return self.api.last_keyboard
|
||||
|
||||
|
||||
class MainMenuTests(BotTestCase):
|
||||
def test_start_and_main_menu_button_are_same(self):
|
||||
api = FakeApi()
|
||||
bot = Bot.objects.create(token='1:test', interface=interfaces.key(MyPointInterface))
|
||||
handle_update(api, bot, message_update(1, '/start'))
|
||||
handle_update(api, bot, message_update(2, 'главное меню'))
|
||||
self.assertEqual(api.sent[0][1], api.sent[1][1])
|
||||
self.assertEqual([button['text'] for button in api.sent[0][2].keyboard[0]], MAIN_KEYBOARD[0])
|
||||
self.assertEqual(self.say('/start'), texts.GREETING)
|
||||
self.assertEqual(self.keyboard, texts.MAIN_KEYBOARD)
|
||||
self.assertEqual(self.say(texts.MAIN_MENU), texts.GREETING)
|
||||
|
||||
def test_lists_points(self):
|
||||
self.say('/start')
|
||||
point = create_point(TelegramUser.objects.get())
|
||||
self.assertIn(point.name, self.say('/start'))
|
||||
self.assertEqual(self.keyboard, texts.MAIN_KEYBOARD_WITH_POINTS)
|
||||
|
||||
def test_faq_and_help(self):
|
||||
self.assertEqual(self.say(texts.FAQ), texts.FAQ_TEXT)
|
||||
self.assertEqual(self.say(texts.HELP), texts.HELP_TEXT)
|
||||
|
||||
|
||||
@mock.patch.object(tasks.install_point, 'send')
|
||||
class AddServerTests(BotTestCase):
|
||||
def fill(self, role_button, credentials):
|
||||
self.say(role_button)
|
||||
return self.say(credentials)
|
||||
|
||||
def test_full_flow(self, send):
|
||||
self.say(texts.ADD_SERVER)
|
||||
self.assertEqual(self.keyboard, texts.DRAFT_KEYBOARD)
|
||||
card = self.fill(texts.SERVER, '10.0.0.1:panel-pw')
|
||||
self.assertIn('Сервер: 10.0.0.1', card)
|
||||
self.assertNotIn('panel-pw', card)
|
||||
self.fill(texts.MASK, '10.0.0.2:mask-pw')
|
||||
self.say(texts.DOMAIN)
|
||||
self.say('Example.COM.')
|
||||
self.say(texts.CLOUDFLARE)
|
||||
self.say(texts.ENABLE)
|
||||
self.say(texts.PROXY)
|
||||
card = self.say(texts.CF_WARP)
|
||||
self.assertIn('Домен: example.com', card)
|
||||
self.assertIn('Cloudflare: Да', card)
|
||||
self.assertIn('Прокси: cf_warp', card)
|
||||
|
||||
self.assertEqual(self.say(texts.INSTALL), texts.INSTALL_STARTED)
|
||||
point = Point.objects.get()
|
||||
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.domain, point.cloudflare, point.outbound_proxy), ('example.com', True, 'cf_warp'))
|
||||
self.assertEqual(point.owner, TelegramUser.objects.get())
|
||||
send.assert_called_once_with(point.pk, Chat.objects.get().pk)
|
||||
# сообщения с паролями удалены из чата
|
||||
self.assertEqual(len(self.api.deleted), 2)
|
||||
self.assertEqual(Chat.objects.get().state, '')
|
||||
|
||||
def test_validation(self, send):
|
||||
self.say(texts.ADD_SERVER)
|
||||
self.assertEqual(self.say(texts.INSTALL), texts.DRAFT_INCOMPLETE)
|
||||
self.assertEqual(self.fill(texts.SERVER, 'nonsense'), texts.CREDENTIALS_INVALID)
|
||||
self.say('10.0.0.1:pw')
|
||||
self.assertEqual(self.fill(texts.MASK, '10.0.0.1:pw'), texts.ADDRESS_DUPLICATE)
|
||||
create_point(TelegramUser.objects.get())
|
||||
self.assertEqual(self.say('10.0.0.2:pw'), texts.ADDRESS_TAKEN.format(address='10.0.0.2'))
|
||||
self.say(texts.CANCEL)
|
||||
self.assertEqual(self.fill(texts.DOMAIN, 'not a domain'), texts.DOMAIN_INVALID)
|
||||
self.say(texts.CANCEL)
|
||||
self.assertEqual(self.fill(texts.PROXY, 'http://nope'), texts.PROXY_INVALID)
|
||||
send.assert_not_called()
|
||||
|
||||
def test_cloudflare_requires_domain(self, send):
|
||||
self.say(texts.ADD_SERVER)
|
||||
self.fill(texts.SERVER, '10.0.0.1:pw')
|
||||
self.fill(texts.MASK, '10.0.0.2:pw')
|
||||
self.fill(texts.CLOUDFLARE, texts.ENABLE)
|
||||
self.assertEqual(self.say(texts.INSTALL), texts.CLOUDFLARE_WITHOUT_DOMAIN)
|
||||
send.assert_not_called()
|
||||
|
||||
def test_cancel_in_prompt_returns_to_draft(self, send):
|
||||
self.say(texts.ADD_SERVER)
|
||||
self.fill(texts.SERVER, '10.0.0.1:pw')
|
||||
self.say(texts.MASK)
|
||||
self.assertIn('Сервер: 10.0.0.1', self.say(texts.CANCEL))
|
||||
self.assertEqual(self.say(texts.CANCEL), texts.GREETING)
|
||||
|
||||
|
||||
class PointMenuTests(BotTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.say('/start')
|
||||
self.point = create_point(TelegramUser.objects.get())
|
||||
self.point.status = Point.Status.READY
|
||||
self.point.save()
|
||||
|
||||
def open_point(self):
|
||||
self.say(texts.MANAGE)
|
||||
return self.say(self.point.name)
|
||||
|
||||
def test_select_point(self):
|
||||
self.assertIn(self.point.name, self.say(texts.MANAGE))
|
||||
self.assertEqual(self.say('unknown'), texts.POINT_NOT_FOUND)
|
||||
card = self.say(self.point.name)
|
||||
self.assertIn('Состояние: активен', card)
|
||||
self.assertEqual(self.keyboard, texts.POINT_KEYBOARD)
|
||||
|
||||
def test_foreign_point_is_not_found(self):
|
||||
other = Conversation(self.bot, user_id=8)
|
||||
other.say(texts.MANAGE)
|
||||
self.assertEqual(other.say(self.point.name), texts.POINT_NOT_FOUND)
|
||||
|
||||
@mock.patch.object(tasks.release_point, 'send')
|
||||
def test_release(self, send):
|
||||
self.open_point()
|
||||
self.say(texts.RELEASE)
|
||||
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)
|
||||
self.point.refresh_from_db()
|
||||
self.assertEqual(self.point.status, Point.Status.RELEASING)
|
||||
|
||||
@mock.patch.object(tasks.release_point, 'send')
|
||||
def test_busy_point(self, send):
|
||||
self.point.status = Point.Status.INSTALLING
|
||||
self.point.save()
|
||||
self.open_point()
|
||||
self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается'))
|
||||
send.assert_not_called()
|
||||
|
||||
@mock.patch.object(tasks.reinstall_point, 'send')
|
||||
def test_reinstall_one_server(self, send):
|
||||
self.open_point()
|
||||
self.say(texts.REINSTALL)
|
||||
self.assertEqual(self.say('10.9.9.9:pw'), texts.REINSTALL_UNKNOWN_ADDRESS.format(address='10.9.9.9'))
|
||||
self.assertEqual(self.say('10.0.0.2:new-pw'), texts.REINSTALL_STARTED)
|
||||
send.assert_called_once_with(self.point.pk, mock.ANY, '10.0.0.2', 'new-pw')
|
||||
self.assertEqual(len(self.api.deleted), 2)
|
||||
|
||||
@mock.patch.object(tasks.reinstall_point, 'send')
|
||||
def test_reinstall_with_old_passwords(self, send):
|
||||
self.open_point()
|
||||
self.say(texts.REINSTALL)
|
||||
self.say(texts.USE_OLD)
|
||||
send.assert_called_once_with(self.point.pk, mock.ANY, None, None)
|
||||
|
||||
def test_server_menu_button_opens_latest_point(self):
|
||||
self.assertIn(self.point.name, self.say(texts.SERVER_MENU))
|
||||
|
||||
|
||||
class TasksTests(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()
|
||||
|
||||
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)
|
||||
return self.api.last_text
|
||||
|
||||
def test_install_notifies_success(self):
|
||||
text = self.run_task(tasks.install_point, self.point.pk, self.chat.pk)
|
||||
self.assertIn(self.point.name, text)
|
||||
self.assertIn('готов к работе', text)
|
||||
self.assertEqual(self.api.last_keyboard, [[texts.SERVER_MENU]])
|
||||
|
||||
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'))
|
||||
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)
|
||||
self.assertIn('10.0.0.1 - пароль root', text)
|
||||
self.assertFalse(Point.objects.exists())
|
||||
@@ -0,0 +1,161 @@
|
||||
from unittest import mock
|
||||
|
||||
import serverus
|
||||
from django.test import TestCase
|
||||
from Telegram.models import User as TelegramUser
|
||||
|
||||
from . import services
|
||||
from .models import Point, Server
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
"""Подменяет запуск плейбуков serverus. fail - плейбук, который завершится ошибкой."""
|
||||
|
||||
def __init__(self, fail: str | None = None):
|
||||
self.calls = []
|
||||
self.fail = fail
|
||||
|
||||
def __call__(self, playbook, host, extravars):
|
||||
self.calls.append((playbook, host, extravars))
|
||||
if playbook == self.fail:
|
||||
return serverus.Result(ok=False, status='failed', error='boom', known_hosts=f'kh {host.address}')
|
||||
data = {'facts': {'uname': 'Linux test'}, 'xui': {'api_token': 'token'}}.get(playbook, {})
|
||||
return serverus.Result(ok=True, status='successful', data=data, known_hosts=f'kh {host.address}')
|
||||
|
||||
def playbooks(self, address):
|
||||
return [playbook for playbook, host, _ in self.calls if host.address == address]
|
||||
|
||||
def call(self, playbook, address):
|
||||
return next((host, extravars) for name, host, extravars in self.calls
|
||||
if name == playbook and host.address == address)
|
||||
|
||||
|
||||
def patch_runner(runner):
|
||||
return mock.patch('serverus.runner.run_playbook', runner)
|
||||
|
||||
|
||||
def create_point(owner=None, **kwargs):
|
||||
owner = owner or TelegramUser.objects.create(tg_id=1)
|
||||
return services.create_point(
|
||||
owner, services.Credentials('10.0.0.1', 'panel-pw'), services.Credentials('10.0.0.2', 'mask-pw'), **kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CreatePointTests(TestCase):
|
||||
def test_creates_point_with_servers_and_panel_access(self):
|
||||
point = create_point(domain='example.com')
|
||||
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.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw'))
|
||||
low, high = services.PANEL_PORT_RANGE
|
||||
self.assertTrue(low <= point.panel_port < high)
|
||||
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
|
||||
|
||||
|
||||
class InstallTests(TestCase):
|
||||
def test_install_point(self):
|
||||
point = create_point(domain='example.com')
|
||||
runner = FakeRunner()
|
||||
with patch_runner(runner):
|
||||
services.install_point(point)
|
||||
|
||||
point.refresh_from_db()
|
||||
self.assertEqual(point.status, Point.Status.READY)
|
||||
self.assertEqual(point.panel_api_token, 'token')
|
||||
self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'facts'])
|
||||
self.assertEqual(runner.playbooks('10.0.0.2'), ['bootstrap', 'proxy', 'facts'])
|
||||
|
||||
host, extravars = runner.call('bootstrap', '10.0.0.1')
|
||||
self.assertEqual((host.port, host.known_hosts), (22, ''))
|
||||
self.assertEqual(extravars['login_password'], 'panel-pw')
|
||||
panel = point.panel
|
||||
self.assertEqual(extravars['new_password'], panel.root_password)
|
||||
self.assertEqual(extravars['ssh_port'], panel.ssh_port)
|
||||
self.assertEqual(extravars['public_key'], panel.public_key)
|
||||
self.assertEqual(panel.known_hosts, 'kh 10.0.0.1')
|
||||
self.assertEqual(panel.uname, 'Linux test')
|
||||
|
||||
host, extravars = runner.call('xui', '10.0.0.1')
|
||||
self.assertEqual((host.port, host.known_hosts), (panel.ssh_port, 'kh 10.0.0.1'))
|
||||
self.assertEqual(extravars['domain'], 'example.com')
|
||||
self.assertEqual(extravars['panel_port'], point.panel_port)
|
||||
|
||||
_, extravars = runner.call('proxy', '10.0.0.2')
|
||||
self.assertEqual(extravars['target_ip'], '10.0.0.1')
|
||||
|
||||
def test_install_failure_marks_point(self):
|
||||
point = create_point()
|
||||
runner = FakeRunner(fail='xui')
|
||||
with patch_runner(runner), self.assertRaises(services.ProvisionError):
|
||||
services.install_point(point)
|
||||
point.refresh_from_db()
|
||||
self.assertEqual(point.status, Point.Status.ERROR)
|
||||
self.assertIn('boom', point.error)
|
||||
# после успешного bootstrap сервер уже на новом порту с новым паролем
|
||||
self.assertNotEqual(point.panel.ssh_port, 22)
|
||||
self.assertNotEqual(point.panel.root_password, 'panel-pw')
|
||||
|
||||
def test_bootstrap_failure_keeps_password_and_key(self):
|
||||
point = create_point()
|
||||
with patch_runner(FakeRunner(fail='bootstrap')), self.assertRaises(services.ProvisionError):
|
||||
services.install_point(point)
|
||||
panel = point.panel
|
||||
self.assertEqual((panel.root_password, panel.ssh_port), ('panel-pw', 22))
|
||||
private_key = panel.private_key
|
||||
self.assertTrue(private_key)
|
||||
with patch_runner(FakeRunner()):
|
||||
services.install_point(point)
|
||||
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
|
||||
|
||||
|
||||
class ReinstallTests(TestCase):
|
||||
def setUp(self):
|
||||
self.point = create_point()
|
||||
with patch_runner(FakeRunner()):
|
||||
services.install_point(self.point)
|
||||
|
||||
def test_reinstall_one_server_with_new_password(self):
|
||||
runner = FakeRunner()
|
||||
with patch_runner(runner):
|
||||
services.reinstall(self.point, '10.0.0.2', 'new-provider-pw')
|
||||
self.assertEqual(runner.playbooks('10.0.0.1'), [])
|
||||
host, extravars = runner.call('bootstrap', '10.0.0.2')
|
||||
self.assertEqual((host.port, host.known_hosts), (22, ''))
|
||||
self.assertEqual(extravars['login_password'], 'new-provider-pw')
|
||||
self.point.refresh_from_db()
|
||||
self.assertEqual(self.point.status, Point.Status.READY)
|
||||
|
||||
def test_reinstall_all_with_old_passwords(self):
|
||||
old_password = self.point.panel.root_password
|
||||
runner = FakeRunner()
|
||||
with patch_runner(runner):
|
||||
services.reinstall(self.point)
|
||||
_, extravars = runner.call('bootstrap', '10.0.0.1')
|
||||
self.assertEqual(extravars['login_password'], old_password)
|
||||
self.assertTrue(runner.playbooks('10.0.0.2'))
|
||||
|
||||
|
||||
class ReleaseTests(TestCase):
|
||||
def setUp(self):
|
||||
self.point = create_point()
|
||||
with patch_runner(FakeRunner()):
|
||||
services.install_point(self.point)
|
||||
|
||||
def test_release_deletes_point_and_returns_passwords(self):
|
||||
runner = FakeRunner()
|
||||
with patch_runner(runner):
|
||||
passwords, errors = services.release_point(self.point)
|
||||
self.assertEqual(errors, {})
|
||||
self.assertEqual(passwords['10.0.0.1'], runner.call('release', '10.0.0.1')[1]['new_password'])
|
||||
self.assertEqual(set(passwords), {'10.0.0.1', '10.0.0.2'})
|
||||
self.assertFalse(Point.objects.exists())
|
||||
self.assertFalse(Server.objects.exists())
|
||||
|
||||
def test_partial_release_keeps_point(self):
|
||||
runner = FakeRunner(fail='release')
|
||||
with patch_runner(runner):
|
||||
passwords, errors = services.release_point(self.point)
|
||||
self.assertEqual(passwords, {})
|
||||
self.assertEqual(set(errors), {'10.0.0.1', '10.0.0.2'})
|
||||
self.point.refresh_from_db()
|
||||
self.assertEqual(self.point.status, Point.Status.ERROR)
|
||||
Reference in new issue
Block a user