chore: интерфесы бота
This commit is contained in:
1 parent
5f53cadf89
commit
6d322f36a9
42 files changed
+1731
-111
No files matched your search
@@ -8,7 +8,7 @@ MANAGE := cd src/ && ../$(PYTHON) manage.py
|
||||
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
|
||||
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp tg_run
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker tg_run
|
||||
|
||||
help:
|
||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||
@@ -65,7 +65,10 @@ dj_run:
|
||||
$(MANAGE) runserver
|
||||
|
||||
dj_test:
|
||||
$(MANAGE) test
|
||||
$(MANAGE) test --settings=MyPointVPN.settings.test
|
||||
|
||||
dj_worker:
|
||||
$(MANAGE) rundramatiq
|
||||
|
||||
dj_startapp:
|
||||
$(MANAGE) startapp $(app)
|
||||
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
RABBITMQ_NODENAME: rabbit@$RABBITMQ_HOST
|
||||
RABBITMQ_ERLANG_COOKIE: $RABBITMQ_ERLANG_COOKIE
|
||||
RABBITMQ_DEFAULT_VHOST: $RABBITMQ_VHOST
|
||||
# guest пускают только с localhost контейнера, поэтому свой пользователь
|
||||
RABBITMQ_DEFAULT_USER: $RABBITMQ_USER
|
||||
RABBITMQ_DEFAULT_PASS: $RABBITMQ_PASSWORD
|
||||
ports:
|
||||
- "127.0.0.1:5672:5672"
|
||||
- "127.0.0.1:15672:15672"
|
||||
|
||||
@@ -13,3 +13,8 @@ DB_PASSWD=
|
||||
RABBITMQ_HOST=rabbitmq
|
||||
RABBITMQ_ERLANG_COOKIE=
|
||||
RABBITMQ_VHOST=/
|
||||
# Пользователь создается только при первом запуске контейнера с пустым volume
|
||||
RABBITMQ_USER=
|
||||
RABBITMQ_PASSWORD=
|
||||
# Адрес для Django и воркера dramatiq
|
||||
RABBITMQ_ADDRESS=127.0.0.1:5672
|
||||
@@ -10,6 +10,7 @@ cffi==2.1.1
|
||||
charset-normalizer==3.5.1
|
||||
cryptography==50.0.1
|
||||
Django==6.1.1
|
||||
django_dramatiq==0.15.0
|
||||
dramatiq==2.2.1
|
||||
frozenlist==1.8.0
|
||||
idna==3.20
|
||||
|
||||
+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)
|
||||
@@ -10,3 +10,5 @@ from .project import *
|
||||
|
||||
# Define your settings here
|
||||
```
|
||||
|
||||
- `test.py` - настройки для тестов: фоновые задачи идут в stub брокер, а не в rabbitmq. `make dj_test` использует их.
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from .base import *
|
||||
|
||||
@@ -17,8 +18,31 @@ MEDIA_URL = '/media/'
|
||||
AUTH_USER_MODEL = 'App.User'
|
||||
|
||||
INSTALLED_APPS += [
|
||||
'django_dramatiq',
|
||||
'Telegram.apps.TelegramConfig',
|
||||
]
|
||||
|
||||
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
|
||||
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '')
|
||||
|
||||
# Фоновые задачи: dramatiq поверх rabbitmq. Воркер: make dj_worker
|
||||
RABBITMQ_URL = 'amqp://{user}:{password}@{address}/{vhost}'.format(
|
||||
user=quote(os.environ.get('RABBITMQ_USER', 'guest'), safe=''),
|
||||
password=quote(os.environ.get('RABBITMQ_PASSWORD', 'guest'), safe=''),
|
||||
address=os.environ.get('RABBITMQ_ADDRESS', '127.0.0.1:5672'),
|
||||
vhost=quote(os.environ.get('RABBITMQ_VHOST', '/'), safe=''),
|
||||
)
|
||||
|
||||
DRAMATIQ_BROKER = {
|
||||
'BROKER': 'dramatiq.brokers.rabbitmq.RabbitmqBroker',
|
||||
'OPTIONS': {
|
||||
'url': RABBITMQ_URL,
|
||||
},
|
||||
'MIDDLEWARE': [
|
||||
'dramatiq.middleware.AgeLimit',
|
||||
'dramatiq.middleware.TimeLimit',
|
||||
'dramatiq.middleware.Callbacks',
|
||||
'dramatiq.middleware.Retries',
|
||||
'django_dramatiq.middleware.DbConnectionsMiddleware',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
from .project import *
|
||||
|
||||
# задачи не уходят в rabbitmq
|
||||
DRAMATIQ_BROKER = {
|
||||
**DRAMATIQ_BROKER,
|
||||
'BROKER': 'dramatiq.brokers.stub.StubBroker',
|
||||
'OPTIONS': {},
|
||||
}
|
||||
@@ -48,16 +48,24 @@ class Shop(Interface):
|
||||
ctx.state = ''
|
||||
ctx.reply('Сохранил')
|
||||
|
||||
@text('отмена', state='await_address')
|
||||
def cancel_address(self, ctx):
|
||||
ctx.state = ''
|
||||
ctx.reply('Отменил')
|
||||
|
||||
def fallback(self, ctx):
|
||||
ctx.reply('Не понял')
|
||||
```
|
||||
|
||||
Порядок разбора сообщения: команда, текст кнопки, состояние диалога, `fallback`. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
|
||||
Порядок разбора сообщения: команда, кнопка текущего состояния (`@text(..., state=...)`), кнопка, состояние диалога, `fallback`.
|
||||
|
||||
`ctx.delete_message()` удаляет входящее сообщение из чата, например с паролем. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
|
||||
|
||||
## Запуск
|
||||
|
||||
1. В админке добавить бота с токеном и выбрать интерфейс.
|
||||
2. Действие "Опубликовать" проверяет токен, подтягивает имя бота и включает бота. "Отозвать" выключает.
|
||||
Если задан `TELEGRAM_API_URL`, при первой публикации бот отключается от облачного API (`logOut`). Вернуть его в облако можно не раньше чем через 10 минут.
|
||||
3. `make tg_run` запускает раннер. Смена интерфейса, публикация и отзыв применяются без перезапуска.
|
||||
|
||||
`TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`).
|
||||
@@ -26,7 +26,9 @@ class BotForm(forms.ModelForm):
|
||||
class BotAdmin(admin.ModelAdmin):
|
||||
form = BotForm
|
||||
list_display = ('__str__', 'name', 'interface_title', 'is_active', 'status')
|
||||
readonly_fields = ('username', 'name', 'is_active', 'status', 'heartbeat_at', 'last_error', 'created_at')
|
||||
readonly_fields = (
|
||||
'username', 'name', 'is_active', 'status', 'cloud_logged_out', 'heartbeat_at', 'last_error', 'created_at',
|
||||
)
|
||||
actions = ('publish', 'revoke')
|
||||
|
||||
@admin.display(description='интерфейс')
|
||||
|
||||
+22
-2
@@ -1,3 +1,4 @@
|
||||
import requests
|
||||
import telebot
|
||||
from django.conf import settings
|
||||
from telebot import apihelper
|
||||
@@ -5,11 +6,30 @@ from telebot import apihelper
|
||||
|
||||
def configure_api_server():
|
||||
"""Направляет pyTelegramBotAPI на свой инстанс Bot API сервера, если он задан в TELEGRAM_API_URL."""
|
||||
url = getattr(settings, 'TELEGRAM_API_URL', '').rstrip('/')
|
||||
if url:
|
||||
if uses_own_server():
|
||||
url = settings.TELEGRAM_API_URL.rstrip('/')
|
||||
apihelper.API_URL = url + '/bot{0}/{1}'
|
||||
apihelper.FILE_URL = url + '/file/bot{0}/{1}'
|
||||
|
||||
|
||||
CLOUD_API_URL = 'https://api.telegram.org'
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def uses_own_server() -> bool:
|
||||
return bool(getattr(settings, 'TELEGRAM_API_URL', ''))
|
||||
|
||||
|
||||
def log_out_from_cloud(token: str):
|
||||
"""Отключает бота от облачного Bot API. Без этого свой сервер не гарантирует получение обновлений."""
|
||||
response = requests.post(f'{CLOUD_API_URL}/bot{token}/logOut', timeout=30)
|
||||
data = response.json()
|
||||
if not data.get('ok'):
|
||||
raise ApiError(f"logOut: {data.get('description', response.status_code)}")
|
||||
|
||||
|
||||
def make_api(token: str) -> telebot.TeleBot:
|
||||
return telebot.TeleBot(token, threaded=False)
|
||||
+10
-4
@@ -48,14 +48,20 @@ class Context:
|
||||
self.chat.state = ''
|
||||
self.chat.data = {}
|
||||
|
||||
def routes(self) -> list[tuple[str, str]]:
|
||||
def routes(self) -> list[interfaces.Route]:
|
||||
routes = []
|
||||
if self.command:
|
||||
routes.append((interfaces.COMMAND, self.command))
|
||||
routes.append((interfaces.TEXT, self.text))
|
||||
routes.append((interfaces.COMMAND, self.command, ''))
|
||||
if self.state:
|
||||
routes.append((interfaces.STATE, self.state))
|
||||
routes.append((interfaces.TEXT, self.text, self.state))
|
||||
routes.append((interfaces.TEXT, self.text, ''))
|
||||
if self.state:
|
||||
routes.append((interfaces.STATE, self.state, ''))
|
||||
return routes
|
||||
|
||||
def delete_message(self):
|
||||
"""Удаляет входящее сообщение из чата, например с паролем. В истории оно остается."""
|
||||
self.api.delete_message(self.chat.chat_id, self.message.message_id)
|
||||
|
||||
def reply(self, text: str, keyboard: messages.Keyboard | None = None, parse_mode: str | None = None):
|
||||
return messages.send(self.chat, text, keyboard=keyboard, parse_mode=parse_mode, api=self.api)
|
||||
@@ -17,7 +17,11 @@ command/text/state и регистрируют класс через register.
|
||||
ctx.data['address'] = ctx.text
|
||||
ctx.state = ''
|
||||
|
||||
Порядок разбора сообщения: команда, текст кнопки, состояние диалога, fallback.
|
||||
@text('отмена', state='await_address')
|
||||
def cancel_address(self, ctx):
|
||||
ctx.state = ''
|
||||
|
||||
Порядок разбора сообщения: команда, кнопка текущего состояния, кнопка, состояние диалога, fallback.
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -31,31 +35,36 @@ TEXT = 'text'
|
||||
STATE = 'state'
|
||||
|
||||
|
||||
def _route(kind: str, values: tuple[str, ...]):
|
||||
Route = tuple[str, str, str]
|
||||
"""(вид, значение, состояние чата). Пустое состояние - в любом состоянии."""
|
||||
|
||||
|
||||
def _route(routes: list[Route]):
|
||||
def decorator(func):
|
||||
func.__dict__.setdefault('_telegram_routes', []).extend((kind, value) for value in values)
|
||||
func.__dict__.setdefault('_telegram_routes', []).extend(routes)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def command(*names: str):
|
||||
"""Обрабатывает команды /name. Имена без слеша, регистр не важен."""
|
||||
return _route(COMMAND, tuple(name.lstrip('/').lower() for name in names))
|
||||
return _route([(COMMAND, name.lstrip('/').lower(), '') for name in names])
|
||||
|
||||
|
||||
def text(*texts: str):
|
||||
"""Обрабатывает сообщение с точно таким текстом, например нажатие кнопки клавиатуры."""
|
||||
return _route(TEXT, texts)
|
||||
def text(*texts: str, state: str = ''):
|
||||
"""Обрабатывает сообщение с точно таким текстом, например нажатие кнопки клавиатуры.
|
||||
С state - только пока чат в этом состоянии, такой обработчик важнее обработчика без состояния."""
|
||||
return _route([(TEXT, value, state) for value in texts])
|
||||
|
||||
|
||||
def state(*states: str):
|
||||
"""Обрабатывает любое сообщение, пока чат находится в этом состоянии."""
|
||||
return _route(STATE, states)
|
||||
return _route([(STATE, value, '') for value in states])
|
||||
|
||||
|
||||
class Interface:
|
||||
title = ''
|
||||
_routes: dict[tuple[str, str], str] = {}
|
||||
_routes: dict[Route, str] = {}
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-24 13:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('Telegram', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bot',
|
||||
name='cloud_logged_out',
|
||||
field=models.BooleanField(default=False, editable=False, help_text='logOut в api.telegram.org выполнен, бот обслуживается своим Bot API сервером', verbose_name='отключен от облачного API'),
|
||||
),
|
||||
]
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from . import interfaces
|
||||
from .api import make_api
|
||||
from .api import log_out_from_cloud, make_api, uses_own_server
|
||||
|
||||
|
||||
class Bot(models.Model):
|
||||
@@ -18,6 +18,10 @@ class Bot(models.Model):
|
||||
help_text='Класс, определяющий поведение бота. Меняется на лету.',
|
||||
)
|
||||
is_active = models.BooleanField('опубликован', default=False, editable=False)
|
||||
cloud_logged_out = models.BooleanField(
|
||||
'отключен от облачного API', default=False, editable=False,
|
||||
help_text='logOut в api.telegram.org выполнен, бот обслуживается своим Bot API сервером',
|
||||
)
|
||||
update_offset = models.BigIntegerField(default=0, editable=False)
|
||||
heartbeat_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
last_error = models.TextField(blank=True, editable=False)
|
||||
@@ -38,7 +42,15 @@ class Bot(models.Model):
|
||||
return cls() if cls else None
|
||||
|
||||
def publish(self):
|
||||
"""Проверяет токен, подтягивает имя бота и включает его обслуживание раннером."""
|
||||
"""Проверяет токен, подтягивает имя бота и включает его обслуживание раннером.
|
||||
|
||||
Если настроен свой Bot API сервер, один раз отключает бота от облачного.
|
||||
"""
|
||||
if uses_own_server() and not self.cloud_logged_out:
|
||||
log_out_from_cloud(self.token)
|
||||
# сохраняем сразу: повторный logOut облако отклонит
|
||||
self.cloud_logged_out = True
|
||||
self.save(update_fields=['cloud_logged_out'])
|
||||
api = self.api()
|
||||
me = api.get_me()
|
||||
api.delete_webhook()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Помощники для тестов приложений, которые строят интерфейсы ботов."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from telebot import types
|
||||
|
||||
USER_ID = 7
|
||||
CHAT_ID = 42
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.deleted = []
|
||||
|
||||
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
|
||||
self.sent.append(SimpleNamespace(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode))
|
||||
return SimpleNamespace(message_id=len(self.sent))
|
||||
|
||||
def delete_message(self, chat_id, message_id):
|
||||
self.deleted.append((chat_id, message_id))
|
||||
|
||||
@property
|
||||
def last_text(self):
|
||||
return self.sent[-1].text if self.sent else None
|
||||
|
||||
@property
|
||||
def last_keyboard(self) -> list[list[str]] | None:
|
||||
markup = self.sent[-1].reply_markup if self.sent else None
|
||||
keyboard = getattr(markup, 'keyboard', None)
|
||||
return [[button['text'] for button in row] for row in keyboard] if keyboard is not None else None
|
||||
|
||||
|
||||
def message_update(update_id: int, text: str, chat_type: str = 'private', user_id: int = USER_ID) -> types.Update:
|
||||
return types.Update.de_json({
|
||||
'update_id': update_id,
|
||||
'message': {
|
||||
'message_id': update_id,
|
||||
'date': 0,
|
||||
'text': text,
|
||||
'chat': {'id': CHAT_ID if user_id == USER_ID else user_id, 'type': chat_type,
|
||||
'title': 'group' if chat_type != 'private' else None},
|
||||
'from': {'id': user_id, 'is_bot': False, 'first_name': 'Ann', 'username': f'user{user_id}'},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class Conversation:
|
||||
"""Диалог пользователя с ботом: say() отправляет сообщение и возвращает ответ бота."""
|
||||
|
||||
def __init__(self, bot, user_id: int = USER_ID, api: FakeApi | None = None):
|
||||
from .dispatcher import handle_update
|
||||
|
||||
self._handle_update = handle_update
|
||||
self.bot = bot
|
||||
self.user_id = user_id
|
||||
self.api = api or FakeApi()
|
||||
self._update_id = 0
|
||||
|
||||
def say(self, text: str) -> str | None:
|
||||
self._update_id += 1
|
||||
sent_before = len(self.api.sent)
|
||||
self._handle_update(self.api, self.bot, message_update(self._update_id, text, user_id=self.user_id))
|
||||
return self.api.last_text if len(self.api.sent) > sent_before else None
|
||||
+51
-25
@@ -1,21 +1,14 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from telebot import types
|
||||
|
||||
from . import interfaces
|
||||
from .dispatcher import handle_update
|
||||
from .interfaces import Interface, command, register, state, text
|
||||
from .models import Bot, Chat, Message, User
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
|
||||
self.sent.append((chat_id, text, reply_markup))
|
||||
return SimpleNamespace(message_id=len(self.sent))
|
||||
from .testing import FakeApi, message_update
|
||||
|
||||
|
||||
@register
|
||||
@@ -33,6 +26,15 @@ class EchoInterface(Interface):
|
||||
ctx.state = 'await_name'
|
||||
ctx.reply('name?')
|
||||
|
||||
@text('отмена', state='await_name')
|
||||
def cancel_name(self, ctx):
|
||||
ctx.state = ''
|
||||
ctx.reply('cancelled')
|
||||
|
||||
@text('отмена')
|
||||
def cancel(self, ctx):
|
||||
ctx.reply('nothing to cancel')
|
||||
|
||||
@state('await_name')
|
||||
def name(self, ctx):
|
||||
ctx.data['name'] = ctx.text
|
||||
@@ -50,19 +52,6 @@ class ChildInterface(EchoInterface):
|
||||
ctx.reply('child')
|
||||
|
||||
|
||||
def message_update(update_id, text, chat_type='private'):
|
||||
return types.Update.de_json({
|
||||
'update_id': update_id,
|
||||
'message': {
|
||||
'message_id': update_id,
|
||||
'date': 0,
|
||||
'text': text,
|
||||
'chat': {'id': 42, 'type': chat_type, 'title': 'group' if chat_type != 'private' else None},
|
||||
'from': {'id': 7, 'is_bot': False, 'first_name': 'Ann', 'username': 'ann'},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def member_update(update_id, status):
|
||||
return types.Update.de_json({
|
||||
'update_id': update_id,
|
||||
@@ -83,7 +72,7 @@ class DispatcherTests(TestCase):
|
||||
|
||||
def send(self, text, update_id=1, **kwargs):
|
||||
handle_update(self.api, self.bot, message_update(update_id, text, **kwargs))
|
||||
return self.api.sent[-1][1] if self.api.sent else None
|
||||
return self.api.last_text
|
||||
|
||||
def test_command_text_state_fallback(self):
|
||||
self.assertEqual(self.send('/start'), 'menu')
|
||||
@@ -92,13 +81,23 @@ class DispatcherTests(TestCase):
|
||||
self.assertEqual(self.send('whatever'), 'echo whatever')
|
||||
self.assertEqual(Chat.objects.get().data, {'name': 'Bob'})
|
||||
|
||||
def test_state_button_has_priority(self):
|
||||
self.assertEqual(self.send('отмена'), 'nothing to cancel')
|
||||
self.send('ask')
|
||||
self.assertEqual(self.send('отмена'), 'cancelled')
|
||||
self.assertEqual(Chat.objects.get().state, '')
|
||||
|
||||
def test_global_button_works_in_state(self):
|
||||
self.send('ask')
|
||||
self.assertEqual(self.send('меню'), 'menu')
|
||||
|
||||
def test_command_with_bot_name_and_args(self):
|
||||
self.assertEqual(self.send('/START@my_bot payload'), 'menu')
|
||||
|
||||
def test_saves_user_chat_and_messages(self):
|
||||
self.send('/start')
|
||||
user = User.objects.get()
|
||||
self.assertEqual((user.tg_id, user.username), (7, 'ann'))
|
||||
self.assertEqual((user.tg_id, user.username), (7, 'user7'))
|
||||
chat = Chat.objects.get()
|
||||
self.assertEqual((chat.chat_id, chat.user, chat.type), (42, user, 'private'))
|
||||
self.assertEqual(
|
||||
@@ -128,6 +127,33 @@ class DispatcherTests(TestCase):
|
||||
self.assertEqual(Chat.objects.count(), 1)
|
||||
|
||||
|
||||
class PublishTests(TestCase):
|
||||
def setUp(self):
|
||||
self.bot = Bot.objects.create(token='1:test')
|
||||
self.api = mock.Mock()
|
||||
self.api.get_me.return_value = SimpleNamespace(username='my_bot', first_name='My')
|
||||
|
||||
def publish(self):
|
||||
with mock.patch.object(Bot, 'api', return_value=self.api), \
|
||||
mock.patch('Telegram.models.log_out_from_cloud') as log_out:
|
||||
self.bot.publish()
|
||||
return log_out
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
|
||||
def test_logs_out_from_cloud_once(self):
|
||||
self.assertEqual(self.publish().call_count, 1)
|
||||
self.bot.refresh_from_db()
|
||||
self.assertTrue(self.bot.cloud_logged_out)
|
||||
self.assertTrue(self.bot.is_active)
|
||||
self.assertEqual(self.bot.username, 'my_bot')
|
||||
self.assertEqual(self.publish().call_count, 0)
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='')
|
||||
def test_cloud_api_without_log_out(self):
|
||||
self.assertEqual(self.publish().call_count, 0)
|
||||
self.assertFalse(self.bot.cloud_logged_out)
|
||||
|
||||
|
||||
class InterfaceRegistryTests(TestCase):
|
||||
def test_registered_interfaces_are_choices(self):
|
||||
self.assertIn((interfaces.key(EchoInterface), 'Echo'), interfaces.choices())
|
||||
|
||||
+12
-1
@@ -57,8 +57,19 @@ server.release(generate_password())
|
||||
- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
||||
- `facts` - `uname -a` и версия ОС.
|
||||
|
||||
Специализации - наследники `Server`:
|
||||
|
||||
- `XuiPanel(host).install(username, password, panel_port, web_base_path, domain='')` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, сертификат на домен или на ip. Открывает 80 (acme), 443 и порт панели. Возвращает `data['api_token']`.
|
||||
- `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip>`, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.**
|
||||
|
||||
## Отпечаток сервера
|
||||
|
||||
`Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении.
|
||||
|
||||
После переустановки ОС у провайдера отпечаток меняется и подключение отклоняется. Сброс - передать пустой `known_hosts`. Смена порта ssh отпечаток не сбрасывает.
|
||||
|
||||
Настройки ssh пишутся в `/etc/ssh/sshd_config.d/00-serverus.conf`, основной конфиг не трогаем.
|
||||
|
||||
Плейбуки лежат в `playbooks/`, общие шаги в `playbooks/tasks/`. Новая операция - это плейбук и метод в `Server` или его наследнике.
|
||||
|
||||
Тесты: `python -m unittest serverus.tests` из `src/`.
|
||||
Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd.
|
||||
@@ -1,13 +1,17 @@
|
||||
from .host import Host
|
||||
from .runner import Result, run_playbook
|
||||
from .credentials import KeyPair, generate_keypair, generate_password, generate_ssh_port
|
||||
from .host import Host
|
||||
from .proxy import RedirectProxy
|
||||
from .runner import Result, run_playbook
|
||||
from .server import Server
|
||||
from .xui import XuiPanel
|
||||
|
||||
__all__ = [
|
||||
'Host',
|
||||
'KeyPair',
|
||||
'RedirectProxy',
|
||||
'Result',
|
||||
'Server',
|
||||
'XuiPanel',
|
||||
'generate_keypair',
|
||||
'generate_password',
|
||||
'generate_ssh_port',
|
||||
|
||||
@@ -10,6 +10,9 @@ class Host:
|
||||
user: str = 'root'
|
||||
private_key: str | None = None
|
||||
"""Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю."""
|
||||
known_hosts: str = ''
|
||||
"""Отпечаток сервера из прошлого Result.known_hosts. Пусто - доверяем ключу при первом подключении.
|
||||
После переустановки ОС отпечаток меняется, его нужно сбросить."""
|
||||
|
||||
def __repr__(self):
|
||||
# ключ не должен попадать в логи
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Перенаправляет весь входящий трафик сервера на целевой ресурс.
|
||||
# Использование: serverus-redirect <target_ip>
|
||||
# SSH_PORT - порт ssh этого сервера, его перенаправлять нельзя, иначе доступ к серверу будет потерян.
|
||||
#
|
||||
# TODO: заглушка. Заменить на настоящий скрипт перенаправления через фаервол.
|
||||
# Правила должны переживать перезапуск nftables: базовый конфиг /etc/nftables.conf начинается с flush ruleset.
|
||||
set -eu
|
||||
|
||||
TARGET_IP="${1:?usage: $0 <target_ip>}"
|
||||
SSH_PORT="${SSH_PORT:?SSH_PORT is required}"
|
||||
|
||||
echo "redirect stub: all traffic except tcp/${SSH_PORT} -> ${TARGET_IP}"
|
||||
@@ -0,0 +1,33 @@
|
||||
# extravars: target_ip
|
||||
#
|
||||
# Превращает сервер в прозрачный прокси: весь трафик, кроме ssh, уходит на target_ip.
|
||||
- name: Redirect traffic to target
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ansible_port }}" ]
|
||||
firewall_forward: true
|
||||
|
||||
- name: Enable ip forwarding
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/sysctl.d/90-serverus-forward.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
net.ipv4.ip_forward = 1
|
||||
|
||||
- name: Apply sysctl
|
||||
ansible.builtin.command: sysctl --system
|
||||
changed_when: false
|
||||
|
||||
- name: Install redirect script
|
||||
ansible.builtin.copy:
|
||||
src: files/redirect.sh
|
||||
dest: /usr/local/sbin/serverus-redirect
|
||||
mode: "0755"
|
||||
|
||||
- name: Redirect traffic
|
||||
ansible.builtin.command: "/usr/local/sbin/serverus-redirect {{ target_ip }}"
|
||||
environment:
|
||||
SSH_PORT: "{{ ansible_port }}"
|
||||
@@ -19,6 +19,18 @@
|
||||
enabled: false
|
||||
state: stopped
|
||||
|
||||
- name: Remove traffic redirect
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /usr/local/sbin/serverus-redirect
|
||||
- /etc/sysctl.d/90-serverus-forward.conf
|
||||
|
||||
- name: Reload sysctl
|
||||
ansible.builtin.command: sysctl --system
|
||||
changed_when: false
|
||||
|
||||
- name: Remove sshd drop-in
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d/00-serverus.conf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# vars: firewall_tcp_ports, firewall_udp_ports (optional)
|
||||
# vars: firewall_tcp_ports, firewall_udp_ports (optional), firewall_forward (optional, пропускать транзитный трафик)
|
||||
- name: Install nftables
|
||||
ansible.builtin.apt:
|
||||
name: nftables
|
||||
|
||||
@@ -19,7 +19,7 @@ table inet filter {
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
type filter hook forward priority filter; policy {{ 'accept' if firewall_forward | default(false) else 'drop' }};
|
||||
}
|
||||
|
||||
chain output {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# extravars: username, password, panel_port, web_base_path, domain (optional), version (optional)
|
||||
#
|
||||
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме.
|
||||
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
|
||||
- name: Install 3x-ui panel
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
xui_bin: /usr/local/x-ui/x-ui
|
||||
xui_domain: "{{ domain | default('') }}"
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443, "{{ panel_port }}" ]
|
||||
|
||||
- name: Download install script
|
||||
ansible.builtin.get_url:
|
||||
url: https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh
|
||||
dest: /root/3x-ui-install.sh
|
||||
mode: "0700"
|
||||
force: true
|
||||
|
||||
- name: Run install script
|
||||
ansible.builtin.command:
|
||||
cmd: "bash /root/3x-ui-install.sh {{ version | default('') }}"
|
||||
creates: "{{ xui_bin }}"
|
||||
environment:
|
||||
XUI_NONINTERACTIVE: "1"
|
||||
XUI_USERNAME: "{{ username }}"
|
||||
XUI_PASSWORD: "{{ password }}"
|
||||
XUI_PANEL_PORT: "{{ panel_port }}"
|
||||
XUI_WEB_BASE_PATH: "{{ web_base_path }}"
|
||||
XUI_SSL_MODE: "{{ 'domain' if xui_domain else 'ip' }}"
|
||||
XUI_DOMAIN: "{{ xui_domain }}"
|
||||
XUI_SERVER_IP: "{{ ansible_host }}"
|
||||
no_log: true
|
||||
|
||||
# скрипт не перезаписывает настройки уже установленной панели, поэтому задаем их явно
|
||||
- name: Apply panel settings
|
||||
ansible.builtin.command: >-
|
||||
{{ xui_bin }} setting -username {{ username | quote }} -password {{ password | quote }}
|
||||
-port {{ panel_port }} -webBasePath {{ web_base_path | quote }}
|
||||
no_log: true
|
||||
|
||||
- name: Restart panel
|
||||
ansible.builtin.systemd_service:
|
||||
name: x-ui
|
||||
state: restarted
|
||||
enabled: true
|
||||
|
||||
- name: Read API token
|
||||
ansible.builtin.shell: "{{ xui_bin }} setting -getApiToken | grep -Eo 'apiToken: .+' | awk '{print $2}'"
|
||||
register: api_token
|
||||
changed_when: false
|
||||
no_log: true
|
||||
|
||||
- name: Return panel access
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
api_token: "{{ api_token.stdout }}"
|
||||
no_log: true
|
||||
@@ -0,0 +1,9 @@
|
||||
from .runner import Result
|
||||
from .server import Server
|
||||
|
||||
|
||||
class RedirectProxy(Server):
|
||||
"""Сервер, который перенаправляет весь трафик на другой сервер, скрывая его адрес."""
|
||||
|
||||
def redirect(self, target_ip: str) -> Result:
|
||||
return self.run('proxy', target_ip=target_ip)
|
||||
+23
-2
@@ -22,6 +22,8 @@ class Result:
|
||||
data: dict = field(default_factory=dict)
|
||||
"""Данные, которые плейбук вернул через set_stats."""
|
||||
error: str = ''
|
||||
known_hosts: str = ''
|
||||
"""Отпечаток сервера после запуска. Его нужно сохранить и передавать в Host.known_hosts."""
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
@@ -45,21 +47,41 @@ def run_playbook(playbook: str, host: Host, extravars: dict) -> Result:
|
||||
'PATH': PATH,
|
||||
# свои ssh control сокеты на каждый запуск: иначе чужое открытое соединение пропустит без авторизации
|
||||
'ANSIBLE_SSH_CONTROL_PATH_DIR': os.path.join(private_dir, 'cp'),
|
||||
# ansible-runner по умолчанию отключает проверку, режим задается в _ssh_args
|
||||
'ANSIBLE_HOST_KEY_CHECKING': 'True',
|
||||
},
|
||||
quiet=True,
|
||||
)
|
||||
events = list(result.events)
|
||||
with result.stdout as stdout:
|
||||
output = stdout.read()
|
||||
known_hosts = _known_hosts_file(private_dir).read_text()
|
||||
ok = result.status == 'successful'
|
||||
return Result(
|
||||
ok=ok,
|
||||
status=result.status,
|
||||
data=_stats_data(events),
|
||||
error='' if ok else _error(events) or output.strip()[-1000:],
|
||||
known_hosts=known_hosts,
|
||||
)
|
||||
|
||||
|
||||
def _known_hosts_file(private_dir: str) -> Path:
|
||||
return Path(private_dir) / 'known_hosts'
|
||||
|
||||
|
||||
def _ssh_args(host: Host, private_dir: str) -> str:
|
||||
known_hosts = _known_hosts_file(private_dir)
|
||||
known_hosts.write_text(host.known_hosts)
|
||||
return ' '.join([
|
||||
# один отпечаток на адрес независимо от порта: смена порта ssh не сбрасывает доверие
|
||||
f'-o HostKeyAlias={host.address}',
|
||||
f'-o UserKnownHostsFile={known_hosts}',
|
||||
f"-o StrictHostKeyChecking={'yes' if host.known_hosts else 'accept-new'}",
|
||||
'-o LogLevel=ERROR',
|
||||
])
|
||||
|
||||
|
||||
def _host_vars(host: Host, private_dir: str) -> dict:
|
||||
host_vars = {
|
||||
'ansible_host': host.address,
|
||||
@@ -68,8 +90,7 @@ def _host_vars(host: Host, private_dir: str) -> dict:
|
||||
'ansible_python_interpreter': 'auto_silent',
|
||||
# вход по паролю без sshpass
|
||||
'ansible_ssh_password_mechanism': 'ssh_askpass',
|
||||
# TODO: хранить known_hosts сервера и включить проверку ключа хоста
|
||||
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR',
|
||||
'ansible_ssh_common_args': _ssh_args(host, private_dir),
|
||||
}
|
||||
if host.private_key:
|
||||
key_file = Path(private_dir) / 'id_host'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .host import Host
|
||||
from .runner import Result, Runner, run_playbook
|
||||
from . import runner as _runner
|
||||
from .runner import Result, Runner
|
||||
|
||||
|
||||
class Server:
|
||||
@@ -8,12 +9,13 @@ class Server:
|
||||
Специализации (панель, прокси, докер-платформа) наследуют этот класс и добавляют свои операции.
|
||||
"""
|
||||
|
||||
def __init__(self, host: Host, runner: Runner = run_playbook):
|
||||
def __init__(self, host: Host, runner: Runner | None = None):
|
||||
self.host = host
|
||||
self._runner = runner
|
||||
|
||||
def run(self, playbook: str, **extravars) -> Result:
|
||||
return self._runner(playbook, self.host, extravars)
|
||||
runner = self._runner or _runner.run_playbook
|
||||
return runner(playbook, self.host, extravars)
|
||||
|
||||
def facts(self) -> Result:
|
||||
"""Возвращает data: {'uname': ..., 'distribution': ..., 'version': ...}."""
|
||||
@@ -35,5 +37,6 @@ class Server:
|
||||
)
|
||||
|
||||
def release(self, new_password: str) -> Result:
|
||||
"""Возвращает сервер владельцу: откатывает ssh и nftables, удаляет ключи, ставит пароль root new_password."""
|
||||
"""Возвращает сервер владельцу: откатывает ssh, nftables и перенаправление трафика, удаляет ключи,
|
||||
ставит пароль root new_password. Установленные сервисы и данные остаются."""
|
||||
return self.run('release', new_password=new_password)
|
||||
@@ -0,0 +1,17 @@
|
||||
from .runner import Result
|
||||
from .server import Server
|
||||
|
||||
|
||||
class XuiPanel(Server):
|
||||
"""Сервер с панелью 3x-ui."""
|
||||
|
||||
def install(self, username: str, password: str, panel_port: int, web_base_path: str, domain: str = '') -> Result:
|
||||
"""Ставит панель и открывает порты 80 (acme), 443 и panel_port. Возвращает data: {'api_token': ...}."""
|
||||
return self.run(
|
||||
'xui',
|
||||
username=username,
|
||||
password=password,
|
||||
panel_port=panel_port,
|
||||
web_base_path=web_base_path,
|
||||
domain=domain,
|
||||
)
|
||||
Reference in new issue
Block a user