Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
bot-backend
779b57e1b3 chore(tradein/proxy): выпилить мёртвые узлы mobileproxy и их ключи из пула (#2613)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
2026-08-01 22:53:21 +03:00
2 changed files with 189 additions and 0 deletions

View file

@ -0,0 +1,83 @@
-- 201_purge_dead_mobileproxy_proxies.sql
-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies
-- вместе с чужим API-ключом, который лежал у них в rotate_url.
--
-- WHY:
-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут.
-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01)
-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy
-- Authentication Required. Последняя успешная проверка (last_check_at) у
-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5.
--
-- Две причины удалить, вторая важнее:
-- 1. Мёртвые узлы засоряют пул и его health-метрики.
-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым
-- текстом чужой ключ провайдера в query-параметре ссылки ротации
-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за
-- неоднородности этой колонки (вперемешку с ASocks-строками, где
-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL,
-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло
-- блокер: вызов ротации для такой строки отправил бы НАШ токен
-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже
-- закрывает саму уязвимость, но чужой секрет в базе держать незачем.
--
-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false):
-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations
-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION.
-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ
-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и
-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies
-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций
-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же
-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy
-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и
-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы
-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick
-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy.
-- Строки — исторический мусор без ссылок, полное удаление честнее частичной
-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает
-- секрет из базы целиком, а не только из одной колонки.
--
-- Matching (по домену url, НЕ по id):
-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима
-- per-среда, тот же класс проблемы решён в 199 через host:port-matching).
-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла
-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не
-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 /
-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе).
--
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01):
-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5)
-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'):
-- 4 (id 1, 9, 10, 11) — все ASocks
-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0
-- Строк в scrape_proxy_rotations всего (таблица пуста): 0
-- Секрет-паттерн (token|bearer|secret|key=|password, regex
-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений
-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/
-- <portId>/refresh-ip, без query-параметров вообще, авторизация Bearer
-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql)
-- FK, ссылающиеся на scrape_proxies: ровно один —
-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION.
--
-- Idempotency:
-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже
-- удалены), no-op. Весь файл в BEGIN/COMMIT.
--
-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ
-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*,
-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе
-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP
-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py,
-- скраперы. Никакого DDL.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled).
-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION).
BEGIN;
DELETE FROM scrape_proxies
WHERE url LIKE '%mobileproxy.space%';
COMMIT;

View file

@ -0,0 +1,106 @@
"""Static guards for migration 201 (issue #2613 — выпилить мёртвые узлы
mobileproxy из scrape_proxies вместе с чужим API-ключом в rotate_url).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ
идемпотентность, domain-based scope (НЕ по id они разъезжаются между
средами) и то, что ASocks-строки миграция не задевает.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_201 = _SQL_DIR / "201_purge_dead_mobileproxy_proxies.sql"
def _sql() -> str:
return _MIGRATION_201.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def test_migration_201_exists() -> None:
assert _MIGRATION_201.exists(), f"missing migration: {_MIGRATION_201}"
def test_migration_201_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_201_deletes_by_domain_not_id() -> None:
"""Условие удаления — по домену mobileproxy.space в url, НЕ по id (id
разъезжается между средами, тот же класс проблемы решён в 199 через
host:port-matching)."""
flat = _flat(_executable_sql())
assert "delete from scrape_proxies" in flat
assert "where url like '%mobileproxy.space%'" in flat
assert (
re.search(r"where\s+id\s*(=|in)", flat) is None
), "миграция не должна фильтровать по id — id разъезжается между средами"
def test_migration_201_is_idempotent_by_construction() -> None:
"""DELETE ... WHERE без вспомогательного флага — повторный прогон
находит 0 строк (уже удалены в первом прогоне), сам DELETE идемпотентен
по построению, отдельного guard-условия не требуется."""
flat = _flat(_executable_sql())
assert flat.count("delete from") == 1
assert "delete from scrape_proxies" in flat
def test_migration_201_does_not_touch_asocks_rows() -> None:
"""ASocks-строки (id 1, 9, 10, 11) адресуются IP-хостами
(212.8.249.134 / 190.2.145.131 / 175.110.115.153 / 109.236.82.42) без
mobileproxy.space в url WHERE их не задевает. Явно запрещаем regression
в сторону id-based или asocks-упоминающего условия."""
flat = _flat(_executable_sql())
assert "asocks" not in flat
for asocks_ip in (
"212.8.249.134",
"190.2.145.131",
"175.110.115.153",
"109.236.82.42",
):
assert asocks_ip not in flat
def test_migration_201_no_ddl() -> None:
"""Только DELETE данных — никакого ALTER/CREATE/DROP TABLE/TRUNCATE."""
flat = _flat(_executable_sql())
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert "truncate" not in flat
def test_migration_201_no_secret_literal_in_file() -> None:
"""Файл миграции не должен содержать сам секрет (query-параметр
proxy_key mobileproxy.space) только описание проблемы текстом."""
sql = _sql()
assert "proxy_key=" not in sql or "proxy_key=..." in sql or "<секрет>" in sql
# Явный запрет на длинные alnum-токены рядом с 'proxy_key=' (сам секрет).
assert not re.search(r"proxy_key=[A-Za-z0-9_-]{10,}", sql)
def test_migration_201_no_psycopg_trap() -> None:
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не
применимо в чистом .sql без bind params, но проверяем на регресс
copy-paste из Python-кода)."""
assert not re.search(r":\w+::", _sql())