From 6d322f36a96d142f423f0e6ec4f68475f3cb4804 Mon Sep 17 00:00:00 2001 From: protokey Date: Thu, 24 Sep 2026 17:33:10 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D1=81=D1=8B=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 7 +- docker-compose.yaml | 3 + env_example | 5 + requirements.txt | 1 + src/App/README.md | 29 +- src/App/admin.py | 42 ++- src/App/apps.py | 2 +- src/App/bot.py | 39 --- src/App/bot/__init__.py | 0 src/App/bot/interface.py | 308 ++++++++++++++++++ src/App/bot/texts.py | 163 +++++++++ src/App/migrations/0002_point_server.py | 58 ++++ src/App/models.py | 91 ++++++ src/App/services.py | 166 ++++++++++ src/App/tasks.py | 48 +++ src/App/tests_bot.py | 221 ++++++++++++- src/App/tests_services.py | 161 +++++++++ src/MyPointVPN/settings/README.md | 2 + src/MyPointVPN/settings/project.py | 24 ++ src/MyPointVPN/settings/test.py | 8 + src/Telegram/README.md | 10 +- src/Telegram/admin.py | 4 +- src/Telegram/api.py | 24 +- src/Telegram/context.py | 14 +- src/Telegram/interfaces.py | 27 +- .../migrations/0002_bot_cloud_logged_out.py | 18 + src/Telegram/models.py | 16 +- src/Telegram/testing.py | 65 ++++ src/Telegram/tests.py | 76 +++-- src/serverus/README.md | 13 +- src/serverus/__init__.py | 8 +- src/serverus/host.py | 3 + src/serverus/playbooks/files/redirect.sh | 13 + src/serverus/playbooks/proxy.yml | 33 ++ src/serverus/playbooks/release.yml | 12 + src/serverus/playbooks/tasks/firewall.yml | 2 +- .../playbooks/templates/nftables.conf.j2 | 2 +- src/serverus/playbooks/xui.yml | 62 ++++ src/serverus/proxy.py | 9 + src/serverus/runner.py | 25 +- src/serverus/server.py | 11 +- src/serverus/xui.py | 17 + 42 files changed, 1731 insertions(+), 111 deletions(-) delete mode 100644 src/App/bot.py create mode 100644 src/App/bot/__init__.py create mode 100644 src/App/bot/interface.py create mode 100644 src/App/bot/texts.py create mode 100644 src/App/migrations/0002_point_server.py create mode 100644 src/App/services.py create mode 100644 src/App/tasks.py create mode 100644 src/App/tests_services.py create mode 100644 src/MyPointVPN/settings/test.py create mode 100644 src/Telegram/migrations/0002_bot_cloud_logged_out.py create mode 100644 src/Telegram/testing.py create mode 100644 src/serverus/playbooks/files/redirect.sh create mode 100644 src/serverus/playbooks/proxy.yml create mode 100644 src/serverus/playbooks/xui.yml create mode 100644 src/serverus/proxy.py create mode 100644 src/serverus/xui.py diff --git a/Makefile b/Makefile index 5add3f7..555567c 100644 --- a/Makefile +++ b/Makefile @@ -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 [ ...] - установить пакет(ы) в $(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) diff --git a/docker-compose.yaml b/docker-compose.yaml index b624b00..a8216b1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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" diff --git a/env_example b/env_example index 8db6429..14cb084 100644 --- a/env_example +++ b/env_example @@ -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 diff --git a/requirements.txt b/requirements.txt index 479b501..f4a4246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/App/README.md b/src/App/README.md index 331a945..a919b10 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -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 сервера и повторяет установку. Отпечаток можно сбросить и в админке. diff --git a/src/App/admin.py b/src/App/admin.py index e0808c3..38c9f50 100644 --- a/src/App/admin.py +++ b/src/App/admin.py @@ -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)}') diff --git a/src/App/apps.py b/src/App/apps.py index 7124f18..f4fd5ab 100644 --- a/src/App/apps.py +++ b/src/App/apps.py @@ -5,4 +5,4 @@ class AppConfig(AppConfig): name = 'App' def ready(self): - from . import bot # noqa: F401 регистрирует интерфейс бота + from .bot import interface # noqa: F401 регистрирует интерфейс бота diff --git a/src/App/bot.py b/src/App/bot.py deleted file mode 100644 index f4374eb..0000000 --- a/src/App/bot.py +++ /dev/null @@ -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) diff --git a/src/App/bot/__init__.py b/src/App/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py new file mode 100644 index 0000000..c336102 --- /dev/null +++ b/src/App/bot/interface.py @@ -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) diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py new file mode 100644 index 0000000..8f790c5 --- /dev/null +++ b/src/App/bot/texts.py @@ -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 = '''Что это за бот? +Бот настраивает ваш собственный VPN на ваших серверах: панель 3x-ui с подключениями vless + tls + reality и сервер маскировки перед ней. + +Что нужно от меня? +Два арендованных сервера с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root каждого. По желанию - домен и прокси на выходе. + +Зачем два сервера? +Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. + +Что бот делает с сервером? +Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы. + +Как забрать сервер назад? +Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся. + +Сервер переустановили у провайдера +Меню сервера - "Переустановить" и новый пароль root.''' + +HELP_TEXT = '''Добавить сервер +1. "добавить сервер" +2. "сервер" - пришлите ip:пароль сервера для панели +3. "маскировка" - пришлите ip:пароль сервера маскировки +4. по желанию "домен", "cloudflare", "прокси" +5. "установить" - установка идет в фоне, по окончании придет уведомление + +Управлять сервером +"управление сервером" и имя сервера из главного меню. + +"главное меню" или /start всегда возвращают в начало.''' + +CREDENTIALS_PROMPT = 'отправь данные для подключения к серверу\nip:root_password' +CREDENTIALS_INVALID = 'Не похоже на ip:root_password. Попробуй еще раз.' +ADDRESS_TAKEN = 'Сервер {address} уже подключен.' +ADDRESS_DUPLICATE = 'Панель и маскировка должны быть разными серверами.' +DOMAIN_PROMPT = 'Пришли доменное имя. Доменное имя лучше подходит для маскировки вашей точки доступа.' +DOMAIN_INVALID = 'Не похоже на доменное имя. Попробуй еще раз.' +CLOUDFLARE_PROMPT = 'Если вы спрятали домен за клаудом, то нужно включить опцию тут.' +PROXY_PROMPT = 'Можно указать свои в формате protocol://username:password@host:port или включить cf_warp.' +PROXY_INVALID = 'Не похоже на protocol://username:password@host:port. Попробуй еще раз.' +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 = '''Будет произведена повторная установка всех компонентов. +Вызывайте эту функцию, только после того, как в панели управления +сервером в аккаунте хостинг провайдера вызовите переустановку сервера. +Если уверены, то пришлите ip:новый_пароль переустановленного сервера. +"Использовать старый" переустановит оба сервера с последними известными паролями.''' +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'{escape(point.name)}\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'Сервер: {escape(point.name)}', + 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)} - проименован как {escape(point.name)}, готов к работе.' + + +def install_failed(point: Point, error: Exception) -> str: + return f'Не удалось настроить {escape(point.name)}:\n{escape(str(error))}' + + +def released(name: str, passwords: dict[str, str], errors: dict[str, str]) -> str: + lines = [f'Серверы {escape(name)} возвращены.' if not errors else f'{escape(name)}:'] + lines += [f'{escape(address)} - пароль root {escape(password)}' for address, password in passwords.items()] + lines += [f'{escape(address)} - не удалось вернуть: {escape(error)}' for address, error in errors.items()] + return '\n'.join(lines) diff --git a/src/App/migrations/0002_point_server.py b/src/App/migrations/0002_point_server.py new file mode 100644 index 0000000..a18f0c1 --- /dev/null +++ b/src/App/migrations/0002_point_server.py @@ -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')], + }, + ), + ] diff --git a/src/App/models.py b/src/App/models.py index d2db44c..9346990 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -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']) diff --git a/src/App/services.py b/src/App/services.py new file mode 100644 index 0000000..85b46f2 --- /dev/null +++ b/src/App/services.py @@ -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 diff --git a/src/App/tasks.py b/src/App/tasks.py new file mode 100644 index 0000000..5b15e28 --- /dev/null +++ b/src/App/tasks.py @@ -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)) diff --git a/src/App/tests_bot.py b/src/App/tests_bot.py index 3171184..47e1863 100644 --- a/src/App/tests_bot.py +++ b/src/App/tests_bot.py @@ -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()) diff --git a/src/App/tests_services.py b/src/App/tests_services.py new file mode 100644 index 0000000..cf21bf6 --- /dev/null +++ b/src/App/tests_services.py @@ -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) diff --git a/src/MyPointVPN/settings/README.md b/src/MyPointVPN/settings/README.md index 024c9f0..12fefb6 100644 --- a/src/MyPointVPN/settings/README.md +++ b/src/MyPointVPN/settings/README.md @@ -10,3 +10,5 @@ from .project import * # Define your settings here ``` + +- `test.py` - настройки для тестов: фоновые задачи идут в stub брокер, а не в rabbitmq. `make dj_test` использует их. diff --git a/src/MyPointVPN/settings/project.py b/src/MyPointVPN/settings/project.py index ecaff75..bcd4cb9 100644 --- a/src/MyPointVPN/settings/project.py +++ b/src/MyPointVPN/settings/project.py @@ -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', + ], +} diff --git a/src/MyPointVPN/settings/test.py b/src/MyPointVPN/settings/test.py new file mode 100644 index 0000000..1185af3 --- /dev/null +++ b/src/MyPointVPN/settings/test.py @@ -0,0 +1,8 @@ +from .project import * + +# задачи не уходят в rabbitmq +DRAMATIQ_BROKER = { + **DRAMATIQ_BROKER, + 'BROKER': 'dramatiq.brokers.stub.StubBroker', + 'OPTIONS': {}, +} diff --git a/src/Telegram/README.md b/src/Telegram/README.md index 027bb5c..b4f0590 100644 --- a/src/Telegram/README.md +++ b/src/Telegram/README.md @@ -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`). diff --git a/src/Telegram/admin.py b/src/Telegram/admin.py index a094cca..c00095f 100644 --- a/src/Telegram/admin.py +++ b/src/Telegram/admin.py @@ -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='интерфейс') diff --git a/src/Telegram/api.py b/src/Telegram/api.py index 7ffdad7..461ce3a 100644 --- a/src/Telegram/api.py +++ b/src/Telegram/api.py @@ -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) diff --git a/src/Telegram/context.py b/src/Telegram/context.py index af67b8c..40582f9 100644 --- a/src/Telegram/context.py +++ b/src/Telegram/context.py @@ -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) diff --git a/src/Telegram/interfaces.py b/src/Telegram/interfaces.py index 09d17af..1505ea4 100644 --- a/src/Telegram/interfaces.py +++ b/src/Telegram/interfaces.py @@ -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) diff --git a/src/Telegram/migrations/0002_bot_cloud_logged_out.py b/src/Telegram/migrations/0002_bot_cloud_logged_out.py new file mode 100644 index 0000000..3462b09 --- /dev/null +++ b/src/Telegram/migrations/0002_bot_cloud_logged_out.py @@ -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'), + ), + ] diff --git a/src/Telegram/models.py b/src/Telegram/models.py index 322b3f2..a7ad6e5 100644 --- a/src/Telegram/models.py +++ b/src/Telegram/models.py @@ -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() diff --git a/src/Telegram/testing.py b/src/Telegram/testing.py new file mode 100644 index 0000000..ddd0233 --- /dev/null +++ b/src/Telegram/testing.py @@ -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 diff --git a/src/Telegram/tests.py b/src/Telegram/tests.py index 5a83393..2e70b58 100644 --- a/src/Telegram/tests.py +++ b/src/Telegram/tests.py @@ -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()) diff --git a/src/serverus/README.md b/src/serverus/README.md index c9054f3..b6bfcf9 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -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 `, который перенаправляет весь трафик, кроме 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. diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index 3a51e80..31567fd 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -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', diff --git a/src/serverus/host.py b/src/serverus/host.py index e734f60..6f5f96e 100644 --- a/src/serverus/host.py +++ b/src/serverus/host.py @@ -10,6 +10,9 @@ class Host: user: str = 'root' private_key: str | None = None """Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю.""" + known_hosts: str = '' + """Отпечаток сервера из прошлого Result.known_hosts. Пусто - доверяем ключу при первом подключении. + После переустановки ОС отпечаток меняется, его нужно сбросить.""" def __repr__(self): # ключ не должен попадать в логи diff --git a/src/serverus/playbooks/files/redirect.sh b/src/serverus/playbooks/files/redirect.sh new file mode 100644 index 0000000..efae030 --- /dev/null +++ b/src/serverus/playbooks/files/redirect.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Перенаправляет весь входящий трафик сервера на целевой ресурс. +# Использование: serverus-redirect +# SSH_PORT - порт ssh этого сервера, его перенаправлять нельзя, иначе доступ к серверу будет потерян. +# +# TODO: заглушка. Заменить на настоящий скрипт перенаправления через фаервол. +# Правила должны переживать перезапуск nftables: базовый конфиг /etc/nftables.conf начинается с flush ruleset. +set -eu + +TARGET_IP="${1:?usage: $0 }" +SSH_PORT="${SSH_PORT:?SSH_PORT is required}" + +echo "redirect stub: all traffic except tcp/${SSH_PORT} -> ${TARGET_IP}" diff --git a/src/serverus/playbooks/proxy.yml b/src/serverus/playbooks/proxy.yml new file mode 100644 index 0000000..829ae40 --- /dev/null +++ b/src/serverus/playbooks/proxy.yml @@ -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 }}" diff --git a/src/serverus/playbooks/release.yml b/src/serverus/playbooks/release.yml index 171f9f5..4e0df71 100644 --- a/src/serverus/playbooks/release.yml +++ b/src/serverus/playbooks/release.yml @@ -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 diff --git a/src/serverus/playbooks/tasks/firewall.yml b/src/serverus/playbooks/tasks/firewall.yml index 7b18019..5b83b8c 100644 --- a/src/serverus/playbooks/tasks/firewall.yml +++ b/src/serverus/playbooks/tasks/firewall.yml @@ -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 diff --git a/src/serverus/playbooks/templates/nftables.conf.j2 b/src/serverus/playbooks/templates/nftables.conf.j2 index a4bafc5..5c8d5aa 100644 --- a/src/serverus/playbooks/templates/nftables.conf.j2 +++ b/src/serverus/playbooks/templates/nftables.conf.j2 @@ -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 { diff --git a/src/serverus/playbooks/xui.yml b/src/serverus/playbooks/xui.yml new file mode 100644 index 0000000..155f1dc --- /dev/null +++ b/src/serverus/playbooks/xui.yml @@ -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 diff --git a/src/serverus/proxy.py b/src/serverus/proxy.py new file mode 100644 index 0000000..936a060 --- /dev/null +++ b/src/serverus/proxy.py @@ -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) diff --git a/src/serverus/runner.py b/src/serverus/runner.py index 643b0f9..9f776e2 100644 --- a/src/serverus/runner.py +++ b/src/serverus/runner.py @@ -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' diff --git a/src/serverus/server.py b/src/serverus/server.py index 3cd1362..208a193 100644 --- a/src/serverus/server.py +++ b/src/serverus/server.py @@ -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) diff --git a/src/serverus/xui.py b/src/serverus/xui.py new file mode 100644 index 0000000..7de7e03 --- /dev/null +++ b/src/serverus/xui.py @@ -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, + )