From 4968144693fca9a0f1c391c1eeeb8f9b66a1b3d7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 13:39:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/proxy=5Frotation):=20=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20IP=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20mobileproxy.space=20(aproxy.site)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пул за всю историю не сделал ни одной ротации: rotate_proxy() жёстко привязан к хосту api.asocks.com, а три купленных порта mobileproxy несут ссылки на aproxy.site и отвергались до HTTP-вызова. При этом замер показал, что ротация — единственное, что снимает выгорание адреса: один IP держит 18-20 карточек Авито (четверти 100/44/0/0), со сменой каждые 15 обвал исчезает совсем (91/64/73/83). Диспетчеризация по хосту rotate_url. Путь ASocks не тронут (POST + Bearer, лимит 3/сутки). Для aproxy.site — GET с format=json без заголовков авторизации: секрет лежит в самом URL, отправлять туда токен ASocks нельзя. Успех признаётся по JSON status==OK, а не по HTTP-коду. new_ip пишется в scrape_proxies.exit_ip, rt отдаётся как RotationResult.reconnect_delay_s — следующей задаче он нужен, чтобы выждать переподключение канала: замер показал, что сразу после смены IP две карточки уходят в таймаут. Отдельный предохранитель MOBILEPROXY_DAILY_ROTATION_LIMIT=200/сутки: кулдауна у провайдера не обнаружено (13 ротаций подряд дали 13 уникальных адресов), потолок нужен только против runaway-цикла. --- .../backend/app/services/proxy_rotation.py | 364 ++++++++++++++---- .../tests/services/test_proxy_rotation.py | 208 ++++++++++ 2 files changed, 506 insertions(+), 66 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py index c17641d8..97f123b1 100644 --- a/tradein-mvp/backend/app/services/proxy_rotation.py +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -1,8 +1,13 @@ -"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5). +"""Ротация exit-IP прокси по требованию, со счётчиком и громким отказом (#2600 п.5). АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный PR #2609, конфликт исключён: вся новая логика тут, в новом модуле). +Два провайдера, диспетчеризуются по хосту scrape_proxies.rotate_url (см. +"⛔ Хост-пиннинг" ниже) — секрет каждого провайдера уходит ТОЛЬКО на его хост. + +── ASocks (api.asocks.com) ──────────────────────────────────────────────────── + Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой): - Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов НЕ работает. @@ -19,12 +24,34 @@ PR #2609, конфликт исключён: вся новая логика ту Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже (logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка. +── mobileproxy.space (aproxy.site) ───────────────────────────────────────────── + +Протокол ротации (эмпирика, проверено вживую на проде): + - GET &format=json — БЕЗ заголовков авторизации, секрет (proxy_key) + уже внутри URL в query-string. Без format=json провайдер отдаёт HTML-страницу + вместо JSON. + - Успех: {"status": "OK", "code": 200, "new_ip": "...", "rt": "2.41", "proxy_id": N}. + Признак успеха — ИМЕННО status == "OK" в теле, не HTTP-код ответа. + "rt" — секунды на переподключение канала (2.4-11.7 в замерах), отдаём + вызывающему как RotationResult.reconnect_delay_s — следующая задача + (интеграция в скраперы) должна выждать это время перед повторным запросом. + - Кулдауна у провайдера не обнаружено (13 ротаций подряд — 13 уникальных IP, + две подряд прошли за 5 и 8 секунд). MOBILEPROXY_DAILY_ROTATION_LIMIT (200) — + это ТОЛЬКО наш предохранитель от runaway-цикла, не лимит провайдера. + - Домен changeip.mobileproxy.space из прод-сети не резолвится/не отвечает — + поддерживаем только aproxy.site. + - proxy_key — секрет ровно как ASOCKS_API_TOKEN: не должен появиться ни в + reason, ни в исключениях, ни в scrape_proxy_rotations, ни в логах (см. + "⛔ Токен никогда не должен появиться..." ниже — тот же принцип). + Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198): - Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера - и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование: - 401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на - уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не - мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые + Против лимита (3/сутки ASocks, 200/сутки mobileproxy) считаются ТОЛЬКО попытки, + реально дошедшие до провайдера и обработанные им — т.е. любой HTTP-ответ + провайдера, КРОМЕ 401. Обоснование: 401 — это буквально описание провайдера + "Unauthenticated": запрос отсеян на уровне аутентификации ДО обращения к самой + логике ротации порта, провайдер не мог засчитать использование ротации тому, + кого даже не подтвердил (у mobileproxy авторизации нет вовсе, поэтому 401 для + него не ожидается, но правило проверки общее для обоих провайдеров). Сетевые ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не @@ -35,27 +62,27 @@ PR #2609, конфликт исключён: вся новая логика ту если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до реальной rotate-логики и мог быть учтён в лимите на его стороне). -⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте -исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через -str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь -admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали, -поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True. +⛔ Токен/ключ провайдера никогда не должен появиться в возвращаемом клиенту +reason, в тексте исключения, ни в одной записи scrape_proxy_rotations. Прецедент +утечки через str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) +changeip-путь admin.rotate_proxy_ip: httpx-исключения несут полный request +URL/детали, поэтому наружу — только нейтральный reason, полные детали — в лог с +exc_info=True. ⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка -НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip- -ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`, -тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip / -Settings.avito_proxy_rotate_url), не ASocks. -Без явной проверки хоста наш `Authorization: Bearer ` ушёл бы -на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно, -реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как -успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет -urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение -это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без -Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый -вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks -ротировал", а фактически задел mobileproxy), которого проще не допустить, чем -потом объяснять админу расхождение счётчиков. +НЕОДНОРОДНА — часть строк пула несёт ASocks-ссылки, часть — mobileproxy +aproxy.site-ссылки (`https://aproxy.site/?proxy_key=<секрет>`), формат которых +похож на старый (ныне неподдерживаемый) changeip.mobileproxy.space путь, но это +ДРУГОЙ хост с ДРУГИМ протоколом ротации (GET, без заголовков). +Без явной проверки хоста наш `Authorization: Bearer ` мог бы +уйти на ЧУЖОЙ провайдер. rotate_proxy ПЕРЕД любым HTTP-вызовом определяет +провайдера по urlparse(rotate_url).hostname (https-only, exact match) через +_rotate_provider_for_host — несовпадение ни с одним известным хостом это ОТКАЗ +(ok=False, нейтральный reason), а НЕ попытка безголового запроса: смысл ручной +ротации — конкретный провайдер, молчаливый вызов чужой ручки — это сюрприз +оператору (он думает "ASocks ротировал", а фактически задел другого +провайдера), которого проще не допустить, чем потом объяснять админу +расхождение счётчиков. psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -77,8 +104,10 @@ from app.services.proxy_pool import clear_source_bans logger = logging.getLogger(__name__) __all__ = [ + "ALLOWED_MOBILEPROXY_ROTATE_HOST", "ALLOWED_ROTATE_HOST", "DAILY_ROTATION_LIMIT", + "MOBILEPROXY_DAILY_ROTATION_LIMIT", "RotationResult", "rotate_proxy", ] @@ -86,23 +115,42 @@ __all__ = [ # Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика). DAILY_ROTATION_LIMIT = 3 -# Таймаут POST refresh-ip. Пункт задачи требует "~30с". +# У mobileproxy.space кулдауна/лимита провайдера не обнаружено (эмпирика: 13 ротаций +# подряд дали 13 уникальных IP). Это наш собственный предохранитель от runaway-цикла +# ротаций, НЕ ограничение провайдера. +MOBILEPROXY_DAILY_ROTATION_LIMIT = 200 + +# Таймаут HTTP-вызова ротации (оба провайдера). Пункт задачи требует "~30с". _ROTATE_TIMEOUT_S = 30.0 # Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке # (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может -# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова. +# нести ЧУЖИЕ rotate-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова. ALLOWED_ROTATE_HOST = "api.asocks.com" +# Единственный хост mobileproxy.space, поддерживаемый нашей ротацией. Домен +# changeip.mobileproxy.space из прод-сети не резолвится/не отвечает — НЕ добавлять. +ALLOWED_MOBILEPROXY_ROTATE_HOST = "aproxy.site" -def _is_allowed_rotate_url(url: str) -> bool: - """https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо — - urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url.""" + +def _rotate_provider_for_host(url: str) -> str | None: + """Определить провайдера ротации по хосту rotate_url (https-only, exact match, + без исключений на кривом url — см. "⛔ Хост-пиннинг" в docstring модуля). + + Возвращает "asocks" / "mobileproxy" / None (неизвестный/неразрешённый хост — + отказ ДО любого HTTP-вызова, чтобы не увести чужой секрет на непроверенный хост). + """ try: parsed = urlparse(url) except ValueError: - return False - return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST + return None + if parsed.scheme != "https": + return None + if parsed.hostname == ALLOWED_ROTATE_HOST: + return "asocks" + if parsed.hostname == ALLOWED_MOBILEPROXY_ROTATE_HOST: + return "mobileproxy" + return None @dataclass @@ -116,7 +164,15 @@ class RotationResult: # Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль # docstring за определением quota-consuming). Для локально отклонённых попыток # (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток. + # Лимит зависит от провайдера (DAILY_ROTATION_LIMIT для ASocks, + # MOBILEPROXY_DAILY_ROTATION_LIMIT для mobileproxy) — значение по умолчанию тут + # используется только до определения провайдера, вызывающая ветка всегда + # подставляет правильное число. rotations_remaining_today: int = DAILY_ROTATION_LIMIT + # mobileproxy-only: секунды на переподключение канала после ротации ("rt" в + # ответе провайдера) — следующая задача (интеграция в скраперы) ждёт это время + # перед повторным запросом через прокси. None для ASocks и для неуспешных попыток. + reconnect_delay_s: float | None = None def _quota_used_today(db: Session, proxy_id: int) -> int: @@ -173,6 +229,21 @@ def _record_attempt( db.commit() +def _update_exit_ip(db: Session, proxy_id: int, new_ip: str) -> None: + """Обновить scrape_proxies.exit_ip после успешной ротации (mobileproxy).""" + db.execute( + text( + """ + UPDATE scrape_proxies + SET exit_ip = CAST(:exit_ip AS text), updated_at = now() + WHERE id = CAST(:proxy_id AS bigint) + """ + ), + {"exit_ip": new_ip, "proxy_id": proxy_id}, + ) + db.commit() + + def _alert_stale_token(proxy_id: int) -> None: """Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort). @@ -222,29 +293,30 @@ def _extract_new_ip(resp: httpx.Response) -> str | None: return None +def _parse_rt(raw: Any) -> float | None: + """Распарсить "rt" (секунды переподключения канала) из ответа mobileproxy. + Формат не документирован — best-effort, неудача не является ошибкой ротации.""" + if raw is None: + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: - """Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5). + """Сменить exit-IP одного прокси пула — диспетчер по провайдеру (#2600 п.5). Порядок: 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). - 3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False - ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy - changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле). - 4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, - внятный отказ, ничего не ломается. - 5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ - обращения к API. - 6. POST rotate_url с Authorization: Bearer , timeout ~30с. - - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL - (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. - - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается - в лимите), нейтральный reason. - - Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл - auth и ответил своей бизнес-логикой), нейтральный reason. - - 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort. + 3. rotate_url хост определяет провайдера (_rotate_provider_for_host) ДО + любого HTTP-вызова — секрет провайдера не должен уйти на чужой хост + (см. "⛔ Хост-пиннинг" в docstring модуля). Неизвестный хост → ok=False. + 4. Известный провайдер → делегируем в _rotate_via_asocks / + _rotate_via_mobileproxy (детали протокола — там и в docstring модуля). - Ни в одном из reason/логов НЕ появляется токен. + Ни в одном из reason/логов НЕ появляется токен/ключ. """ row = ( db.execute( @@ -266,24 +338,51 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" ) - if not _is_allowed_rotate_url(rotate_url): - # scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут - # mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш - # Authorization: Bearer на непроверенный хост нельзя. - # Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он - # несёт их собственный секрет в query-string, тот же класс утечки, что - # и в rotate_proxy_ip, см. модуль docstring). - logger.warning( - "proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host " - "(%s) — refusing before any HTTP call to avoid leaking the token to it", - proxy_id, - urlparse(rotate_url).hostname, - ALLOWED_ROTATE_HOST, - ) - return RotationResult( - ok=False, reason="rotation not supported for this proxy (unexpected rotate host)" - ) + provider = _rotate_provider_for_host(rotate_url) + if provider == "asocks": + return await _rotate_via_asocks(db, proxy_id, rotate_url) + + if provider == "mobileproxy": + return await _rotate_via_mobileproxy(db, proxy_id, rotate_url) + + # scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут + # ссылки других провайдеров) — отправлять секрет ЛЮБОГО провайдера на + # непроверенный хост нельзя. Логируем ТОЛЬКО hostname (не полный url — на + # других провайдерах он несёт их собственный секрет в query-string, тот же + # класс утечки, что и в rotate_proxy_ip, см. модуль docstring). + logger.warning( + "proxy_rotation: proxy_id=%d rotate_url host=%r is not a recognised rotation " + "provider (allowed: %s, %s) — refusing before any HTTP call to avoid leaking " + "credentials to it", + proxy_id, + urlparse(rotate_url).hostname, + ALLOWED_ROTATE_HOST, + ALLOWED_MOBILEPROXY_ROTATE_HOST, + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (unexpected rotate host)" + ) + + +async def _rotate_via_asocks(db: Session, proxy_id: int, rotate_url: str) -> RotationResult: + """ASocks refresh-ip (см. docstring модуля § ASocks). rotate_url уже проверен + вызывающим (_rotate_provider_for_host == "asocks"). + + Порядок: + 1. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + внятный отказ, ничего не ломается. + 2. Суточный лимит (см. _quota_used_today, DAILY_ROTATION_LIMIT) исчерпан → + ok=False, отказ БЕЗ обращения к API. + 3. POST rotate_url с Authorization: Bearer , timeout ~30с. + - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL + (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. + - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается + в лимите), нейтральный reason. + - Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл + auth и ответил своей бизнес-логикой), нейтральный reason. + - 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort. + """ token = settings.asocks_api_token if not token: logger.warning( @@ -374,3 +473,136 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: new_ip=new_ip, rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), ) + + +async def _rotate_via_mobileproxy(db: Session, proxy_id: int, rotate_url: str) -> RotationResult: + """mobileproxy.space (aproxy.site) rotation (см. docstring модуля § mobileproxy). + rotate_url уже проверен вызывающим (_rotate_provider_for_host == "mobileproxy"). + + Порядок: + 1. Предохранительный суточный лимит (см. _quota_used_today, + MOBILEPROXY_DAILY_ROTATION_LIMIT) исчерпан → ok=False, отказ БЕЗ + обращения к API (это наш собственный runaway-guard, не лимит провайдера). + 2. GET rotate_url&format=json, БЕЗ заголовков, timeout ~30с. + - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL + (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. + - HTTP 4xx/5xx → аудит-запись (считается в лимите), нейтральный reason. + - HTTP 2xx, но JSON status != "OK" → аудит-запись success=False (считается + в лимите — провайдер ответил, просто бизнес-неудача), нейтральный reason. + - HTTP 2xx и status == "OK" → аудит-запись success=True, new_ip записывается + в scrape_proxies.exit_ip, бан-история узла снимается (тот же принцип, + что и у ASocks), rt возвращается вызывающему как reconnect_delay_s. + """ + used = _quota_used_today(db, proxy_id) + if used >= MOBILEPROXY_DAILY_ROTATION_LIMIT: + logger.warning( + "proxy_rotation: mobileproxy daily limit reached proxy_id=%d used=%d/%d — " + "skipping API call", + proxy_id, + used, + MOBILEPROXY_DAILY_ROTATION_LIMIT, + ) + return RotationResult( + ok=False, + reason=f"daily rotation limit reached ({MOBILEPROXY_DAILY_ROTATION_LIMIT}/day)", + rotations_remaining_today=0, + ) + + # proxy_key живёт в query-string самого rotate_url — request_url несёт его + # так же, как и исходный url. НИКОГДА не логировать/не возвращать request_url + # или rotate_url целиком (см. "⛔ Токен..." в docstring модуля). + request_url = httpx.URL(rotate_url).copy_merge_params({"format": "json"}) + + try: + async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: + resp = await client.get(request_url) + except Exception as exc: + # Тот же принцип, что и у ASocks выше: str(exc) может нести полный URL + # (значит и proxy_key) — наружу только exc_info=True в лог, + # type(exc).__name__ в note (не секрет, полезен оператору). + logger.warning( + "proxy_rotation: mobileproxy request failed (no response) proxy_id=%d", + proxy_id, + exc_info=True, + ) + _record_attempt( + db, + proxy_id, + success=False, + http_status=None, + note=f"request failed: {type(exc).__name__}", + ) + return RotationResult( + ok=False, + reason="rotation request failed (network error)", + rotations_remaining_today=max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - used), + ) + + status = resp.status_code + remaining_after = max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - (used + 1)) + + if status >= 400: + logger.warning( + "proxy_rotation: mobileproxy provider returned error proxy_id=%d status=%d", + proxy_id, + status, + ) + _record_attempt( + db, proxy_id, success=False, http_status=status, note="provider returned error" + ) + return RotationResult( + ok=False, + reason=f"rotation request failed (provider status {status})", + rotations_remaining_today=remaining_after, + ) + + data: Any + try: + data = resp.json() + except Exception: + data = None + + provider_status = data.get("status") if isinstance(data, dict) else None + if provider_status != "OK": + logger.warning( + "proxy_rotation: mobileproxy provider reported non-OK status proxy_id=%d status=%r", + proxy_id, + provider_status, + ) + _record_attempt( + db, + proxy_id, + success=False, + http_status=status, + note=f"provider status={provider_status!r}", + ) + return RotationResult( + ok=False, + reason="rotation request failed (provider reported failure)", + rotations_remaining_today=remaining_after, + ) + + raw_new_ip = data.get("new_ip") + new_ip = str(raw_new_ip) if raw_new_ip else None + reconnect_delay_s = _parse_rt(data.get("rt")) + + logger.info( + "proxy_rotation: mobileproxy rotated proxy_id=%d status=%d new_ip=%s rt=%s", + proxy_id, + status, + new_ip, + reconnect_delay_s, + ) + _record_attempt(db, proxy_id, success=True, http_status=status, note=None) + if new_ip: + _update_exit_ip(db, proxy_id, new_ip) + # Тот же принцип, что и у ASocks (см. docstring модуля): IP сменился → баны + # площадок на СТАРОМ адресе недействительны. + clear_source_bans(db, proxy_id, reason=f"exit ip rotated (mobileproxy status={status})") + return RotationResult( + ok=True, + reason=None, + new_ip=new_ip, + rotations_remaining_today=remaining_after, + reconnect_delay_s=reconnect_delay_s, + ) diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py index b4fb5b0b..e10c73df 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -70,6 +70,7 @@ class FakeSession: # адреса недействителен), см. proxy_pool.clear_source_bans. self.source_bans: list[dict[str, Any]] = source_bans or [] self.commits = 0 + self.exit_ip_updates: list[tuple[int, str]] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) @@ -109,6 +110,12 @@ class FakeSession: self.source_bans = [b for b in self.source_bans if b not in cleared] return _FakeResult([{"source": b["source"]} for b in cleared]) + if "UPDATE scrape_proxies" in sql and "exit_ip" in sql: + self.exit_ip_updates.append((p["proxy_id"], p["exit_ip"])) + if self.proxy_row is not None and self.proxy_row["id"] == p["proxy_id"]: + self.proxy_row["exit_ip"] = p["exit_ip"] + return _FakeResult([]) + raise AssertionError(f"unhandled SQL: {sql}") def commit(self) -> None: @@ -179,10 +186,45 @@ def _no_http_allowed(): async def post(self, *a: object, **kw: object) -> None: raise AssertionError("HTTP call must NOT happen for this scenario") + async def get(self, *a: object, **kw: object) -> None: + raise AssertionError("HTTP call must NOT happen for this scenario") + return _ForbiddenClient +def _fake_async_client_get( + *, + response: tuple[int, dict[str, Any] | None] | None, + exception: Exception | None, +): + """Аналог _fake_async_client, но для GET (mobileproxy — без Authorization, + секрет живёт в URL). calls накапливает str(url) каждого get().""" + calls: list[dict[str, Any]] = [] + + class _FakeClientImpl: + def __init__(self, timeout: float | None = None) -> None: + self.timeout = timeout + + async def __aenter__(self) -> _FakeClientImpl: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def get(self, url: object) -> _FakeResponse: + calls.append({"url": str(url)}) + if exception is not None: + raise exception + assert response is not None + status, body = response + return _FakeResponse(status, body) + + return _FakeClientImpl, calls + + _DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" +MOBILEPROXY_KEY = "deadbeef00112233deadbeef00112233" +_MOBILEPROXY_ROTATE_URL = f"https://aproxy.site/?proxy_key={MOBILEPROXY_KEY}" # (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один # из response/exception задан, либо оба None (локальный отказ, HTTP не идёт). @@ -557,3 +599,169 @@ async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.Mo result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type] assert result.ok is False assert result.reason is not None + + +# ── mobileproxy.space (aproxy.site) ───────────────────────────────────────── + + +async def test_mobileproxy_success_writes_history_and_exit_ip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_client, calls = _fake_async_client_get( + response=(200, {"status": "OK", "code": 200, "new_ip": "128.71.225.59", "rt": "2.41"}), + exception=None, + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert result.new_ip == "128.71.225.59" + assert result.reconnect_delay_s == pytest.approx(2.41) + assert ( + result.rotations_remaining_today + == proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT - 1 + ) + + assert len(calls) == 1 + assert "format=json" in calls[0]["url"] + + assert len(db.rotations) == 1 + assert db.rotations[0]["success"] is True + assert db.rotations[0]["http_status"] == 200 + assert db.exit_ip_updates == [(1, "128.71.225.59")] + + +async def test_mobileproxy_success_clears_source_bans(monkeypatch: pytest.MonkeyPatch) -> None: + fake_client, _calls = _fake_async_client_get( + response=(200, {"status": "OK", "new_ip": "9.9.9.9", "rt": "3.1"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession( + _proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL), + source_bans=[{"proxy_id": 1, "source": "avito"}], + ) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert db.source_bans == [] + + +async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 200, но body.status != "OK" — провал, даже без транспортной ошибки. + Провайдер ответил (транспортно дошли) → попытка засчитывается в предохранитель.""" + fake_client, _calls = _fake_async_client_get( + response=(200, {"status": "FAIL", "code": 400}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.new_ip is None + assert db.exit_ip_updates == [] + assert len(db.rotations) == 1 + assert db.rotations[0]["success"] is False + assert db.rotations[0]["http_status"] == 200 + assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] + + +async def test_mobileproxy_http_error_status_is_failure(monkeypatch: pytest.MonkeyPatch) -> None: + fake_client, _calls = _fake_async_client_get(response=(503, None), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations[0]["http_status"] == 503 + + +async def test_mobileproxy_network_error_does_not_consume_quota( + monkeypatch: pytest.MonkeyPatch, +) -> None: + boom = httpx.ConnectError(f"connection failed for proxy_key={MOBILEPROXY_KEY}") + fake_client, _calls = _fake_async_client_get(response=None, exception=boom) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations[0]["http_status"] is None + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + assert MOBILEPROXY_KEY not in (result.reason or "") + assert MOBILEPROXY_KEY not in (db.rotations[0]["note"] or "") + + +async def test_mobileproxy_daily_limit_rejected_without_api_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession( + _proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL), + _quota_rows(1, proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT), + ) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower() + assert result.rotations_remaining_today == 0 + assert len(db.rotations) == proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT + + +async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Проверка секретной гигиены для proxy_key — тот же класс требований, что и + для ASOCKS_API_TOKEN (см. модуль docstring): reason, note аудит-записи и + аргументы logger.*() не должны содержать ключ ни в одном сценарии.""" + scenarios: list[tuple[str, tuple[int, dict[str, Any] | None] | None, Exception | None]] = [ + ("success", (200, {"status": "OK", "new_ip": "1.1.1.1", "rt": "2.0"}), None), + ("non_ok", (200, {"status": "FAIL"}), None), + ("http_error", (500, {"message": "err"}), None), + ( + "network_error", + None, + httpx.ConnectError(f"boom proxy_key={MOBILEPROXY_KEY}"), + ), + ] + + for name, response, exception in scenarios: + fake_client, _ = _fake_async_client_get(response=response, exception=exception) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL)) + with caplog.at_level(logging.DEBUG): + caplog.clear() + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert MOBILEPROXY_KEY not in (result.reason or ""), name + for row in db.rotations: + assert MOBILEPROXY_KEY not in (row["note"] or ""), name + for record in caplog.records: + assert MOBILEPROXY_KEY not in record.getMessage(), ( + f"scenario={name}: proxy_key leaked into log message args" + ) + + +async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Хост-пиннинг: неизвестный хост, похожий на mobileproxy по форме query + (проверка того, что дефолтный отказ путь тоже не светит секрет из url).""" + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + foreign_url = f"https://changeip.mobileproxy.space/?proxy_key={MOBILEPROXY_KEY}" + db = FakeSession(_proxy_row(rotate_url=foreign_url)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations == [] + assert MOBILEPROXY_KEY not in (result.reason or "") -- 2.45.3