Files
portal/app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php
T
Дмитрий 9247281688 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
2026-08-07 08:04:35 +03:00

156 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}