9247281688
Тайный ключ 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
156 lines
6.1 KiB
PHP
156 lines
6.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
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}:
|
||
* реализует {@see BatchPageFetcher} (значит и {@see PageFetcher}). Просит виртуалку отрисовать страницу
|
||
* с маскировкой+прокси и вернуть данные: для 2ГИС — `initialState` (разбирает {@see TwoGisJsonReader}),
|
||
* для Яндекса/сайтов — HTML. Секрет портал↔виртуалка — в заголовке `X-Render-Key` (только из .env).
|
||
* Без ключа/адреса — молча пусто (деградация, как 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
|
||
{
|
||
if (! $this->ready()) {
|
||
return '';
|
||
}
|
||
|
||
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
|
||
$data = $this->decode($this->request($url));
|
||
if ($data !== '') {
|
||
return $data;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $urls
|
||
* @return array<string, string>
|
||
*/
|
||
public function htmlBatch(array $urls): array
|
||
{
|
||
$urls = array_values(array_unique($urls));
|
||
if (! $this->ready() || $urls === []) {
|
||
return array_fill_keys($urls, '');
|
||
}
|
||
|
||
$result = array_fill_keys($urls, '');
|
||
foreach (array_chunk($urls, max(1, $this->concurrency)) as $chunk) {
|
||
$this->fetchChunkInto($chunk, $result);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Догружает порцию параллельно, ретраит оставшиеся пустыми (флак) до $retries раз.
|
||
*
|
||
* @param list<string> $chunk
|
||
* @param array<string, string> $result
|
||
*/
|
||
private function fetchChunkInto(array $chunk, array &$result): void
|
||
{
|
||
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
|
||
$pending = array_values(array_filter($chunk, fn (string $u): bool => $result[$u] === ''));
|
||
if ($pending === []) {
|
||
return;
|
||
}
|
||
|
||
$responses = Http::pool(fn (Pool $pool) => array_map(
|
||
fn (string $u) => $pool->as($u)
|
||
->timeout($this->httpTimeout)
|
||
->withHeaders(['X-Render-Key' => $this->key])
|
||
->asJson()
|
||
->post($this->endpoint, ['url' => $u, 'type' => $this->typeFor($u)]),
|
||
$pending,
|
||
));
|
||
|
||
foreach ($pending as $u) {
|
||
$result[$u] = $this->decode($responses[$u] ?? null);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function request(string $url): Response|\Throwable|null
|
||
{
|
||
return Http::timeout($this->httpTimeout)
|
||
->withHeaders(['X-Render-Key' => $this->key])
|
||
->asJson()
|
||
->post($this->endpoint, ['url' => $url, 'type' => $this->typeFor($url)]);
|
||
}
|
||
|
||
/** 2ГИС отдаёт встроенный initialState (JSON), Яндекс/сайты — HTML. */
|
||
private function typeFor(string $url): string
|
||
{
|
||
return str_contains($url, '2gis') ? 'initialState' : 'html';
|
||
}
|
||
|
||
private function decode(Response|\Throwable|null $resp): string
|
||
{
|
||
if (! $resp instanceof Response || ! $resp->successful()) {
|
||
return '';
|
||
}
|
||
|
||
$data = $resp->json('data');
|
||
|
||
return is_string($data) ? $data : '';
|
||
}
|
||
|
||
/**
|
||
* Открыта ли дверь наружу. Закрыта — говорим об этом в журнал ОДИН раз на обращение и
|
||
* никуда не идём. 🪤 В журнал уходят причина и адрес, но НЕ тайный ключ.
|
||
*/
|
||
private function ready(): bool
|
||
{
|
||
$zakryta = $this->dver->pochemuZakryta();
|
||
|
||
if ($zakryta === '') {
|
||
return true;
|
||
}
|
||
|
||
Log::warning('поиск клиентов: дверь к рендер-виртуалке закрыта — '.$zakryta, [
|
||
'adres' => $this->endpoint,
|
||
]);
|
||
|
||
return false;
|
||
}
|
||
}
|