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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+361
@@ -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. 🟢 **Выкат не мой.** Ничего не выкачено, ничего не запушено.
|
||||
Reference in New Issue
Block a user