fix обзвон: к роботу только по шифрованной дороге, кроме своей машины — Р123

Дверь пускала http к разрешённому хосту, и тайный ключ уезжал заголовком
открытым текстом — молча, без единого признака. Одной опечатки в OBZVON_ENDPOINT
хватало. Теперь адрес без шифрования портал не принимает: дверь закрыта, звонка
нет, причина сказана словами и лежит в журнале уровнем предупреждения.

Исключение ровно одно — разработчик на своём компьютере — и устроено оно ДВУМЯ
независимыми ответами, чтобы одной перепутанной настройки на боевом для утечки
не хватило:

1. приложение само говорит, где живёт: спрашиваем Laravel, а не список слов.
   Список окружений положительный, а не «всё кроме боевого»: любое неназванное
   окружение обязано шифрование.
2. робот стоит на ЭТОЙ ЖЕ машине: адрес ведёт на петлю — вся сеть 127.0.0.0/8,
   ::1, ::ffff:127.x.x.x, имена localhost и *.localhost по RFC 6761. Определяется
   двоичным сравнением адреса, а не совпадением слов. Резолвер имён не зовём
   намеренно: он вносит в проверку безопасности неопределённость и щель между
   «проверили» и «пошли».

Именно второй ответ и закрывает то, что владелец назвал незакрытым: одной
ошибки в APP_ENV на боевом теперь мало — боевой робот петлёй не станет. На это
стоит именной сторож.

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

12 новых сторожей: четыре случая ножа приёмки ж, з, и, к; сторож на само
исключение; сторож против совпадения слов вместо петли; сторож на подложное
шифрование, когда проверку чужого удостоверения снимают. Каждый показан
красным — починка ронялась четырьмя разными способами, и каждый раз краснело
именно то, что этот способ ломает. Возврат доказан слепком со снятием невидимых
знаков конца строки.

Моя ошибка первой: первый сторож про журнал зеленел не по той причине — любая
строка предупреждения его устраивала. Нашёлся только арифметикой прогона, потому
что упал в поле ошибок, а не провалов. Переписан.

