chore: интерфесы бота

This commit is contained in:
protokey committed 2026-09-24 17:33:10 +04:00
1 parent 5f53cadf89
commit 6d322f36a9
42 files changed
+1731 -111

No files matched your search

+5 -2
View File
@@ -8,7 +8,7 @@ MANAGE := cd src/ && ../$(PYTHON) manage.py
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) 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: help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -65,7 +65,10 @@ dj_run:
$(MANAGE) runserver $(MANAGE) runserver
dj_test: dj_test:
$(MANAGE) test $(MANAGE) test --settings=MyPointVPN.settings.test
dj_worker:
$(MANAGE) rundramatiq
dj_startapp: dj_startapp:
$(MANAGE) startapp $(app) $(MANAGE) startapp $(app)
+3
View File
@@ -38,6 +38,9 @@ services:
RABBITMQ_NODENAME: rabbit@$RABBITMQ_HOST RABBITMQ_NODENAME: rabbit@$RABBITMQ_HOST
RABBITMQ_ERLANG_COOKIE: $RABBITMQ_ERLANG_COOKIE RABBITMQ_ERLANG_COOKIE: $RABBITMQ_ERLANG_COOKIE
RABBITMQ_DEFAULT_VHOST: $RABBITMQ_VHOST RABBITMQ_DEFAULT_VHOST: $RABBITMQ_VHOST
# guest пускают только с localhost контейнера, поэтому свой пользователь
RABBITMQ_DEFAULT_USER: $RABBITMQ_USER
RABBITMQ_DEFAULT_PASS: $RABBITMQ_PASSWORD
ports: ports:
- "127.0.0.1:5672:5672" - "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672" - "127.0.0.1:15672:15672"
+5
View File
@@ -13,3 +13,8 @@ DB_PASSWD=
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
RABBITMQ_ERLANG_COOKIE= RABBITMQ_ERLANG_COOKIE=
RABBITMQ_VHOST=/ RABBITMQ_VHOST=/
# Пользователь создается только при первом запуске контейнера с пустым volume
RABBITMQ_USER=
RABBITMQ_PASSWORD=
# Адрес для Django и воркера dramatiq
RABBITMQ_ADDRESS=127.0.0.1:5672
+1
View File
@@ -10,6 +10,7 @@ cffi==2.1.1
charset-normalizer==3.5.1 charset-normalizer==3.5.1
cryptography==50.0.1 cryptography==50.0.1
Django==6.1.1 Django==6.1.1
django_dramatiq==0.15.0
dramatiq==2.2.1 dramatiq==2.2.1
frozenlist==1.8.0 frozenlist==1.8.0
idna==3.20 idna==3.20
+28 -1
View File
@@ -3,4 +3,31 @@
Django приложение связывающее все части сайта в одном месте. Никто не импортирует App, кроме MyPointVPN, а App может импортировать все. Django приложение связывающее все части сайта в одном месте. Никто не импортирует App, кроме MyPointVPN, а App может импортировать все.
-[x] Реализует кастомный менеджер модели и кастомную модель пользователя с отключенным email. -[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
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import User from .models import Point, Server, User
class UserCreationForm(AdminUserCreationForm): class UserCreationForm(AdminUserCreationForm):
@@ -28,3 +28,43 @@ class UserAdmin(BaseUserAdmin):
) )
list_display = ('username', 'first_name', 'last_name', 'is_staff') list_display = ('username', 'first_name', 'last_name', 'is_staff')
search_fields = ('username', 'first_name', 'last_name') 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
View File
@@ -5,4 +5,4 @@ class AppConfig(AppConfig):
name = 'App' name = 'App'
def ready(self): def ready(self):
from . import bot # noqa: F401 регистрирует интерфейс бота from .bot import interface # noqa: F401 регистрирует интерфейс бота
-39
View File
@@ -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)
View File
Whitespace-only changes.
+308
View File
@@ -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)
+163
View File
@@ -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)
+58
View File
@@ -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')],
},
),
]
+91
View File
@@ -1,5 +1,7 @@
from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models
from serverus import Host
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -46,3 +48,92 @@ class User(AbstractUser):
def email_user(self, *args, **kwargs): def email_user(self, *args, **kwargs):
raise NotImplementedError('User has no email') 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'])
+166
View File
@@ -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
+48
View File
@@ -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
View File
@@ -1,18 +1,215 @@
from unittest import mock
from django.test import TestCase from django.test import TestCase
from Telegram import interfaces from Telegram import interfaces
from Telegram.dispatcher import handle_update from Telegram.models import Bot, Chat, User as TelegramUser
from Telegram.models import Bot from Telegram.testing import Conversation, FakeApi
from Telegram.tests import FakeApi, message_update
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): def test_start_and_main_menu_button_are_same(self):
api = FakeApi() self.assertEqual(self.say('/start'), texts.GREETING)
bot = Bot.objects.create(token='1:test', interface=interfaces.key(MyPointInterface)) self.assertEqual(self.keyboard, texts.MAIN_KEYBOARD)
handle_update(api, bot, message_update(1, '/start')) self.assertEqual(self.say(texts.MAIN_MENU), texts.GREETING)
handle_update(api, bot, message_update(2, 'главное меню'))
self.assertEqual(api.sent[0][1], api.sent[1][1]) def test_lists_points(self):
self.assertEqual([button['text'] for button in api.sent[0][2].keyboard[0]], MAIN_KEYBOARD[0]) 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())
+161
View File
@@ -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)
+2
View File
@@ -10,3 +10,5 @@ from .project import *
# Define your settings here # Define your settings here
``` ```
- `test.py` - настройки для тестов: фоновые задачи идут в stub брокер, а не в rabbitmq. `make dj_test` использует их.
+24
View File
@@ -1,4 +1,5 @@
import os import os
from urllib.parse import quote
from .base import * from .base import *
@@ -17,8 +18,31 @@ MEDIA_URL = '/media/'
AUTH_USER_MODEL = 'App.User' AUTH_USER_MODEL = 'App.User'
INSTALLED_APPS += [ INSTALLED_APPS += [
'django_dramatiq',
'Telegram.apps.TelegramConfig', 'Telegram.apps.TelegramConfig',
] ]
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org # Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') 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',
],
}
+8
View File
@@ -0,0 +1,8 @@
from .project import *
# задачи не уходят в rabbitmq
DRAMATIQ_BROKER = {
**DRAMATIQ_BROKER,
'BROKER': 'dramatiq.brokers.stub.StubBroker',
'OPTIONS': {},
}
+9 -1
View File
@@ -48,16 +48,24 @@ class Shop(Interface):
ctx.state = '' ctx.state = ''
ctx.reply('Сохранил') ctx.reply('Сохранил')
@text('отмена', state='await_address')
def cancel_address(self, ctx):
ctx.state = ''
ctx.reply('Отменил')
def fallback(self, ctx): def fallback(self, ctx):
ctx.reply('Не понял') ctx.reply('Не понял')
``` ```
Порядок разбора сообщения: команда, текст кнопки, состояние диалога, `fallback`. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`. Порядок разбора сообщения: команда, кнопка текущего состояния (`@text(..., state=...)`), кнопка, состояние диалога, `fallback`.
`ctx.delete_message()` удаляет входящее сообщение из чата, например с паролем. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
## Запуск ## Запуск
1. В админке добавить бота с токеном и выбрать интерфейс. 1. В админке добавить бота с токеном и выбрать интерфейс.
2. Действие "Опубликовать" проверяет токен, подтягивает имя бота и включает бота. "Отозвать" выключает. 2. Действие "Опубликовать" проверяет токен, подтягивает имя бота и включает бота. "Отозвать" выключает.
Если задан `TELEGRAM_API_URL`, при первой публикации бот отключается от облачного API (`logOut`). Вернуть его в облако можно не раньше чем через 10 минут.
3. `make tg_run` запускает раннер. Смена интерфейса, публикация и отзыв применяются без перезапуска. 3. `make tg_run` запускает раннер. Смена интерфейса, публикация и отзыв применяются без перезапуска.
`TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`). `TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`).
+3 -1
View File
@@ -26,7 +26,9 @@ class BotForm(forms.ModelForm):
class BotAdmin(admin.ModelAdmin): class BotAdmin(admin.ModelAdmin):
form = BotForm form = BotForm
list_display = ('__str__', 'name', 'interface_title', 'is_active', 'status') 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') actions = ('publish', 'revoke')
@admin.display(description='интерфейс') @admin.display(description='интерфейс')
+22 -2
View File
@@ -1,3 +1,4 @@
import requests
import telebot import telebot
from django.conf import settings from django.conf import settings
from telebot import apihelper from telebot import apihelper
@@ -5,11 +6,30 @@ from telebot import apihelper
def configure_api_server(): def configure_api_server():
"""Направляет pyTelegramBotAPI на свой инстанс Bot API сервера, если он задан в TELEGRAM_API_URL.""" """Направляет pyTelegramBotAPI на свой инстанс Bot API сервера, если он задан в TELEGRAM_API_URL."""
url = getattr(settings, 'TELEGRAM_API_URL', '').rstrip('/') if uses_own_server():
if url: url = settings.TELEGRAM_API_URL.rstrip('/')
apihelper.API_URL = url + '/bot{0}/{1}' apihelper.API_URL = url + '/bot{0}/{1}'
apihelper.FILE_URL = url + '/file/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: def make_api(token: str) -> telebot.TeleBot:
return telebot.TeleBot(token, threaded=False) return telebot.TeleBot(token, threaded=False)
+10 -4
View File
@@ -48,14 +48,20 @@ class Context:
self.chat.state = '' self.chat.state = ''
self.chat.data = {} self.chat.data = {}
def routes(self) -> list[tuple[str, str]]: def routes(self) -> list[interfaces.Route]:
routes = [] routes = []
if self.command: if self.command:
routes.append((interfaces.COMMAND, self.command)) routes.append((interfaces.COMMAND, self.command, ''))
routes.append((interfaces.TEXT, self.text))
if self.state: 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 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): 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) return messages.send(self.chat, text, keyboard=keyboard, parse_mode=parse_mode, api=self.api)
+18 -9
View File
@@ -17,7 +17,11 @@ command/text/state и регистрируют класс через register.
ctx.data['address'] = ctx.text ctx.data['address'] = ctx.text
ctx.state = '' ctx.state = ''
Порядок разбора сообщения: команда, текст кнопки, состояние диалога, fallback. @text('отмена', state='await_address')
def cancel_address(self, ctx):
ctx.state = ''
Порядок разбора сообщения: команда, кнопка текущего состояния, кнопка, состояние диалога, fallback.
""" """
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -31,31 +35,36 @@ TEXT = 'text'
STATE = 'state' STATE = 'state'
def _route(kind: str, values: tuple[str, ...]): Route = tuple[str, str, str]
"""(вид, значение, состояние чата). Пустое состояние - в любом состоянии."""
def _route(routes: list[Route]):
def decorator(func): 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 func
return decorator return decorator
def command(*names: str): def command(*names: str):
"""Обрабатывает команды /name. Имена без слеша, регистр не важен.""" """Обрабатывает команды /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): def text(*texts: str, state: str = ''):
"""Обрабатывает сообщение с точно таким текстом, например нажатие кнопки клавиатуры.""" """Обрабатывает сообщение с точно таким текстом, например нажатие кнопки клавиатуры.
return _route(TEXT, texts) С state - только пока чат в этом состоянии, такой обработчик важнее обработчика без состояния."""
return _route([(TEXT, value, state) for value in texts])
def state(*states: str): def state(*states: str):
"""Обрабатывает любое сообщение, пока чат находится в этом состоянии.""" """Обрабатывает любое сообщение, пока чат находится в этом состоянии."""
return _route(STATE, states) return _route([(STATE, value, '') for value in states])
class Interface: class Interface:
title = '' title = ''
_routes: dict[tuple[str, str], str] = {} _routes: dict[Route, str] = {}
def __init_subclass__(cls, **kwargs): def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**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
View File
@@ -1,7 +1,7 @@
from django.db import models from django.db import models
from . import interfaces from . import interfaces
from .api import make_api from .api import log_out_from_cloud, make_api, uses_own_server
class Bot(models.Model): class Bot(models.Model):
@@ -18,6 +18,10 @@ class Bot(models.Model):
help_text='Класс, определяющий поведение бота. Меняется на лету.', help_text='Класс, определяющий поведение бота. Меняется на лету.',
) )
is_active = models.BooleanField('опубликован', default=False, editable=False) 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) update_offset = models.BigIntegerField(default=0, editable=False)
heartbeat_at = models.DateTimeField(null=True, blank=True, editable=False) heartbeat_at = models.DateTimeField(null=True, blank=True, editable=False)
last_error = models.TextField(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 return cls() if cls else None
def publish(self): 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() api = self.api()
me = api.get_me() me = api.get_me()
api.delete_webhook() api.delete_webhook()
+65
View File
@@ -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
View File
@@ -1,21 +1,14 @@
from types import SimpleNamespace 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 telebot import types
from . import interfaces from . import interfaces
from .dispatcher import handle_update from .dispatcher import handle_update
from .interfaces import Interface, command, register, state, text from .interfaces import Interface, command, register, state, text
from .models import Bot, Chat, Message, User from .models import Bot, Chat, Message, User
from .testing import FakeApi, message_update
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))
@register @register
@@ -33,6 +26,15 @@ class EchoInterface(Interface):
ctx.state = 'await_name' ctx.state = 'await_name'
ctx.reply('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') @state('await_name')
def name(self, ctx): def name(self, ctx):
ctx.data['name'] = ctx.text ctx.data['name'] = ctx.text
@@ -50,19 +52,6 @@ class ChildInterface(EchoInterface):
ctx.reply('child') 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): def member_update(update_id, status):
return types.Update.de_json({ return types.Update.de_json({
'update_id': update_id, 'update_id': update_id,
@@ -83,7 +72,7 @@ class DispatcherTests(TestCase):
def send(self, text, update_id=1, **kwargs): def send(self, text, update_id=1, **kwargs):
handle_update(self.api, self.bot, message_update(update_id, text, **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): def test_command_text_state_fallback(self):
self.assertEqual(self.send('/start'), 'menu') self.assertEqual(self.send('/start'), 'menu')
@@ -92,13 +81,23 @@ class DispatcherTests(TestCase):
self.assertEqual(self.send('whatever'), 'echo whatever') self.assertEqual(self.send('whatever'), 'echo whatever')
self.assertEqual(Chat.objects.get().data, {'name': 'Bob'}) 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): def test_command_with_bot_name_and_args(self):
self.assertEqual(self.send('/START@my_bot payload'), 'menu') self.assertEqual(self.send('/START@my_bot payload'), 'menu')
def test_saves_user_chat_and_messages(self): def test_saves_user_chat_and_messages(self):
self.send('/start') self.send('/start')
user = User.objects.get() 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() chat = Chat.objects.get()
self.assertEqual((chat.chat_id, chat.user, chat.type), (42, user, 'private')) self.assertEqual((chat.chat_id, chat.user, chat.type), (42, user, 'private'))
self.assertEqual( self.assertEqual(
@@ -128,6 +127,33 @@ class DispatcherTests(TestCase):
self.assertEqual(Chat.objects.count(), 1) 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): class InterfaceRegistryTests(TestCase):
def test_registered_interfaces_are_choices(self): def test_registered_interfaces_are_choices(self):
self.assertIn((interfaces.key(EchoInterface), 'Echo'), interfaces.choices()) self.assertIn((interfaces.key(EchoInterface), 'Echo'), interfaces.choices())
+12 -1
View File
@@ -57,8 +57,19 @@ server.release(generate_password())
- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys. - `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `facts` - `uname -a` и версия ОС. - `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`, основной конфиг не трогаем. Настройки ssh пишутся в `/etc/ssh/sshd_config.d/00-serverus.conf`, основной конфиг не трогаем.
Плейбуки лежат в `playbooks/`, общие шаги в `playbooks/tasks/`. Новая операция - это плейбук и метод в `Server` или его наследнике. Плейбуки лежат в `playbooks/`, общие шаги в `playbooks/tasks/`. Новая операция - это плейбук и метод в `Server` или его наследнике.
Тесты: `python -m unittest serverus.tests` из `src/`. Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd.
+6 -2
View File
@@ -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 .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 .server import Server
from .xui import XuiPanel
__all__ = [ __all__ = [
'Host', 'Host',
'KeyPair', 'KeyPair',
'RedirectProxy',
'Result', 'Result',
'Server', 'Server',
'XuiPanel',
'generate_keypair', 'generate_keypair',
'generate_password', 'generate_password',
'generate_ssh_port', 'generate_ssh_port',
+3
View File
@@ -10,6 +10,9 @@ class Host:
user: str = 'root' user: str = 'root'
private_key: str | None = None private_key: str | None = None
"""Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю.""" """Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю."""
known_hosts: str = ''
"""Отпечаток сервера из прошлого Result.known_hosts. Пусто - доверяем ключу при первом подключении.
После переустановки ОС отпечаток меняется, его нужно сбросить."""
def __repr__(self): def __repr__(self):
# ключ не должен попадать в логи # ключ не должен попадать в логи
+13
View File
@@ -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}"
+33
View File
@@ -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 }}"
+12
View File
@@ -19,6 +19,18 @@
enabled: false enabled: false
state: stopped 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 - name: Remove sshd drop-in
ansible.builtin.file: ansible.builtin.file:
path: /etc/ssh/sshd_config.d/00-serverus.conf path: /etc/ssh/sshd_config.d/00-serverus.conf
+1 -1
View File
@@ -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 - name: Install nftables
ansible.builtin.apt: ansible.builtin.apt:
name: nftables name: nftables
@@ -19,7 +19,7 @@ table inet filter {
} }
chain forward { 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 { chain output {
+62
View File
@@ -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
+9
View File
@@ -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
View File
@@ -22,6 +22,8 @@ class Result:
data: dict = field(default_factory=dict) data: dict = field(default_factory=dict)
"""Данные, которые плейбук вернул через set_stats.""" """Данные, которые плейбук вернул через set_stats."""
error: str = '' error: str = ''
known_hosts: str = ''
"""Отпечаток сервера после запуска. Его нужно сохранить и передавать в Host.known_hosts."""
class Runner(Protocol): class Runner(Protocol):
@@ -45,21 +47,41 @@ def run_playbook(playbook: str, host: Host, extravars: dict) -> Result:
'PATH': PATH, 'PATH': PATH,
# свои ssh control сокеты на каждый запуск: иначе чужое открытое соединение пропустит без авторизации # свои ssh control сокеты на каждый запуск: иначе чужое открытое соединение пропустит без авторизации
'ANSIBLE_SSH_CONTROL_PATH_DIR': os.path.join(private_dir, 'cp'), 'ANSIBLE_SSH_CONTROL_PATH_DIR': os.path.join(private_dir, 'cp'),
# ansible-runner по умолчанию отключает проверку, режим задается в _ssh_args
'ANSIBLE_HOST_KEY_CHECKING': 'True',
}, },
quiet=True, quiet=True,
) )
events = list(result.events) events = list(result.events)
with result.stdout as stdout: with result.stdout as stdout:
output = stdout.read() output = stdout.read()
known_hosts = _known_hosts_file(private_dir).read_text()
ok = result.status == 'successful' ok = result.status == 'successful'
return Result( return Result(
ok=ok, ok=ok,
status=result.status, status=result.status,
data=_stats_data(events), data=_stats_data(events),
error='' if ok else _error(events) or output.strip()[-1000:], 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: def _host_vars(host: Host, private_dir: str) -> dict:
host_vars = { host_vars = {
'ansible_host': host.address, 'ansible_host': host.address,
@@ -68,8 +90,7 @@ def _host_vars(host: Host, private_dir: str) -> dict:
'ansible_python_interpreter': 'auto_silent', 'ansible_python_interpreter': 'auto_silent',
# вход по паролю без sshpass # вход по паролю без sshpass
'ansible_ssh_password_mechanism': 'ssh_askpass', 'ansible_ssh_password_mechanism': 'ssh_askpass',
# TODO: хранить known_hosts сервера и включить проверку ключа хоста 'ansible_ssh_common_args': _ssh_args(host, private_dir),
'ansible_ssh_common_args': '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR',
} }
if host.private_key: if host.private_key:
key_file = Path(private_dir) / 'id_host' key_file = Path(private_dir) / 'id_host'
+7 -4
View File
@@ -1,5 +1,6 @@
from .host import Host from .host import Host
from .runner import Result, Runner, run_playbook from . import runner as _runner
from .runner import Result, Runner
class Server: 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.host = host
self._runner = runner self._runner = runner
def run(self, playbook: str, **extravars) -> Result: 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: def facts(self) -> Result:
"""Возвращает data: {'uname': ..., 'distribution': ..., 'version': ...}.""" """Возвращает data: {'uname': ..., 'distribution': ..., 'version': ...}."""
@@ -35,5 +37,6 @@ class Server:
) )
def release(self, new_password: str) -> Result: 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) return self.run('release', new_password=new_password)
+17
View File
@@ -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,
)