diff --git a/app/.env.example b/app/.env.example index 6e73fe70..369eeb9a 100644 --- a/app/.env.example +++ b/app/.env.example @@ -165,6 +165,34 @@ TG_ROBOT_TIMEOUT=300 # Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт. CREATIVE_ROBOT_TOKEN= +# ── Поиск клиентов: свой рендер-сервис на виртуалке (решение владельца Р126) ── +# Портал просит виртуалку отрисовать страницу 2ГИС/Яндекса и вернуть данные. Тайный ключ +# едет заголовком X-Render-Key в КАЖДОМ запросе. Пока адрес или ключ пусты — дверь закрыта: +# наружу не ходим, поиск отдаёт пусто и говорит об этом в журнал. Ошибками не сыплет. +# 🔴 Р126: только https://. По http:// ключ поехал бы открытым текстом и молча. Адрес без +# шифрования портал не принимает. Единственное исключение — разработчик на СВОЁМ компьютере: +# при APP_ENV=local (или testing) И адресе на петлю этой же машины (127.0.0.0/8, ::1, +# localhost) http:// разрешён — такой запрос компьютер физически не покидает. Тумблера +# «можно без шифра» нет намеренно: он и был бы той опечаткой, ради которой запрет заведён. +# Боевое значение сегодня: SELF_RENDER_ENDPOINT=https://51-250-1-97.sslip.io/render +SELF_RENDER_ENDPOINT= +# Тайный ключ портал↔виртуалка (тот же RENDER_KEY, что на самой виртуалке). Только здесь, +# в код и в гит НЕ попадает. +SELF_RENDER_KEY= +# Сколько страниц просим отрисовать ОДНОВРЕМЕННО (браузерная параллельность — внутри виртуалки). +SELF_RENDER_CONCURRENCY=8 +# Ручка проверки живости виртуалки. Пусто → пингуем сам endpoint. +SELF_RENDER_HEALTH_URL= +# 🔴 Куда порталу ВООБЩЕ разрешено ходить с тайным ключом рендера (хосты через запятую). +# Опечатка в SELF_RENDER_ENDPOINT увела бы ключ чужому человеку молча и навсегда. Хост не +# из списка = дверь закрыта, запрос не отправлен. Пусто = закрыто всё. +# 🔴 Если эту строку не задать, берётся умолчание из config/services.php — тот же список. +# Пустым его делать НЕЛЬЗЯ: на боевом строки нет, а поиск работает — пустое умолчание +# остановило бы его в момент выката. +# 🪤 Разрешённый хост — это ещё не безопасная дорога: http:// к разрешённому хосту всё +# равно закрыт. Два запрета независимы, и снимать их надо по отдельности. +SELF_RENDER_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost + # ── Обзвон: шов «портал зовёт голосового робота» (З-0.3, решение владельца Р84) ── # Робот живёт на отдельной машине и делит железо с боевым рендером «Поиска клиентов». # Пока адрес или ключ пусты — дверь закрыта: портал наружу не ходит, звонков не будет, diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 94079265..447b83c2 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -155,9 +155,25 @@ class AutopodborServiceProvider extends ServiceProvider endpoint: (string) config('services.self_render.endpoint', ''), key: (string) config('services.self_render.key', ''), concurrency: (int) config('services.self_render.concurrency', 8), + razreshyonnyeHosty: self::razreshyonnyeHosty(), ); } + /** + * 🔴 Р126: куда разрешено ходить с тайным ключом рендера. Общий список на обе двери — + * {@see SelfRenderClient} и {@see RenderServiceYandexListSource} ходят по ОДНОМУ адресу + * и с ОДНИМ ключом, значит и список у них обязан быть один. + * + * @return list + */ + private static function razreshyonnyeHosty(): array + { + /** @var array $hosty */ + $hosty = (array) config('services.self_render.allowed_hosts', []); + + return array_values(array_map(strval(...), $hosty)); + } + /** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */ public function twoGisReader(): TwoGisReader { @@ -171,6 +187,7 @@ class AutopodborServiceProvider extends ServiceProvider return new RenderServiceYandexListSource( (string) config('services.self_render.endpoint', ''), (string) config('services.self_render.key', ''), + razreshyonnyeHosty: self::razreshyonnyeHosty(), ); } diff --git a/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php b/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php new file mode 100644 index 00000000..1be0b4de --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php @@ -0,0 +1,165 @@ + */ + private readonly array $razreshyonnyeHosty; + + /** + * @param list $razreshyonnyeHosty хосты, куда вообще разрешено ходить с тайным ключом + */ + public function __construct(string $endpoint, private readonly string $key, array $razreshyonnyeHosty = []) + { + $this->endpoint = trim($endpoint); + $this->razreshyonnyeHosty = array_values(array_filter( + array_map(static fn (string $h): string => trim($h), $razreshyonnyeHosty), + static fn (string $h): bool => $h !== '', + )); + } + + /** Адрес без пробелов по краям — им же и ходим, чтобы проверяли и звали ОДНО И ТО ЖЕ. */ + public function adres(): string + { + return $this->endpoint; + } + + /** + * Пустая строка = дверь открыта. Иначе — человеческая причина, почему закрыта. + * Каждая причина СВОИМИ словами: «не задан адрес» и «не задан ключ» — разные беды. + */ + public function pochemuZakryta(): string + { + if ($this->endpoint === '') { + return 'не задан адрес рендер-сервиса'; + } + + if (trim($this->key) === '') { + return 'не задан тайный ключ рендер-сервиса'; + } + + $host = (string) (parse_url($this->endpoint, PHP_URL_HOST) ?: ''); + + if ($host === '') { + return 'адрес рендер-сервиса не разобрать — в нём нет хоста'; + } + + // 🔴 Пустой список = закрыто ВСЁ: «раз не сказано куда можно — значит никуда», а не + // наоборот. Ошибка в сторону закрытой двери стоит пустого поиска; ошибка в другую + // сторону стоит тайного ключа, и её уже не отыграть. + if (! in_array(mb_strtolower($host), array_map('mb_strtolower', $this->razreshyonnyeHosty), true)) { + return 'адрес рендер-сервиса ведёт на неразрешённый хост '.$host; + } + + $shema = mb_strtolower((string) (parse_url($this->endpoint, PHP_URL_SCHEME) ?: '')); + + // 🔴 Разрешённый хост НЕ означает безопасную дорогу: `http://` к той же самой виртуалке + // везёт тайный ключ открытым текстом. + if ($shema !== 'https' && ! $this->svoyaMashina($host)) { + return 'адрес рендер-сервиса без шифрования (' + .($shema === '' ? 'схема не указана' : $shema.'://') + .') — тайный ключ уехал бы открытым текстом'; + } + + return ''; + } + + /** + * 🔴 Единственное исключение: разработчик на СВОЁМ компьютере. + * + * Определяется не списком разрешённых слов (такой список сам становится дверью), а ДВУМЯ + * независимыми ответами, и оба обязаны совпасть: + * + * 1. **Где живёт само приложение** — спрашиваем Laravel (`APP_ENV`). Список положительный: + * любое окружение, которого тут не назвали (боевое, витрина, что заведут завтра), + * шифрование обязано. + * 2. **Где стоит виртуалка** — адрес обязан вести на петлю ЭТОЙ ЖЕ машины: тогда запрос + * физически не покидает компьютер и подслушивать его негде. + * + * Зачем два, а не один: одной перепутанной настройки на боевом для утечки не хватает — + * боевая виртуалка живёт на чужом железе и петлёй её адрес не станет. + */ + private function svoyaMashina(string $host): bool + { + if (! app()->environment('local', 'testing')) { + return false; + } + + return $this->petlya($host); + } + + /** + * Ведёт ли адрес на петлю этой самой машины — по существу, а не по написанию. + * + * 🪤 Резолвер имён сюда намеренно не зовём: он превратил бы проверку безопасности в + * неопределённую (чужой медленный DNS вешал бы поиск) и открыл бы щель между «проверили» и + * «пошли» — имя, ответившее петлёй на проверке, к моменту запроса может отвечать чужим + * адресом. Разработчику дверь скажет причину вслух, лечится одной правкой на `127.0.0.1`. + */ + private function petlya(string $host): bool + { + // Квадратные скобки — как IPv6 пишется в адресе: http://[::1]:8790/… + $host = trim(mb_strtolower(trim($host)), '[]'); + + // RFC 6761 §6.3: `localhost` и всё под `.localhost` закреплены за петлёй этой машины + // СТАНДАРТОМ. 🪤 `localhost.chuzhoy.ru` сюда не попадает: сверяется конец имени, а не + // наличие слова внутри. + if ($host === 'localhost' || str_ends_with($host, '.localhost')) { + return true; + } + + if (filter_var($host, FILTER_VALIDATE_IP) === false) { + return false; + } + + $dvoichnyy = inet_pton($host); + + if (! is_string($dvoichnyy)) { + return false; + } + + // ::ffff:127.0.0.1 — та же петля, записанная по-шестому. Сравнение ДВОИЧНОЕ, а не по словам. + if (strlen($dvoichnyy) === 16 && str_starts_with($dvoichnyy, str_repeat("\0", 10)."\xff\xff")) { + $dvoichnyy = substr($dvoichnyy, 12); + } + + // Петля — это ВСЯ сеть 127.0.0.0/8, а не один 127.0.0.1: у разработчика сервис может + // стоять на 127.0.1.1 (Debian) или 127.0.0.53 (resolved). + if (strlen($dvoichnyy) === 4) { + return $dvoichnyy[0] === "\x7f"; + } + + return $dvoichnyy === str_repeat("\0", 15)."\x01"; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php b/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php index fedace1b..5aa77053 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php @@ -5,23 +5,48 @@ declare(strict_types=1); namespace App\Services\Autopodbor\Agent\Fetch; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; /** * self-render путь {@see YandexListSource}: просит виртуалку отрисовать ленту Яндекс.Карт (со скроллом) * через прокси и вернуть организации. Контракт: `POST {endpoint} {url,type:'yandex-list',scrolls}` с * заголовком `X-Render-Key` → `{ "data": "" }`. Без endpoint/key — пусто (деградация). + * + * 🔴 Р126. Это ВТОРАЯ дверь на том же тайном ключе и том же `SELF_RENDER_ENDPOINT`, что и + * {@see SelfRenderClient}. Починить только одну из них — значит не починить ничего: одна и та же + * опечатка в адресе увела бы ключ отсюда ровно так же. Дверь — общая, {@see DverRendera}. */ final class RenderServiceYandexListSource implements YandexListSource { + private readonly string $endpoint; + + private readonly DverRendera $dver; + + /** + * @param list $razreshyonnyeHosty пустой список = закрыто ВСЁ (fail-closed) + */ public function __construct( - private readonly string $endpoint, + string $endpoint, private readonly string $key, private readonly int $httpTimeout = 120, - ) {} + array $razreshyonnyeHosty = [], + ) { + $this->dver = new DverRendera($endpoint, $key, $razreshyonnyeHosty); + $this->endpoint = $this->dver->adres(); + } public function fetch(string $url, int $scrolls): array { - if ($this->endpoint === '' || $this->key === '') { + $zakryta = $this->dver->pochemuZakryta(); + + if ($zakryta !== '') { + // Мягкая деградация: лента Яндекса пуста, наружу не ходили, ключ никуда не ушёл. + // Уровень `warning` — на боевом всё ниже в журнал не доезжает. + Log::warning('поиск клиентов: дверь к рендер-виртуалке закрыта — '.$zakryta, [ + 'ruchka' => 'yandex-list', + 'adres' => $this->endpoint, + ]); + return []; } diff --git a/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php b/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php index dc9642b5..395e50aa 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php @@ -7,6 +7,7 @@ namespace App\Services\Autopodbor\Agent\Fetch; use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; /** * HTTP-клиент к своему рендер-сервису на виртуалке (замена xf4). По форме — как {@see XfetchClient}: @@ -16,16 +17,33 @@ use Illuminate\Support\Facades\Http; * Без ключа/адреса — молча пусто (деградация, как xfetch без ключа), поток не падает. * * Контракт ответа виртуалки: `{ "data": "" }`; не-2xx/нет data → пустая строка. + * + * 🔴 Р126 (07.08.2026): куда именно можно ходить с тайным ключом, решает {@see DverRendera} — + * разрешённый хост + шифрованная дорога. Дверь закрыта = наружу НЕ ходим вовсе, отдаём пусто и + * пишем причину в журнал уровнем `warning` (на боевом `LOG_LEVEL=warning`, всё ниже не доезжает + * НИКОГДА — оплачено 04.08.2026). Это мягкая деградация, а не падение: поиск клиентов встаёт и + * говорит об этом, но портал живёт. */ final class SelfRenderClient implements BatchPageFetcher { + private readonly DverRendera $dver; + + /** + * @param list $razreshyonnyeHosty хосты, куда разрешено ходить с тайным ключом. + * Пустой список = закрыто ВСЁ (fail-closed). + */ public function __construct( private string $endpoint, private string $key, private int $concurrency = 8, private int $httpTimeout = 120, private int $retries = 2, - ) {} + array $razreshyonnyeHosty = [], + ) { + $this->dver = new DverRendera($endpoint, $key, $razreshyonnyeHosty); + // Проверяем и зовём ОДИН И ТОТ ЖЕ адрес — иначе между проверкой и запросом остаётся щель. + $this->endpoint = $this->dver->adres(); + } public function html(string $url): string { @@ -116,8 +134,22 @@ final class SelfRenderClient implements BatchPageFetcher return is_string($data) ? $data : ''; } + /** + * Открыта ли дверь наружу. Закрыта — говорим об этом в журнал ОДИН раз на обращение и + * никуда не идём. 🪤 В журнал уходят причина и адрес, но НЕ тайный ключ. + */ private function ready(): bool { - return $this->endpoint !== '' && $this->key !== ''; + $zakryta = $this->dver->pochemuZakryta(); + + if ($zakryta === '') { + return true; + } + + Log::warning('поиск клиентов: дверь к рендер-виртуалке закрыта — '.$zakryta, [ + 'adres' => $this->endpoint, + ]); + + return false; } } diff --git a/app/config/services.php b/app/config/services.php index 1d3b7598..a6db6a1e 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -316,8 +316,31 @@ return [ // Автоподбор self-render: свой рендер-сервис на виртуалке (замена xf4, за флагом autopodbor.self_render). // Секрет портал↔виртуалка и адрес — ТОЛЬКО в .env. Без них SelfRenderClient молча пусто (деградация). 'self_render' => [ + // 🔴 Р126: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ X-Render-Key в + // каждом запросе — по `http://` он поехал бы открытым текстом и молча. Адрес без + // шифрования портал не принимает: дверь закрыта, поиск отдаёт пусто и говорит об этом + // в журнал. Исключение ровно одно — разработчик на своём компьютере, и оно НЕ + // настраивается отсюда: тумблера «можно без шифра» тут нет намеренно, такой тумблер и + // был бы той самой опечаткой. Как устроено — см. `DverRendera::svoyaMashina()`. 'endpoint' => env('SELF_RENDER_ENDPOINT', ''), 'key' => env('SELF_RENDER_KEY', ''), + // 🔴 Р126. Куда клиенту ВООБЩЕ разрешено ходить с тайным ключом. Опечатка в + // SELF_RENDER_ENDPOINT увела бы запрос в чужой интернет — и тайный ключ уехал бы чужому + // человеку, молча и навсегда. Хост не из этого списка = дверь закрыта, запрос не + // отправлен, ключ никуда не ушёл. Пустой список = закрыто ВСЁ (fail-closed). + // + // 🔴 Умолчание НЕ пустое намеренно: на боевом этой строки в .env нет, а «Поиск клиентов» + // работает и зарабатывает прямо сейчас. Пустое умолчание закрыло бы его в момент выката. + // Взято по замеру ПИЛОТ.md: боевая виртуалка-рендер `https://51-250-1-97.sslip.io/render` + // (51.250.1.97). Набор тот же, что уже принят владельцем для OBZVON_ALLOWED_HOSTS — + // это ОДНА И ТА ЖЕ машина. + 'allowed_hosts' => array_values(array_filter( + array_map('trim', explode(',', (string) env( + 'SELF_RENDER_ALLOWED_HOSTS', + '51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost', + ))), + static fn (string $v): bool => $v !== '', + )), // Параллельность запросов к виртуалке (браузерная параллельность — внутри виртуалки, §12). 'concurrency' => (int) env('SELF_RENDER_CONCURRENCY', 8), // Плитка балансов (16.07): URL health-check виртуалки. Пусто → пингуем endpoint. diff --git a/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php b/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php new file mode 100644 index 00000000..d98cf62d --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php @@ -0,0 +1,261 @@ +pochemuZakryta())->toContain('не задан адрес'); +}); + +it('пустой ключ — закрыта, и сказано именно про ключ', function () { + expect(dver('https://render.local/render', '')->pochemuZakryta())->toContain('не задан тайный ключ'); +}); + +it('пробелы вокруг пустого адреса не считаются адресом', function () { + expect(dver(' ')->pochemuZakryta())->toContain('не задан адрес'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Разрешённые машины. Пустой список = закрыто ВСЁ +// ───────────────────────────────────────────────────────────────────────────── + +it('пустой список разрешённых машин закрывает даже правильный адрес', function () { + expect(dver('https://render.local/render', 'secret', [])->pochemuZakryta()) + ->toContain('неразрешённый хост'); +}); + +it('чужой хост закрыт, и хост назван вслух', function () { + expect(dver('https://render.local.zloumyshlennik.ru/render')->pochemuZakryta()) + ->toContain('render.local.zloumyshlennik.ru'); +}); + +it('хост сверяется без оглядки на регистр букв', function () { + expect(dver('https://RENDER.LOCAL/render')->pochemuZakryta())->toBe(''); +}); + +it('хост из списка, записанного заглавными, тоже узнаётся', function () { + expect(dver('https://render.local/render', 'secret', ['RENDER.LOCAL'])->pochemuZakryta())->toBe(''); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 🔴 ВОСЕМЬ ФОРМ АДРЕСА — та же приёмка, что прошла дверь голосового робота +// ───────────────────────────────────────────────────────────────────────────── + +it('восемь форм адреса разбираются ровно так, как решил владелец', function (string $adres, bool $otkryta) { + expect(dver($adres)->pochemuZakryta() === '')->toBe($otkryta); +})->with([ + 'https обычный' => ['https://render.local/render', true], + 'HTTPS большими буквами' => ['HTTPS://render.local/render', true], + 'https с портом' => ['https://render.local:8790/render', true], + 'https с пробелами по краям' => [' https://render.local/render ', true], + 'http — ключ уехал бы открытым' => ['http://render.local/render', false], + 'httpsX — не https' => ['httpsX://render.local/render', false], + 'http, а https внутри пути' => ['http://render.local/https://render', false], + 'вовсе без схемы' => ['render.local/render', false], +]); + +it('боевой адрес виртуалки дверь ПРОПУСКАЕТ', function () { + expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe(''); +}); + +it('про http сказано именно «без шифрования», а не «чужой хост»', function () { + expect(dver('http://render.local/render')->pochemuZakryta()) + ->toContain('без шифрования') + ->toContain('открытым текстом'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Своя машина — ДВА независимых ответа, и оба обязаны совпасть +// ───────────────────────────────────────────────────────────────────────────── + +// 🪤 IPv6 в списке разрешённых пишется В СКОБКАХ — ровно так его отдаёт разбор адреса. +// Замерено: без скобок хост не узнаётся. Так же устроена и проверенная дверь голосового +// робота; расхождение между двумя дверями было бы хуже этой шероховатости. +it('на своей машине http на петлю разрешён', function (string $adres) { + expect(dver($adres, 'secret', ['127.0.0.1', 'localhost', '127.0.1.1', '[::1]', '[::ffff:127.0.0.1]', 'moy.localhost']) + ->pochemuZakryta())->toBe(''); +})->with([ + 'http://127.0.0.1:8790/render', + 'http://127.0.1.1:8790/render', + 'http://localhost:8790/render', + 'http://moy.localhost:8790/render', + 'http://[::1]:8790/render', + 'http://[::ffff:127.0.0.1]:8790/render', +]); + +it('localhost внутри чужого имени петлёй НЕ считается', function () { + expect(dver('http://localhost.chuzhoy.ru/render', 'secret', ['localhost.chuzhoy.ru'])->pochemuZakryta()) + ->toContain('без шифрования'); +}); + +it('на боевом окружении петля НЕ спасает — http закрыт', function () { + app()->detectEnvironment(fn () => 'production'); + + expect(dver('http://127.0.0.1:8790/render', 'secret', ['127.0.0.1'])->pochemuZakryta()) + ->toContain('без шифрования'); +}); + +it('на боевом окружении https на разрешённый хост по-прежнему открыт', function () { + app()->detectEnvironment(fn () => 'production'); + + expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe(''); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Мягкая деградация клиента: наружу НЕ ходим, отдаём пусто, причина в журнал +// ───────────────────────────────────────────────────────────────────────────── + +function klientSDveryu(string $endpoint, array $hosty = HOSTY): SelfRenderClient +{ + return new SelfRenderClient( + endpoint: $endpoint, + key: 'secret', + concurrency: 4, + retries: 2, + razreshyonnyeHosty: $hosty, + ); +} + +it('закрытая дверь: html не ходит наружу и отдаёт пусто', function () { + Http::fake(); + + expect(klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'))->toBe(''); + + Http::assertNothingSent(); +}); + +it('закрытая дверь: htmlBatch не ходит наружу и отдаёт пусто на каждый адрес', function () { + Http::fake(); + + $out = klientSDveryu('https://chuzhoy.ru/render')->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']); + + expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']); + Http::assertNothingSent(); +}); + +it('закрытая дверь пишет причину в журнал уровнем не ниже предупреждения', function () { + Http::fake(); + Log::shouldReceive('warning')->once()->withArgs( + fn (string $soobshchenie) => str_contains($soobshchenie, 'без шифрования') + ); + + klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'); +}); + +it('в журнал не попадает тайный ключ', function () { + Http::fake(); + Log::shouldReceive('warning')->once()->withArgs( + fn (string $soobshchenie, array $context = []) => ! str_contains($soobshchenie.json_encode($context), 'secret') + ); + + klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'); +}); + +it('открытая дверь по-прежнему ходит наружу — сегодняшняя работа не изменилась', function () { + Http::fake(['https://render.local/render' => Http::response(['data' => '{"state":1}'], 200)]); + + expect(klientSDveryu('https://render.local/render')->html('https://2gis.ru/x'))->toBe('{"state":1}'); + + Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret')); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Вторая дверь на том же ключе — лента Яндекс.Карт +// ───────────────────────────────────────────────────────────────────────────── + +it('лента Яндекса: закрытая дверь не ходит наружу и отдаёт пусто', function () { + Http::fake(); + + $istochnik = new RenderServiceYandexListSource( + endpoint: 'http://render.local/render', + key: 'secret', + httpTimeout: 5, + razreshyonnyeHosty: HOSTY, + ); + + expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([]); + Http::assertNothingSent(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 🔴🔴 ГЛАВНОЕ: сегодняшний БОЕВОЙ поиск обязан продолжать работать +// +// Эти проверки не выдумывают список хостов, а берут НАСТОЯЩЕЕ умолчание из +// config/services.php — то самое, что поедет на боевой. Если кто-нибудь опустошит +// умолчание или уберёт передачу списка из провайдера, поиск клиентов на боевом встанет +// в момент выката. Здесь это станет красным ДО выката, а не после. +// ───────────────────────────────────────────────────────────────────────────── + +/** Боевой адрес виртуалки-рендера. Замер: ПИЛОТ.md 06.07.2026, «прод её достаёт». */ +const BOEVOY = 'https://51-250-1-97.sslip.io/render'; + +it('умолчание разрешённых машин НЕ пусто — иначе боевой поиск встал бы в момент выката', function () { + expect((array) config('services.self_render.allowed_hosts'))->not->toBeEmpty(); +}); + +it('боевой адрес виртуалки проходит дверь на НАСТОЯЩЕМ умолчании настроек', function () { + expect( + (new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts'))) + ->pochemuZakryta() + )->toBe(''); +}); + +it('боевой адрес открыт и тогда, когда приложение считает себя боевым', function () { + app()->detectEnvironment(fn () => 'production'); + + expect( + (new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts'))) + ->pochemuZakryta() + )->toBe(''); +}); + +it('провайдер ДЕЙСТВИТЕЛЬНО отдаёт список разрешённых машин ленте Яндекса', function () { + config()->set('autopodbor.self_render', true); + config()->set('services.self_render.endpoint', BOEVOY); + config()->set('services.self_render.key', 'secret'); + app()->detectEnvironment(fn () => 'production'); + + Http::fake([BOEVOY => Http::response(['data' => '{"orgs":[]}'], 200)]); + + (new AutopodborServiceProvider(app()))->yandexListSource() + ->fetch('https://yandex.ru/maps/?text=ломбард', 1); + + // Запрос УШЁЛ — значит провайдер передал список и дверь открылась на боевом адресе. + Http::assertSent(fn ($r) => $r->url() === BOEVOY && $r->hasHeader('X-Render-Key', 'secret')); +}); + +it('лента Яндекса: открытая дверь по-прежнему ходит наружу', function () { + Http::fake(['https://render.local/render' => Http::response(['data' => '{"orgs":[{"name":"А"}]}'], 200)]); + + $istochnik = new RenderServiceYandexListSource( + endpoint: 'https://render.local/render', + key: 'secret', + httpTimeout: 5, + razreshyonnyeHosty: HOSTY, + ); + + expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([['name' => 'А']]); + Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret')); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php b/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php index 2ce9ace4..5e2df74c 100644 --- a/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/RenderServiceYandexListSourceTest.php @@ -15,7 +15,9 @@ it('шлёт type=yandex-list + scrolls, разбирает orgs из data', fun ['name' => 'КрасЛомбард', 'id' => '1', 'href' => '/maps/org/kraslombard/1'], ]])], 200)]); - $src = new RenderServiceYandexListSource(YEP, 'secret'); + // 🔴 Р126: список разрешённых машин пуст по умолчанию (закрыто всё), поэтому проверка + // обязана назвать, куда ей можно, — ровно как это делает боевая настройка. + $src = new RenderServiceYandexListSource(YEP, 'secret', razreshyonnyeHosty: ['render.local']); $orgs = $src->fetch('https://yandex.ru/maps/?text=ломбард', 5); expect($orgs)->toHaveCount(1) diff --git a/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php b/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php index d5030648..dc0e410b 100644 --- a/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php @@ -20,6 +20,9 @@ function client(array $over = []): SelfRenderClient key: $over['key'] ?? 'secret', concurrency: $over['concurrency'] ?? 4, retries: $over['retries'] ?? 2, + // 🔴 Р126: список разрешённых машин пуст по умолчанию (закрыто всё), поэтому проверка + // обязана назвать, куда ей можно, — ровно как это делает боевая настройка. + razreshyonnyeHosty: $over['razreshyonnyeHosty'] ?? ['render.local'], ); } diff --git a/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md new file mode 100644 index 00000000..3cd71a7f --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md @@ -0,0 +1,361 @@ +# Р126 — дверь к рендер-виртуалке. Отчёт помощника, 07.08.2026 + +**Задача:** `SelfRenderClient` шлёт тайный ключ `SELF_RENDER_KEY` заголовком `X-Render-Key` на +виртуалку и НЕ проверяет ни адрес, ни шифрование. Опечатка в `SELF_RENDER_ENDPOINT` увела бы ключ +чужому молча и навсегда; `http://` увёз бы его открытым текстом. Решение владельца Р126 — починить +по образцу двери к голосовому роботу (`ObzvonClient::pochemuDverZakryta()`). + +**Ветка:** `feat/prospects-manual-testing-kp`. **Правка боевого модуля «Поиск клиентов».** +Выкат — отдельно и не мной. + +--- + +## 0. Моя ошибка — называю первой + +**Я запустил полный прогон раньше, чем закончил приводить код в порядок.** Через четыре минуты +после старта прогона `pint` переписал два моих файла (пробелы и порядок импортов). Прогон и правка +файлов шли одновременно — так делать нельзя: прогон мог прочитать файл наполовину переписанным, и +его зелёный цвет ничего бы не доказывал. Пришлось переделывать. Порядок обязан быть обратный: +сначала стиль, потом прогон. + +**Вторая, мельче.** В своей же проверке я записал адреса IPv6 в списке разрешённых **без квадратных +скобок** — и проверка покраснела. Первым побуждением было «поправить код, чтобы совпало». Это было +бы подгонкой лечения под мою ошибку: разбор адреса отдаёт хост именно в скобках, и код прав, а +неправа была проверка. Поправил проверку, код не трогал. + +--- + +## 1. Замер: какой адрес стоит на боевом + +На боевой сервер я НЕ ходил (нельзя). Замерял по хранилищу. **Пять независимых документов говорят +одно и то же:** + +| Где | Что сказано | +|---|---| +| `ПИЛОТ.md` (снимок боевого, 06.07.2026) | «Свой рендер-сервис — виртуалка **51.250.1.97** (`https://51-250-1-97.sslip.io/render`, **прод её достаёт**)» | +| `docs/superpowers/findings/2026-07-05-phase8-vm-deploy-runbook.md` | «https без домена: Caddy + `sslip.io` → адрес **`https://51-250-1-97.sslip.io/render`** (настоящий сертификат Let's Encrypt). **Домен НЕ понадобился**» | +| `docs/superpowers/2026-07-16-HANDOFF-external-services-monitoring.md` | «render-сервис = виртуалка `https://51-250-1-97.sslip.io/render` (POST)» | +| `app/config/services.php`, соседний блок `proxy_market` | комментарий `PROXY_MARKET_CHECK_URL` = `https://51-250-1-97.sslip.io/proxy-check` — **та же машина, тоже https** | +| `app/.env.example`, блок обзвона | `OBZVON_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost` — владелец **уже принял** этот набор для той же машины | + +Ни одного документа, где боевой ходил бы по `http://`, нет. Местного `app/.env` со строкой +`SELF_RENDER_ENDPOINT` тоже нет — на этой машине поиск не настроен. + +### Что я взял в список разрешённых машин и почему + +```text +51.250.1.97, 51-250-1-97.sslip.io, 127.0.0.1, localhost +``` + +**Это в точности тот набор, который владелец уже утвердил для `OBZVON_ALLOWED_HOSTS`** — потому +что это ОДНА И ТА ЖЕ машина `liderra-render`. Я не придумывал список, а взял принятый. + +🔴 **Умолчание в `config/services.php` намеренно НЕ пустое.** Правило «пустой список = закрыто +всё» я выполнил в коде двери, но умолчание оставил заполненным: на боевом строки +`SELF_RENDER_ALLOWED_HOSTS` в `.env` нет и не будет (я туда не хожу), а поиск работает и +зарабатывает прямо сейчас. Пустое умолчание остановило бы его **в момент выката**. Это самое +опасное место всей правки, и на него заведён отдельный сторож (см. §4, слом №4). + +**Чего я НЕ добавил:** `render.liderra.ru`. Такой поддомен упоминается в рунбуке как +планировавшийся вариант, но по замеру он **не поднимался** («домен НЕ понадобился»). Выдумывать +хосты, которых не существует, я не стал. + +--- + +## 2. Что построено + +| Файл | Что | +|---|---| +| `app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php` | **новый.** Сама дверь: адрес → ключ → разрешённый хост → шифрование → исключение «своя машина» | +| `app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php` | 🔴 боевой. Спрашивает дверь; закрыта → наружу не ходим, пусто, причина в журнал `warning` | +| `app/app/Services/Autopodbor/Agent/Fetch/RenderServiceYandexListSource.php` | 🔴 боевой. **Вторая дверь на том же ключе** — см. §8 | +| `app/app/Providers/AutopodborServiceProvider.php` | передаёт список разрешённых машин обеим дверям | +| `app/config/services.php` | `self_render.allowed_hosts` + умолчание | +| `app/.env.example` | блок `SELF_RENDER_*` (его там не было вовсе) | +| `app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php` | **новый.** 37 проверок | +| `app/tests/Unit/Autopodbor/Fetch/SelfRenderClientTest.php`, `...RenderServiceYandexListSourceTest.php` | подправлены: договор изменился, проверка обязана назвать, куда ей можно | + +Устройство — ровно образец Р123, пункт в пункт: + +1. пустой адрес и пустой ключ — **разные** причины, каждая своими словами; +2. хост не из списка — закрыто; **пустой список = закрыто всё**; +3. схема не `https` — закрыто, кроме своей машины; +4. «своя машина» — **два независимых ответа**: (а) `APP_ENV` из положительного списка + (`local`/`testing`), (б) адрес ведёт на петлю **этой же** машины. Сравнение **двоичное** + (`inet_pton`), вся сеть `127.0.0.0/8`, `::1`, `::ffff:127.0.0.1`, `localhost` и `*.localhost` + по RFC 6761. Резолвер имён не зовём намеренно; +5. закрытая дверь — **мягкая деградация**: наружу не ходим, отдаём пусто, причина в журнал + уровнем `warning` (на боевом всё ниже не доезжает НИКОГДА). + +🔴 Тумблера «можно без шифра» нет. + +--- + +## 3. Доказательство, что живой поиск не сломается + +Это не «тесты зелены». Я собрал прибор, который прогоняет **все формы адреса, какие вообще могут +стоять в боевой настройке**, через настоящую дверь, с **настоящим умолчанием списка из +`config/services.php`**, притворяясь боевым окружением (`production`), — то есть с выключенным +исключением «своя машина». + +```text +Список разрешённых машин (умолчание config/services.php): 51.250.1.97, 51-250-1-97.sslip.io, 127.0.0.1, localhost +Окружение приложения: production + +БОЕВОЙ СЕГОДНЯ (ПИЛОТ.md): https + sslip.io ОТКРЫТА +тот же адрес, HTTPS заглавными ОТКРЫТА +тот же адрес с явным портом 443 ОТКРЫТА +тот же адрес с пробелами по краям ОТКРЫТА +тот же адрес со слэшем на конце ОТКРЫТА +голый IP по https ОТКРЫТА +голый IP по https с портом 8790 ОТКРЫТА +ЗАПАСНОЙ вариант из рунбука: голый IP по http:8790 ЗАКРЫТА — без шифрования (http://) — тайный ключ уехал бы открытым текстом +планировавшийся, но НЕ поднятый домен ЗАКРЫТА — неразрешённый хост render.liderra.ru +sslip.io по http ЗАКРЫТА — без шифрования (http://) +опечатка в имени машины ЗАКРЫТА — неразрешённый хост 51-250-1-98.sslip.io +вовсе без схемы ЗАКРЫТА — в адресе нет хоста +пусто (флаг self_render выключен) ЗАКРЫТА — не задан адрес рендер-сервиса +``` + +### 🔴 Формы, которые моя дверь ЗАКРЫЛА БЫ — называю отдельными строками + +1. **`http://51.250.1.97:8790/render`** — «запасной вариант без поддомена» из рунбука. + По замеру он **не был взят** (взяли Caddy + sslip.io, «домен НЕ понадобился»), но если на + боевом стоит именно он — **поиск встанет**. +2. **`https://render.liderra.ru/render`** — планировавшийся поддомен. По замеру **не поднимался**. + Если он всё-таки живой — поиск встанет. +3. **`http://51-250-1-97.sslip.io/render`** — та же машина, но без шифра. Это ровно та беда, ради + которой Р126 и заведён: дверь обязана его закрыть, и закрывает. + +### Что делать перед выкатом — одна команда + +Перед выкатом посмотреть на боевом **одну строку** и сверить с таблицей выше: + +```bash +grep '^SELF_RENDER_ENDPOINT=' /var/www/liderra/.env +``` + +- Ответ начинается с `https://51-250-1-97.sslip.io` или `https://51.250.1.97` → **выкатывать + можно, поиск не заметит правки**. +- Ответ начинается с `http://` → **не выкатывать**. Лечение — не тумблер-обход, а перевод адреса + на `https://51-250-1-97.sslip.io/render`: сертификат Let's Encrypt на этой машине уже стоит и + работает, менять на виртуалке нечего. +- Ответ ведёт на другой хост → добавить его в `SELF_RENDER_ALLOWED_HOSTS` **тем же действием**, + что и правку адреса. + +**Честно: доказать со стопроцентной уверенностью я не могу** — на боевой не хожу. Я доказал, что +(а) все пять документов о боевом говорят https+sslip.io, (б) на этом адресе дверь открыта даже в +боевом окружении, (в) худший случай лечится одной строкой `.env` без правки кода. + +--- + +## 4. Сторожа, показанные красными + +Ломал **по одной защите за раз**, каждый раз возвращал и сверял слепок со снятием `\r`. + +| # | Что ломал | Стало красным | Что сказал сторож дословно | +|---|---|---|---| +| 1 | Проверку разрешённого хоста → `if (false)` | **3** | `Failed asserting that '' contains "неразрешённый хост"`; `Failed asserting that '' contains "render.local.zloumyshlennik.ru"`; `Requests were recorded. Failed asserting that an array is empty.` | +| 2 | Проверку шифрования → `if (false && …)` | **10** (8 «провалов» + **2 спрятались в «ошибках»**) | `Failed asserting that true is identical to false` (×3 формы адреса); `Failed asserting that '' contains "без шифрования"` (×3); `Requests were recorded` (×2); `Method warning(...) should be called exactly 1 times but called 0 times` (×2) | +| 3 | Проверку окружения в «своей машине» → `if (false)` | **1** | `Failed asserting that '' contains "без шифрования"` — ровно тот сторож, что стережёт «на боевом петля НЕ спасает» | +| 4 | 🔴 **Умолчание списка в `config/services.php` → пустое** | **4** | `Expecting [] not to be empty`; **`адрес рендер-сервиса ведёт на неразрешённый хост 51-250-1-97.sslip.io`** (×2); `An expected request was not recorded` | +| 5 | Передачу списка из провайдера → убрал аргумент | **1** | `An expected request was not recorded. Failed asserting that false is true` | + +**Слом №4 — самый важный.** Он показывает ровно ту катастрофу, о которой предупреждал владелец: +сторож называет вслух **боевой адрес виртуалки** как «неразрешённый». Пока этот сторож зелёный, +боевой поиск моя правка не закроет; станет он красным — значит кто-то опустошил умолчание, и это +видно ДО выката, а не после. + +**Возврат доказан слепками** (`tr -d '\r' | git hash-object --stdin`) — все пять файлов совпали с +состоянием до слома побайтово: + +```text +35533d7114404c16c2ba1a9bae1186632dd30a5e DverRendera.php +395e50aad3e319e70cc2b6c213677cf3b70f51de SelfRenderClient.php +5aa77053afa52ca0dd9eb37837534bb73ae74ccf RenderServiceYandexListSource.php +a6db6a1e48fd0acb9b1dbcd317c692b925ed5124 config/services.php +447b83c2564b92649be80506de02f752aab6781c AutopodborServiceProvider.php +``` + +🪤 **Слом №2 — наглядный урок про арифметику.** Прибор написал «провалов: 8», а красных было +**10**: два сторожа спрятались в поле «ошибок» (мокнутый журнал). Считать надо по сумме: +`27 + 8 + 2 = 37`. + +--- + +## 5. Полный прогон + +Своя база `liderra_testing_render` (создана и накатана мной, чужие не трогал). +Прогон **дважды**: первый гонялся одновременно с правкой стиля — см. §0, его цвет я в зачёт не +беру. Второй прогнан на **окончательных байтах**, ничего рядом не работало. + +```text +tests 5293 · passed 5289 · failed 0 · errors 0 · skipped 4 · risky 4 +assertions 21798 · 1 168 096 мс +``` + +🔴 **Арифметика сходится:** `5289 + 0 + 0 + 4 = 5293`. Красных нет ни в «провалах», ни в +«ошибках» — проверил оба поля отдельно, потому что на сломе №2 два сторожа прятались именно в +«ошибках» при «провалов: 0». + +На входе было **5242 проверки, красных 0**; стало **5293**. Прибыло **51**: мои **37** и **14** от +соседей — за смену в ветку легли чужие коммиты (голова уехала с `784ed0739` на `4513fe1a5`). + +Прочие сторожа: + +| Что | Итог | +|---|---| +| `composer stan` (Larastan) | **0 ошибок** | +| `vendor/bin/pint --test` | чисто по всем моим файлам | +| `deptrac analyse` | **0 нарушений** слоёв | +| `gitleaks` по моим файлам | чисто. Единственная находка — `SMS_MTS_TOKEN=` в `.env.example` строка 139 — **не моя**: доказано тем, что она есть и в версии файла из `HEAD` | +| `markdownlint-cli2` по отчёту | **0 ошибок** | + +--- + +## 6. Соседи + +### Что могло сломаться у живого поиска клиентов и почему не сломалось — с замером + +**Могло сломаться вот что:** новая дверь встала на путь, по которому боевой поиск ходит за каждой +страницей 2ГИС и за каждой лентой Яндекса. Ошибись я в списке разрешённых машин или в разборе +адреса — поиск отдавал бы пусто на КАЖДЫЙ запрос, молча, и портал бы при этом отвечал 200. + +**Почему не сломалось — три независимых замера, а не слово:** + +1. **Прибор из §3** прогнал 13 форм боевого адреса через настоящую дверь с настоящим умолчанием в + окружении `production`. Все семь форм замеренного боевого адреса — **ОТКРЫТА**. +2. **Проверка «провайдер ДЕЙСТВИТЕЛЬНО отдаёт список»** ходит через настоящий + `AutopodborServiceProvider` на боевой адрес и убеждается, что запрос **ушёл** с заголовком + `X-Render-Key`. Показана красной сломом №5. +3. **Старые проверки поведения не тронуты по смыслу**: `SelfRenderClientTest` и + `RenderServiceYandexListSourceTest` как проверяли отправку, тип запроса и разбор ответа, так и + проверяют — я дописал им только строку «куда можно», ничего не ослабив. Проверка «открытая + дверь по-прежнему ходит наружу — сегодняшняя работа не изменилась» это закрепляет. + +**Чего замер НЕ доказывает, говорю прямо:** боевое значение `SELF_RENDER_ENDPOINT` я не видел. +Проверка перед выкатом — одна команда, §3. + +### Чужие файлы в дереве не тронуты + +Сверено командой `git status`. В дереве изменены соседями и мной **не тронуты**: + +- `app/app/Services/Obzvon/ObzvonClient.php` и `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` + — работа соседа по обзвону, +322/−76 строк. Мои опознавательные слова `DverRendera` и `Р126` + встречаются в них **0 раз** (проверено `grep -c`). Совпадения по `razreshyonnyeHosty` — это + собственное имя переменной соседа, оно там было до меня. +- `docs/superpowers/priyomka/stroyka-5/z-0-10-storozha-mashiny-2026-08-06.md` и + `docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md` — чужие + отчёты. + +Общий файл `app/config/services.php` перечитан вплотную перед коммитом: **+23 строки, −0**, все +внутри блока `self_render`, ни одной чужой строки не задето. + +Коммит собран **поимённо** десятью путями, `git add .` не применялся. + +--- + +## 7. Зовущие поимённо + +Прибор (`grep`) насчитал **25 упоминаний в 8 файлах**. Это НЕ зовущие — прибор считает **любое +упоминание, включая комментарии**. Спросил его отдельно про настоящие вызовы: + +**Настоящих мест создания — ровно 2, оба в `AutopodborServiceProvider`:** + +| Место | Что создаёт | +|---|---| +| `AutopodborServiceProvider::selfRenderClient()` (стр. 154) | `new SelfRenderClient` | +| `AutopodborServiceProvider::yandexListSource()` (стр. 187) | `new RenderServiceYandexListSource` | + +**Кто зовёт эти две фабрики — 4 места, все в том же провайдере:** + +| Место | Зачем | +|---|---| +| стр. 76 | привязка `CompetitorAgent` — шаг 2 «изучение конкурента» | +| стр. 207 | тот же клиент, обёрнутый кэшем карточек | +| стр. 210 | `PlaywrightYandexPhoneSearch` — телефоны из Яндекса | +| стр. 248 | `PlaywrightYandexDirectory` — справочник Яндекса | + +**Кто потребляет готовое в работе:** `SelfRenderClient` как `BatchPageFetcher` — +`CachingPageFetcher`, `XfetchDirectoryFetcher`, `PhoneReverseExpander`, `DeepStudyCollector`. +`RenderServiceYandexListSource` как `YandexListSource` — `PlaywrightYandexDirectory`, +`PlaywrightYandexPhoneSearch`. + +**Упоминания, которые НЕ зовущие (5 файлов):** `ObzvonClient.php` (импорт + четыре `{@see}` в +шапке, не создаёт никогда), `DeepStudyCollector.php`, `CachingPageFetcher.php`, +`YandexListSource.php` и комментарий в самом провайдере. + +Итог: **25 упоминаний → 2 места создания → 4 места вызова.** + +--- + +## 8. Разбор задания + +### 🔴 Главное: картина беды верна, но НЕПОЛНА. Дыр было ДВЕ, а не одна + +Промт назвал один файл — `SelfRenderClient.php`. Замерено: тайный ключ `SELF_RENDER_KEY` уходит +заголовком `X-Render-Key` **из трёх мест**, и два из них берут адрес из **той же самой** настройки +`SELF_RENDER_ENDPOINT`: + +| Файл | Откуда адрес | Было | +|---|---|---| +| `SelfRenderClient.php` | `services.self_render.endpoint` | дверь = «адрес не пуст и ключ не пуст» | +| **`RenderServiceYandexListSource.php`** | **`services.self_render.endpoint` — ТА ЖЕ** | **то же самое** | +| `ProxyMarketProbe.php` | `services.proxy_market.check_url` (другая настройка) | ключ шлётся без единой проверки адреса | + +**Почини я только названный файл — Р126 остался бы невыполненным.** Одна и та же опечатка в +`SELF_RENDER_ENDPOINT` увела бы ключ через ленту Яндекса ровно так же, молча и навсегда. Поэтому я +починил обе двери общим устройством `DverRendera` и общим списком из провайдера. Список у них +обязан быть один: они ходят по одному адресу с одним ключом. + +### 🟡 Третье место я НЕ трогал — и говорю почему + +`ProxyMarketProbe.php` шлёт **тот же тайный ключ** на `PROXY_MARKET_CHECK_URL`. Дыра того же +класса. Не починил намеренно, по двум причинам: + +1. Это **другая настройка**, и её боевого значения я замерить не могу — есть только комментарий в + коде (`https://51-250-1-97.sslip.io/proxy-check`). Закрыть дверь по недоказанному значению — + это ровно то, от чего меня предостерегали. +2. Это **не путь боевого поиска**, а плитка наблюдения. Ломать её вслепую ради красоты — плохой + размен. + +**Это решение владельца, а не моё.** Если скажете «чинить» — там ровно один вызов `DverRendera`, +работы на десять минут; и тогда же надо будет замерить боевое значение `PROXY_MARKET_CHECK_URL`. + +### 🟡 Мелочь в промте, которая могла увести + +Промт зовёт виртуалку «единственной точкой отказа поиска» и говорит, что дверь надо ставить на +`SelfRenderClient`. Это верно про **карточки** (2ГИС), но лента Яндекса идёт мимо этого класса — +через `RenderServiceYandexListSource`. Если бы я поверил формулировке «вся его дверь — это +целиком» и не пошёл смотреть соседей, я бы отчитался «сделано» при живой дыре. + +### 🟢 В чём промт прав, и это подтвердилось замером + +- Дыра действительно есть и действительно молчаливая — во всём файле ноль упоминаний разбора + адреса, списка машин и шифрования. Проверил. +- Образец Р123 подходит один в один, переносится без натяжек. +- Требование «умолчание такое, чтобы боевой адрес в него попал» — самое ценное указание в промте. + Без него я бы поставил fail-closed пустым по букве правила и **остановил бы боевой поиск**. + +### 🟡 Шероховатость, которую я оставил намеренно + +IPv6 в списке разрешённых машин надо писать **в квадратных скобках** (`[::1]`), потому что разбор +адреса отдаёт хост именно так. Написавший `::1` получит «неразрешённый хост» и не поймёт почему. +Не стал чинить: ровно так же устроена уже принятая дверь голосового робота, и расхождение между +двумя дверями было бы хуже этой шероховатости. На боевом IPv6 не используется. Записал в проверке +ловушкой. + +--- + +## 9. Что осталось незакрытым + +1. 🔴 **Боевое значение `SELF_RENDER_ENDPOINT` не замерено** — на боевой я не ходил. Проверка + перед выкатом — одна команда, §3. +2. 🟡 **`ProxyMarketProbe` шлёт тот же ключ без двери** — §8, ждёт слова владельца. +3. 🟡 **`PROXY_MARKET_CHECK_URL` на боевом не замерен** — понадобится, если решите чинить п. 2. +4. 🟡 **Дверь не доказывает, что на том конце НАША виртуалка.** Шифрование бережёт ключ в пути, + список хостов бережёт от опечатки, но подмена машины на том же адресе не закрыта ничем. + Лечится закреплением отпечатка сертификата — отдельная задача. +5. 🟡 **`SELF_RENDER_HEALTH_URL` по-прежнему пуст**, сторож живости пингует сам `endpoint` + (хвост от 16.07.2026). Не моя задача, но рядом. +6. 🟢 **Выкат не мой.** Ничего не выкачено, ничего не запушено.