Merge pull request 'feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)' (#2611) from feat/tradein-asocks-rotation into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m2s

This commit is contained in:
lekss361 2026-08-01 19:23:51 +00:00
commit 887df54908
6 changed files with 1040 additions and 0 deletions

View file

@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_session_svc
from app.services import proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod
from app.services.geocoder import geocode
from app.services.scheduler import has_running_run
@ -2889,3 +2890,44 @@ def patch_proxy(
created_at=_iso(row["created_at"]),
updated_at=_iso(row["updated_at"]),
)
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
#
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# app.services.proxy_rotation.rotate_proxy.
class ProxyRotateResponse(BaseModel):
ok: bool
reason: str | None = None
new_ip: str | None = None
rotations_remaining_today: int
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
async def rotate_pool_proxy(
proxy_id: int,
db: Annotated[Session, Depends(get_db)],
) -> ProxyRotateResponse:
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
Делегирует в app.services.proxy_rotation.rotate_proxy читает rotate_url
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
ok=False ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
доходит страница-заглушка отдаёт 200) только этот ручной вызов.
"""
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
return ProxyRotateResponse(
ok=result.ok,
reason=result.reason,
new_ip=result.new_ip,
rotations_remaining_today=result.rotations_remaining_today,
)

View file

@ -548,6 +548,18 @@ class Settings(BaseSettings):
proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
# (app.services.proxy_rotation). Документированный публичный API (GET
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.

View file

@ -0,0 +1,370 @@
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health параллельный
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
для безлимитных портов НЕ работает.
- Ротация сменой session-суффикса логина (-session-N) НЕ работает exit-IP
не меняется (три варианта дали один и тот же IP).
- Единственный рабочий путь ручка веб-кабинета:
POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip
Authorization: Bearer <токен>
Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}.
scrape_proxies.rotate_url уже несёт этот URL (миграция 199) токен НЕ в URL,
он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token).
- Лимит провайдера: 3 ротации в сутки на порт.
- Токен сессионный, однажды протухнет (осознанное решение владельца аккаунта).
Когда это случится, провайдер ответит 401 это ГРОМКИЙ отказ ниже
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
и обработанные им т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
401 это буквально описание провайдера "Unauthenticated": запрос отсеян на
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
ошибки (таймаут / разрыв соединения ответа вообще нет) по той же логике не
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
доходят вовсе в таблицу не пишутся и лимит не трогают.
quota-consuming := http_status IS NOT NULL AND http_status != 401
(успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
реальной rotate-логики и мог быть учтён в лимите на его стороне).
Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
str(exc) см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
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>`,
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks.
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (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), которого проще не допустить, чем
потом объяснять админу расхождение счётчиков.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
__all__ = [
"ALLOWED_ROTATE_HOST",
"DAILY_ROTATION_LIMIT",
"RotationResult",
"rotate_proxy",
]
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
DAILY_ROTATION_LIMIT = 3
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
_ROTATE_TIMEOUT_S = 30.0
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
ALLOWED_ROTATE_HOST = "api.asocks.com"
def _is_allowed_rotate_url(url: str) -> bool:
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
try:
parsed = urlparse(url)
except ValueError:
return False
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
@dataclass
class RotationResult:
"""Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный
(безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты."""
ok: bool
reason: str | None
new_ip: str | None = None
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
# docstring за определением quota-consuming). Для локально отклонённых попыток
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
def _quota_used_today(db: Session, proxy_id: int) -> int:
"""Число quota-consuming попыток за последние 24ч (см. docstring модуля).
http_status IS NOT NULL AND != 401 успех И любой не-401 ответ провайдера.
401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются.
"""
row = (
db.execute(
text(
"""
SELECT count(*) AS n
FROM scrape_proxy_rotations
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND rotated_at > now() - interval '24 hours'
AND http_status IS NOT NULL
AND http_status != 401
"""
),
{"proxy_id": proxy_id},
)
.mappings()
.fetchone()
)
return int(row["n"]) if row is not None else 0
def _record_attempt(
db: Session,
proxy_id: int,
*,
success: bool,
http_status: int | None,
note: str | None,
) -> None:
"""Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос
к провайдеру реально был сделан (локально отклонённые попытки не пишутся
см. модуль docstring)."""
db.execute(
text(
"""
INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note)
VALUES (
CAST(:proxy_id AS bigint),
CAST(:success AS boolean),
CAST(:http_status AS integer),
CAST(:note AS text)
)
"""
),
{"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note},
)
db.commit()
def _alert_stale_token(proxy_id: int) -> None:
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
401 значит, что провайдер отверг Authorization-заголовок токен протух (issue
#2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение
владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать
об этом сразу, а не когда прокси уже забанены неделю.
"""
logger.error(
"proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d"
"ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy "
"until the token is refreshed in web-cabinet + env",
proxy_id,
)
try:
import sentry_sdk
sentry_sdk.capture_message(
f"ASocks rotation token rejected (401) for proxy_id={proxy_id}"
"ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed",
level="error",
)
except Exception:
pass # sentry_sdk not initialised in dev — best-effort only
def _extract_new_ip(resp: httpx.Response) -> str | None:
"""Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа
refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) —
парсинг заведомо defensive, неудача не является ошибкой ротации."""
try:
data: Any = resp.json()
except Exception:
return None
if not isinstance(data, dict):
return None
for key in ("new_ip", "ip", "exit_ip"):
val = data.get(key)
if val:
return str(val)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("new_ip", "ip", "exit_ip"):
val = nested.get(key)
if val:
return str(val)
return None
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
"""Сменить exit-IP одного прокси пула через ASocks refresh-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 <token>, 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.
Ни в одном из reason/логов НЕ появляется токен.
"""
row = (
db.execute(
text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
if row is None:
return RotationResult(ok=False, reason="proxy not found")
rotate_url = row["rotate_url"]
if not rotate_url:
logger.info(
"proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id
)
return 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 <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
# Логируем ТОЛЬКО 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)"
)
token = settings.asocks_api_token
if not token:
logger.warning(
"proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped",
proxy_id,
)
return RotationResult(ok=False, reason="rotation not configured (missing API token)")
used = _quota_used_today(db, proxy_id)
if used >= DAILY_ROTATION_LIMIT:
logger.warning(
"proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call",
proxy_id,
used,
DAILY_ROTATION_LIMIT,
)
return RotationResult(
ok=False,
reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)",
rotations_remaining_today=0,
)
try:
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
except Exception as exc:
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
logger.warning(
"proxy_rotation: 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, DAILY_ROTATION_LIMIT - used),
)
status = resp.status_code
if status == 401:
_alert_stale_token(proxy_id)
_record_attempt(
db,
proxy_id,
success=False,
http_status=401,
note="unauthenticated — token expired/invalid (excluded from daily quota)",
)
return RotationResult(
ok=False,
reason="rotation service rejected credentials — alerted, contact operator",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
)
if status >= 400:
logger.warning(
"proxy_rotation: 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=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)
new_ip = _extract_new_ip(resp)
logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip)
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
return RotationResult(
ok=True,
reason=None,
new_ip=new_ip,
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)

