diff --git a/app/config.py b/app/config.py index 4d2f184a..fe233c4a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import logging import os import re @@ -276,6 +278,9 @@ class Settings(BaseSettings): BACKUP_SEND_CHAT_ID: Optional[str] = None BACKUP_SEND_TOPIC_ID: Optional[int] = None + EXTERNAL_ADMIN_TOKEN: Optional[str] = None + EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None + @field_validator('SERVER_STATUS_MODE', mode='before') @classmethod def normalize_server_status_mode(cls, value: Optional[str]) -> str: @@ -556,6 +561,37 @@ class Settings(BaseSettings): def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL + + def build_external_admin_token(self, bot_username: str) -> str: + """Генерирует детерминированный и криптографически стойкий токен внешней админки.""" + normalized = (bot_username or "").strip().lstrip("@").lower() + if not normalized: + raise ValueError("Bot username is required to build external admin token") + + secret = (self.BOT_TOKEN or "").strip() + if not secret: + raise ValueError("Bot token is required to build external admin token") + + digest = hmac.new( + key=secret.encode("utf-8"), + msg=f"remnawave.external_admin::{normalized}".encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + return digest[:48] + + def get_external_admin_token(self) -> Optional[str]: + token = (self.EXTERNAL_ADMIN_TOKEN or "").strip() + return token or None + + def get_external_admin_bot_id(self) -> Optional[int]: + try: + return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None + except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений + logging.getLogger(__name__).warning( + "Некорректный идентификатор бота для внешней админки: %s", + self.EXTERNAL_ADMIN_TOKEN_BOT_ID, + ) + return None def is_traffic_selectable(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "selectable" diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index ad0bea38..9b1e2cc1 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -18,7 +18,10 @@ from app.config import settings from app.services.remnawave_service import RemnaWaveService from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -from app.services.system_settings_service import bot_configuration_service +from app.services.system_settings_service import ( + ReadOnlySettingError, + bot_configuration_service, +) from app.states import BotConfigStates from app.utils.decorators import admin_required, error_handler from app.utils.currency_converter import currency_converter @@ -107,6 +110,12 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "icon": "⚡", "categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"), }, + "external_admin": { + "title": "🛡️ Внешняя админка", + "description": "Токен, по которому внешняя админка проверяет запросы.", + "icon": "🛡️", + "categories": ("EXTERNAL_ADMIN",), + }, } CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( @@ -123,6 +132,7 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( "server", "maintenance", "advanced", + "external_admin", ) CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple( @@ -687,6 +697,12 @@ async def apply_preset( try: await bot_configuration_service.set_value(db, setting_key, value) applied.append(setting_key) + except ReadOnlySettingError: + logging.getLogger(__name__).info( + "Пропускаем настройку %s из пресета %s: только для чтения", + setting_key, + preset_key, + ) except Exception as error: logging.getLogger(__name__).warning( "Не удалось применить пресет %s для %s: %s", @@ -854,8 +870,14 @@ async def handle_import_message( errors.append(f"{setting_key}: {error}") continue - await bot_configuration_service.set_value(db, setting_key, value_to_apply) - applied.append(setting_key) + if bot_configuration_service.is_read_only(setting_key): + skipped.append(setting_key) + continue + try: + await bot_configuration_service.set_value(db, setting_key, value_to_apply) + applied.append(setting_key) + except ReadOnlySettingError: + skipped.append(setting_key) await db.commit() @@ -1361,9 +1383,10 @@ def _build_setting_keyboard( definition = bot_configuration_service.get_definition(key) rows: list[list[types.InlineKeyboardButton]] = [] callback_token = bot_configuration_service.get_callback_token(key) + is_read_only = bot_configuration_service.is_read_only(key) choice_options = bot_configuration_service.get_choice_options(key) - if choice_options: + if choice_options and not is_read_only: current_value = bot_configuration_service.get_current_value(key) choice_buttons: list[types.InlineKeyboardButton] = [] for option in choice_options: @@ -1385,7 +1408,7 @@ def _build_setting_keyboard( for chunk in _chunk(choice_buttons, 2): rows.append(list(chunk)) - if definition.python_type is bool: + if definition.python_type is bool and not is_read_only: rows.append([ types.InlineKeyboardButton( text="🔁 Переключить", @@ -1395,16 +1418,17 @@ def _build_setting_keyboard( ) ]) - rows.append([ - types.InlineKeyboardButton( - text="✏️ Изменить", - callback_data=( - f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + if not is_read_only: + rows.append([ + types.InlineKeyboardButton( + text="✏️ Изменить", + callback_data=( + f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ) + ]) - if bot_configuration_service.has_override(key): + if bot_configuration_service.has_override(key) and not is_read_only: rows.append([ types.InlineKeyboardButton( text="♻️ Сбросить", @@ -1414,6 +1438,14 @@ def _build_setting_keyboard( ) ]) + if is_read_only: + rows.append([ + types.InlineKeyboardButton( + text="🔒 Только для чтения", + callback_data="botcfg_group:noop", + ) + ]) + rows.append([ types.InlineKeyboardButton( text="⬅️ Назад", @@ -1438,6 +1470,11 @@ def _render_setting_text(key: str) -> str: f"📌 Текущее: {summary['current']}", f"📦 По умолчанию: {summary['original']}", f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}", + *( + ["🔒 Режим: Только для чтения (управляется автоматически)"] + if summary.get("is_read_only") + else [] + ), "", f"📘 Описание: {guidance['description']}", f"📐 Формат: {guidance['format']}", @@ -2066,6 +2103,9 @@ async def start_edit_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return + if bot_configuration_service.is_read_only(key): + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return definition = bot_configuration_service.get_definition(key) summary = bot_configuration_service.get_setting_summary(key) @@ -2131,13 +2171,23 @@ async def handle_edit_setting( await state.clear() return + if bot_configuration_service.is_read_only(key): + await message.answer("⚠️ Эта настройка доступна только для чтения.") + await state.clear() + return + try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - await bot_configuration_service.set_value(db, key, value) + try: + await bot_configuration_service.set_value(db, key, value) + except ReadOnlySettingError: + await message.answer("⚠️ Эта настройка доступна только для чтения.") + await state.clear() + return await db.commit() text = _render_setting_text(key) @@ -2172,13 +2222,23 @@ async def handle_direct_setting_input( if not key: return + if bot_configuration_service.is_read_only(key): + await message.answer("⚠️ Эта настройка доступна только для чтения.") + await state.clear() + return + try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - await bot_configuration_service.set_value(db, key, value) + try: + await bot_configuration_service.set_value(db, key, value) + except ReadOnlySettingError: + await message.answer("⚠️ Эта настройка доступна только для чтения.") + await state.clear() + return await db.commit() text = _render_setting_text(key) @@ -2220,7 +2280,14 @@ async def reset_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - await bot_configuration_service.reset_value(db, key) + if bot_configuration_service.is_read_only(key): + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return + try: + await bot_configuration_service.reset_value(db, key) + except ReadOnlySettingError: + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return await db.commit() text = _render_setting_text(key) @@ -2260,9 +2327,16 @@ async def toggle_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return + if bot_configuration_service.is_read_only(key): + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return current = bot_configuration_service.get_current_value(key) new_value = not bool(current) - await bot_configuration_service.set_value(db, key, new_value) + try: + await bot_configuration_service.set_value(db, key, new_value) + except ReadOnlySettingError: + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return await db.commit() text = _render_setting_text(key) @@ -2304,6 +2378,9 @@ async def apply_setting_choice( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return + if bot_configuration_service.is_read_only(key): + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return try: value = bot_configuration_service.resolve_choice_token(key, choice_token) @@ -2311,7 +2388,11 @@ async def apply_setting_choice( await callback.answer("Это значение больше недоступно", show_alert=True) return - await bot_configuration_service.set_value(db, key, value) + try: + await bot_configuration_service.set_value(db, key, value) + except ReadOnlySettingError: + await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + return await db.commit() text = _render_setting_text(key) diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py new file mode 100644 index 00000000..c70ba0a1 --- /dev/null +++ b/app/services/external_admin_service.py @@ -0,0 +1,160 @@ +"""Утилиты для синхронизации токена внешней админки.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.config import settings +from app.database.database import AsyncSessionLocal +from app.database.models import SystemSetting +from app.services.system_settings_service import ( + ReadOnlySettingError, + bot_configuration_service, +) + + +logger = logging.getLogger(__name__) + + +async def ensure_external_admin_token( + bot_username: Optional[str], + bot_id: Optional[int], +) -> Optional[str]: + """Генерирует и сохраняет токен внешней админки, если требуется.""" + + username_raw = (bot_username or "").strip() + if not username_raw: + logger.warning( + "⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует", + ) + return None + + normalized_username = username_raw.lstrip("@").lower() + if not normalized_username: + logger.warning( + "⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации", + ) + return None + + try: + token = settings.build_external_admin_token(normalized_username) + except Exception as error: # pragma: no cover - защитный блок + logger.error("❌ Ошибка генерации токена внешней админки: %s", error) + return None + + try: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SystemSetting.key, SystemSetting.value).where( + SystemSetting.key.in_( + ["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"] + ) + ) + ) + rows = dict(result.all()) + existing_token = rows.get("EXTERNAL_ADMIN_TOKEN") + existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID") + + existing_bot_id: Optional[int] = None + if existing_bot_id_raw is not None: + try: + existing_bot_id = int(existing_bot_id_raw) + except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений + logger.warning( + "⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s", + existing_bot_id_raw, + ) + + if existing_token == token and existing_bot_id == bot_id: + if settings.get_external_admin_token() != token: + settings.EXTERNAL_ADMIN_TOKEN = token + if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id: + settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id + return token + + if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id: + logger.error( + "❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s", + existing_bot_id, + bot_id, + ) + + try: + await bot_configuration_service.reset_value( + session, + "EXTERNAL_ADMIN_TOKEN", + force=True, + ) + await bot_configuration_service.reset_value( + session, + "EXTERNAL_ADMIN_TOKEN_BOT_ID", + force=True, + ) + await session.commit() + logger.warning( + "⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота", + ) + except Exception as cleanup_error: # pragma: no cover - защитный блок + await session.rollback() + logger.error( + "❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s", + cleanup_error, + ) + finally: + settings.EXTERNAL_ADMIN_TOKEN = None + settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None + + return None + + updates: list[tuple[str, object]] = [] + if existing_token != token: + updates.append(("EXTERNAL_ADMIN_TOKEN", token)) + + if bot_id is not None and existing_bot_id != bot_id: + updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id)) + + if not updates: + # Токен совпал, но могли отсутствовать значения в настройках приложения + if settings.get_external_admin_token() != (existing_token or token): + settings.EXTERNAL_ADMIN_TOKEN = existing_token or token + if existing_bot_id is not None and ( + settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id + ): + settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id + elif ( + bot_id is not None + and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id + and existing_bot_id is None + ): + settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id + return existing_token or token + + try: + for key, value in updates: + await bot_configuration_service.set_value( + session, + key, + value, + force=True, + ) + await session.commit() + logger.info( + "✅ Токен внешней админки синхронизирован для @%s", + normalized_username, + ) + except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение + await session.rollback() + logger.warning( + "⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа", + ) + return None + + return token + except SQLAlchemyError as error: + logger.error("❌ Ошибка сохранения токена внешней админки: %s", error) + return None + diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index e8529e0d..cca1771f 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -56,9 +56,16 @@ class ChoiceOption: description: Optional[str] = None +class ReadOnlySettingError(RuntimeError): + """Исключение, выбрасываемое при попытке изменить настройку только для чтения.""" + + class BotConfigurationService: EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"} + READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} + PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} + CATEGORY_TITLES: Dict[str, str] = { "CORE": "🤖 Основные настройки", "SUPPORT": "💬 Поддержка и тикеты", @@ -71,6 +78,7 @@ class BotConfigurationService: "TRIBUTE": "🎁 Tribute", "MULENPAY": "💰 MulenPay", "PAL24": "🏦 PAL24 / PayPalych", + "EXTERNAL_ADMIN": "🛡️ Внешняя админка", "SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты", "PERIODS": "📆 Периоды подписок", "SUBSCRIPTION_PRICES": "💵 Стоимость тарифов", @@ -118,6 +126,7 @@ class BotConfigurationService: "PAL24": "PAL24 / PayPalych подключения и лимиты.", "TRIBUTE": "Tribute и донат-сервисы.", "TELEGRAM": "Telegram Stars и их стоимость.", + "EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.", "SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.", "PERIODS": "Доступные периоды подписок и продлений.", "SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.", @@ -255,6 +264,7 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", + "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "CONNECT_BUTTON_HAPP": "HAPP", "HAPP_": "HAPP", "SKIP_": "SKIP", @@ -394,6 +404,20 @@ class BotConfigurationService: "warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.", "dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD", }, + "EXTERNAL_ADMIN_TOKEN": { + "description": "Приватный токен, который использует внешняя админка для проверки запросов.", + "format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.", + "example": "Генерируется автоматически", + "warning": "Токен обновится при смене username или токена бота.", + "dependencies": "Username телеграм-бота, токен бота", + }, + "EXTERNAL_ADMIN_TOKEN_BOT_ID": { + "description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.", + "format": "Проставляется автоматически после первого запуска и не редактируется вручную.", + "example": "123456789", + "warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.", + "dependencies": "Результат вызова getMe() в Telegram Bot API", + }, } @classmethod @@ -405,6 +429,10 @@ class BotConfigurationService: definition = cls.get_definition(key) return definition.python_type is bool + @classmethod + def is_read_only(cls, key: str) -> bool: + return key in cls.READ_ONLY_KEYS + @classmethod def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: if isinstance(value, bool): @@ -455,6 +483,8 @@ class BotConfigurationService: cleaned = value.strip() if not cleaned: return "—" + if key in cls.PLAIN_TEXT_KEYS: + return cleaned if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")): return "••••••••" items = cls._split_comma_values(cleaned) @@ -860,7 +890,17 @@ class BotConfigurationService: return parsed_value @classmethod - async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None: + async def set_value( + cls, + db: AsyncSession, + key: str, + value: Any, + *, + force: bool = False, + ) -> None: + if cls.is_read_only(key) and not force: + raise ReadOnlySettingError(f"Setting {key} is read-only") + raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) cls._overrides_raw[key] = raw_value @@ -870,7 +910,16 @@ class BotConfigurationService: await cls._sync_default_web_api_token() @classmethod - async def reset_value(cls, db: AsyncSession, key: str) -> None: + async def reset_value( + cls, + db: AsyncSession, + key: str, + *, + force: bool = False, + ) -> None: + if cls.is_read_only(key) and not force: + raise ReadOnlySettingError(f"Setting {key} is read-only") + await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) original = cls.get_original_value(key) @@ -925,6 +974,7 @@ class BotConfigurationService: "category_key": definition.category_key, "category_label": definition.category_label, "has_override": has_override, + "is_read_only": cls.is_read_only(key), } diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 92869266..46965da4 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -5,7 +5,10 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.services.system_settings_service import bot_configuration_service +from app.services.system_settings_service import ( + ReadOnlySettingError, + bot_configuration_service, +) from ..dependencies import get_db_session, require_api_token from ..schemas.config import ( @@ -94,6 +97,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe current=current, original=original, has_override=has_override, + read_only=bot_configuration_service.is_read_only(definition.key), choices=choices, ) @@ -153,7 +157,10 @@ async def update_setting( raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error value = _coerce_value(key, payload.value) - await bot_configuration_service.set_value(db, key, value) + try: + await bot_configuration_service.set_value(db, key, value) + except ReadOnlySettingError as error: + raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() return _serialize_definition(definition) @@ -170,6 +177,9 @@ async def reset_setting( except KeyError as error: raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error - await bot_configuration_service.reset_value(db, key) + try: + await bot_configuration_service.reset_value(db, key) + except ReadOnlySettingError as error: + raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() return _serialize_definition(definition) diff --git a/app/webapi/schemas/config.py b/app/webapi/schemas/config.py index 39aedbad..7497444d 100644 --- a/app/webapi/schemas/config.py +++ b/app/webapi/schemas/config.py @@ -45,6 +45,7 @@ class SettingDefinition(BaseModel): current: Any | None = Field(default=None) original: Any | None = Field(default=None) has_override: bool + read_only: bool = Field(default=False) choices: list[SettingChoice] = Field(default_factory=list) model_config = ConfigDict(extra="forbid") diff --git a/main.py b/main.py index 51d6bfa2..7c0d92c4 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service +from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service from app.utils.startup_timeline import StartupTimeline @@ -186,6 +187,25 @@ async def main(): payment_service = PaymentService(bot) + async with timeline.stage( + "Внешняя админка", + "🛡️", + success_message="Токен внешней админки готов", + ) as stage: + try: + bot_user = await bot.get_me() + token = await ensure_external_admin_token( + bot_user.username, + bot_user.id, + ) + if token: + stage.log("Токен синхронизирован") + else: + stage.warning("Не удалось получить токен внешней админки") + except Exception as error: # pragma: no cover - защитный блок + stage.warning(f"Ошибка подготовки внешней админки: {error}") + logger.error("❌ Ошибка подготовки внешней админки: %s", error) + webhook_needed = ( settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()