feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600) #2611
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2611
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-asocks-rotation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
app/services/proxy_rotation.py(rotate_proxy(db, proxy_id)): POSTrotate_urlизscrape_proxiesсAuthorization: Bearer <ASOCKS_API_TOKEN>(env,app/core/config.py), таймаут ~30с.scrape_proxy_rotations(миграция 198).rotate_urlдля 4 существующих ASocks-портов пула, сопоставление поhost:port(суффиксurl), НЕ поid.POST /api/v1/admin/proxies/{proxy_id}/rotate— ручная ротация поproxy_idиз пула (существующий/scraper/{source}/rotate-ipдля env-прокси mobileproxy НЕ тронут).logger.error+sentry_sdk.capture_message(level="error")— токен протух, молчаливая остановка недопустима.exc_info=True(прецедент утечки — см. комментарий уrotate_proxy_ipвadmin.py).Какие попытки считаются против суточного лимита
quota-consuming := http_status IS NOT NULL AND http_status != 401— засчитывается любой реальный ответ провайдера (успех 200 и его собственные 4xx/5xx), но НЕ 401 (auth-отсев ДО обращения к rotate-логике порта — провайдер буквально отвечает "Unauthenticated", не мог засчитать использование тому, кого не подтвердил) и НЕ сетевые ошибки (ответа вообще не было — нет подтверждения, что запрос дошёл до провайдера). Локальные отказы (нетrotate_url/ нет токена / лимит уже исчерпан) до HTTP-вызова не доходят и в таблицу не пишутся вовсе.Known limitation (issue #2600 п.5, явно)
Автоматический вызов по детекту бана НЕ реализован — сигнал бана до пула пока не доходит (страница-заглушка отдаёт 200, issue #2600 п.2, отдельная задача). Прошивать вызов в скраперы сейчас — риск на горячем пути. Следующий шаг: когда детект бана появится, дергать
rotate_proxyиз него.Границы
app/services/proxy_pool.py,packages/scraper-kit/**,browser_fetcher.py,providers/_proxy.py— не тронуты (параллельный PR правит proxy_pool.py).Test plan
ruff check— чисто (config.py, admin.py, proxy_rotation.py, test_proxy_rotation.py)tests/services/test_proxy_rotation.py— все сценарии из задачи (no rotate_url, no token, 4-я попытка за сутки, success пишет историю, 401 громкий + мониторинг + исключён из лимита, токен не течёт в 4 сценариях, unknown proxy_id)pytestвtradein-mvp/backend: 3021 passed, 9 skipped, 1 pre-existing fail (tests/test_search_api.py::test_search_cache_hit, 401 от RBAC, известен, не мой)Refs #2600
Deep review (deep-code-reviewer) — CHANGES REQUESTED: 1 блокер
Проверено на
b1563b86в отдельном worktree (PYTHONPATH форсирован на worktree, трассировка импорта подтверждена). Полный pytest: 3021 passed / 9 skipped / 1 failed (pre-existing test_search_cache_hit). Фальсификации утечки токена в reason/note и off-by-one в лимите — воспроизведены, тесты падают как заявлено.BLOCK: ASocks Bearer-токен уходит на чужой хост
app/services/proxy_rotation.py:256постит вrotate_urlиз БД, приклеиваяAuthorization: Bearer <ASOCKS_API_TOKEN>, БЕЗ проверки хоста. В проде прямо сейчас:Значит
POST /api/v1/admin/proxies/3/rotateотправит наш ASocks-токен наchangeip.mobileproxy.space. Это ровно те узлы (consecutive_fails=5, disabled), которые оператор в первую очередь и захочет «ротировать». Воспроизведено оффлайн на прод-подобной строке:Второй контур той же дыры:
rotate_urlзадаётся черезPOST /proxies/bulkпроизвольной строкой — админ API-уровня получает примитив для эксфильтрации env-секрета, к которому иначе доступа не имеет.Фикс — пин хоста перед отправкой заголовка:
rotate_url='https://changeip.mobileproxy.space/?proxy_key=x'с_no_http_allowed()→ HTTP-вызова быть не должно.Утечка токена — остальные пути чисты
scrape_proxy_rotations— токена нет, покрыто тестами (фальсифицировано 4 способами, каждый ловится).exc_info=True— стандартный traceback не печатает locals;include_local_variables=Falseвapp/main.py:85, так что и Sentry-события фреймов не тащат.capture_message/logger.errorв_alert_stale_tokenсобраны изproxy_id, токена не касаются.sentry_scrub.pyтокен НЕ покрывает (_URL_SECRET_QUERY_REтребует?/&перед именем, формаBearer <...>мимо) — сейчас неважно, потому что токен туда не попадает, но подпорки под ошибку нет.rotate_urlв БД токена не содержит (миграция 199 кладёт голый URL).Медиум/лоу (не блокеры)
_quota_used_today→ 30-секундныйawait→ INSERT. Два параллельных вызова читают одинаковый used и оба ротируют. При единственном uvicorn-воркере это ловится легко (второй запрос обслуживается ровно во время await). Лечится не блокировкой строки через await (это заморозит луп), а резервированием: INSERT попытки ДО POST + UPDATE статусом после.POST /proxies/bulkделаетrotate_url = EXCLUDED.rotate_urlбез COALESCE, аPATCH /proxies/{id}умеет толькоenabled. Повторная загрузка пула (именно так эти 4 строки и появились) молча обнулит rotate_url из 199, и восстановить его можно будет только руками — миграция уже в_schema_migrations.http_status=NULL→ квота не тратится.ReadTimeout(запрос ушёл, ответа нет) стоит считать против лимита,ConnectError— нет. Минимум — писать класс исключения вnote(type(exc).__name__безопасен), сейчас там константа.status >= 400пропускает 3xx как успех (follow_redirects=Falseпо умолчанию) и не смотрит на{"success": false}в 2xx-теле.db.rollback()после проверки квоты.capture_message(проверил: обе подмены проходят зелёными).Что чисто
Миграции 198/199 идемпотентны, в BEGIN/COMMIT, номера свободны и на main, и на всех открытых ветках. WHERE из 199 прогнал read-only на проде — ровно id 1/9/10/11. RBAC:
/api/v1/admin/*+_ADMIN_API_RE→ admin-only. Без токена — внятный отказ, ничего не падает. Границы диффа соблюдены (proxy_pool.py / scraper-kit / browser_fetcher / providers/_proxy не тронуты),/scraper/{source}/rotate-ipне задет. ruff clean.Security-блокер (токен на чужой хост) исправлен в
bed2b7bc: rotate_url теперь пиннится к ALLOWED_ROTATE_HOST=api.asocks.com (https-only) ДО любого HTTP-вызова — несовпадение хоста = отказ, никакого безголового запроса без Authorization. Добавлены тесты (host на changeip.mobileproxy.space отклоняется без HTTP-вызова, case-insensitive host матчинг, http-даунгрейд отклоняется) + falsification (временно снёс пиннинг — тест поймал). Плюс из того же ревью: exception class name в note сетевой ошибки (ConnectError/ReadTimeout), расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note) — тоже falsified. Полный pytest: 3025 passed, 9 skipped, 1 known pre-existing fail (test_search_cache_hit, не мой).Deep review дельты b1563b86..bed2b7bc — APPROVE, смержено
Блокер закрыт.
merge_commit 887df549, деплойsuccess19:28:55Z, прод проверен.Фальсификации (повторил сам, правкой файла)
if not _is_allowed_rotate_url(...)→if False:test_rotate_url_on_foreign_host_refused_before_http_call— в аудите появляется строкаnote='request failed: AssertionError', т.е.post()реально вызвался. HTTP-вызов состоялся, а не просто reason другой.capture_messagelogger.warningПробел, который я нашёл в прошлый раз, закрыт ровно там, где был.
Нит на будущее: комментарий в тесте «_no_http_allowed() would have raised AssertionError from within rotate_proxy» неточен — этот AssertionError глотает широкий
except Exceptionвокруг httpx-вызова. Ловит на самом делеassert db.rotations == []. Хорошо, что он есть.Обход пина — не нашёл
Прогнал ~30 враждебных URL, сравнивая вердикт
_is_allowed_rotate_url()с тем, куда реально ушёл бы httpx 0.28.1 (httpx.URL(u).host). Расхождений нет: любой URL, проходящий пин, у httpx резолвится вhttps://api.asocks.com.https://api.asocks.com@evil.tld/x→ REFUSE (и urlparse, и httpx видятevil.tld) — классический userinfo-обход закрытhttps://evil.tld\@api.asocks.com/x→ PASS, и httpx тожеapi.asocks.com: httpx НЕ применяет WHATWG-правило «backslash = разделитель», расхождения нетapi.asocks.com。evil.tld(U+FF61),аpi.asocks.com(кириллическая а),xn--pi-8ma.asocks.com→ REFUSE; первый httpx резолвил бы вapi.asocks.com.evil.tld, то есть промах в безопасную сторонуapi.asocks.com.evil.tld,evil-api.asocks.com,sub.api.asocks.com,api.asocks.com.(trailing dot),[::1],127.0.0.1,//host,javascript:,file:→ REFUSE:8443,user:pw@) проходят, но хост остаётся запиненным — не утечкаfollow_redirectsв httpx 0.28.1 по умолчаниюFalse(проверилinspect.signature), автор его не включал; плюс httpx сам снимает Authorization при cross-origin редиректе — двойная защитаhttps://[::1, нулевой байт, порт 99999) исключений не кидаютПорядок проверок
rotate_urlпуст (259) → пин (267) →token = settings.asocks_api_token(285) → лимит (293) → POST (309). На чужом хосте токен вообще не связывается в кадре стека — пин это самостоятельная защита, а не надстройка надinclude_local_variables=False.Подтверждено живьём на проде (токен ещё не задан):
proxies/3/rotate(mobileproxy) →"unexpected rotate host",proxies/9/rotate(ASocks) →"rotation not configured (missing API token)". Разные причины у двух строк = пин реально срабатывает раньше. В логе только hostname,proxy_keymobileproxy не засветился.Регрессия
tests/services/test_proxy_rotation.py→ 14 passed. Полный pytest →3025 passed, 9 skipped, 1 failed(+4 = ровно новые тесты; фейл — известный pre-existingtest_search_cache_hit).ruff checkчисто. Дельта толькоproxy_rotation.py+ его тест.Прод после деплоя
_schema_migrations: 198 применена 19:28:41, 199 — 19:28:42scrape_proxy_rotationsсоздана, 0 строк, колонки и индекс(proxy_id, rotated_at)как в миграцииhttps://api.asocks.com/unlimited-proxy/{223610715,225031312,231878029,231878030}/refresh-ip, токена нетchangeip.mobileproxy.space— ровно та неоднородность, которую теперь и стережёт пинX-Authenticated-User: user2→ 403admin onlyОстальные пункты прошлого ревью (гонка счётчика,
ReadTimeoutв квоту,rotate_url = EXCLUDEDв/proxies/bulk, 3xx как успех,db.rollback()перед await) остаются follow-up'ами.