View file

@ -0,0 +1,49 @@
-- 198_scrape_proxy_rotations.sql
-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким
-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме
-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609).
--
-- WHY:
-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя
-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation
-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток —
-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только
-- текущее состояние, не историю).
--
-- Semantics:
-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый
-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет
-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до
-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring
-- за полным обоснованием "какие попытки считать против лимита").
-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще).
--
-- Idempotency:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон
-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет).
BEGIN;
CREATE TABLE IF NOT EXISTS scrape_proxy_rotations (
id bigserial PRIMARY KEY,
proxy_id bigint NOT NULL REFERENCES scrape_proxies (id),
rotated_at timestamptz NOT NULL DEFAULT now(),
success boolean NOT NULL,
http_status integer,
note text
);
COMMENT ON TABLE scrape_proxy_rotations IS
'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks '
'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.'
'proxy_rotation._quota_used_today считает только строки с http_status '
'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.';
-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at).
CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time
ON scrape_proxy_rotations (proxy_id, rotated_at);
COMMIT;

View file

@ -0,0 +1,58 @@
-- 199_scrape_proxies_asocks_rotate_url.sql
-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула,
-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться.
--
-- WHY:
-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через
-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP
-- для ASocks-безлимитных портов — ручка веб-кабинета
-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком
-- Authorization: Bearer <ASOCKS_API_TOKEN> (env, НЕ в URL — секретов в миграции
-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для
-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация
-- session-суффиксом логина тоже не работает (проверено пробой, три варианта —
-- один и тот же exit-IP).
--
-- Matching (важно — НЕ по id):
-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся
-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец
-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути,
-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через
-- right(url, length(hostport)) = hostport. portId → host:port (проверено
-- владельцем аккаунта, issue #2600 п.5):
-- 223610715 → 212.8.249.134:10423
-- 225031312 → 190.2.145.131:10313
-- 231878029 → 175.110.115.153:10492
-- 231878030 → 109.236.82.42:11048
--
-- Idempotency:
-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по
-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) —
-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.rotate_url).
BEGIN;
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048';
COMMIT;

