diff --git a/Makefile b/Makefile index 3c832ca..5add3f7 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,12 @@ PYTHON := $(VENV)/bin/python PIP := $(VENV)/bin/pip WHEELS_DIR := wheels REQUIREMENTS := requirements.txt +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 +.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp tg_run help: @echo "make install [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -52,19 +53,22 @@ clean: @: dj_makemigrations: - cd src/ && ./manage.py makemigrations + $(MANAGE) makemigrations dj_migrate: - cd src/ && ./manage.py migrate + $(MANAGE) migrate dj_superuser: - cd src/ && ./manage.py createsuperuser + $(MANAGE) createsuperuser dj_run: - cd src/ && ./manage.py runserver + $(MANAGE) runserver dj_test: - cd src/ && ./manage.py test + $(MANAGE) test dj_startapp: - cd src/ && ./manage.py startapp $(app) + $(MANAGE) startapp $(app) + +tg_run: + $(MANAGE) telegram_run diff --git a/env_example b/env_example index 5b610bb..8db6429 100644 --- a/env_example +++ b/env_example @@ -1,6 +1,8 @@ # Telegram Bot API server (https://my.telegram.org/apps) TG_APP_ID= TG_APP_HASH= +# Адрес своего Bot API сервера для Django. Пусто - api.telegram.org +TELEGRAM_API_URL=http://127.0.0.1:8081 # PostgreSQL DB_NAME= diff --git a/requirements.txt b/requirements.txt index b4b48c1..479b501 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,35 @@ aiohappyeyeballs==2.7.1 aiohttp==3.14.3 aiosignal==1.4.0 +ansible-core==2.21.4 +ansible-runner==2.4.3 asgiref==3.12.1 attrs==26.1.0 certifi==2026.7.22 +cffi==2.1.1 charset-normalizer==3.5.1 +cryptography==50.0.1 Django==6.1.1 dramatiq==2.2.1 frozenlist==1.8.0 idna==3.20 +Jinja2==3.1.6 +lockfile==0.12.2 +MarkupSafe==3.0.3 multidict==6.9.1 +packaging==26.3 +pexpect==4.9.0 pika==1.4.4 propcache==0.5.4 psycopg2-binary==2.9.13 +ptyprocess==0.7.0 +pycparser==3.0 pyTelegramBotAPI==4.37.0 +python-daemon==3.1.2 +PyYAML==6.0.3 redis==8.1.0 requests==2.34.2 +resolvelib==1.2.1 sqlparse==0.6.0 typing_extensions==4.16.0 urllib3==2.8.0 diff --git a/src/App/apps.py b/src/App/apps.py index 2126825..7124f18 100644 --- a/src/App/apps.py +++ b/src/App/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class AppConfig(AppConfig): name = 'App' + + def ready(self): + from . import bot # noqa: F401 регистрирует интерфейс бота diff --git a/src/App/bot.py b/src/App/bot.py new file mode 100644 index 0000000..f4374eb --- /dev/null +++ b/src/App/bot.py @@ -0,0 +1,39 @@ +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/tests_bot.py b/src/App/tests_bot.py new file mode 100644 index 0000000..3171184 --- /dev/null +++ b/src/App/tests_bot.py @@ -0,0 +1,18 @@ +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 .bot import MAIN_KEYBOARD, MyPointInterface + + +class MyPointInterfaceTests(TestCase): + 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]) diff --git a/src/MyPointVPN/settings/project.py b/src/MyPointVPN/settings/project.py index 9d9967f..ecaff75 100644 --- a/src/MyPointVPN/settings/project.py +++ b/src/MyPointVPN/settings/project.py @@ -1,3 +1,5 @@ +import os + from .base import * DEBUG = True @@ -13,3 +15,10 @@ MEDIA_ROOT = BASE_DIR.parent / 'extra/media' MEDIA_URL = '/media/' AUTH_USER_MODEL = 'App.User' + +INSTALLED_APPS += [ + 'Telegram.apps.TelegramConfig', +] + +# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org +TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') diff --git a/src/README.md b/src/README.md index 824a58f..41854b9 100644 --- a/src/README.md +++ b/src/README.md @@ -4,6 +4,7 @@ - `design` - фронт - `App` - главное приложение +- `Telegram` - Django приложение для чат-ботов - `MyPointVPN` - настройки django - `manage.py` - точка входа diff --git a/src/Telegram/README.md b/src/Telegram/README.md index 6719ce8..027bb5c 100644 --- a/src/Telegram/README.md +++ b/src/Telegram/README.md @@ -9,3 +9,55 @@ Смена интерфейса бота в админке переключает поведение бота в рантайме. В первой итерации нужно реализовать поддержку создания интерфейсов чат-ботов. Которые сообщениями отвечают на сообщения. И поддержку приватных сообщений только. Но мы подразумеваем что в будущем это приложение может быть расширено. + +## Как устроено + +Зависимости: Django и pyTelegramBotAPI. Приложение ничего не знает о других приложениях проекта: оно предоставляет базовый класс интерфейса, а приложения регистрируют свои интерфейсы сами. + +- `models.py` - `Bot`, `User`, `Chat` (у каждого чата состояние диалога `state` и данные `data`), `Message`. +- `interfaces.py` - базовый класс `Interface`, декораторы `command`, `text`, `state` и реестр `register`. +- `context.py` - `Context`, который получает обработчик: текст, команда, собеседник, состояние, `reply()`. +- `messages.py` - `send()` для отправки сообщения в чат вне обработчика, например уведомления из фоновой задачи. +- `dispatcher.py` - разбор обновления: сохраняет пользователя, чат, сообщение и передает его интерфейсу бота. +- `runner.py` - long polling: по потоку на каждый опубликованный бот, супервизор сверяется с базой каждые 5 секунд. + +## Интерфейс бота + +```python +from Telegram.interfaces import Interface, command, register, state, text + + +@register +class Shop(Interface): + title = 'Магазин' + + @command('start') + @text('главное меню') + def menu(self, ctx): + ctx.reset() + ctx.reply('Привет', keyboard=[['адрес']]) + + @text('адрес') + def ask_address(self, ctx): + ctx.state = 'await_address' + ctx.reply('Пришли адрес', keyboard=[['главное меню']]) + + @state('await_address') + def address(self, ctx): + ctx.data['address'] = ctx.text + ctx.state = '' + ctx.reply('Сохранил') + + def fallback(self, ctx): + ctx.reply('Не понял') +``` + +Порядок разбора сообщения: команда, текст кнопки, состояние диалога, `fallback`. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`. + +## Запуск + +1. В админке добавить бота с токеном и выбрать интерфейс. +2. Действие "Опубликовать" проверяет токен, подтягивает имя бота и включает бота. "Отозвать" выключает. +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 8c38f3f..a094cca 100644 --- a/src/Telegram/admin.py +++ b/src/Telegram/admin.py @@ -1,3 +1,87 @@ -from django.contrib import admin +from datetime import timedelta -# Register your models here. +from django import forms +from django.contrib import admin, messages +from django.utils import timezone + +from . import interfaces +from .models import Bot, Chat, Message, User +from .runner import HEARTBEAT_TIMEOUT + + +class BotForm(forms.ModelForm): + interface = forms.ChoiceField(required=False) + + class Meta: + model = Bot + fields = ['token', 'interface'] + widgets = {'token': forms.PasswordInput(render_value=True)} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['interface'].choices = [('', '---------')] + interfaces.choices() + + +@admin.register(Bot) +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') + actions = ('publish', 'revoke') + + @admin.display(description='интерфейс') + def interface_title(self, bot): + cls = interfaces.get(bot.interface) + return (cls.title or bot.interface) if cls else '-' + + @admin.display(description='состояние') + def status(self, bot): + if not bot.is_active: + return 'выключен' + if bot.last_error: + return f'ошибка: {bot.last_error}' + online = bot.heartbeat_at and timezone.now() - bot.heartbeat_at < timedelta(seconds=HEARTBEAT_TIMEOUT) + return 'работает' if online else 'ожидает раннер' + + @admin.action(description='Опубликовать') + def publish(self, request, queryset): + for bot in queryset: + try: + bot.publish() + except Exception as error: + self.message_user(request, f'{bot}: {error}', messages.ERROR) + else: + self.message_user(request, f'{bot} опубликован') + + @admin.action(description='Отозвать') + def revoke(self, request, queryset): + for bot in queryset: + bot.revoke() + self.message_user(request, f'Отозвано ботов: {len(queryset)}') + + +class ReadOnlyAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(User) +class UserAdmin(ReadOnlyAdmin): + list_display = ('tg_id', 'username', 'first_name', 'last_name', 'created_at') + search_fields = ('tg_id', 'username', 'first_name', 'last_name') + + +@admin.register(Chat) +class ChatAdmin(ReadOnlyAdmin): + list_display = ('__str__', 'bot', 'type', 'state', 'created_at', 'deleted_at') + list_filter = ('bot', 'type') + + +@admin.register(Message) +class MessageAdmin(ReadOnlyAdmin): + list_display = ('chat', 'direction', 'text', 'created_at') + list_filter = ('direction', 'chat__bot') + search_fields = ('text',) diff --git a/src/Telegram/api.py b/src/Telegram/api.py new file mode 100644 index 0000000..7ffdad7 --- /dev/null +++ b/src/Telegram/api.py @@ -0,0 +1,15 @@ +import telebot +from django.conf import settings +from telebot import apihelper + + +def configure_api_server(): + """Направляет pyTelegramBotAPI на свой инстанс Bot API сервера, если он задан в TELEGRAM_API_URL.""" + url = getattr(settings, 'TELEGRAM_API_URL', '').rstrip('/') + if url: + apihelper.API_URL = url + '/bot{0}/{1}' + apihelper.FILE_URL = url + '/file/bot{0}/{1}' + + +def make_api(token: str) -> telebot.TeleBot: + return telebot.TeleBot(token, threaded=False) diff --git a/src/Telegram/apps.py b/src/Telegram/apps.py index dfd03e6..0018490 100644 --- a/src/Telegram/apps.py +++ b/src/Telegram/apps.py @@ -2,4 +2,10 @@ from django.apps import AppConfig class TelegramConfig(AppConfig): - name = 'Telegram' + name = 'Telegram' + verbose_name = 'Telegram' + default_auto_field = 'django.db.models.BigAutoField' + + def ready(self): + from .api import configure_api_server + configure_api_server() diff --git a/src/Telegram/context.py b/src/Telegram/context.py new file mode 100644 index 0000000..af67b8c --- /dev/null +++ b/src/Telegram/context.py @@ -0,0 +1,61 @@ +from telebot import types + +from . import interfaces, messages +from .models import Bot, Chat, User + + +class Context: + """Всё, что нужно обработчику интерфейса: входящее сообщение, собеседник, состояние диалога и ответ.""" + + def __init__(self, api, bot: Bot, chat: Chat, user: User, message: types.Message): + self.api = api + self.bot = bot + self.chat = chat + self.user = user + self.message = message + + @property + def text(self) -> str: + return self.message.text or '' + + @property + def command(self) -> str | None: + """'start' для '/start arg' и '/start@my_bot'.""" + if not self.text.startswith('/'): + return None + return self.text.split()[0][1:].split('@')[0].lower() + + @property + def args(self) -> str: + """Текст после команды.""" + parts = self.text.split(maxsplit=1) + return parts[1] if self.command and len(parts) > 1 else '' + + @property + def state(self) -> str: + return self.chat.state + + @state.setter + def state(self, value: str): + self.chat.state = value or '' + + @property + def data(self) -> dict: + return self.chat.data + + def reset(self): + """Сбрасывает состояние и данные диалога.""" + self.chat.state = '' + self.chat.data = {} + + def routes(self) -> list[tuple[str, str]]: + routes = [] + if self.command: + routes.append((interfaces.COMMAND, self.command)) + routes.append((interfaces.TEXT, self.text)) + if self.state: + routes.append((interfaces.STATE, self.state)) + return routes + + 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/dispatcher.py b/src/Telegram/dispatcher.py new file mode 100644 index 0000000..b5e7c24 --- /dev/null +++ b/src/Telegram/dispatcher.py @@ -0,0 +1,66 @@ +import logging + +from django.utils import timezone +from telebot import types + +from .context import Context +from .models import Bot, Chat, Message, User + +logger = logging.getLogger(__name__) + +ALLOWED_UPDATES = ['message', 'my_chat_member'] +PRIVATE = 'private' +LEFT_STATUSES = ('left', 'kicked') + + +def handle_update(api, bot: Bot, update: types.Update): + if update.my_chat_member: + _handle_membership(bot, update.my_chat_member) + elif update.message: + _handle_message(api, bot, update.message) + + +def _handle_membership(bot: Bot, member: types.ChatMemberUpdated): + chat = _save_chat(bot, member.chat) + if member.new_chat_member.status in LEFT_STATUSES: + chat.deleted_at = timezone.now() + chat.save(update_fields=['deleted_at']) + + +def _handle_message(api, bot: Bot, message: types.Message): + # первая итерация: только личные сообщения + if message.chat.type != PRIVATE or not message.from_user: + return + user = _save_user(message.from_user) + chat = _save_chat(bot, message.chat, user) + Message.objects.create( + chat=chat, user=user, direction=Message.Direction.IN, message_id=message.message_id, text=message.text or '', + ) + + interface = bot.get_interface() + if interface is None: + logger.warning('%s has no interface, message ignored', bot) + return + interface.dispatch(Context(api, bot, chat, user, message)) + chat.save(update_fields=['state', 'data']) + + +def _save_user(tg_user: types.User) -> User: + user, _ = User.objects.update_or_create( + tg_id=tg_user.id, + defaults={ + 'username': tg_user.username or '', + 'first_name': tg_user.first_name or '', + 'last_name': tg_user.last_name or '', + 'language_code': tg_user.language_code or '', + }, + ) + return user + + +def _save_chat(bot: Bot, tg_chat: types.Chat, user: User | None = None) -> Chat: + defaults = {'type': tg_chat.type, 'title': tg_chat.title or '', 'deleted_at': None} + if user: + defaults['user'] = user + chat, _ = Chat.objects.update_or_create(bot=bot, chat_id=tg_chat.id, defaults=defaults) + return chat diff --git a/src/Telegram/interfaces.py b/src/Telegram/interfaces.py new file mode 100644 index 0000000..09d17af --- /dev/null +++ b/src/Telegram/interfaces.py @@ -0,0 +1,94 @@ +"""Интерфейсы ботов. + +Интерфейс - класс, определяющий поведение бота. Приложения наследуют Interface, размечают методы декораторами +command/text/state и регистрируют класс через register. В админке боту выбирается один из зарегистрированных интерфейсов. + + @register + class Shop(Interface): + title = 'Магазин' + + @command('start') + @text('главное меню') + def menu(self, ctx): + ctx.reply('Привет', keyboard=[['каталог']]) + + @state('await_address') + def address(self, ctx): + ctx.data['address'] = ctx.text + ctx.state = '' + +Порядок разбора сообщения: команда, текст кнопки, состояние диалога, fallback. +""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .context import Context + +_registry: dict[str, type['Interface']] = {} + +COMMAND = 'command' +TEXT = 'text' +STATE = 'state' + + +def _route(kind: str, values: tuple[str, ...]): + def decorator(func): + func.__dict__.setdefault('_telegram_routes', []).extend((kind, value) for value in values) + return func + return decorator + + +def command(*names: str): + """Обрабатывает команды /name. Имена без слеша, регистр не важен.""" + return _route(COMMAND, tuple(name.lstrip('/').lower() for name in names)) + + +def text(*texts: str): + """Обрабатывает сообщение с точно таким текстом, например нажатие кнопки клавиатуры.""" + return _route(TEXT, texts) + + +def state(*states: str): + """Обрабатывает любое сообщение, пока чат находится в этом состоянии.""" + return _route(STATE, states) + + +class Interface: + title = '' + _routes: dict[tuple[str, str], str] = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + routes = {} + for klass in reversed(cls.__mro__): + for name, attr in vars(klass).items(): + for route in getattr(attr, '_telegram_routes', ()): + routes[route] = name + cls._routes = routes + + def dispatch(self, ctx: 'Context'): + for route in ctx.routes(): + name = self._routes.get(route) + if name: + return getattr(self, name)(ctx) + return self.fallback(ctx) + + def fallback(self, ctx: 'Context'): + """Вызывается, если сообщение не подошло ни под один обработчик.""" + + +def key(cls: type[Interface]) -> str: + return f'{cls.__module__}.{cls.__qualname__}' + + +def register(cls: type[Interface]) -> type[Interface]: + _registry[key(cls)] = cls + return cls + + +def get(interface_key: str) -> type[Interface] | None: + return _registry.get(interface_key) + + +def choices() -> list[tuple[str, str]]: + return [(k, cls.title or k) for k, cls in sorted(_registry.items())] diff --git a/src/__init__.py b/src/Telegram/management/__init__.py similarity index 100% rename from src/__init__.py rename to src/Telegram/management/__init__.py diff --git a/src/Telegram/management/commands/__init__.py b/src/Telegram/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Telegram/management/commands/telegram_run.py b/src/Telegram/management/commands/telegram_run.py new file mode 100644 index 0000000..c82c723 --- /dev/null +++ b/src/Telegram/management/commands/telegram_run.py @@ -0,0 +1,15 @@ +from django.core.management.base import BaseCommand + +from Telegram.runner import Supervisor + + +class Command(BaseCommand): + help = 'Запускает long polling для всех опубликованных ботов' + + def handle(self, *args, **options): + supervisor = Supervisor() + self.stdout.write('Telegram runner started. Ctrl+C to stop.') + try: + supervisor.run() + except KeyboardInterrupt: + supervisor.stop() diff --git a/src/Telegram/messages.py b/src/Telegram/messages.py new file mode 100644 index 0000000..2c34d9a --- /dev/null +++ b/src/Telegram/messages.py @@ -0,0 +1,24 @@ +from telebot import types + +from .models import Chat, Message + +Keyboard = list[list[str]] + + +def build_keyboard(keyboard: Keyboard | None): + """None - не менять клавиатуру у пользователя, [] - убрать клавиатуру.""" + if keyboard is None: + return None + if not keyboard: + return types.ReplyKeyboardRemove() + markup = types.ReplyKeyboardMarkup(resize_keyboard=True) + for row in keyboard: + markup.row(*row) + return markup + + +def send(chat: Chat, text: str, keyboard: Keyboard | None = None, parse_mode: str | None = None, api=None) -> Message: + """Отправляет сообщение в чат от имени его бота и сохраняет его в историю.""" + api = api or chat.bot.api() + sent = api.send_message(chat.chat_id, text, reply_markup=build_keyboard(keyboard), parse_mode=parse_mode) + return Message.objects.create(chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=text) diff --git a/src/Telegram/migrations/0001_initial.py b/src/Telegram/migrations/0001_initial.py new file mode 100644 index 0000000..99bc84e --- /dev/null +++ b/src/Telegram/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# Generated by Django 6.1.1 on 2026-09-24 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Bot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=128, unique=True)), + ('username', models.CharField(blank=True, editable=False, max_length=64)), + ('name', models.CharField(blank=True, editable=False, max_length=128)), + ('interface', models.CharField(blank=True, help_text='Класс, определяющий поведение бота. Меняется на лету.', max_length=255)), + ('is_active', models.BooleanField(default=False, editable=False, verbose_name='опубликован')), + ('update_offset', models.BigIntegerField(default=0, editable=False)), + ('heartbeat_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('last_error', models.TextField(blank=True, editable=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'бот', + 'verbose_name_plural': 'боты', + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tg_id', models.BigIntegerField(unique=True)), + ('username', models.CharField(blank=True, max_length=64)), + ('first_name', models.CharField(blank=True, max_length=128)), + ('last_name', models.CharField(blank=True, max_length=128)), + ('language_code', models.CharField(blank=True, max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'пользователь telegram', + 'verbose_name_plural': 'пользователи telegram', + }, + ), + migrations.CreateModel( + name='Chat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chat_id', models.BigIntegerField()), + ('type', models.CharField(max_length=16)), + ('title', models.CharField(blank=True, max_length=255)), + ('state', models.CharField(blank=True, help_text='Состояние диалога, задается интерфейсом бота', max_length=128)), + ('data', models.JSONField(blank=True, default=dict, help_text='Данные диалога, задаются интерфейсом бота')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chats', to='Telegram.bot')), + ('user', models.ForeignKey(blank=True, help_text='Собеседник в личном чате', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chats', to='Telegram.user')), + ], + options={ + 'verbose_name': 'чат', + 'verbose_name_plural': 'чаты', + }, + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('direction', models.CharField(choices=[('in', 'входящее'), ('out', 'исходящее')], max_length=3)), + ('message_id', models.BigIntegerField(blank=True, null=True)), + ('text', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='Telegram.chat')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='Telegram.user')), + ], + options={ + 'verbose_name': 'сообщение', + 'verbose_name_plural': 'сообщения', + 'ordering': ['-created_at'], + }, + ), + migrations.AddConstraint( + model_name='chat', + constraint=models.UniqueConstraint(fields=('bot', 'chat_id'), name='telegram_chat_unique'), + ), + ] diff --git a/src/Telegram/models.py b/src/Telegram/models.py index 71dfc7e..322b3f2 100644 --- a/src/Telegram/models.py +++ b/src/Telegram/models.py @@ -1,31 +1,129 @@ from django.db import models +from . import interfaces +from .api import make_api + class Bot(models.Model): """ Пользователь заводит ботов указывая токен. Имя бота можно получить из API. Из админки нужен интерфейс публикации и отключения бота, а также отображение актуального состояния. """ - ... + + token = models.CharField(max_length=128, unique=True) + username = models.CharField(max_length=64, blank=True, editable=False) + name = models.CharField(max_length=128, blank=True, editable=False) + interface = models.CharField( + max_length=255, blank=True, + help_text='Класс, определяющий поведение бота. Меняется на лету.', + ) + is_active = models.BooleanField('опубликован', default=False, editable=False) + 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) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = 'бот' + verbose_name_plural = 'боты' + + def __str__(self): + return f'@{self.username}' if self.username else f'bot #{self.pk}' + + def api(self): + return make_api(self.token) + + def get_interface(self) -> interfaces.Interface | None: + cls = interfaces.get(self.interface) + return cls() if cls else None + + def publish(self): + """Проверяет токен, подтягивает имя бота и включает его обслуживание раннером.""" + api = self.api() + me = api.get_me() + api.delete_webhook() + self.username = me.username or '' + self.name = me.first_name or '' + self.is_active = True + self.last_error = '' + self.save() + + def revoke(self): + self.is_active = False + self.save(update_fields=['is_active']) class User(models.Model): """ Когда клиент из тг пишет боту личное сообщение, то пользователя нужно завести в базу. """ - ... + + tg_id = models.BigIntegerField(unique=True) + username = models.CharField(max_length=64, blank=True) + first_name = models.CharField(max_length=128, blank=True) + last_name = models.CharField(max_length=128, blank=True) + language_code = models.CharField(max_length=16, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'пользователь telegram' + verbose_name_plural = 'пользователи telegram' + + def __str__(self): + return f'@{self.username}' if self.username else str(self.tg_id) class Chat(models.Model): """ При добавлении бота в группу, нужно добавить запись об этом в базу. При удалении, нужно пометить запись как удаленную, но не удалять. + + Чат принадлежит боту: у одного пользователя с разными ботами разные чаты и разное состояние диалога. """ - ... + + bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name='chats') + chat_id = models.BigIntegerField() + type = models.CharField(max_length=16) + title = models.CharField(max_length=255, blank=True) + user = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, related_name='chats', + help_text='Собеседник в личном чате', + ) + state = models.CharField(max_length=128, blank=True, help_text='Состояние диалога, задается интерфейсом бота') + data = models.JSONField(default=dict, blank=True, help_text='Данные диалога, задаются интерфейсом бота') + created_at = models.DateTimeField(auto_now_add=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = 'чат' + verbose_name_plural = 'чаты' + constraints = [models.UniqueConstraint(fields=['bot', 'chat_id'], name='telegram_chat_unique')] + + def __str__(self): + return self.title or str(self.user or self.chat_id) class Message(models.Model): """ Исходящие и входящие сообщения между администратором и пользователем. """ - ... + + class Direction(models.TextChoices): + IN = 'in', 'входящее' + OUT = 'out', 'исходящее' + + chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='messages') + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='messages') + direction = models.CharField(max_length=3, choices=Direction.choices) + message_id = models.BigIntegerField(null=True, blank=True) + text = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = 'сообщение' + verbose_name_plural = 'сообщения' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.get_direction_display()}: {self.text[:50]}' diff --git a/src/Telegram/runner.py b/src/Telegram/runner.py new file mode 100644 index 0000000..9ab795c --- /dev/null +++ b/src/Telegram/runner.py @@ -0,0 +1,90 @@ +"""Long polling раннер: по потоку на каждый опубликованный бот. + +Супервизор периодически сверяется с базой, поэтому публикация, отзыв и смена интерфейса бота в админке +применяются без перезапуска. +""" +import logging +import threading + +from django.db import close_old_connections +from django.utils import timezone + +from .dispatcher import ALLOWED_UPDATES, handle_update +from .models import Bot + +logger = logging.getLogger(__name__) + +POLL_TIMEOUT = 25 +SYNC_INTERVAL = 5 +ERROR_DELAY = 5 +HEARTBEAT_TIMEOUT = POLL_TIMEOUT * 2 + + +class BotWorker(threading.Thread): + def __init__(self, bot: Bot): + super().__init__(name=f'telegram-{bot.pk}', daemon=True) + self.bot_id = bot.pk + self.token = bot.token + self._stop_event = threading.Event() + + def stop(self): + self._stop_event.set() + + def run(self): + api = Bot(token=self.token).api() + while not self._stop_event.is_set(): + try: + self._poll(api) + except Exception as error: + logger.exception('Bot %s polling failed', self.bot_id) + Bot.objects.filter(pk=self.bot_id).update(last_error=str(error)) + self._stop_event.wait(ERROR_DELAY) + finally: + close_old_connections() + + def _poll(self, api): + offset = Bot.objects.values_list('update_offset', flat=True).get(pk=self.bot_id) + updates = api.get_updates( + offset=offset, timeout=POLL_TIMEOUT, long_polling_timeout=POLL_TIMEOUT, allowed_updates=ALLOWED_UPDATES, + ) + for update in updates: + if self._stop_event.is_set(): + break + # бот перечитывается на каждое обновление, чтобы смена интерфейса применялась сразу + bot = Bot.objects.get(pk=self.bot_id) + try: + handle_update(api, bot, update) + except Exception: + logger.exception('Bot %s failed to handle update %s', self.bot_id, update.update_id) + Bot.objects.filter(pk=self.bot_id).update(update_offset=update.update_id + 1) + Bot.objects.filter(pk=self.bot_id).update(heartbeat_at=timezone.now(), last_error='') + + +class Supervisor: + def __init__(self): + self.workers: dict[int, BotWorker] = {} + self._stop_event = threading.Event() + + def stop(self): + self._stop_event.set() + + def run(self): + while not self._stop_event.is_set(): + self.sync() + close_old_connections() + self._stop_event.wait(SYNC_INTERVAL) + for worker in self.workers.values(): + worker.stop() + + def sync(self): + active = {bot.pk: bot for bot in Bot.objects.filter(is_active=True)} + for bot_id, worker in list(self.workers.items()): + bot = active.get(bot_id) + if bot is None or bot.token != worker.token or not worker.is_alive(): + worker.stop() + del self.workers[bot_id] + for bot_id, bot in active.items(): + if bot_id not in self.workers: + logger.info('Starting %s', bot) + self.workers[bot_id] = worker = BotWorker(bot) + worker.start() diff --git a/src/Telegram/tests.py b/src/Telegram/tests.py index 7ce503c..5a83393 100644 --- a/src/Telegram/tests.py +++ b/src/Telegram/tests.py @@ -1,3 +1,148 @@ -from django.test import TestCase +from types import SimpleNamespace -# Create your tests here. +from django.test import TestCase +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)) + + +@register +class EchoInterface(Interface): + title = 'Echo' + + @command('start') + @text('меню') + def menu(self, ctx): + ctx.reset() + ctx.reply('menu', keyboard=[['ask']]) + + @text('ask') + def ask(self, ctx): + ctx.state = 'await_name' + ctx.reply('name?') + + @state('await_name') + def name(self, ctx): + ctx.data['name'] = ctx.text + ctx.state = '' + ctx.reply(f'hi {ctx.text}') + + def fallback(self, ctx): + ctx.reply(f'echo {ctx.text}') + + +@register +class ChildInterface(EchoInterface): + @command('start') + def child_start(self, ctx): + 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, + 'my_chat_member': { + 'chat': {'id': -100, 'type': 'group', 'title': 'group'}, + 'from': {'id': 7, 'is_bot': False, 'first_name': 'Ann'}, + 'date': 0, + 'old_chat_member': {'user': {'id': 1, 'is_bot': True, 'first_name': 'bot'}, 'status': 'left'}, + 'new_chat_member': {'user': {'id': 1, 'is_bot': True, 'first_name': 'bot'}, 'status': status}, + }, + }) + + +class DispatcherTests(TestCase): + def setUp(self): + self.api = FakeApi() + self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(EchoInterface)) + + 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 + + def test_command_text_state_fallback(self): + self.assertEqual(self.send('/start'), 'menu') + self.assertEqual(self.send('ask'), 'name?') + self.assertEqual(self.send('Bob'), 'hi Bob') + self.assertEqual(self.send('whatever'), 'echo whatever') + self.assertEqual(Chat.objects.get().data, {'name': 'Bob'}) + + 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')) + chat = Chat.objects.get() + self.assertEqual((chat.chat_id, chat.user, chat.type), (42, user, 'private')) + self.assertEqual( + list(Message.objects.order_by('pk').values_list('direction', 'text')), + [('in', '/start'), ('out', 'menu')], + ) + + def test_ignores_group_messages(self): + self.assertIsNone(self.send('/start', chat_type='group')) + self.assertFalse(Message.objects.exists()) + + def test_interface_switch_applies_immediately(self): + self.bot.interface = interfaces.key(ChildInterface) + self.assertEqual(self.send('/start'), 'child') + self.assertEqual(self.send('меню'), 'menu') + + def test_no_interface(self): + self.bot.interface = '' + self.assertIsNone(self.send('/start')) + self.assertEqual(Message.objects.count(), 1) + + def test_membership_marks_chat_deleted(self): + handle_update(self.api, self.bot, member_update(1, 'member')) + self.assertIsNone(Chat.objects.get().deleted_at) + handle_update(self.api, self.bot, member_update(2, 'kicked')) + self.assertIsNotNone(Chat.objects.get().deleted_at) + self.assertEqual(Chat.objects.count(), 1) + + +class InterfaceRegistryTests(TestCase): + def test_registered_interfaces_are_choices(self): + self.assertIn((interfaces.key(EchoInterface), 'Echo'), interfaces.choices()) + + class Unregistered(Interface): + pass + + self.assertIsNone(interfaces.get(interfaces.key(Unregistered))) + + +class AdminTests(TestCase): + def test_bot_admin_pages(self): + from django.contrib.auth import get_user_model + admin = get_user_model().objects.create_superuser('root', 'secret-pass-123') + self.client.force_login(admin) + bot = Bot.objects.create(token='1:test') + for url in ('/admin/Telegram/bot/', '/admin/Telegram/bot/add/', f'/admin/Telegram/bot/{bot.pk}/change/'): + self.assertEqual(self.client.get(url).status_code, 200, url) diff --git a/src/Telegram/views.py b/src/Telegram/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/src/Telegram/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/src/serverus/README.md b/src/serverus/README.md index a57a99c..c9054f3 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -28,3 +28,37 @@ - 20Gb диска Делается под Debian 13. + +## Использование + +Зависимости: ansible-core, ansible-runner (и cryptography, которая приходит с ansible-core). Django не нужен. +На машине, откуда идет управление, должен быть `ssh`. `sshpass` не нужен: вход по паролю идет через `ssh_askpass`. + +```python +from serverus import Host, Server, generate_keypair, generate_password, generate_ssh_port + +keys = generate_keypair() +root_password = generate_password() +ssh_port = generate_ssh_port() + +# свежий сервер: вход по паролю на 22 порт +server = Server(Host('1.2.3.4', private_key=keys.private_key)) +result = server.bootstrap('пароль от провайдера', keys.public_key, root_password, ssh_port) + +# дальше только по ключу на новом порту +server = Server(Host('1.2.3.4', port=ssh_port, private_key=keys.private_key)) +server.facts().data # {'uname': ..., 'distribution': 'Debian', 'version': '13'} +server.release(generate_password()) +``` + +Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны. + +- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ. +- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys. +- `facts` - `uname -a` и версия ОС. + +Настройки ssh пишутся в `/etc/ssh/sshd_config.d/00-serverus.conf`, основной конфиг не трогаем. + +Плейбуки лежат в `playbooks/`, общие шаги в `playbooks/tasks/`. Новая операция - это плейбук и метод в `Server` или его наследнике. + +Тесты: `python -m unittest serverus.tests` из `src/`. diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index e69de29..3a51e80 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -0,0 +1,15 @@ +from .host import Host +from .runner import Result, run_playbook +from .credentials import KeyPair, generate_keypair, generate_password, generate_ssh_port +from .server import Server + +__all__ = [ + 'Host', + 'KeyPair', + 'Result', + 'Server', + 'generate_keypair', + 'generate_password', + 'generate_ssh_port', + 'run_playbook', +] diff --git a/src/serverus/credentials.py b/src/serverus/credentials.py new file mode 100644 index 0000000..0350cc3 --- /dev/null +++ b/src/serverus/credentials.py @@ -0,0 +1,41 @@ +import secrets +import string +from dataclasses import dataclass + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +PASSWORD_ALPHABET = string.ascii_letters + string.digits +SSH_PORT_RANGE = (20000, 60000) + + +@dataclass(frozen=True) +class KeyPair: + private_key: str + public_key: str + + def __repr__(self): + return f'KeyPair({self.public_key})' + + +def generate_keypair(comment: str = 'serverus') -> KeyPair: + key = Ed25519PrivateKey.generate() + private = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ).decode() + public = key.public_key().public_bytes( + serialization.Encoding.OpenSSH, + serialization.PublicFormat.OpenSSH, + ).decode() + return KeyPair(private_key=private, public_key=f'{public} {comment}') + + +def generate_password(length: int = 32) -> str: + return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length)) + + +def generate_ssh_port() -> int: + low, high = SSH_PORT_RANGE + return low + secrets.randbelow(high - low) diff --git a/src/serverus/host.py b/src/serverus/host.py new file mode 100644 index 0000000..e734f60 --- /dev/null +++ b/src/serverus/host.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Host: + """Адрес сервера и способ входа на него.""" + + address: str + port: int = 22 + user: str = 'root' + private_key: str | None = None + """Приватный ключ в формате OpenSSH. Без него вход возможен только по паролю.""" + + def __repr__(self): + # ключ не должен попадать в логи + return f'Host({self.user}@{self.address}:{self.port})' diff --git a/src/serverus/playbooks/bootstrap.yml b/src/serverus/playbooks/bootstrap.yml new file mode 100644 index 0000000..1340e4e --- /dev/null +++ b/src/serverus/playbooks/bootstrap.yml @@ -0,0 +1,72 @@ +# extravars: login_password, public_key, new_password, ssh_port +# +# Порт ssh меняется в два шага, чтобы не потерять сервер: +# сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается. + +- name: Prepare fresh server (password login) + hosts: target + gather_facts: true + vars: + ansible_password: "{{ login_password }}" + tasks: + - name: Require Debian 13 + ansible.builtin.assert: + that: + - ansible_facts.distribution == 'Debian' + - ansible_facts.distribution_major_version == '13' + fail_msg: "Supported only Debian 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}" + + - name: Create .ssh directory + ansible.builtin.file: + path: /root/.ssh + state: directory + mode: "0700" + + - name: Authorize key + ansible.builtin.lineinfile: + path: /root/.ssh/authorized_keys + line: "{{ public_key }}" + create: true + mode: "0600" + + - ansible.builtin.import_tasks: tasks/root_password.yml + + - name: Upgrade system + ansible.builtin.apt: + update_cache: true + upgrade: dist + + - ansible.builtin.import_tasks: tasks/firewall.yml + vars: + firewall_tcp_ports: [ "{{ ansible_port }}", "{{ ssh_port }}" ] + + - ansible.builtin.import_tasks: tasks/sshd.yml + vars: + sshd_ports: [ "{{ ansible_port }}", "{{ ssh_port }}" ] + sshd_keys_only: false + +- name: Harden ssh (key login on new port) + hosts: target + gather_facts: false + vars: + ansible_port: "{{ ssh_port }}" + tasks: + - name: Check key login on new port + ansible.builtin.wait_for_connection: + timeout: 60 + + - ansible.builtin.import_tasks: tasks/firewall.yml + vars: + firewall_tcp_ports: [ "{{ ssh_port }}" ] + + - ansible.builtin.import_tasks: tasks/sshd.yml + vars: + sshd_ports: [ "{{ ssh_port }}" ] + sshd_keys_only: true + + - name: Reset ssh connection + ansible.builtin.meta: reset_connection + + - name: Check hardened ssh + ansible.builtin.wait_for_connection: + timeout: 60 diff --git a/src/serverus/playbooks/facts.yml b/src/serverus/playbooks/facts.yml new file mode 100644 index 0000000..d7948d9 --- /dev/null +++ b/src/serverus/playbooks/facts.yml @@ -0,0 +1,16 @@ +- name: Collect server facts + hosts: target + gather_facts: true + tasks: + - name: Read uname + ansible.builtin.command: uname -a + register: uname + changed_when: false + + - name: Return facts + ansible.builtin.set_stats: + aggregate: false + data: + uname: "{{ uname.stdout }}" + distribution: "{{ ansible_facts.distribution }}" + version: "{{ ansible_facts.distribution_major_version }}" diff --git a/src/serverus/playbooks/release.yml b/src/serverus/playbooks/release.yml new file mode 100644 index 0000000..171f9f5 --- /dev/null +++ b/src/serverus/playbooks/release.yml @@ -0,0 +1,39 @@ +# extravars: new_password +- name: Release server to owner + hosts: target + gather_facts: false + tasks: + - ansible.builtin.import_tasks: tasks/root_password.yml + + - name: Restore default nftables config + ansible.builtin.copy: + dest: /etc/nftables.conf + mode: "0755" + content: | + #!/usr/sbin/nft -f + flush ruleset + + - name: Flush and disable nftables + ansible.builtin.systemd_service: + name: nftables + enabled: false + state: stopped + + - name: Remove sshd drop-in + ansible.builtin.file: + path: /etc/ssh/sshd_config.d/00-serverus.conf + state: absent + + - name: Validate sshd config + ansible.builtin.command: /usr/sbin/sshd -t + changed_when: false + + - name: Remove authorized keys + ansible.builtin.file: + path: /root/.ssh/authorized_keys + state: absent + + - name: Restart ssh + ansible.builtin.systemd_service: + name: ssh + state: restarted diff --git a/src/serverus/playbooks/tasks/firewall.yml b/src/serverus/playbooks/tasks/firewall.yml new file mode 100644 index 0000000..7b18019 --- /dev/null +++ b/src/serverus/playbooks/tasks/firewall.yml @@ -0,0 +1,18 @@ +# vars: firewall_tcp_ports, firewall_udp_ports (optional) +- name: Install nftables + ansible.builtin.apt: + name: nftables + state: present + +- name: Write nftables config + ansible.builtin.template: + src: templates/nftables.conf.j2 + dest: /etc/nftables.conf + mode: "0755" + validate: /usr/sbin/nft -c -f %s + +- name: Apply nftables + ansible.builtin.systemd_service: + name: nftables + enabled: true + state: restarted diff --git a/src/serverus/playbooks/tasks/root_password.yml b/src/serverus/playbooks/tasks/root_password.yml new file mode 100644 index 0000000..2b8f83e --- /dev/null +++ b/src/serverus/playbooks/tasks/root_password.yml @@ -0,0 +1,6 @@ +# vars: new_password +- name: Set root password + ansible.builtin.shell: chpasswd + args: + stdin: "root:{{ new_password }}" + no_log: true diff --git a/src/serverus/playbooks/tasks/sshd.yml b/src/serverus/playbooks/tasks/sshd.yml new file mode 100644 index 0000000..713cc7d --- /dev/null +++ b/src/serverus/playbooks/tasks/sshd.yml @@ -0,0 +1,16 @@ +# vars: sshd_ports, sshd_keys_only +- name: Write sshd drop-in + ansible.builtin.template: + src: templates/sshd.conf.j2 + dest: /etc/ssh/sshd_config.d/00-serverus.conf + mode: "0644" + validate: /usr/sbin/sshd -t -f %s + +- name: Validate full sshd config + ansible.builtin.command: /usr/sbin/sshd -t + changed_when: false + +- name: Restart ssh + ansible.builtin.systemd_service: + name: ssh + state: restarted diff --git a/src/serverus/playbooks/templates/nftables.conf.j2 b/src/serverus/playbooks/templates/nftables.conf.j2 new file mode 100644 index 0000000..a4bafc5 --- /dev/null +++ b/src/serverus/playbooks/templates/nftables.conf.j2 @@ -0,0 +1,28 @@ +#!/usr/sbin/nft -f +# {{ ansible_managed }} + +flush ruleset + +table inet filter { + chain input { + type filter hook input priority filter; policy drop; + + iif "lo" accept + ct state established,related accept + ct state invalid drop + meta l4proto { icmp, ipv6-icmp } accept + + tcp dport { {{ firewall_tcp_ports | join(', ') }} } accept +{% if firewall_udp_ports | default([]) %} + udp dport { {{ firewall_udp_ports | join(', ') }} } accept +{% endif %} + } + + chain forward { + type filter hook forward priority filter; policy drop; + } + + chain output { + type filter hook output priority filter; policy accept; + } +} diff --git a/src/serverus/playbooks/templates/sshd.conf.j2 b/src/serverus/playbooks/templates/sshd.conf.j2 new file mode 100644 index 0000000..434eb92 --- /dev/null +++ b/src/serverus/playbooks/templates/sshd.conf.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +# Удаление этого файла возвращает настройки ssh, которые были до serverus. +{% for port in sshd_ports %} +Port {{ port }} +{% endfor %} +{% if sshd_keys_only %} +PermitRootLogin prohibit-password +PasswordAuthentication no +KbdInteractiveAuthentication no +AuthenticationMethods publickey +{% endif %} diff --git a/src/serverus/runner.py b/src/serverus/runner.py new file mode 100644 index 0000000..643b0f9 --- /dev/null +++ b/src/serverus/runner.py @@ -0,0 +1,95 @@ +import os +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + +import ansible_runner + +from .host import Host + +PLAYBOOKS_DIR = Path(__file__).resolve().parent / 'playbooks' +TARGET = 'target' +# ansible-playbook ставится рядом с интерпретатором, но venv может быть не активирован +PATH = os.pathsep.join([str(Path(sys.executable).parent), os.environ.get('PATH', '')]) + + +@dataclass(frozen=True) +class Result: + ok: bool + status: str + data: dict = field(default_factory=dict) + """Данные, которые плейбук вернул через set_stats.""" + error: str = '' + + +class Runner(Protocol): + def __call__(self, playbook: str, host: Host, extravars: dict) -> Result: ... + + +def run_playbook(playbook: str, host: Host, extravars: dict) -> Result: + """Выполняет плейбук из serverus/playbooks на одном хосте. + + Все файлы запуска (ключ, extravars, артефакты) живут во временной папке и удаляются после выполнения. + """ + with tempfile.TemporaryDirectory(prefix='serverus-') as private_dir: + result = ansible_runner.run( + private_data_dir=private_dir, + project_dir=str(PLAYBOOKS_DIR), + playbook=f'{playbook}.yml', + inventory={'all': {'hosts': {TARGET: _host_vars(host, private_dir)}}}, + extravars=extravars, + envvars={ + 'ANSIBLE_NOCOLOR': '1', + 'PATH': PATH, + # свои ssh control сокеты на каждый запуск: иначе чужое открытое соединение пропустит без авторизации + 'ANSIBLE_SSH_CONTROL_PATH_DIR': os.path.join(private_dir, 'cp'), + }, + quiet=True, + ) + events = list(result.events) + with result.stdout as stdout: + output = stdout.read() + 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:], + ) + + +def _host_vars(host: Host, private_dir: str) -> dict: + host_vars = { + 'ansible_host': host.address, + 'ansible_port': host.port, + 'ansible_user': host.user, + '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', + } + if host.private_key: + key_file = Path(private_dir) / 'id_host' + key_file.write_text(host.private_key.rstrip('\n') + '\n') + os.chmod(key_file, 0o600) + host_vars['ansible_ssh_private_key_file'] = str(key_file) + return host_vars + + +def _stats_data(events: list[dict]) -> dict: + for event in reversed(events): + if event.get('event') == 'playbook_on_stats': + return event.get('event_data', {}).get('artifact_data', {}) or {} + return {} + + +def _error(events: list[dict]) -> str: + for event in reversed(events): + if event.get('event') in ('runner_on_failed', 'runner_on_unreachable'): + event_data = event.get('event_data', {}) + res = event_data.get('res', {}) + return f"{event_data.get('task', '')}: {res.get('msg') or res.get('stderr') or 'failed'}" + return '' diff --git a/src/serverus/server.py b/src/serverus/server.py new file mode 100644 index 0000000..3cd1362 --- /dev/null +++ b/src/serverus/server.py @@ -0,0 +1,39 @@ +from .host import Host +from .runner import Result, Runner, run_playbook + + +class Server: + """Фасад над плейбуками базовой настройки сервера. + + Специализации (панель, прокси, докер-платформа) наследуют этот класс и добавляют свои операции. + """ + + def __init__(self, host: Host, runner: Runner = run_playbook): + self.host = host + self._runner = runner + + def run(self, playbook: str, **extravars) -> Result: + return self._runner(playbook, self.host, extravars) + + def facts(self) -> Result: + """Возвращает data: {'uname': ..., 'distribution': ..., 'version': ...}.""" + return self.run('facts') + + def bootstrap(self, password: str, public_key: str, new_password: str, ssh_port: int) -> Result: + """Первичная настройка свежего сервера (Debian 13). + + Входит по паролю на host.port, прописывает public_key, меняет пароль root на new_password, обновляет систему, + переносит ssh на ssh_port с входом только по ключу и включает nftables. + Для проверки входа по ключу host.private_key должен быть парой к public_key. + """ + return self.run( + 'bootstrap', + login_password=password, + public_key=public_key, + new_password=new_password, + ssh_port=ssh_port, + ) + + def release(self, new_password: str) -> Result: + """Возвращает сервер владельцу: откатывает ssh и nftables, удаляет ключи, ставит пароль root new_password.""" + return self.run('release', new_password=new_password) diff --git a/src/serverus/tests.py b/src/serverus/tests.py new file mode 100644 index 0000000..7ae1248 --- /dev/null +++ b/src/serverus/tests.py @@ -0,0 +1,65 @@ +import unittest + +from . import Host, Result, Server, generate_keypair, generate_password, generate_ssh_port, run_playbook +from .credentials import SSH_PORT_RANGE + + +class FakeRunner: + def __init__(self): + self.calls = [] + + def __call__(self, playbook, host, extravars): + self.calls.append((playbook, host, extravars)) + return Result(ok=True, status='successful') + + +class CredentialsTests(unittest.TestCase): + def test_keypair(self): + pair = generate_keypair('test') + self.assertTrue(pair.private_key.startswith('-----BEGIN OPENSSH PRIVATE KEY-----')) + self.assertTrue(pair.public_key.startswith('ssh-ed25519 ')) + self.assertTrue(pair.public_key.endswith(' test')) + self.assertNotIn('PRIVATE', repr(pair)) + + def test_password(self): + self.assertEqual(len(generate_password()), 32) + self.assertNotEqual(generate_password(), generate_password()) + + def test_ssh_port(self): + low, high = SSH_PORT_RANGE + self.assertTrue(low <= generate_ssh_port() < high) + + def test_host_repr_hides_key(self): + self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret'))) + + +class ServerTests(unittest.TestCase): + def test_operations_call_playbooks(self): + runner = FakeRunner() + server = Server(Host('1.2.3.4'), runner=runner) + server.facts() + server.bootstrap('old', 'ssh-ed25519 AAA', 'new', 40000) + server.release('other') + self.assertEqual([call[0] for call in runner.calls], ['facts', 'bootstrap', 'release']) + self.assertEqual(runner.calls[1][2], { + 'login_password': 'old', + 'public_key': 'ssh-ed25519 AAA', + 'new_password': 'new', + 'ssh_port': 40000, + }) + + +class RunPlaybookTests(unittest.TestCase): + def test_facts_on_localhost(self): + result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'}) + self.assertTrue(result.ok, result.error) + self.assertIn('Linux', result.data['uname']) + + def test_unreachable_host_reports_error(self): + result = run_playbook('facts', Host('127.0.0.1', port=1, private_key='bad'), {}) + self.assertFalse(result.ok) + self.assertTrue(result.error) + + +if __name__ == '__main__': + unittest.main()