Найдено и НЕ тронуто второе место того же класса: services.self_render шлёт
секрет заголовком, и его дверь не проверяет ни схему, ни хост вовсе. Путь живой,
боевой. Вынесено решением владельцу отдельно.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-07 06:31:35 +03:00
parent f671e21d01
commit dd3b6fb4b5
5 changed files with 678 additions and 1 deletions
+10
View File
@@ -170,6 +170,14 @@ CREATIVE_ROBOT_TOKEN=
# Пока адрес или ключ пусты — дверь закрыта: портал наружу не ходит, звонков не будет,
# и он говорит об этом вслух. Ошибками не сыплет.
# БАЗОВЫЙ адрес приёмника заданий, без хвоста ручки (портал сам добавит /pozvonit и др.).
# 🔴 Р123: только https://. Тайный ключ едет заголовком в каждом запросе — по http:// он
# поехал бы открытым текстом и молча. Адрес без шифрования портал не принимает: дверь
# закрыта, звонков нет. Живой робот 51.250.1.97 слушает 443 уже сегодня.
# Единственное исключение — разработчик на СВОЁМ компьютере: при APP_ENV=local (или
# testing) И адресе на петлю этой же машины (127.0.0.0/8, ::1, localhost) http:// разрешён,
# потому что такой запрос компьютер физически не покидает. Тумблера «можно без шифра»
# нет намеренно: он и был бы той опечаткой, ради которой запрет заведён.
# Пример для своей машины: OBZVON_ENDPOINT=http://127.0.0.1:8080/lena
OBZVON_ENDPOINT=
# Тайный ключ портал↔робот. Только здесь, в код и в гит НЕ попадает.
OBZVON_KEY=
@@ -189,4 +197,6 @@ OBZVON_TIMEOUT=15
# 🔴 Куда порталу ВООБЩЕ разрешено ходить с тайным ключом (хосты через запятую).
# Опечатка в OBZVON_ENDPOINT увела бы ключ чужому человеку молча. Хост не из списка =
# дверь закрыта. Пусто = закрыто всё. Переезд робота — осознанная правка в двух местах.
# 🪤 Разрешённый хост — это ещё не безопасная дорога: http:// к разрешённому хосту всё
# равно закрыт (Р123). Два запрета независимы, и снимать их надо по отдельности.
OBZVON_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost
+115 -1
View File
@@ -71,6 +71,31 @@ use Illuminate\Support\Facades\Log;
* ЗАЯВЛЕНА, но не замкнута. Так и записано в отчёте, «сделано» не называется.
*
* ─────────────────────────────────────────────────────────────────────────────
* 🔴 Р123 К РОБОТУ ТОЛЬКО ПО ШИФРОВАННОЙ ДОРОГЕ, КРОМЕ СВОЕЙ МАШИНЫ
* ─────────────────────────────────────────────────────────────────────────────
*
* Тайный ключ едет ЗАГОЛОВКОМ в каждом запросе. Приёмка 07.08.2026 замерила:
* разрешённый хост + `http://` уходил наружу молча ключ ехал открытым текстом,
* и признака этому не было никакого. Одной опечатки в `OBZVON_ENDPOINT` хватало.
*
* Решение владельца: адрес без шифрования портал просто НЕ ПРИНИМАЕТ говорит
* «дверь закрыта» и звонить не будет. Исключение ровно одно разработчик на
* своём компьютере, и оно устроено ДВУМЯ независимыми ответами
* ({@see self::svoyaMashina()}), чтобы одной перепутанной настройки на боевом
* для утечки не хватило.
*
* 🪤 Чего Р123 НЕ закрывает, говорю прямо: шифрование бережёт ключ В ПУТИ, но
* НЕ доказывает, что на том конце наш робот, а не чужая машина, занявшая тот же
* адрес. Это отдельная задача (закрепить отпечаток или свой корень доверия), и
* она не здесь.
*
* 🪤 И второе: если робот когда-нибудь уедет за посредника, снимающего
* шифрование на входе (`http://robot.vnutri:8080`), эта дверь его закроет и
* закроет правильно. Лечится это шифрованием на внутреннем участке либо новым
* решением владельца, но НЕ настройкой-обходом: настройка-обход и есть та самая
* опечатка, ради которой всё это заведено.
*
* ─────────────────────────────────────────────────────────────────────────────
* 🔴 ПРЕДЕЛ ОДНОВРЕМЕННЫХ ЧТО ОН НА САМОМ ДЕЛЕ ОГРАНИЧИВАЕТ
* ─────────────────────────────────────────────────────────────────────────────
*
@@ -242,7 +267,8 @@ final class ObzvonClient
}
/**
* Открыта ли дверь: есть адрес, есть ключ, и адрес ведёт на РАЗРЕШЁННЫЙ хост.
* Открыта ли дверь: есть адрес, есть ключ, адрес ведёт на РАЗРЕШЁННЫЙ хост
* и по ШИФРОВАННОЙ дороге (решение владельца Р123).
*
* Пустая строка = открыта. Иначе человеческая причина, почему закрыта.
*/
@@ -269,9 +295,97 @@ final class ObzvonClient
return 'адрес робота ведёт на неразрешённый хост '.$host;
}
$shema = mb_strtolower((string) (parse_url($this->endpoint, PHP_URL_SCHEME) ?: ''));
// 🔴 Р123. Разрешённый хост НЕ означает безопасную дорогу: `http://` к тому
// же самому роботу везёт тайный ключ открытым текстом. Замерено приёмкой
// 07.08.2026 — уходило молча, без единого признака.
if ($shema !== 'https' && ! $this->svoyaMashina($host)) {
return 'адрес робота без шифрования ('
.($shema === '' ? 'схема не указана' : $shema.'://')
.') — тайный ключ уехал бы открытым текстом';
}
return '';
}
/**
* 🔴 Единственное исключение из Р123: разработчик на СВОЁМ компьютере.
*
* «Своя машина» определяется не списком разрешённых слов такой список сам
* становится дверью («сегодня localhost и 127.0.0.1, завтра четвёртый»), а
* ДВУМЯ независимыми ответами, и оба обязаны совпасть:
*
* 1. **Где живёт само приложение.** Спрашиваем не себя, а Laravel: он знает
* это из `APP_ENV`. Список положительный, а не «всё кроме боевого»: любое
* окружение, которого тут не назвали (боевое, витрина, что заведут завтра),
* шифрование обязано. Это та же осторожность, что и у пустого списка
* хостов выше «не сказано, что можно, значит нельзя».
* 2. **Где стоит робот.** Адрес обязан вести на петлю ЭТОЙ ЖЕ машины: тогда
* запрос физически не покидает компьютер и подслушивать его негде.
*
* 🔴 Зачем именно два, а не один. Владелец назвал незакрытым: «ошибись
* кто-нибудь настройкой на боевом так, чтобы портал счёл себя своей машиной,
* запрет отвалится». С двумя ответами одной такой ошибки НЕ хватает:
* боевой робот живёт на чужом железе, петлёй его адрес не станет, и ключ
* наружу всё равно не уйдёт. Чтобы утечка состоялась, сломать надо оба.
*/
private function svoyaMashina(string $host): bool
{
if (! app()->environment('local', 'testing')) {
return false;
}
return $this->petlya($host);
}
/**
* Ведёт ли адрес на петлю этой самой машины по существу, а не по написанию.
*
* 🪤 Резолвер имён сюда намеренно не зовём. Он превратил бы проверку
* безопасности в неопределённую (чужой медленный DNS вешает обзвон) и открыл
* бы щель между «проверили» и «пошли»: имя, ответившее петлёй на проверке,
* к моменту запроса может отвечать чужим адресом. Разработчику, у которого
* робот назван своим именем в hosts, дверь скажет причину вслух, а лечится
* это одной правкой адреса на `127.0.0.1`.
*/
private function petlya(string $host): bool
{
// Квадратные скобки — как IPv6 пишется в адресе: http://[::1]:8080/…
$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";
}
/**
* Одно обращение к роботу: занять место сходить отдать место.
*
+8
View File
@@ -456,6 +456,14 @@ return [
// БАЗОВЫЙ адрес приёмника заданий на машине робота (без хвоста ручки):
// клиент сам добавит /pozvonit, /sostoyanie, /ostanovit. Сам приёмник —
// задача З-0.6, его ещё нет; портальная половина от этого не зависит.
//
// 🔴 Р123: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ в
// каждом запросе — по `http://` он поехал бы открытым текстом и молча.
// Адрес без шифрования портал не принимает: дверь закрыта, звонка нет.
// Исключение ровно одно — разработчик на своём компьютере, и оно НЕ
// настраивается отсюда: тумблера «можно без шифра» тут нет намеренно,
// такой тумблер и был бы той самой опечаткой. Как устроено исключение —
// см. `ObzvonClient::svoyaMashina()`.
'endpoint' => env('OBZVON_ENDPOINT', ''),
'key' => env('OBZVON_KEY', ''),
@@ -318,6 +318,194 @@ it('адрес без хоста вовсе — дверь закрыта', func
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Р123: к роботу только по шифрованной дороге, кроме своей машины
//
// Замерено прибором приёмки 07.08.2026: разрешённый хост + `http://` уходил
// наружу молча, и тайный ключ ехал в заголовке ОТКРЫТЫМ ТЕКСТОМ. Одна опечатка
// в `OBZVON_ENDPOINT` — и ключ у чужого человека, без единого признака.
//
// 🪤 Каждый случай меряется ОТДЕЛЬНО, своим подставным роботом: на прошлой
// приёмке подставной робот протёк из случая в случай и два замера были ложью.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит', function () {
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
->and($otvet->poyasnenie)->toContain('без шифрования');
Http::assertNothingSent();
});
it('🔴 з: тот же хост по https — работает и после запрета', function () {
// Контроль. Без него запрет мог бы закрыть дверь вообще всем и выглядеть
// «зелёным»: живой робот на 51.250.1.97 слушает 443 уже сегодня.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-7'], 200));
$otvet = klientObzvona([
'endpoint' => 'https://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
});
it('🔴 и: свой компьютер, http на localhost — работает, разработка не встаёт', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
$otvet = klientObzvona([
'endpoint' => 'http://localhost:8080/lena',
'razreshyonnyeHosty' => ['localhost'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
it('🔴 и: свой компьютер, http на 127.0.0.1 — работает', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
it('🔴 к: тот же местный адрес НА БОЕВОМ — дверь закрыта', function () {
app()->detectEnvironment(fn () => 'production');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Сторож на само исключение: «своя машина» — ДВА независимых ответа
//
// Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так,
// чтобы портал счёл себя своей машиной, — запрет отвалится, и сторожа на это
// нет». Сторож — здесь. Чтобы ключ уехал открытым, обязаны сломаться ОБА
// ответа сразу: и «где я живу», и «робот стоит на этой же машине». Одной
// опечатки в APP_ENV на боевом для утечки НЕ хватает.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу', function () {
// Ровно тот случай: настройку окружения перепутали, портал считает себя
// своей машиной. Робот при этом на чужом железе — ключ поехал бы в интернет.
app()->detectEnvironment(fn () => 'local');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 «своя машина» — не список слов: похожее имя хоста своей машиной не считается', function () {
// `localhost.zloumyshlennik.ru` кончается на что угодно и начинается со
// слова «localhost». Проверкой «есть ли в строке localhost» он бы прошёл.
app()->detectEnvironment(fn () => 'local');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true], 200));
// 127.0.0.1 — не единственный адрес петли; сервер разработчика может жить
// на 127.0.0.53 (systemd-resolved) или 127.0.1.1 (Debian).
expect(klientObzvona([
'endpoint' => 'http://127.0.1.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.1.1'],
])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
});
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake();
// 127 внутри, но это чужая машина в интернете.
$otvet = klientObzvona([
'endpoint' => 'http://10.0.127.1:8080/lena',
'razreshyonnyeHosty' => ['10.0.127.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 запрет http на боевом виден в журнале, и уровень не ниже предупреждения', function () {
app()->detectEnvironment(fn () => 'production');
$zhurnal = Log::spy();
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
// том же походе: сломай запрет — запрос уйдёт, робот ответит не тем, о чём
// договаривались, и строка `warning` появится сама, не о том.
expect($otvet->poyasnenie)->toContain('без шифрования');
Http::assertNothingSent();
// На бою LOG_LEVEL=warning: молчаливый отказ звонить был бы вторым сортом
// той же беды — обзвон встал бы, и никто бы не узнал почему.
$zhurnal->shouldHaveReceived('warning');
$zhurnal->shouldNotHaveReceived('info');
$zhurnal->shouldNotHaveReceived('notice');
$zhurnal->shouldNotHaveReceived('debug');
});
it('🔴 умолчание настроек не открывает http: в .env.example адрес робота шифрованный', function () {
// Сторож против тихого возврата дыры через образец настроек: скопировал
// .env.example — получил открытую дорогу.
$obrazec = (string) file_get_contents(__DIR__.'/../../../.env.example');
expect($obrazec)->not->toMatch('/^OBZVON_ENDPOINT=http:\/\//m');
});
it('🔴 проверка чужого удостоверения не отключена — иначе https был бы одной видимостью', function () {
// Та же беда с другого конца: `https://` в адресе, а проверку удостоверения
// машины кто-то снял, чтобы «заработало с самодельным сертификатом». Тогда
// шифрование есть, а подставиться на дороге может кто угодно — и ключ
// уедет ему. Laravel проверяет удостоверение по умолчанию; сторож стоит на
// том, чтобы эту проверку тут никогда не сняли.
$ishodnik = (string) file_get_contents(__DIR__.'/../../../app/Services/Obzvon/ObzvonClient.php');
expect($ishodnik)->not->toContain('withoutVerifying')
->and($ishodnik)->not->toContain("'verify' => false");
});
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 4 плана: предел одновременных заданий
// ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,357 @@
# Отчёт помощника — Р123: к роботу только по шифрованной дороге
**Дата:** 07.08.2026
**Ветка:** `feat/prospects-manual-testing-kp`
**Задача:** исполнить решение владельца Р123 в двери `ObzvonClient::pochemuDverZakryta()`
и завести сторожа на случай, когда исключение «своя машина» отваливается не там, где должно.
## 0. Моя ошибка — называю первой
Первый вариант сторожа про журнал **зеленел не по той причине**. Он проверял «была ли
вообще строка уровня предупреждения». Со сломанной починкой запрос уходил наружу, робот
отвечал не тем, о чём договаривались, строка `warning` появлялась сама — и сторож был
зелёный при живой дыре. Увидел это только на прогоне со сломанной починкой: сторож
провалился **не в поле «провалов», а в поле «ошибок»** (`40 зелёных + 5 провалов + 1
ошибка = 46`), то есть по коду возврата и по слову «провалов: 5» я бы его не нашёл.
Переписал: сторож сперва требует, чтобы дверь закрылась **именно из-за шифрования**
(`poyasnenie` содержит «без шифрования»), и только потом смотрит на журнал.
Вторая, мелкая: добавил `use Mockery;` — PHP на это ругается («имя без составных частей»),
и предупреждение печаталось перед разбором вывода. Убрал вместе с самим `Mockery`.
Третья: правил файл сторожей, когда полный прогон уже шёл. Пришлось прогон убить и
запустить заново на окончательных файлах — числа в §4 сняты с последнего запуска.
Четвёртая, из того же теста: ждал конца прогона по признаку «файл вывода перестал быть
пустым». Признак негодный — в файл по ходу падают посторонние строки замеров, и ожидание
кончилось на середине прогона. Чуть не снял числа с недобежавшего прогона. Переделал на
ожидание самой итоговой строки.
🔴 Пятая, и самая дорогая: пока шёл полный прогон, я запустил **на той же базе** ещё
один, короткий. Две пробы полезли в одну базу — и полный прогон вернул 52 небылицы
подряд: 47 обрывов внутри подключения к базе и 5 в стороже схемы, который смотрит на
живую базу глазами. Ни одна из них не была настоящей. Правило простое: **своя база — это
одна проба за раз**, а не «своя от соседей». Прогон переделан начисто.
## 1. Что было замерено ДО правки
Прогон только по файлу сторожей обзвона, починка ещё не написана:
```text
всего 46, зелёных 41, красных 5
```
Все пять красных — это случаи «дверь обязана быть закрыта», и все пятеро вернули
`Neizvestno` вместо `DverZakryta`. `Neizvestno` тут значит ровно одно: **запрос ушёл
наружу**, тайный ключ уехал в заголовке. Дыра из приёмки воспроизведена.
## 2. Что построено
### 2.1. Запрет в двери
В `ObzvonClient::pochemuDverZakryta()` после проверки разрешённого хоста добавлена
проверка схемы адреса:
- `https://` — проходит;
- любая другая схема (и её отсутствие) — **дверь закрыта**, наружу не ходим, звонка
не было, причина сказана словами и ложится в журнал уровнем `warning`.
Единственное исключение — `svoyaMashina()`.
### 2.2. «Своя машина» — ДВА независимых ответа, а не список слов
Оба обязаны совпасть:
1. **Где живёт приложение.** Спрашиваем Laravel: `app()->environment('local', 'testing')`.
Список **положительный**, а не «всё кроме боевого»: любое окружение, которого тут не
назвали (боевое, витрина, что заведут завтра), обязано шифрование. Это та же
осторожность, что у пустого списка хостов рядом — «не сказано, что можно, значит нельзя».
2. **Где стоит робот.** Адрес обязан вести на петлю **этой же** машины: тогда запрос
физически не покидает компьютер и подслушивать его негде. Петля определяется по
существу — двоичным сравнением адреса: вся сеть `127.0.0.0/8` (а не один `127.0.0.1`:
у разработчика сервер может стоять на `127.0.1.1` или `127.0.0.53`), `::1`, а также
`::ffff:127.x.x.x`. Имена — только `localhost` и `*.localhost`, и это не наш список
разрешений, а RFC 6761 §6.3, где эти имена закреплены за петлёй стандартом.
🪤 Резолвер имён намеренно не зовём. Он превратил бы проверку безопасности в
неопределённую (чужой медленный DNS вешает обзвон) и открыл бы щель между «проверили» и
«пошли». Разработчику, назвавшему робота своим именем в `hosts`, дверь скажет причину
вслух, а лечится это правкой адреса на `127.0.0.1`.
### 2.3. Почему ответов два — это и есть сторож на «незакрытое» владельца
Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так, чтобы портал
счёл себя своей машиной, — запрет отвалится, и сторожа на это нет».
С двумя независимыми ответами **одной такой ошибки не хватает**: боевой робот живёт на
чужом железе, петлёй его адрес не станет, и ключ наружу всё равно не уйдёт. Чтобы утечка
состоялась, сломать надо оба ответа сразу. На это стоит именной сторож
«APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу».
### 2.4. Тумблера-обхода нет намеренно
Настройки `OBZVON_ALLOW_HTTP` или подобной я **не заводил**. Такой тумблер и есть та самая
одна опечатка, ради которой запрет принят: он снимается так же молча, как ставится.
Это записано и в `config/services.php`, и в `.env.example`, чтобы следующая смена не
завела его «для удобства».
### 2.5. Файлы
| Файл | Что |
|---|---|
| `app/app/Services/Obzvon/ObzvonClient.php` | запрет схемы, `svoyaMashina()`, `petlya()`, раздел Р123 в шапке |
| `app/config/services.php` | только комментарий у `obzvon.endpoint` (общий файл — дописаны 8 строк, чужого не тронуто) |
| `app/.env.example` | почему только `https`, как устроено исключение, пример для своей машины |
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | 12 новых сторожей |
## 3. Чем ронял сторожей и что они сказали дословно
Ронял четырьмя разными способами — каждый раз проверял, что краснеет **именно то**, что
этот способ ломает, а не «вообще что-нибудь».
### Слом А — снял запрет схемы целиком
`if (false && $shema !== 'https' && …)`
```text
всего 46, зелёных 40, красных 6
```
```text
* ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит
Failed asserting that two variables reference the same object.
-App\Services\Obzvon\IskhodObrashcheniya Enum (DverZakryta, 'dver_zakryta')
+App\Services\Obzvon\IskhodObrashcheniya Enum (Neizvestno, 'neizvestno')
* к: тот же местный адрес НА БОЕВОМ — дверь закрыта (то же)
* APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу (то же)
* «своя машина» — не список слов: похожее имя хоста своей машиной не считается (то же)
* адрес, похожий на петлю, но ею не являющийся — дверь закрыта (то же)
* запрет http на боевом виден в журнале, и уровень не ниже предупреждения
Failed asserting that 'ответ без условленного слова ok' contains "без шифрования".
```
🔴 Последняя строка — сама по себе доказательство дыры: при снятом запрете портал **сходил
к роботу** и получил от него ответ. Ключ уехал.
### Слом Б — убрал только первый ответ (где живёт приложение)
`svoyaMashina()` перестал спрашивать `app()->environment()`.
```text
всего 46, зелёных 45, красных 1
* к: тот же местный адрес НА БОЕВОМ — дверь закрыта
Failed asserting that two variables reference the same object.
```
Ровно один красный — тот, что охраняет именно эту половину. Остальные не шелохнулись:
удалённый робот петлёй не стал.
### Слом В — заменил «петлю по существу» на совпадение слов
`str_contains($host, 'localhost') || str_contains($host, '127')`
```text
всего 46, зелёных 44, красных 2
* «своя машина» — не список слов: похожее имя хоста своей машиной не считается
* адрес, похожий на петлю, но ею не являющийся — дверь закрыта
```
`localhost.zloumyshlennik.ru` и `10.0.127.1` при таком «сторожении» становятся своей
машиной — и оба сторожа это поймали.
### Слом Г — потребовал шифрование ВСЕГДА, без исключения
Это отрезанная владельцем ветка. Проверял, что запрет не закрыл дверь заодно и разработке.
```text
всего 46, зелёных 43, красных 3
* и: свой компьютер, http на localhost — работает, разработка не встаёт
* и: свой компьютер, http на 127.0.0.1 — работает
* вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет
Failed asserting that false is true.
```
### Возврат починки — доказан слепком со снятием невидимых знаков
```text
tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin
до слома: 487b60c24b101cc9a258f94045eb653674aa9b5f
после: 487b60c24b101cc9a258f94045eb653674aa9b5f
```
### Зелёный прогон файла сторожей после починки
```text
всего 47, зелёных 47, красных 0, ошибок 0
```
(47, а не 46 — добавлен ещё один сторож, см. §8 про подложное шифрование.)
## 4. Полный прогон
БД своя, `liderra_testing_r123`, создана и промигрирована отдельно.
### Прогон первый
```text
всего 5243, зелёных 5238, провалов 0, ошибок 1, пропущено 4
арифметика: 5238 + 0 + 1 + 4 = 5243 — сходится
```
🔴 Единственная ошибка — **не моя и уже не существует**:
```text
Tests\Feature\ClientSms\VychitanieVremennyyTest
«ВРЕМЕННО: новое правило без второго окна отвечает ровно то же, что старое»
Class "Tests\Temp\SmsQuietHoursEtalon" not found
```
Это соседняя смена по СМС: временный сравнительный сторож с временным же
образцовым классом. Пока мой прогон шёл, соседи файл убрали — к концу прогона ни
`app/tests/Feature/ClientSms/VychitanieVremennyyTest.php`, ни папки `app/tests/Temp`
в дереве уже не было. К обзвону отношения не имеет; моих файлов не касается.
### Прогон второй — отброшен, испорчен мной
```text
всего 5242, зелёных 5186, провалов 5, ошибок 47, пропущено 4
арифметика: 5186 + 5 + 47 + 4 = 5242 — сходится
```
Числами не считаю: все 52 небылицы — моя вина, см. §0 ошибку пятую. 47 обрывов лежат
внутри подключения к базе, ещё 5 — в стороже, который смотрит на живую базу глазами
(`ObzvonCallsSchemaTest`, «канон и база разошлись по столбцам»). Ни одна к обзвону
отношения не имеет; их создала вторая проба, которую я запустил на той же базе.
### Прогон третий — начисто, база ничем больше не занята
```text
всего 5242, зелёных 5238, провалов 0, ошибок 0, пропущено 4
проверок сделано 21 718, время 18 мин 45 с
арифметика: 5238 + 0 + 0 + 4 = 5242 — сходится
```
Это и есть число смены. Поля «ошибок» и «провалов» смотрел отдельно — оба ноль, а не
только код возврата. 21 718 проверок против 21 355 у испорченного прогона — ещё один
признак, что второй прогон недосчитался, а не «нашёл настоящее».
Мои 47 сторожей обзвона внутри: отдельным запуском файла — `всего 47, зелёных 47`.
Прочее: `composer stan` — 0 ошибок; `vendor/bin/pint --test` по моим файлам — чисто;
`gitleaks` по каждому моему файлу — чисто. 🪤 `gitleaks` находит одну помету в
`app/.env.example` на строке 139 — это `SMS_MTS_TOKEN` соседней смены, ложное срабатывание,
оно есть и на текущей главной без моих правок; мои строки лежат ниже и чисты.
## 5. Соседи
- моего имени нет ни в одном выводе ни одного сторожа
- чужие файлы в дереве не тронуты
Проверено `git status`: из отслеживаемых мною изменены ровно четыре — `app/.env.example`,
`app/app/Services/Obzvon/ObzvonClient.php`, `app/config/services.php`,
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php`. Всё прочее изменённое в дереве
(`app/app/Services/ClientSms/…`, `bots/lena-golos/…`, `docs/lending-sborka/…`,
`docs/observer/STATUS.md`, `docs/superpowers/plans/…`, `docs/superpowers/priyomka/stroyka-5/…`,
`package.json`, `package-lock.json`) — работа соседних смен, идущая прямо сейчас: список
менялся у меня на глазах трижды за смену. Я к ним не прикасался. Общий файл `config/services.php`
перечитан построчным сравнением: в моём изменении только 8 добавленных строк
комментария, ни одной чужой.
## 6. Зовущие поимённо
Смотрел глазами по всему `app/app` и `app/tests`, а затем поиском по всему хранилищу:
вне кеша статанализа ни одного упоминания сверх перечисленного.
| Кто зовёт | Что именно |
|---|---|
| `ObzvonClient::obratitsya()` — сам класс, строка 396 | `pochemuDverZakryta()` перед каждым походом |
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | весь класс целиком |
| `app/app/Services/Obzvon/ObzvonOtvet.php` | только упоминание в поясняющем тексте, вызова нет |
**Больше не зовёт никто.** Зовущей половины (кто именно решает позвонить и когда) ещё
нет — она отдельная задача. Значит, мой запрет сегодня не может сломать ничего живого:
он охраняет дверь, в которую пока никто не ходит, кроме сторожей.
## 7. Мой разбор задания
### 7.1. Где задание право
Дыра настоящая и опасная ровно так, как описана: `http://` к **разрешённому** хосту
уходил молча, ключ ехал открытым текстом. Воспроизвёл красным до правки, посмотрел на
это своими глазами.
### 7.2. 🔴 Где подсказка задания привела бы к негодной починке
Задание говорит: «спроси у самого приложения, где оно живёт: у Laravel есть на это прямой
ответ». Прямой ответ — `app()->environment()`. **Если бы я исполнил только это, починка
была бы негодной, а сторожа — зелёными.**
Причина: случаи «ж» и «и» из вашего же ножа отличаются **только хостом**, а живут в одном
и том же окружении. Сторожа гоняются при `APP_ENV=testing`, то есть «не боевое». Проверка
«не боевое → http можно» пропустила бы случай «ж» — `http://51.250.1.97` — наружу, и
именно та дыра, ради которой всё затеяно, осталась бы жить при зелёном прогоне.
Поэтому исключение сделано **двумя независимыми ответами**: окружение И петля этой машины.
Ваш собственный нож этого требовал — подсказка про Laravel одна его не удовлетворяет.
Говорю прямо, потому что следующая смена, читая только подсказку, повторит эту ошибку.
Побочная и приятная сторона: именно второй ответ и закрывает то, что владелец назвал
незакрытым (§2.3). Без него сторожить было бы нечего — одна перепутанная настройка
`APP_ENV` открывала бы всё.
### 7.3. Проверил, о чём просили: посредник, снимающий шифрование на входе
**Сегодня не ломает ничего.** Замерил:
- `OBZVON_ENDPOINT` **не задан** ни в местном `.env`, ни в `.env.example` — в эту дверь
ещё никто не ходит. 🪤 Боевой я не смотрел: туда без разрешения владельца не хожу;
- разрешённые хосты по умолчанию — сама машина робота (`51.250.1.97`,
`51-250-1-97.sslip.io`) и петля. Посредника в этой дороге нет;
- разведка приёмки: `51.250.1.97` слушает шифрованный 443 уже сегодня.
**Но если робот когда-нибудь уедет за внутренний посредник**, снимающий шифрование
(`http://robot.vnutri:8080`), эта дверь его закроет — и закроет правильно, потому что на
внутреннем участке ключ поедет открытым. Как быть тогда: либо поднять шифрование на
внутреннем участке, либо новое решение владельца. **Настройкой-обходом — нельзя**, и
тумблера для этого я не завёл: тумблер и есть та опечатка, против которой принято Р123.
Решение Р123 не отменяю и не смягчаю — это не моё право.
### 7.4. 🔴 Второе место того же класса — нашёл, НЕ трогал
`services.self_render` + `app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php`
— тот самый шов, который был образцом для обзвона. Он шлёт секрет
`SELF_RENDER_KEY` заголовком `X-Render-Key` на ту же виртуалку. Его дверь
(`SelfRenderClient`, строка 121) проверяет ровно два условия:
```php
return $this->endpoint !== '' && $this->key !== '';
```
**Ни списка разрешённых хостов, ни проверки схемы там нет вовсе.** То есть у соседа дыра
не одна, а полторы: `http://` уведёт секрет открытым текстом, а опечатка в имени хоста
уведёт его вообще чужому человеку — и это на живом пути «Поиска клиентов», который
сегодня работает на бою.
Не чинил: файл не мой, путь боевой, а поспешная правка там роняет работающий поиск.
Выношу решением владельцу отдельно.
## 8. Что осталось незакрытым
1. **Шифрование не проверяет, ЧЕЙ робот отвечает.** Названо владельцем, не закрыто и
мной. Ключ защищён в пути, но машина, занявшая тот же адрес, получит его так же.
Лечится закреплением отпечатка машины или своим корнем доверия — отдельная работа.
2. **Остаток дыры «своя машина».** Сужен, но не убит: если на боевом сервере одновременно
перепутать `APP_ENV` **и** поставить робота на петлю самого боевого сервера — запрет
отвалится. Изнутри портала этого не видно в принципе. Настоящее лекарство — проверка
снаружи: сторож выката обязан требовать `APP_ENV=production` на боевом. Этого я не
делал (файлы не мои).
3. **Заведён сторож на подложное шифрование.** `https://` перестаёт быть защитой, если
кто-то снимет проверку удостоверения машины («чтобы заработало с самодельным
сертификатом»). Laravel проверяет её по умолчанию, и в нашем клиенте она не снята;
новый сторож краснеет, если в файле появится `withoutVerifying` или `'verify' => false`.
🪤 Он смотрит **текст файла**, а не поведение — от снятия проверки где-нибудь глубже
(общая настройка перевозчика) он не защищает.
4. **Приёмник заданий на стороне робота (З-0.6) — не мой.** Слушает ли он 443 своей
ручкой `/lena/pozvonit`, проверено не мной. Если приёмник поднимут только на 80,
портал честно скажет «дверь закрыта» и звонить не станет.
5. **`services.self_render`** — см. §7.4, ждёт решения владельца.