View file

@ -0,0 +1,509 @@
"""Offline-тесты ротации exit-IP ASocks (#2600 п.5).
Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком,
FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations
(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml).
- rotate_url пуст «не поддерживается», НЕ ошибка, HTTP не дёргается.
- ASOCKS_API_TOKEN не задан внятный отказ, HTTP не дёргается.
- 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки).
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
- 401 logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
аудит-запись пишется, но НЕ считается против суточного лимита.
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в
тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
- rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) отказ ДО HTTP-вызова
scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip-
ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
from app.services import proxy_rotation
SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c"
# ── stateful fakes ────────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]):
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def fetchone(self) -> dict[str, Any] | None:
return self._rows[0] if self._rows else None
class FakeSession:
"""Эмуляция Session: одна строка scrape_proxies + append-only
scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же
паттерн, что tests/services/test_proxy_pool.py)."""
def __init__(
self,
proxy_row: dict[str, Any] | None,
rotations: list[dict[str, Any]] | None = None,
):
self.proxy_row = proxy_row
self.rotations: list[dict[str, Any]] = rotations or []
self.commits = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
p = params or {}
if "SELECT id, rotate_url FROM scrape_proxies" in sql:
if self.proxy_row is None or self.proxy_row["id"] != p["id"]:
return _FakeResult([])
return _FakeResult([dict(self.proxy_row)])
if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql:
cutoff = datetime.now(UTC) - timedelta(hours=24)
n = sum(
1
for r in self.rotations
if r["proxy_id"] == p["proxy_id"]
and r["rotated_at"] > cutoff
and r["http_status"] is not None
and r["http_status"] != 401
)
return _FakeResult([{"n": n}])
if "INSERT INTO scrape_proxy_rotations" in sql:
self.rotations.append(
{
"proxy_id": p["proxy_id"],
"success": p["success"],
"http_status": p["http_status"],
"note": p["note"],
"rotated_at": datetime.now(UTC),
}
)
return _FakeResult([])
raise AssertionError(f"unhandled SQL: {sql}")
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
pass
class _FakeResponse:
def __init__(self, status_code: int, json_data: dict[str, Any] | None):
self.status_code = status_code
self._json_data = json_data
def json(self) -> dict[str, Any]:
if self._json_data is None:
raise ValueError("no json body")
return self._json_data
def _fake_async_client(
*,
response: tuple[int, dict[str, Any] | None] | None,
exception: Exception | None,
):
"""Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть.
Ровно один из (response, exception) задан. calls накапливает (url, headers)
каждого post() тест проверяет по ним, был ли вообще HTTP-вызов.
"""
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 post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
calls.append({"url": url, "headers": headers or {}})
if exception is not None:
raise exception
assert response is not None
status, body = response
return _FakeResponse(status, body)
return _FakeClientImpl, calls
def _no_http_allowed():
"""httpx.AsyncClient-заглушка, падающая AssertionError при любом post() —
для сценариев, где HTTP до провайдера дойти НЕ должно."""
class _ForbiddenClient:
def __init__(self, timeout: float | None = None) -> None:
pass
async def __aenter__(self) -> _ForbiddenClient:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def post(self, *a: object, **kw: object) -> None:
raise AssertionError("HTTP call must NOT happen for this scenario")
return _ForbiddenClient
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None]
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
return {"id": 1, "rotate_url": rotate_url}
def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]:
now = datetime.now(UTC)
return [
{
"proxy_id": proxy_id,
"success": http_status < 400,
"http_status": http_status,
"note": None,
"rotated_at": now - timedelta(minutes=i),
}
for i in range(n)
]
# ── no rotate_url → not an error ────────────────────────────────────────────
async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=None))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert "rotat" in result.reason.lower() # human-readable, not a crash
assert db.rotations == [] # ничего не писалось — попытки не было
# ── host pinning (security review PR #2611) ─────────────────────────────────
#
# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy
# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция
# 199). Без host-пиннинга наш Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы
# на чужой провайдер.
async def test_rotate_url_on_foreign_host_refused_before_http_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret"
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 result.reason is not None
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call (i.e. sent our token) — reaching this
# line means it refused first. Belt-and-suspenders: no audit row either
# (this is a local rejection, same as no-rotate_url/no-token/limit).
assert db.rotations == []
assert SECRET_TOKEN not in result.reason
async def test_allowed_host_case_insensitive_still_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот
же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг
превратился бы в ложный отказ на легитимном rotate_url."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert len(calls) == 1
async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
"""http:// (не https://) на тот же хост — отказ (защита от даунгрейда
транспорта, которым Authorization ушёл бы в открытом виде)."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations == []
# ── missing token → neutral refusal, no crash ───────────────────────────────
async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "")
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert db.rotations == []
# ── daily limit ──────────────────────────────────────────────────────────────
async def test_fourth_attempt_today_rejected_without_api_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
# 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются).
db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.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
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call — reaching here means it didn't.
assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано
# ── success writes history ──────────────────────────────────────────────────
async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert result.new_ip == "9.9.9.9"
assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1
assert len(calls) == 1
assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}"
assert len(db.rotations) == 1
row = db.rotations[0]
assert row["success"] is True
assert row["http_status"] == 200
assert db.commits >= 1
# ── 401 → loud failure ───────────────────────────────────────────────────────
async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(
response=(401, {"success": False, "message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
sentry_calls: list[tuple[str, str | None]] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_calls.append((msg, level)),
)
db = FakeSession(_proxy_row())
with caplog.at_level(logging.ERROR):
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert len(calls) == 1 # запрос реально ушёл
# громкий отказ: и лог, и мониторинг — не молчаливая остановка
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
assert any("401" in r.getMessage() for r in error_records)
assert len(sentry_calls) == 1
assert sentry_calls[0][1] == "error"
# аудит записан, но 401 НЕ считается против суточного лимита (см. модуль
# docstring: auth-отсев до провайдера, лимит на его стороне не тратится).
assert len(db.rotations) == 1
assert db.rotations[0]["http_status"] == 401
assert db.rotations[0]["success"] is False
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
# 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен
assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT
# ── token never leaks ────────────────────────────────────────────────────────
async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert SECRET_TOKEN not in (result.new_ip or "")
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None)
fake_client, _ = _fake_async_client(
response=(401, {"message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
async def test_token_never_appears_in_reason_on_network_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""httpx-исключения могут нести полный request-контекст (URL/детали) —
прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен
живёт только в headers (не в URL), но проверяем end-to-end: даже если
exception-текст содержит секрет (симулируем это явно), наружу он не идёт."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}")
fake_client, _ = _fake_async_client(response=None, exception=boom)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
assert db.rotations[0]["http_status"] is None
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
# exception class name (не секрет) в note — оператор отличит "не дозвонились"
# (ConnectError) от "дозвонились, зависли" (ReadTimeout).
assert "ConnectError" in (db.rotations[0]["note"] or "")
async def test_token_never_appears_on_provider_error_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(
response=(500, {"message": "internal error"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
# провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту
assert db.rotations[0]["http_status"] == 500
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
async def test_token_never_appears_in_log_messages_or_sentry_text(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Расширенное leak-покрытие (security review PR #2611): предыдущие тесты
проверяли только reason/note. Здесь текст, реально уходящий в logging и в
Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали
исключения см. модуль docstring; это осознанно разрешённое место).
caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ
exc_text то есть эта проверка ловит именно "секрет попал в аргумент
logger.*()", а не в traceback.
"""
sentry_texts: list[str] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_texts.append(msg),
)
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
scenarios: list[_LogScenario] = [
("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None),
("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None),
("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None),
(
"network_error",
_DEFAULT_ROTATE_URL,
None,
httpx.ConnectError(f"boom token={SECRET_TOKEN}"),
),
("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None),
]
for name, rotate_url, response, exception in scenarios:
if response is not None or exception is not None:
fake_client, _ = _fake_async_client(response=response, exception=exception)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
else:
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=rotate_url))
with caplog.at_level(logging.DEBUG):
caplog.clear()
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
for record in caplog.records:
assert (
SECRET_TOKEN not in record.getMessage()
), f"scenario={name}: token leaked into log message args"
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
assert all(SECRET_TOKEN not in text for text in sentry_texts)
# ── proxy not found ──────────────────────────────────────────────────────────
async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(None)
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None