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
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)
+3
View File
@@ -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"
+5
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
from django.utils.translation import gettext_lazy as _
from .models import 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
View File
@@ -5,4 +5,4 @@ class AppConfig(AppConfig):
name = 'App'
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.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'])
+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 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())
+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
```
- `test.py` - настройки для тестов: фоновые задачи идут в stub брокер, а не в rabbitmq. `make dj_test` использует их.
+24
View File
@@ -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',
],
}
+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.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`).
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+18 -9
View File
@@ -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
View File
@@ -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()
+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 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
View File
@@ -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.
+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 .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',
+3
View File
@@ -10,6 +10,9 @@ class Host:
user: str = 'root'
private_key: str | None = None
"""Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю."""
known_hosts: str = ''
"""Отпечаток сервера из прошлого Result.known_hosts. Пусто - доверяем ключу при первом подключении.
После переустановки ОС отпечаток меняется, его нужно сбросить."""
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
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 -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
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 {
+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)
"""Данные, которые плейбук вернул через 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'
+7 -4
View File
@@ -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)
+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,
)