fix поиск клиентов: дверь к рендер-виртуалке — разрешённые машины и обязательное шифрование, Р126

Тайный ключ SELF_RENDER_KEY уходил заголовком X-Render-Key на любой адрес, какой окажется
в настройке. Проверялось ровно одно: адрес не пуст и ключ не пуст. Отсюда две молчаливые беды:
опечатка в SELF_RENDER_ENDPOINT увела бы ключ чужому человеку навсегда и без признаков,
а http:// увёз бы его открытым текстом.

Дверь повторяет проверенное устройство Р123 от голосового робота: своя причина на пустой
адрес и на пустой ключ, список разрешённых машин с пустым списком как «закрыто всё»,
обязательное https кроме своей машины, а «своя машина» — два независимых ответа: окружение
из положительного списка И двоичная проверка петли 127.0.0.0/8, ::1, localhost по RFC 6761.
Закрытая дверь = мягкая деградация: наружу не ходим, отдаём пусто, причина в журнал уровнем
warning, потому что на боевом всё ниже не доезжает.

Тумблера «можно без шифра» нет намеренно: он и был бы той самой опечаткой.

Дыр было ДВЕ, а не одна. Тот же ключ и тот же SELF_RENDER_ENDPOINT уходят из
RenderServiceYandexListSource — лента Яндекс.Карт. Починка только названного файла оставила
бы дыру открытой, поэтому дверь общая на обе.

Боевой поиск не сломается: умолчание списка НЕ пустое и содержит замеренный боевой адрес
виртуалки 51-250-1-97.sslip.io — пустое умолчание остановило бы поиск в момент выката.
Набор тот же, что владелец уже принял для OBZVON_ALLOWED_HOSTS: это одна и та же машина.
Прогнаны все формы адреса, какие могут стоять в боевой настройке, в окружении production.

Проверок 37, сторожа показаны красными пятью отдельными сломами, возврат доказан слепками.
Larastan 0, Pint чисто.

Не закрыто и ждёт слова владельца: ProxyMarketProbe шлёт тот же ключ на другую настройку
без двери; боевое значение SELF_RENDER_ENDPOINT не замерено, на боевой не ходил.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md
This commit is contained in:
Дмитрий
2026-08-07 08:04:35 +03:00
parent 475e2d6245
commit 9247281688
10 changed files with 923 additions and 6 deletions
+28
View File
@@ -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) ──
# Робот живёт на отдельной машине и делит железо с боевым рендером «Поиска клиентов».
# Пока адрес или ключ пусты — дверь закрыта: портал наружу не ходит, звонков не будет,
@@ -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<string>
*/
private static function razreshyonnyeHosty(): array
{
/** @var array<array-key, mixed> $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(),
);
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use App\Services\Obzvon\ObzvonClient;
/**
* 🔴 Р126 ДВЕРЬ К РЕНДЕР-ВИРТУАЛКЕ. Решение владельца от 07.08.2026.
*
* Тайный ключ `SELF_RENDER_KEY` едет ЗАГОЛОВКОМ `X-Render-Key` в КАЖДОМ запросе к виртуалке.
* До этой двери проверялось ровно одно: «адрес не пуст и ключ не пуст». Отсюда две беды, и обе
* молчаливые:
*
* 1. **Опечатка в `SELF_RENDER_ENDPOINT` увела бы ключ чужому человеку** навсегда и без
* единого признака: запрос ушёл бы, ответ не подошёл бы, поиск отдал бы пусто, а ключ
* остался бы у того, кто принял запрос.
* 2. **`http://` увёз бы ключ открытым текстом** по той же опечатке в одной букве.
*
* Устройство повторяет дверь к голосовому роботу ({@see ObzvonClient}, Р123),
* уже проверенную приёмкой на восьми формах адреса. Порядок проверок тот же и он важен: сперва
* «есть ли что проверять», потом КУДА, и только потом КАК.
*
* 🔴 Тумблера «можно без шифра» здесь нет намеренно. Такой тумблер и был бы той самой опечаткой,
* ради которой дверь заведена.
*
* 🪤 Чего дверь НЕ закрывает, говорю прямо: шифрование бережёт ключ В ПУТИ и разрешённый хост
* бережёт от опечатки, но НИ ТО НИ ДРУГОЕ не доказывает, что на том конце наша виртуалка, а не
* чужая машина, занявшая тот же адрес. Это отдельная задача (закрепить отпечаток сертификата),
* и она не здесь.
*/
final class DverRendera
{
private readonly string $endpoint;
/** @var list<string> */
private readonly array $razreshyonnyeHosty;
/**
* @param list<string> $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";
}
}
@@ -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": "<JSON {orgs:[...]}>" }`. Без 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<string> $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 [];
}
@@ -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": "<initialState|html>" }`; не-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<string> $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;
}
}
+23
View File
@@ -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.
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use App\Providers\AutopodborServiceProvider;
use App\Services\Autopodbor\Agent\Fetch\DverRendera;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
// Р126. Дверь к рендер-виртуалке: тайный ключ SELF_RENDER_KEY едет ЗАГОЛОВКОМ X-Render-Key в
// каждом запросе. Опечатка в адресе увела бы его чужому молча и навсегда; `http://` увёз бы его
// открытым текстом. Устройство повторяет проверенную дверь к голосовому роботу (Р123).
/** Хосты, куда разрешено в этих проверках. Боевой список — в config/services.php. */
const HOSTY = ['render.local', '51-250-1-97.sslip.io'];
function dver(string $endpoint, string $key = 'secret', ?array $hosty = null): DverRendera
{
return new DverRendera($endpoint, $key, $hosty ?? HOSTY);
}
// ─────────────────────────────────────────────────────────────────────────────
// Две причины «нет настройки» — каждая СВОИМИ словами, а не одним «не готово»
// ─────────────────────────────────────────────────────────────────────────────
it('пустой адрес — закрыта, и сказано именно про адрес', function () {
expect(dver('')->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'));
});
@@ -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)
@@ -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'],
);
}
@@ -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. 🟢 **Выкат не мой.** Ничего не выкачено, ничего не запушено.