From ab492f3aefde4f7f6993d3878aa368f8d5ecd9ce Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 16 Jan 2026 12:18:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + app/config.py | 23 +++++- app/middlewares/display_name_restriction.py | 21 +++++- app/services/traffic_monitoring_service.py | 83 +++++++++++++++++---- 4 files changed, 112 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index c5071757..e013568e 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,9 @@ SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для увед TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все) TRAFFIC_IGNORED_NODES= # Исключить эти ноды +# Исключить пользователей (UUID через запятую) +TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи + # Производительность TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API diff --git a/app/config.py b/app/config.py index 2489c969..c87757f5 100644 --- a/app/config.py +++ b/app/config.py @@ -256,6 +256,7 @@ class Settings(BaseSettings): # Фильтрация по серверам (UUID нод через запятую) TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все) TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды + TRAFFIC_EXCLUDED_USER_UUIDS: str = "" # Исключить пользователей (UUID через запятую) # Параллельность и кулдаун TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей @@ -854,13 +855,31 @@ class Settings(BaseSettings): """Возвращает список UUID нод для мониторинга (пусто = все)""" if not self.TRAFFIC_MONITORED_NODES: return [] - return [n.strip() for n in self.TRAFFIC_MONITORED_NODES.split(",") if n.strip()] + # Убираем комментарии (все после #) + value = self.TRAFFIC_MONITORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] def get_traffic_ignored_nodes(self) -> List[str]: """Возвращает список UUID нод для исключения из мониторинга""" if not self.TRAFFIC_IGNORED_NODES: return [] - return [n.strip() for n in self.TRAFFIC_IGNORED_NODES.split(",") if n.strip()] + # Убираем комментарии (все после #) + value = self.TRAFFIC_IGNORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] + + def get_traffic_excluded_user_uuids(self) -> List[str]: + """Возвращает список UUID пользователей для исключения из мониторинга (например, тунельные/служебные)""" + if not self.TRAFFIC_EXCLUDED_USER_UUIDS: + return [] + # Убираем комментарии (все после #) + value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split("#")[0].strip() + if not value: + return [] + return [uuid.strip().lower() for uuid in value.split(",") if uuid.strip()] def get_traffic_daily_check_time(self) -> Optional[time]: """Возвращает время суточной проверки трафика""" diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 0060737e..7cebaae9 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -137,13 +137,28 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): if any(pattern.search(lower_value) for pattern in LINK_PATTERNS): return True - if DOMAIN_OBFUSCATION_PATTERN.search(lower_value): - return True + # Проверяем обфусцированные ссылки типа "t . m e" или "т м е" + # Но НЕ блокируем если это часть обычного слова/имени + domain_match = DOMAIN_OBFUSCATION_PATTERN.search(lower_value) + if domain_match: + # Проверяем контекст: если "tme" внутри слова (с буквами с обеих сторон) - пропускаем + start_pos = domain_match.start() + end_pos = domain_match.end() + + # Проверяем символ ДО и ПОСЛЕ совпадения + has_letter_before = start_pos > 0 and lower_value[start_pos - 1].isalpha() + has_letter_after = end_pos < len(lower_value) and lower_value[end_pos].isalpha() + + # Если с ОБЕИХ сторон буквы - скорее всего это просто имя/фамилия + if not (has_letter_before and has_letter_after): + return True normalized = self._normalize_text(lower_value) collapsed = COLLAPSE_PATTERN.sub("", normalized) - if "tme" in collapsed: + # Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах) + # Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг + if re.search(r"(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)", collapsed, re.IGNORECASE): return True banned_keywords = settings.get_display_name_banned_keywords() diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index 9b583128..d2b5ff25 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -97,6 +97,9 @@ class TrafficMonitoringServiceV2: def get_ignored_nodes(self) -> List[str]: return settings.get_traffic_ignored_nodes() + def get_excluded_user_uuids(self) -> List[str]: + return settings.get_traffic_excluded_user_uuids() + def get_daily_check_time(self) -> Optional[time]: return settings.get_traffic_daily_check_time() @@ -133,7 +136,8 @@ class TrafficMonitoringServiceV2: """Загружает snapshot трафика из Redis""" try: snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY) - if snapshot_data and isinstance(snapshot_data, dict): + # ВАЖНО: пустой словарь {} - это валидный snapshot! + if snapshot_data is not None and isinstance(snapshot_data, dict): # Конвертируем обратно в float result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()} logger.debug(f"📦 Snapshot загружен из Redis: {len(result)} пользователей") @@ -176,6 +180,23 @@ class TrafficMonitoringServiceV2: logger.error(f"❌ Ошибка получения времени уведомления: {e}") return None + # ============== Работа с нодами ============== + + async def _load_nodes_cache(self): + """Загружает названия нод в кеш""" + try: + nodes = await self.remnawave_service.get_all_nodes() + self._nodes_cache = {node['uuid']: node['name'] for node in nodes if node.get('uuid') and node.get('name')} + logger.debug(f"📋 Загружено {len(self._nodes_cache)} нод в кеш") + except Exception as e: + logger.error(f"❌ Ошибка загрузки нод в кеш: {e}") + + def get_node_name(self, node_uuid: Optional[str]) -> Optional[str]: + """Возвращает название ноды по UUID из кеша""" + if not node_uuid: + return None + return self._nodes_cache.get(node_uuid) + # ============== Фильтрация по нодам ============== def should_monitor_node(self, node_uuid: Optional[str]) -> bool: @@ -274,13 +295,13 @@ class TrafficMonitoringServiceV2: async def has_snapshot(self) -> bool: """Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)""" - # Проверяем Redis + # Проверяем Redis (пустой словарь {} - это тоже валидный snapshot!) snapshot = await self._load_snapshot_from_redis() - if snapshot: + if snapshot is not None: return True # Fallback на память - return bool(self._memory_snapshot) and self._memory_snapshot_time is not None + return self._memory_snapshot_time is not None async def get_snapshot_age_minutes(self) -> float: """Возвращает возраст snapshot в минутах (Redis + fallback на память)""" @@ -328,9 +349,9 @@ class TrafficMonitoringServiceV2: Если в Redis уже есть snapshot — использует его (персистентность). Возвращает количество пользователей в snapshot. """ - # Проверяем есть ли snapshot в Redis + # Проверяем есть ли snapshot в Redis (пустой {} тоже валидный snapshot!) existing_snapshot = await self._load_snapshot_from_redis() - if existing_snapshot: + if existing_snapshot is not None: age = await self.get_snapshot_age_minutes() logger.info( f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " @@ -382,6 +403,24 @@ class TrafficMonitoringServiceV2: start_time = datetime.utcnow() is_first_run = not await self.has_snapshot() + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + + # Логируем фильтры + monitored_nodes = self.get_monitored_nodes() + ignored_nodes = self.get_ignored_nodes() + excluded_user_uuids = self.get_excluded_user_uuids() + + if monitored_nodes: + logger.info(f"🔍 Мониторим только ноды: {monitored_nodes}") + elif ignored_nodes: + logger.info(f"🚫 Игнорируем ноды: {ignored_nodes}") + else: + logger.info(f"📊 Мониторим все ноды") + + if excluded_user_uuids: + logger.info(f"🚫 Исключены пользователи: {excluded_user_uuids}") + if is_first_run: logger.info("🚀 Первый запуск быстрой проверки — создаём snapshot...") else: @@ -396,7 +435,7 @@ class TrafficMonitoringServiceV2: # Загружаем предыдущий snapshot (из Redis или памяти) previous_snapshot = await self._get_current_snapshot() - logger.debug(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей") + logger.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})") checked_users = 0 users_with_delta = 0 @@ -420,6 +459,7 @@ class TrafficMonitoringServiceV2: # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) if user.uuid not in previous_snapshot: + logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем") continue # Получаем предыдущее значение @@ -437,15 +477,22 @@ class TrafficMonitoringServiceV2: if delta_bytes < threshold_bytes: continue - logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ)") + logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)") + + # Проверяем исключённых пользователей (служебные/тунельные) + if user.uuid.lower() in excluded_user_uuids: + logger.info(f"⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)") + continue # Проверяем фильтр по нодам last_node_uuid = user_traffic.last_connected_node_uuid if not self.should_monitor_node(last_node_uuid): + logger.warning(f"⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or 'неизвестна'} не в списке мониторинга") continue # Создаём violation delta_gb = round(delta_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) violation = TrafficViolation( user_uuid=user.uuid, telegram_id=user.telegram_id, @@ -454,7 +501,7 @@ class TrafficMonitoringServiceV2: used_traffic_gb=delta_gb, # Это дельта, не общий трафик! threshold_gb=self.get_fast_check_threshold_gb(), last_node_uuid=last_node_uuid, - last_node_name=None, + last_node_name=node_name, check_type="fast" ) violations.append(violation) @@ -464,6 +511,7 @@ class TrafficMonitoringServiceV2: # Обновляем snapshot (в Redis с fallback на память) await self._save_snapshot(new_snapshot) + logger.info(f"💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей") elapsed = (datetime.utcnow() - start_time).total_seconds() @@ -495,6 +543,9 @@ class TrafficMonitoringServiceV2: logger.info("🚀 Запуск суточной проверки трафика...") start_time = datetime.utcnow() + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + violations: List[TrafficViolation] = [] threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3) @@ -537,6 +588,7 @@ class TrafficMonitoringServiceV2: return None used_gb = round(total_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) return TrafficViolation( user_uuid=user.uuid, telegram_id=user.telegram_id, @@ -545,7 +597,7 @@ class TrafficMonitoringServiceV2: used_traffic_gb=used_gb, threshold_gb=self.get_daily_threshold_gb(), last_node_uuid=last_node_uuid, - last_node_name=None, + last_node_name=node_name, check_type="daily" ) @@ -594,7 +646,7 @@ class TrafficMonitoringServiceV2: for i, violation in enumerate(violations): try: if not await self.should_send_notification(violation.user_uuid): - logger.debug(f"⏭️ Кулдаун для {violation.user_uuid}, пропускаем") + logger.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)") continue # Получаем информацию о пользователе из БД @@ -632,8 +684,13 @@ class TrafficMonitoringServiceV2: f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n" ) - if violation.last_node_uuid: - message += f"\n🖥 Последняя нода: {violation.last_node_uuid}" + # Показываем название ноды и UUID + if violation.last_node_name: + message += f"\n🖥 Сервер: {violation.last_node_name}" + if violation.last_node_uuid: + message += f"\n {violation.last_node_uuid}" + elif violation.last_node_uuid: + message += f"\n🖥 Сервер: {violation.last_node_uuid}" message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC"