From 67195f0bf8411c3b341a9d66a36ea3e99fe3b29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 6 Aug 2026 22:31:13 +0300 Subject: [PATCH] =?UTF-8?q?feat=20=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD:=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D0=B0=20=D1=80=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=B5=D0=B1=D1=8F=20=D1=81=D0=B0=D0=BC=D0=B0,?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=20=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B7=D0=B0=20=D1=81=D0=B2=D0=B5=D0=B6?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сторожа уборки записей телефонных разговоров запускал только человек, который про них помнил. Уборка могла умереть молча, и голоса чужих людей копились бы месяцами при обещанном сроке хранения. Теперь машина раз в сутки гоняет своих сторожей сама и стучится в портал с итогом. Портал хранит итог вместе с ЕГО СОБСТВЕННОЙ датой и шлёт письмо, когда итог красный ИЛИ протух. Зелёный итог недельной давности хуже красного сегодняшнего, поэтому возраст проверяется всегда. Решение владельца Р116: стучится машина, портал на машину не ходит — ключа от машины с голосами живых людей у боевого портала быть не должно. Решение владельца Р117: адрес письма из настройки MONITORING_ALERT_EMAIL, а не вписанный в код. На машине - суточный обход и его расписание, 05:20 МСК, под flock, от ubuntu - машинная копия 12 эталонов: своего ssh-ключа у машины нет и не будет - у сторожей появилась вторая створка двери LENA_MESTNAYA=1 — работа без ssh В портале - приём итога: подпись тела, без слова в адресе. Адреса текут в журналы nginx - ежедневная проверка свежести и красноты, письмо только когда плохо - итог лежит в scheduler_heartbeats, схема базы не тронута - сама проверка записана в EXPECTED_INTERVALS: присмотр без присмотра не жил бы Сторож нового обхода ходит с рабочего места по ssh и сверяет машинную копию с хранилищем побайтово — обход на машине сравнил бы копию сам с собой. Попутно закрыто: копия моста и журнал обхода стали новыми местами, где ложится похожее на речь. Разобраны поимённо, журнал обнуляется каждым прогоном. Замеры: сторожа 26/46/27 не изменились, обход 2,2 с под нагрузкой nice, проверок в портале 21 из 21, полный прогон 5161 при одной чужой красной. Три ножа показаны красными; один мой сторож при этом оказался ложно зелёным и переписан. Не выкачено: портальная половина не на бою, дорога с машины не настроена. Co-Authored-By: Claude Opus 5 --- app/.env.example | 14 + .../Commands/LenaProverkaObhodaStorozhey.php | 110 +++++ .../Api/LenaStorozhaWebhookController.php | 260 +++++++++++ app/app/Mail/LenaStorozhaTrevogaMail.php | 51 +++ .../Services/SchedulerHeartbeatTracker.php | 11 + app/config/storozha_mashiny.php | 72 +++ .../emails/lena_storozha_trevoga.blade.php | 36 ++ app/routes/console.php | 13 + app/routes/web.php | 7 + .../Feature/Lena/StorozhaMashinyTest.php | 337 ++++++++++++++ bots/lena-golos/README.md | 175 +++++++ bots/lena-golos/proverka-raspisaniya.sh | 23 +- bots/lena-golos/proverka-sutochnogo-obhoda.sh | 353 ++++++++++++++ bots/lena-golos/proverka-tekstovogo-sleda.sh | 45 +- bots/lena-golos/sutochnyy-obhod.cron | 58 +++ bots/lena-golos/sutochnyy-obhod.sh | 225 +++++++++ .../otchyot-pomoshchnika-z-0-10-2026-08-06.md | 431 ++++++++++++++++++ 17 files changed, 2216 insertions(+), 5 deletions(-) create mode 100644 app/app/Console/Commands/LenaProverkaObhodaStorozhey.php create mode 100644 app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php create mode 100644 app/app/Mail/LenaStorozhaTrevogaMail.php create mode 100644 app/config/storozha_mashiny.php create mode 100644 app/resources/views/emails/lena_storozha_trevoga.blade.php create mode 100644 app/tests/Feature/Lena/StorozhaMashinyTest.php create mode 100644 bots/lena-golos/proverka-sutochnogo-obhoda.sh create mode 100644 bots/lena-golos/sutochnyy-obhod.cron create mode 100644 bots/lena-golos/sutochnyy-obhod.sh create mode 100644 docs/superpowers/priyomka/stroyka-5/otchyot-pomoshchnika-z-0-10-2026-08-06.md diff --git a/app/.env.example b/app/.env.example index c8c30cee..dd8ea8b3 100644 --- a/app/.env.example +++ b/app/.env.example @@ -42,6 +42,20 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru # Supplier alerts (email через Unisender Go relay) SUPPLIER_ALERT_EMAIL= +# Сторожа машины робота (З-0.10). Машина 51.250.1.97 раз в сутки гоняет своих +# сторожей и стучится сюда с итогом. Тревога уходит на MONITORING_ALERT_EMAIL +# (он же падает на SUPPLIER_ALERT_EMAIL, тот — на ops@liderra.ru). +# 🔴 LENA_STOROZHA_SLOVO — тайное слово подписи тела. Короче 32 знаков или +# пусто = дорога закрыта совсем. То же слово кладётся на машину в файл +# /home/ubuntu/lena-golos/nastroyki-obhoda (права 600), в хранилище его нет. +LENA_STOROZHA_SLOVO= +# Кому позволено стучать: адреса/подсети через запятую. Пусто на бою = закрыто. +LENA_STOROZHA_ADRESA= +# Сколько сторожей ждём в итоге. Прибавили сторожа на машине — прибавьте здесь. +LENA_STOROZHA_SKOLKO=3 +# Сколько часов итог считается свежим (обход ходит раз в сутки). +LENA_STOROZHA_SVEZHEST_CHASOV=26 + # SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin), # допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd. ADMIN_ALLOWED_USERS=admin diff --git a/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php b/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php new file mode 100644 index 00000000..f8649f82 --- /dev/null +++ b/app/app/Console/Commands/LenaProverkaObhodaStorozhey.php @@ -0,0 +1,110 @@ +table('scheduler_heartbeats') + ->where('command_name', $imya) + ->first(); + + $prichina = null; + $podrobno = null; + $sobranoV = null; + $vozrastChasov = null; + + if ($stroka === null || $stroka->last_run_at === null) { + $prichina = 'итога обхода сторожей НЕ БЫЛО НИ РАЗУ'; + $podrobno = 'Машина 51.250.1.97 ни разу не постучалась в портал. ' + .'Либо расписание /etc/cron.d/lena-sutochnyy-obhod не заведено, ' + .'либо отправка не настроена, либо стук не доходит.'; + } else { + $sobranoV = Carbon::parse($stroka->last_run_at); + $vozrastChasov = (int) $sobranoV->diffInHours(now()); + + if ($vozrastChasov > $chasov) { + // 🔴 Сначала возраст, и только потом содержание. Протухший + // ЗЕЛЁНЫЙ итог обязан поднимать тревогу так же, как красный. + $prichina = "итог обхода ПРОТУХ: собран {$vozrastChasov} ч назад, а ждём не старше {$chasov} ч"; + $podrobno = "Дата сборки итога (не получения!): {$sobranoV->toIso8601String()}. " + .($stroka->last_error === null + ? 'Внутри последнего итога всё было зелено — тем хуже: зелёный старый итог усыпляет.' + : 'Внутри последнего итога было: '.$stroka->last_error); + } elseif ($stroka->last_error !== null) { + $prichina = 'сторож на машине робота ПОКРАСНЕЛ'; + $podrobno = $stroka->last_error; + } + } + + if ($prichina === null) { + $this->info('Обход сторожей свеж и зелен. Письма нет — и не должно быть.'); + + return self::SUCCESS; + } + + $this->warn('Тревога: '.$prichina); + $this->line((string) $podrobno); + + if ($this->option('suho')) { + return self::SUCCESS; + } + + $komu = (string) config('services.monitoring.alert_email', 'ops@liderra.ru'); + Mail::to($komu)->send(new LenaStorozhaTrevogaMail( + prichina: $prichina, + podrobno: (string) $podrobno, + sobranoV: $sobranoV?->toIso8601String(), + vozrastChasov: $vozrastChasov, + skolkoNeudach: $stroka !== null ? (int) $stroka->consecutive_failures : 0, + )); + + $this->info('Письмо отправлено на '.$komu); + + return self::SUCCESS; + } +} diff --git a/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php b/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php new file mode 100644 index 00000000..f7ac9fe1 --- /dev/null +++ b/app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php @@ -0,0 +1,260 @@ +podpisVerna($request)) { + $this->skazatVZhurnal('otkaz_podpis', $request->ip(), null); + + return response()->json(['message' => 'Not found.'], 404); + } + + if (! $this->adresRazreshyon($request->ip())) { + $this->skazatVZhurnal('otkaz_adres', $request->ip(), null); + + return response()->json(['message' => 'Not found.'], 404); + } + + $itog = $request->json()->all(); + $bedy = $this->chtoNeTak($itog); + + // Дата сборки итога. Не разобрали — не выдумываем «сейчас»: строка без + // собственной даты бесполезна как датчик свежести, поэтому кладём такую + // дату, при которой итог заведомо протух, и говорим об этом в ошибке. + $sobranoV = $this->razobratDatu($itog['sobrano_v'] ?? null); + if ($sobranoV === null) { + $bedy[] = 'дата сборки итога непонятна или её нет вовсе'; + $sobranoV = Carbon::createFromTimestampUTC(0); + } elseif ($sobranoV->greaterThan(now()->addMinutes(self::ZAPAS_VPERYOD_MINUT))) { + $bedy[] = 'дата сборки итога в будущем: '.$sobranoV->toIso8601String(); + } + + $this->polozhitOtmetku($itog, $sobranoV, $bedy); + $this->skazatVZhurnal($bedy === [] ? 'prinyat' : 'prinyat_krasnym', $request->ip(), $bedy); + + // 202 и при красном итоге: стук ПРИНЯТ, а краснота — это его содержание, + // а не ошибка отправителя. Ответь мы 4xx — машина решила бы, что стук не + // дошёл, и красный итог остался бы неучтённым. + return response()->json([ + 'status' => $bedy === [] ? 'принят' : 'принят, но красный', + 'bedy' => $bedy, + ], 202); + } + + /** + * Что не так с итогом. Пустой список = всё зелено. + * + * @param array $itog + * @return list + */ + private function chtoNeTak(array $itog): array + { + $bedy = []; + + if ((int) ($itog['shema'] ?? 0) !== 1) { + $bedy[] = 'непонятная разновидность итога: '.var_export($itog['shema'] ?? null, true); + + return $bedy; // Дальше разбирать нечего — устройство файла нам неизвестно. + } + + $zhdyom = (int) config('storozha_mashiny.ozhidaem_storozhey'); + $vsego = (int) ($itog['storozhey_vsego'] ?? -1); + + // 🔴 Тот самый случай мимо допущения «сторожей три»: сторожей стало + // четыре, а стучит по-прежнему три (или наоборот). Молчать нельзя. + if ($vsego !== $zhdyom) { + $bedy[] = "сторожей в обходе {$vsego}, а ждём {$zhdyom}"; + } + + $spisok = $itog['storozha'] ?? null; + if (! is_array($spisok) || $spisok === []) { + $bedy[] = 'в итоге нет ни одного сторожа'; + + return $bedy; + } + + foreach ($spisok as $s) { + if (! is_array($s)) { + $bedy[] = 'строка сторожа не разобрана'; + + continue; + } + $imya = (string) ($s['imya'] ?? '(без имени)'); + $kod = (int) ($s['kod'] ?? -1); + $proverok = (int) ($s['proverok'] ?? -1); + $provalov = (int) ($s['provalov'] ?? -1); + + if ($kod !== 0 || $provalov !== 0) { + $bedy[] = "{$imya}: код {$kod}, проверок {$proverok}, красных {$provalov}"; + } elseif ($proverok <= 0) { + // Зелёный сторож, не сделавший ни одной проверки, — не сторож. + $bedy[] = "{$imya}: код 0, но проверок {$proverok} — сторож ничего не проверил"; + } + } + + return $bedy; + } + + /** + * Кладёт итог в scheduler_heartbeats. + * + * @param array $itog + * @param list $bedy + */ + private function polozhitOtmetku(array $itog, Carbon $sobranoV, array $bedy): void + { + $imya = (string) config('storozha_mashiny.imya_otmetki'); + $now = now(); + $db = DB::connection('pgsql_supplier'); + $dlilos = (int) ($itog['dlilos_ms'] ?? 0); + + if ($bedy === []) { + $db->statement( + <<<'SQL' + INSERT INTO scheduler_heartbeats + (command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, 0, ?, ?) + ON CONFLICT (command_name) DO UPDATE SET + last_run_at = EXCLUDED.last_run_at, + last_success_at = EXCLUDED.last_success_at, + last_error = NULL, + runtime_ms = EXCLUDED.runtime_ms, + consecutive_failures = 0, + updated_at = EXCLUDED.updated_at + SQL, + [$imya, $sobranoV, $sobranoV, $dlilos, $now, $now], + ); + + return; + } + + $tekst = substr(implode('; ', $bedy), 0, 2000); + + $db->statement( + <<<'SQL' + INSERT INTO scheduler_heartbeats + (command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at) + VALUES (?, ?, NULL, ?, ?, 1, ?, ?) + ON CONFLICT (command_name) DO UPDATE SET + last_run_at = EXCLUDED.last_run_at, + last_error = EXCLUDED.last_error, + runtime_ms = EXCLUDED.runtime_ms, + consecutive_failures = scheduler_heartbeats.consecutive_failures + 1, + updated_at = EXCLUDED.updated_at + SQL, + [$imya, $sobranoV, $tekst, $dlilos, $now, $now], + ); + } + + private function podpisVerna(Request $request): bool + { + $slovo = (string) config('storozha_mashiny.tainoe_slovo'); + if (strlen($slovo) < 32) { + // Дорога не открывается сама собой: слова нет — двери нет. + return false; + } + + $podpis = (string) $request->header('X-Storozh-Podpis', ''); + $podpis = str_starts_with($podpis, 'sha256=') ? substr($podpis, 7) : $podpis; + if ($podpis === '') { + return false; + } + + return hash_equals(hash_hmac('sha256', $request->getContent(), $slovo), $podpis); + } + + private function adresRazreshyon(?string $adres): bool + { + if ($adres === null) { + return false; + } + + $spisok = array_values(array_filter(array_map( + 'trim', + explode(',', (string) config('storozha_mashiny.adresa')) + ))); + + if ($spisok === []) { + // На бою пустой список = закрыто (как у вебхука поставщика: пустой + // список на проде значит «забыли заполнить», а не «пускать всех»). + return ! app()->environment('production'); + } + + return IpUtils::checkIp($adres, $spisok); + } + + /** + * @param list|null $bedy + */ + private function skazatVZhurnal(string $chto, ?string $adres, ?array $bedy): void + { + // 🔴 Уровень warning нарочно, а не info: на боевом LOG_LEVEL=warning, + // и всё ниже предупреждения не доезжает до журнала НИКОГДА. + Log::warning('lena_storozha.'.$chto, [ + 'ip' => $adres, + 'bedy' => $bedy, + ]); + } + + private function razobratDatu(mixed $syryo): ?Carbon + { + if (! is_string($syryo) || $syryo === '') { + return null; + } + + try { + return Carbon::parse($syryo); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/app/Mail/LenaStorozhaTrevogaMail.php b/app/app/Mail/LenaStorozhaTrevogaMail.php new file mode 100644 index 00000000..a8670f52 --- /dev/null +++ b/app/app/Mail/LenaStorozhaTrevogaMail.php @@ -0,0 +1,51 @@ +prichina, + ); + } + + public function content(): Content + { + return new Content( + text: 'emails.lena_storozha_trevoga', + with: [ + 'prichina' => $this->prichina, + 'podrobno' => $this->podrobno, + 'sobranoV' => $this->sobranoV, + 'vozrastChasov' => $this->vozrastChasov, + 'skolkoNeudach' => $this->skolkoNeudach, + 'seychas' => now()->timezone('Europe/Moscow')->toIso8601String(), + ], + ); + } +} diff --git a/app/app/Services/SchedulerHeartbeatTracker.php b/app/app/Services/SchedulerHeartbeatTracker.php index 3773c2d7..3d8ee5a4 100644 --- a/app/app/Services/SchedulerHeartbeatTracker.php +++ b/app/app/Services/SchedulerHeartbeatTracker.php @@ -49,6 +49,17 @@ final class SchedulerHeartbeatTracker // он ТОЛЬКО по этому списку: команды, которой здесь нет, сторож не видит. 'obzvon:chistka' => 1440, // daily 'obzvon:chistka-materialov' => 1440, // daily + // 🔴 З-0.10. Проверка суточного обхода сторожей на машине робота. Она + // сама — присмотр, и без этой строки была бы присмотром БЕЗ ПРИСМОТРА: + // портал ждёт стука с машины, а что живо само ожидание — не проверял бы + // никто. Здесь она попадает под штатный часовой сторож пульса, то есть + // за молчуном второго слоя следит уже построенное. + // NB: сам итог с машины лежит в scheduler_heartbeats под именем + // 'lena:obhod-storozhey', но в ЭТОТ список не входит нарочно — его + // возраст и содержание разбирает lena:proverka-obhoda, и только она + // умеет отличить «протух» от «покраснел» и написать на адрес из + // настройки, а не на вписанный в код. + 'lena:proverka-obhoda' => 1440, // daily ]; /** diff --git a/app/config/storozha_mashiny.php b/app/config/storozha_mashiny.php new file mode 100644 index 00000000..06dfbe97 --- /dev/null +++ b/app/config/storozha_mashiny.php @@ -0,0 +1,72 @@ + (string) env('LENA_STOROZHA_SLOVO', ''), + + /* + | Кому позволено стучать: адреса или подсети через запятую. + | Пусто на бою — приём закрыт (fail-closed), как у вебхука поставщика. + | Пусто НЕ на бою — пускаем всех: иначе не проверить на своей машине. + */ + 'adresa' => (string) env('LENA_STOROZHA_ADRESA', ''), + + /* + | Сколько сторожей мы ЖДЁМ в итоге. + | + | 🔴 Это число закрывает целый род беды: «сторожей стало четыре, а стучит + | по-прежнему три». Обход на машине ищет сторожей списком файлов и + | называет, скольких нашёл. Не сойдётся с этим числом в любую сторону — + | итог считается красным и уходит письмо. Прибавили сторожа на машине — + | обязаны прибавить и здесь, руками, осознанно. + */ + 'ozhidaem_storozhey' => (int) env('LENA_STOROZHA_SKOLKO', 3), + + /* + | Сколько часов итог считается свежим. + | + | 🔴 Главное число всей затеи. Зелёный итог недельной давности хуже + | красного сегодняшнего: он усыпляет. Обход ходит раз в сутки, 26 часов + | дают два часа запаса на сдвиг расписания и на долгий обход, но не + | дают пропустить ЦЕЛЫЙ пропущенный день. + */ + 'svezhest_chasov' => (int) env('LENA_STOROZHA_SVEZHEST_CHASOV', 26), + + /* + | Имя строки в scheduler_heartbeats, под которым лежит итог обхода. + | Своей таблицы не заводим: у портала уже есть таблица отметок пульса, + | и итог обхода — ровно такая же отметка. + */ + 'imya_otmetki' => 'lena:obhod-storozhey', +]; diff --git a/app/resources/views/emails/lena_storozha_trevoga.blade.php b/app/resources/views/emails/lena_storozha_trevoga.blade.php new file mode 100644 index 00000000..18f1bf09 --- /dev/null +++ b/app/resources/views/emails/lena_storozha_trevoga.blade.php @@ -0,0 +1,36 @@ +СТОРОЖА МАШИНЫ РОБОТА: ТРЕВОГА +============================== +Что случилось: {{ $prichina }} +Подробно: {{ $podrobno }} +@if($sobranoV) +Итог собран: {{ $sobranoV }} (это дата ИЗ САМОГО ИТОГА, а не время получения) +@endif +@if($vozrastChasov !== null) +Возраст итога: {{ $vozrastChasov }} ч +@endif +Неудач подряд: {{ $skolkoNeudach }} +Сейчас: {{ $seychas }} (МСК) + +О ЧЁМ ЭТО ПИСЬМО +На машине робота (51.250.1.97) раз в сутки в 05:20 МСК проходит обход +сторожей. Сторожа проверяют, что уборка записей телефонных разговоров с +живыми людьми вправду работает и что слова этих людей не текут в журналы. +Это персональные данные: записи обязаны уходить по сроку, а не копиться. + +ЧТО ДЕЛАТЬ +1. Прогнать сторожей рукой с рабочего места и посмотреть, что именно красное: + bash bots/lena-golos/proverka-raspisaniya.sh + bash bots/lena-golos/proverka-tekstovogo-sleda.sh + bash bots/lena-golos/proverka-chistki.sh +2. Проверить, жив ли сам обход на машине: + bash bots/lena-golos/proverka-sutochnogo-obhoda.sh +3. Заглянуть в журнал обхода на машине: + /home/ubuntu/lena-golos/sutochnyy-obhod.log + +ЕСЛИ НАПИСАНО «ПРОТУХ» +Значит машина перестала стучаться. Внутри последнего итога могло быть всё +зелено — это НЕ утешение: зелёный старый итог как раз и усыпляет. Смотреть +надо расписание /etc/cron.d/lena-sutochnyy-obhod, сам обход и дорогу до +портала, а не содержание последнего итога. + +Это автоматическое сообщение от команды lena:proverka-obhoda (Лидерра). diff --git a/app/routes/console.php b/app/routes/console.php index 4f061d23..a7e1568f 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -460,3 +460,16 @@ Schedule::command('obzvon:chistka-materialov') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('obzvon:chistka-materialov', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('obzvon:chistka-materialov', false, 'Command failed', null)); + +// З-0.10 (обзвон): проверка суточного обхода сторожей на машине робота. +// 🔴 У этой команды зовущий ОДИН — эти строки. Не будь их, машина стучалась бы +// в пустоту: итог ложился бы в таблицу отметок, и никто бы на него не смотрел. +// Именно так и выглядит молчун — всё построено и всё мертво. +// 06:10 МСК — обход на машине ходит в 05:20 МСК, 50 минут запаса на долгий +// обход и на сдвиг расписания. Раньше звать нельзя: увидим вчерашний итог и +// заругаемся зря. +Schedule::command('lena:proverka-obhoda') + ->dailyAt('06:10') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('lena:proverka-obhoda', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('lena:proverka-obhoda', false, 'Command failed', null)); diff --git a/app/routes/web.php b/app/routes/web.php index ac3be9f1..b767d6d9 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -728,6 +728,13 @@ Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsD Route::match(['get', 'post'], '/api/webhook/t2-delivery/{secret}', 'App\Http\Controllers\Api\T2DeliveryWebhookController@receive') ->where('secret', '[A-Za-z0-9_\-]+'); +// Суточный итог сторожей с машины робота (З-0.10, решение владельца Р116). +// Стучится МАШИНА, портал на машину не ходит: ключа от машины, где лежат +// голоса живых людей, у боевого портала быть не должно. +// 🔴 Секрета в АДРЕСЕ здесь НЕТ намеренно — только подпись тела +// (X-Storozh-Podpis). Адреса целиком текут в журналы nginx, тело — нет. +Route::post('/api/webhook/lena-storozha', 'App\Http\Controllers\Api\LenaStorozhaWebhookController@receive'); + // Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt. // Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7. Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive'); diff --git a/app/tests/Feature/Lena/StorozhaMashinyTest.php b/app/tests/Feature/Lena/StorozhaMashinyTest.php new file mode 100644 index 00000000..a74f1cdb --- /dev/null +++ b/app/tests/Feature/Lena/StorozhaMashinyTest.php @@ -0,0 +1,337 @@ +setPdo(DB::connection('pgsql')->getPdo()); + DB::connection('pgsql_supplier')->setReadPdo(DB::connection('pgsql')->getReadPdo()); + + config()->set('storozha_mashiny.tainoe_slovo', SLOVO); + config()->set('storozha_mashiny.adresa', ''); + config()->set('storozha_mashiny.ozhidaem_storozhey', 3); + config()->set('storozha_mashiny.svezhest_chasov', 26); + config()->set('storozha_mashiny.imya_otmetki', 'lena:obhod-storozhey'); + config()->set('services.monitoring.alert_email', 'ops@liderra.ru'); + + Mail::fake(); +}); + +// ---------------------------------------------------------------- помощники -- + +/** + * Итог ровно такой формы, какую пишет sutochnyy-obhod.sh на машине. + * Числа проверок — те самые, что сторожа дают сегодня: 26 / 46 / 27. + */ +function itogSTremyaZelenymi(?string $sobranoV = null): string +{ + return (string) json_encode([ + 'shema' => 1, + 'sobrano_v' => $sobranoV ?? now()->toIso8601String(), + 'mashina' => 'fhmg5qpo4jconna4s9jo', + 'dlilos_ms' => 42000, + 'storozhey_vsego' => 3, + 'storozhey_krasnyh' => 0, + 'slepok_etalonov' => str_repeat('a', 64), + 'storozha' => [ + ['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0], + ['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0], + ['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0], + ], + ], JSON_UNESCAPED_UNICODE); +} + +function postuchat(string $telo, ?string $podpis = null): TestResponse +{ + $podpis ??= 'sha256='.hash_hmac('sha256', $telo, SLOVO); + + return test()->call( + 'POST', + '/api/webhook/lena-storozha', + [], [], [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_X_STOROZH_PODPIS' => $podpis], + $telo, + ); +} + +function otmetka(): ?object +{ + return DB::table('scheduler_heartbeats')->where('command_name', 'lena:obhod-storozhey')->first(); +} + +function polozhitOtmetku(array $polya): void +{ + DB::table('scheduler_heartbeats')->insert(array_merge([ + 'command_name' => 'lena:obhod-storozhey', + 'last_run_at' => null, + 'last_success_at' => null, + 'last_error' => null, + 'runtime_ms' => 1000, + 'consecutive_failures' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], $polya)); +} + +// ============================================================ ПРИЁМ СТУКА ==== + +it('принимает подписанный зелёный итог и кладёт отметку', function (): void { + $otvet = postuchat(itogSTremyaZelenymi()); + + $otvet->assertStatus(202); + $o = otmetka(); + expect($o)->not->toBeNull() + ->and($o->last_error)->toBeNull() + ->and((int) $o->consecutive_failures)->toBe(0); +}); + +it('🔴 считает возраст по дате ИЗ ИТОГА, а не по времени получения', function (): void { + // Случай мимо допущения «стук дошёл — значит сторожа отработали»: сторожа + // не запускались, а стук отправил ВЧЕРАШНИЙ файл. Отметка обязана взять + // вчерашнюю дату, иначе портал считал бы всё свежим вечно. + $vchera = now()->subHours(30); + postuchat(itogSTremyaZelenymi($vchera->toIso8601String()))->assertStatus(202); + + // 🪤 Сравниваем по модулю и в секундах. Первый заход этого сторожа стоял на + // голом diffInMinutes(), а он у Carbon 3 знаковый: разница выходила -1800 + // и «меньше двух» проходило ВСЕГДА. Сторож был зелёным и не сторожил + // ничего — поймано только тем, что его показали красным на подсунутой + // ошибке (подмена sobrano_v на now() в контроллере). + $o = otmetka(); + expect(abs(Carbon::parse($o->last_run_at)->getTimestamp() - $vchera->getTimestamp())) + ->toBeLessThan(2); +}); + +it('гонит стук с негодной подписью', function (): void { + postuchat(itogSTremyaZelenymi(), 'sha256='.str_repeat('f', 64))->assertStatus(404); + expect(otmetka())->toBeNull(); +}); + +it('гонит стук вовсе без подписи', function (): void { + $telo = itogSTremyaZelenymi(); + test()->call('POST', '/api/webhook/lena-storozha', [], [], [], ['CONTENT_TYPE' => 'application/json'], $telo) + ->assertStatus(404); + expect(otmetka())->toBeNull(); +}); + +it('дорога закрыта, пока тайное слово не задано', function (): void { + config()->set('storozha_mashiny.tainoe_slovo', ''); + postuchat(itogSTremyaZelenymi(), 'sha256='.hash_hmac('sha256', itogSTremyaZelenymi(), '')) + ->assertStatus(404); + expect(otmetka())->toBeNull(); +}); + +it('гонит стук с чужого адреса, когда список задан', function (): void { + config()->set('storozha_mashiny.adresa', '51.250.1.97, 10.0.0.0/8'); + postuchat(itogSTremyaZelenymi())->assertStatus(404); + expect(otmetka())->toBeNull(); +}); + +it('🔴 называет ИМЯ покрасневшего сторожа', function (): void { + $telo = (string) json_encode([ + 'shema' => 1, + 'sobrano_v' => now()->toIso8601String(), + 'mashina' => 'fhmg5qpo4jconna4s9jo', + 'dlilos_ms' => 40000, + 'storozhey_vsego' => 3, + 'storozhey_krasnyh' => 1, + 'slepok_etalonov' => str_repeat('b', 64), + 'storozha' => [ + ['imya' => 'proverka-raspisaniya', 'kod' => 1, 'proverok' => 26, 'provalov' => 3], + ['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0], + ['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0], + ], + ], JSON_UNESCAPED_UNICODE); + + postuchat($telo)->assertStatus(202); + + $o = otmetka(); + expect($o->last_error)->toContain('proverka-raspisaniya') + ->and($o->last_error)->not->toContain('proverka-chistki') + ->and((int) $o->consecutive_failures)->toBe(1); +}); + +it('🔴 краснеет, если сторожей стало больше, чем мы ждём', function (): void { + // Случай мимо допущения «сторожей три»: на машину положили четвёртого, + // а портал по-прежнему ждёт троих. Молчать нельзя. + $itog = json_decode(itogSTremyaZelenymi(), true); + $itog['storozhey_vsego'] = 4; + $itog['storozha'][] = ['imya' => 'proverka-zagotovok', 'kod' => 0, 'proverok' => 23, 'provalov' => 0]; + + postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202); + + expect(otmetka()->last_error)->toContain('сторожей в обходе 4, а ждём 3'); +}); + +it('краснеет на зелёном стороже, не сделавшем ни одной проверки', function (): void { + $itog = json_decode(itogSTremyaZelenymi(), true); + $itog['storozha'][1]['proverok'] = 0; + + postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202); + + expect(otmetka()->last_error)->toContain('ничего не проверил'); +}); + +it('краснеет на дате сборки из будущего', function (): void { + postuchat(itogSTremyaZelenymi(now()->addDays(3)->toIso8601String()))->assertStatus(202); + + expect(otmetka()->last_error)->toContain('в будущем'); +}); + +it('краснеет и протухает, когда даты сборки нет вовсе', function (): void { + $itog = json_decode(itogSTremyaZelenymi(), true); + unset($itog['sobrano_v']); + + postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202); + + $o = otmetka(); + expect($o->last_error)->toContain('дата сборки итога непонятна') + ->and(Carbon::parse($o->last_run_at)->year)->toBe(1970); +}); + +it('краснеет на неизвестной разновидности итога', function (): void { + postuchat((string) json_encode(['shema' => 99, 'sobrano_v' => now()->toIso8601String()])) + ->assertStatus(202); + + expect(otmetka()->last_error)->toContain('непонятная разновидность'); +}); + +it('🔴 разбирает НАСТОЯЩЕЕ тело, снятое с машины, а не только своё выдуманное', function (): void { + // 🪤 Шов между двумя честными половинками. Все проверки выше кормят портал + // телом, которое сами же и составили. Так можно доказать что угодно и + // не заметить, что машина шлёт другое. Это тело снято 06.08.2026 с + // настоящего обхода на машине 51.250.1.97 — байт в байт то, что дошло + // до подставного слушателя на ней (проверено cmp) вместе с подписью. + $telo = <<<'JSON' + { + "shema": 1, + "sobrano_v": "2026-08-06T18:42:52Z", + "mashina": "fhmg5qpo4jconna4s9jo", + "dlilos_ms": 2000, + "storozhey_vsego": 3, + "storozhey_krasnyh": 0, + "slepok_etalonov": "df0eedf9be30e6e5f81ecd1d7f4ef46ef0901e1d4563230b79b0778ef6236b23", + "storozha": [ + {"imya": "proverka-chistki", "kod": 0, "proverok": 27, "provalov": 0}, + {"imya": "proverka-raspisaniya", "kod": 0, "proverok": 26, "provalov": 0}, + {"imya": "proverka-tekstovogo-sleda", "kod": 0, "proverok": 46, "provalov": 0} + ] + } + JSON; + + postuchat($telo)->assertStatus(202); + + $o = otmetka(); + expect($o)->not->toBeNull() + ->and($o->last_error)->toBeNull() + ->and(Carbon::parse($o->last_run_at)->toIso8601String()) + ->toStartWith('2026-08-06T18:42:52'); + + // И тот же настоящий итог, состарившись, обязан поднять тревогу. + DB::table('scheduler_heartbeats') + ->where('command_name', 'lena:obhod-storozhey') + ->update(['last_run_at' => now()->subHours(50)]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + Mail::assertSent(LenaStorozhaTrevogaMail::class); +}); + +// ====================================================== ЕЖЕДНЕВНАЯ ПРОВЕРКА == + +it('при здоровье письма НЕ шлёт', function (): void { + polozhitOtmetku(['last_run_at' => now()->subHours(2), 'last_success_at' => now()->subHours(2)]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertNothingSent(); +}); + +it('🔴 шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено', function (): void { + // Тот самый нож: допущение «итог пришёл — значит машина жива». Итог + // пришёл ВЧЕРА и был зелёным, сегодня машина молчит. Тревога обязана быть + // от ВОЗРАСТА, а не от содержания. + polozhitOtmetku([ + 'last_run_at' => now()->subHours(40), + 'last_success_at' => now()->subHours(40), + 'last_error' => null, + ]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) { + return str_contains($m->prichina, 'ПРОТУХ') && $m->vozrastChasov >= 40; + }); +}); + +it('🔴 шлёт письмо на красного сторожа и называет его', function (): void { + polozhitOtmetku([ + 'last_run_at' => now()->subHours(1), + 'last_error' => 'proverka-raspisaniya: код 1, проверок 26, красных 3', + 'consecutive_failures' => 1, + ]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) { + return str_contains($m->prichina, 'ПОКРАСНЕЛ') + && str_contains($m->podrobno, 'proverka-raspisaniya'); + }); +}); + +it('шлёт письмо, когда итога не было ни разу', function (): void { + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) { + return str_contains($m->prichina, 'НЕ БЫЛО НИ РАЗУ'); + }); +}); + +it('🔴 шлёт письмо на адрес ИЗ НАСТРОЙКИ, а не на вписанный в код', function (): void { + // Решение владельца Р117. Здешняя беда, которую не повторяем: соседний + // сторож пульса шлёт на личную почту, ЖЁСТКО вписанную в его код. + config()->set('services.monitoring.alert_email', 'ops-proba@liderra.ru'); + polozhitOtmetku(['last_run_at' => now()->subHours(99)]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) { + return $m->hasTo('ops-proba@liderra.ru'); + }); + expect(file_get_contents(app_path('Console/Commands/LenaProverkaObhodaStorozhey.php'))) + ->not->toContain('kdv1@bk.ru'); +}); + +it('порог свежести берётся из настройки', function (): void { + config()->set('storozha_mashiny.svezhest_chasov', 100); + polozhitOtmetku(['last_run_at' => now()->subHours(40), 'last_success_at' => now()->subHours(40)]); + + test()->artisan('lena:proverka-obhoda')->assertSuccessful(); + + Mail::assertNothingSent(); +}); + +// ======================================================== ВТОРОЙ СЛОЙ ======== + +it('🔴 сама ежедневная проверка стоит под присмотром пульса', function (): void { + // Иначе завели бы молчуна вторым слоем: портал ждёт стука, а что живо + // само ожидание — не проверяет никто. + expect(SchedulerHeartbeatTracker::EXPECTED_INTERVALS) + ->toHaveKey('lena:proverka-obhoda'); +}); + +it('🔴 команда стоит в расписании портала — зовущий у неё есть', function (): void { + $raspisanie = file_get_contents(base_path('routes/console.php')); + expect($raspisanie)->toContain("Schedule::command('lena:proverka-obhoda')"); +}); diff --git a/bots/lena-golos/README.md b/bots/lena-golos/README.md index b5dec6cb..0634d655 100644 --- a/bots/lena-golos/README.md +++ b/bots/lena-golos/README.md @@ -30,6 +30,9 @@ | `zagotovki-slepki.sha256` | слепок каждой заготовки — по нему видно не только пропажу, но и порчу | | `lena-zagotovki.conf` | копия правила, которое возвращает заготовки в `/tmp` при загрузке — эталон | | `proverka-zagotovok.sh` | сторож заготовок: хранилище, машина, правило и живой опыт воссоздания | +| `sutochnyy-obhod.sh` | суточный обход: машина сама гоняет своих сторожей и складывает итог | +| `sutochnyy-obhod.cron` | копия расписания обхода с сервера — эталон | +| `proverka-sutochnogo-obhoda.sh` | сторож самого обхода — тот, кто следит за следящим | ## Ключ @@ -630,6 +633,178 @@ grep '^[0-9a-f]' bots/lena-golos/zagotovki-slepki.sha256 \ Обе команды обязаны не напечатать **ничего**. +## Суточный обход: машина проверяет себя сама, а портал за этим следит + +### Чем эта затея отличается от всего, что выше + +Всё, что описано выше, — сторожа. Они умеют показать, жива ли уборка записей +телефонных разговоров с живыми людьми. Но до 06.08.2026 их запускал **только +человек, который про них помнил.** А значит — почти никогда. + +Вред от этого не теоретический. Уборка умирает молча: расписание сломается, +скрипт исчезнет, права папки съедут — и голоса чужих людей копятся месяцами, +пока кто-нибудь случайно не прогонит сторожа рукой. Это персональные данные и +закон. + +С 06.08.2026 обход ходит сам: + +```text +машина 51.250.1.97 портал liderra.ru +────────────────── ───────────────── +05:20 МСК, /etc/cron.d/lena-sutochnyy-obhod + └─ sutochnyy-obhod.sh + ├─ гоняет ВСЕХ сторожей из своей папки + ├─ кладёт итог файлом otchyot-storozhey.json + └─ стучится с ним в портал ──────────► POST /api/webhook/lena-storozha + └─ кладёт отметку + scheduler_heartbeats + 'lena:obhod-storozhey' + 06:10 МСК lena:proverka-obhoda + ├─ итог протух? ─┐ + ├─ сторож красный? ├─► письмо + └─ иначе молчит ───┘ +``` + +### 🔴 Главное, что здесь легко сломать по невнимательности + +**Возраст итога считается по дате ВНУТРИ итога, а не по времени получения.** +Дату сборки ставит сама машина в момент обхода, поле называется так: + +```text +"sobrano_v": "2026-08-06T18:42:52Z" +``` + +Отправь кто-нибудь вчерашний файл сегодня — портал увидит вчерашнюю дату и +заругается, хотя стук дошёл минуту назад. Замените в приёмщике эту дату на +«сейчас» — и вся затея превратится в украшение: всё будет вечно свежим. + +**Зелёный итог недельной давности хуже красного сегодняшнего.** Проверка +возраста работает ВСЕГДА, даже когда внутри итога всё зелено. Это не +перестраховка: именно так выглядит тихая смерть машины. + +**Письма при здоровье нет.** Ежедневное «всё хорошо» перестают читать через +неделю, а вместе с ним перестают замечать и настоящую тревогу. + +### Кто зовёт каждую часть + +```text +обход на машине ← расписание /etc/cron.d/lena-sutochnyy-obhod + не станет зовущего → итог перестанет собираться, + портал заругается по ВОЗРАСТУ + +приём итога в портале ← сама машина, curl из обхода + не станет зовущего → стук не дойдёт, + портал заругается по ВОЗРАСТУ + +ежедневная проверка ← app/routes/console.php, 06:10 МСК + не станет зовущего → тревога замолчит, см. строку ниже + +она же КАК ЗАДАЧА ← записана в SchedulerHeartbeatTracker, + за ней следит штатный часовой + scheduler:check-heartbeats + не станет зовущего → присмотр остался бы без присмотра +``` + +### Что лежит на машине + +Папка `/home/ubuntu/lena-golos/` — **машинная копия** двенадцати эталонов из +`bots/lena-golos/`. Обход и сторожа читают эталоны оттуда: своего ssh-ключа у +машины нет и заводить его нельзя (ключ от самой себя на машине с голосами +живых людей — дыра, а не удобство). + +🔴 Отсюда вылезает круговая порука: обход на машине сверяет живые файлы с +**машинной копией**, а не с хранилищем. Разойдись копия с хранилищем — обход +сравнил бы копию сам с собой и промолчал. Разрывает это только взгляд снаружи: +сторож обхода ходит **с рабочего места по ssh** и сверяет обе стороны +побайтово. + +Рядом лежат четыре рабочих файла, которых в хранилище нет и не будет: + +```text +otchyot-storozhey.json последний итог обхода +sutochnyy-obhod.log подробный вывод сторожей + ОБНУЛЯЕТСЯ каждым обходом +sutochnyy-obhod.log.otvet что ответил портал на последний стук +nastroyki-obhoda адрес портала и тайное слово, права 600 +``` + +Журнал обнуляется не для аккуратности. В него ложится вывод сторожей, а там +строки вида «челове[к]: 5 знаков, 1 слов» — сегодня это **мера**, а не слова. +Но место, куда что-то похожее на речь ложится без срока и без уборки, заводить +нельзя: именно такие места здесь и вылавливают. Обнуление даёт потолок — в +журнале живёт ровно один, последний обход. + +### Как проверить, что обход жив + +```bash +bash bots/lena-golos/proverka-sutochnogo-obhoda.sh +``` + +42 проверки. Он смотрит расписание, побайтовое совпадение всех двенадцати +эталонов с машиной, состав папки (лишний `proverka-*.sh` обход подхватит и +погонит — значит лишних быть не должно), свежесть итога, его поля, и гоняет +сам обход на **подставных** сторожах во временной папке. + +🔴 Подставные сторожа — нарочно. Настоящих на машине руками не ломаем +(решение владельца Р94). **Чего такая подмена не доказывает:** что настоящий +сторож, увидев настоящую поломку уборки, покраснеет. Это дело самих сторожей, +и каждого показывали красным в своём круге. Здесь доказывается только +проводка: обход замечает красноту и доносит её до итога поимённо. + +### Что ещё не доделано + +**Портальная половина не выкачена.** Пока это так, файл настроек отправки на +машине пуст, обход пишет итог файлом и говорит в журнал «отправка не +настроена», а сторож обхода честно краснеет одной проверкой из 42 — «дорога до +портала НЕ настроена». Это не поломка, это незавершённый выкат. Когда портал +выкатят, четыре шага: + +```bash +# 1. на портале, в окружении (слово взять из генератора, не короче 32 знаков) +LENA_STOROZHA_SLOVO=... +LENA_STOROZHA_ADRESA=51.250.1.97 + +# 2. на машине то же слово и адрес портала, права 600 +sudo -u ubuntu tee /home/ubuntu/lena-golos/nastroyki-obhoda <<'EOF' +PORTAL_URL=https://liderra.ru/api/webhook/lena-storozha +TAYNOE_SLOVO=... +EOF +chmod 600 /home/ubuntu/lena-golos/nastroyki-obhoda + +# 3. прогнать рукой и увидеть в журнале «портал принял итог, ответ 202» +bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh + +# 4. прогнать сторожа обхода — он должен стать зелёным целиком +bash bots/lena-golos/proverka-sutochnogo-obhoda.sh +``` + +🔴 **Тайного слова в хранилище нет и быть не должно.** Сторож обхода это +проверяет отдельной проверкой. + +### Прибавили сторожа — скажите порталу + +Обход ищет сторожей **списком файлов** в своей папке: положили новый +`proverka-*.sh` — он попадёт в обход сам, и число в итоге вырастет. Портал +сверит это число со своей настройкой (по умолчанию 3) и заругается, пока +человек не подтвердит прибавление руками. Так «сторожей стало четыре, а стучит +по-прежнему три» перестаёт быть возможным молча. + +```text +сегодня в обход входят трое: + proverka-raspisaniya + proverka-tekstovogo-sleda + proverka-chistki + +четвёртый, proverka-zagotovok, В ОБХОД НЕ ВХОДИТ: + он сторожит заготовки подбора голоса, а не уборку чужих + записей — то есть не ту беду, ради которой обход заведён + +захотите включить — три шага: + 1. положить его в /home/ubuntu/lena-golos/ + 2. поднять LENA_STOROZHA_SKOLKO до 4 + 3. дописать его в список эталонов сторожа обхода +``` + ## Как сверить копию настройки с сервером `extensions-lena.conf` — копия `/etc/asterisk/extensions.conf` с сервера diff --git a/bots/lena-golos/proverka-raspisaniya.sh b/bots/lena-golos/proverka-raspisaniya.sh index 43bebc26..6eb0a212 100644 --- a/bots/lena-golos/proverka-raspisaniya.sh +++ b/bots/lena-golos/proverka-raspisaniya.sh @@ -44,7 +44,28 @@ zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; } krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; } # Одна дверь на сервер: и проще читать, и легче подменить при пробе. -na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; } +# +# 🔴 У двери две створки. Обычная — по ssh с рабочего места. Вторая, «местная» +# (LENA_MESTNAYA=1), означает «мы УЖЕ на этой машине»: команды идут прямо +# здесь, без ssh. Она нужна суточному обходу sutochnyy-obhod.sh, который +# машина запускает сама по расписанию: закрытого ssh-ключа у машины нет, и +# заводить его нельзя — ключ от самой себя на машине, где лежат голоса +# живых людей, это дыра, а не удобство. +# +# 🔴 Что при местной створке ТЕРЯЕТСЯ, говорим вслух, а не умалчиваем: +# - проверка 1 «достучались до машины» становится бессодержательной: +# раз мы исполняемся, машина жива по определению. Смерть машины целиком +# этим сторожем больше не ловится — её ловит портал по ВОЗРАСТУ итога; +# - эталоны берутся из машинной копии папки, а не из хранилища. Совпадение +# машинной копии с хранилищем этот сторож доказать не может — это +# проверяет отдельный сторож proverka-sutochnogo-obhoda.sh, и он ходит +# с рабочего места по ssh, то есть смотрит на обе стороны сразу. +if [ "${LENA_MESTNAYA:-0}" = "1" ]; then + SERVER="сама машина (без ssh)" + na_servere() { bash -c "$1" 2>/dev/null; } +else + na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; } +fi echo "Сторож уборки на сервере робота. Сервер: $SERVER" echo diff --git a/bots/lena-golos/proverka-sutochnogo-obhoda.sh b/bots/lena-golos/proverka-sutochnogo-obhoda.sh new file mode 100644 index 00000000..03363611 --- /dev/null +++ b/bots/lena-golos/proverka-sutochnogo-obhoda.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# Сторож СУТОЧНОГО ОБХОДА — тот, кто следит за следящим. +# +# Чем он отличается от остальных. Остальные сторожа проверяют уборку записей. +# Этот проверяет ОБХОД — то, что заставляет остальных сторожей ходить самих, +# без человека. Обход — новый молчун: умри он тихо, последний зелёный итог +# останется лежать на месте, и всё будет выглядеть как «хорошо», пока голоса +# чужих людей копятся месяцами. +# +# 🔴 Он ходит С РАБОЧЕГО МЕСТА по ssh, а не с машины, и это принципиально. +# Обход на машине сверяет живые файлы с МАШИННОЙ копией эталонов. Разойдись +# машинная копия с хранилищем — обход этого не увидит: он сравнил бы копию +# сам с собой. Круговую поруку разрывает только взгляд снаружи, и это здесь. +# +# 🔴 Ничего на машине не удаляет и не портит (решения владельца Р94, Р84). +# Проба обхода идёт в своей временной папке на подставных сторожах. +# +# Запуск одной командой: +# bash bots/lena-golos/proverka-sutochnogo-obhoda.sh +# +# Настройки — переменными окружения: +# LENA_SERVER куда ходить (по умолчанию ubuntu@51.250.1.97) +# LENA_KLYUCH каким ключом (по умолчанию ~/.ssh/liderra_deploy) +# LENA_STOROZHA_SKOLKO сколько сторожей ждём (по умолчанию 3 — как в портале) +# LENA_SVEZHEST_CHASOV сколько часов итог считается свежим (по умолчанию 26) +# +# Возвращает 0, если всё цело, и 1, если хоть одна проверка покраснела. + +set -u + +ZDES="$(cd "$(dirname "$0")" && pwd)" + +SERVER="${LENA_SERVER:-ubuntu@51.250.1.97}" +KLYUCH="${LENA_KLYUCH:-$HOME/.ssh/liderra_deploy}" +OZHIDAEM="${LENA_STOROZHA_SKOLKO:-3}" +SVEZHEST="${LENA_SVEZHEST_CHASOV:-26}" + +PAPKA_TAM="/home/ubuntu/lena-golos" +RASPISANIE_TAM="/etc/cron.d/lena-sutochnyy-obhod" +OTCHYOT_TAM="$PAPKA_TAM/otchyot-storozhey.json" +NASTROYKI_TAM="$PAPKA_TAM/nastroyki-obhoda" + +# 🔴 Полный состав машинной копии. Ни больше, ни меньше. Лишний proverka-*.sh +# в этой папке обход подхватит и погонит — поэтому «лишних быть не должно» +# здесь такая же проверка, как «нужные на месте». +ETALONY=( + sutochnyy-obhod.sh + proverka-raspisaniya.sh + proverka-tekstovogo-sleda.sh + proverka-chistki.sh + chistka-zapisey.sh + chistka-zapisey.cron + chistka-sleda-prob.sh + chistka-sleda-prob.cron + lena-inworld.conf + tekstovyy-sled.logrotate + most.py + proba-mosta-bez-zvonka.py +) + +PROVALOV=0 +PROVERENO=0 + +zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; } +krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; } + +na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; } + +# Побайтовая сверка со снятием \r: рабочее дерево на Windows добавляет его сам, +# и сырое сравнение слепков здесь врёт — замерено не один раз. +sverit_pobaytno() { # <файл здесь> <файл там> <как называется> + local sha_tam sha_zdes + sha_tam="$(na_servere "tr -d '\r' < '$2' 2>/dev/null | sha256sum | cut -d' ' -f1")" + sha_zdes="$(tr -d '\r' < "$1" 2>/dev/null | sha256sum | cut -d' ' -f1)" + if [ -n "$sha_tam" ] && [ "$sha_tam" = "$sha_zdes" ]; then + zelenyy "$3: текст на машине и в хранилище один и тот же" + else + krasnyy "$3: текст РАЗОШЁЛСЯ — машина ${sha_tam:-(файла нет)}, хранилище ${sha_zdes:-(файла нет)}" + fi +} + +echo "Сторож суточного обхода. Машина: $SERVER" +echo + +# ------------------------------------------------------- 1. дверь ---------- +echo "1. Достучались ли до машины" +if [ "$(na_servere 'echo zhiv')" = "zhiv" ]; then + zelenyy "машина отвечает" +else + krasnyy "машина НЕ отвечает — дальше мерить нечем" + echo "──────────────────────────────────────────────" + echo "🔴 БЕДА: проверок $PROVERENO, красных $PROVALOV" + exit 1 +fi +echo + +# --------------------------------------------- 2. расписание обхода -------- +echo "2. Расписание: зовёт ли обход хоть кто-нибудь" + +if [ "$(na_servere "test -f '$RASPISANIE_TAM' && echo est")" = "est" ]; then + zelenyy "файл расписания на месте: $RASPISANIE_TAM" +else + krasnyy "файла расписания НЕТ: $RASPISANIE_TAM — обход не зовёт НИКТО" +fi + +sverit_pobaytno "$ZDES/sutochnyy-obhod.cron" "$RASPISANIE_TAM" "расписание обхода" + +# 🔴 cron берёт из /etc/cron.d только имена из букв, цифр, подчёркиваний и +# дефисов. Точка в имени — и файл МОЛЧА не исполняется никогда. +IMYA_FAYLA="$(basename "$RASPISANIE_TAM")" +if printf '%s' "$IMYA_FAYLA" | grep -qE '^[A-Za-z0-9_-]+$'; then + zelenyy "имя годное для cron: «$IMYA_FAYLA» — без точки" +else + krasnyy "имя «$IMYA_FAYLA» НЕ годится: cron молча пропустит такой файл" +fi + +VLADELETS="$(na_servere "stat -c '%U %a' '$RASPISANIE_TAM' 2>/dev/null")" +if [ "$VLADELETS" = "root 644" ]; then + zelenyy "владелец и права расписания: $VLADELETS" +else + krasnyy "владелец/права расписания «${VLADELETS:-непонятно}», ждали «root 644»" +fi +echo + +# --------------------------------------------- 3. машинная копия ----------- +echo "3. Машинная копия эталонов: та же ли она, что в хранилище" +echo " 🔴 Это и есть тот взгляд снаружи, которого у обхода на машине нет." + +if [ "$(na_servere "test -d '$PAPKA_TAM' && echo est")" = "est" ]; then + zelenyy "папка обхода на месте: $PAPKA_TAM" +else + krasnyy "папки обхода НЕТ: $PAPKA_TAM" +fi + +for F in "${ETALONY[@]}"; do + if [ ! -f "$ZDES/$F" ]; then + krasnyy "эталона нет в ХРАНИЛИЩЕ: $F" + continue + fi + sverit_pobaytno "$ZDES/$F" "$PAPKA_TAM/$F" "$F" +done + +# Лишние файлы. Своё рабочее (итог, журналы, настройки) не считаем. +LISHNIE="$(na_servere "cd '$PAPKA_TAM' 2>/dev/null && ls -1A | grep -vE '^(otchyot-storozhey\.json|sutochnyy-obhod\.log|sutochnyy-obhod\.log\.otvet|nastroyki-obhoda)\$'")" +OZHIDAEMYE="$(printf '%s\n' "${ETALONY[@]}" | LC_ALL=C sort)" +FAKTICHESKIE="$(printf '%s\n' "$LISHNIE" | grep -v '^$' | LC_ALL=C sort)" +if [ "$OZHIDAEMYE" = "$FAKTICHESKIE" ]; then + zelenyy "в папке ровно ${#ETALONY[@]} эталонов, лишнего нет" +else + krasnyy "состав папки РАЗОШЁЛСЯ с хранилищем. На машине:" + printf '%s\n' "$FAKTICHESKIE" | sed 's/^/ | /' +fi + +if [ "$(na_servere "test -x '$PAPKA_TAM/sutochnyy-obhod.sh' && echo da")" = "da" ]; then + zelenyy "обход исполним" +else + krasnyy "обход НЕ исполним — расписание его не запустит" +fi +echo + +# --------------------------------------------- 4. сколько сторожей --------- +echo "4. Сколько сторожей обход найдёт — столько ли, сколько ждёт портал" + +NASHLOS="$(na_servere "ls -1 '$PAPKA_TAM'/proverka-*.sh 2>/dev/null | wc -l")" +if [ "${NASHLOS:-0}" = "$OZHIDAEM" ]; then + zelenyy "сторожей в папке $NASHLOS — ровно столько, сколько ждёт портал" +else + krasnyy "сторожей в папке ${NASHLOS:-непонятно}, а портал ждёт $OZHIDAEM. Либо положили сторожа и забыли сказать порталу, либо сторож пропал" +fi +echo + +# --------------------------------------------- 5. итог обхода -------------- +echo "5. Итог: есть ли он, свеж ли, и что в нём написано" + +if [ "$(na_servere "test -f '$OTCHYOT_TAM' && echo est")" = "est" ]; then + zelenyy "итог на месте: $OTCHYOT_TAM" + + VOZRAST_SEK="$(na_servere "echo \$(( \$(date -u +%s) - \$(stat -c %Y '$OTCHYOT_TAM') ))")" + PREDEL=$(( SVEZHEST * 3600 )) + if [ -n "${VOZRAST_SEK:-}" ] && [ "$VOZRAST_SEK" -le "$PREDEL" ]; then + zelenyy "итог свежий: $(( VOZRAST_SEK / 3600 )) ч, порог $SVEZHEST ч" + else + krasnyy "итог ПРОТУХ: $(( ${VOZRAST_SEK:-0} / 3600 )) ч, порог $SVEZHEST ч. Зелёный старый итог хуже красного свежего — он усыпляет" + fi + + SODERZHANIE="$(na_servere "cat '$OTCHYOT_TAM'")" + for POLE in shema sobrano_v mashina storozhey_vsego storozhey_krasnyh slepok_etalonov storozha; do + if printf '%s' "$SODERZHANIE" | grep -qF "\"$POLE\""; then + zelenyy "в итоге есть поле «$POLE»" + else + krasnyy "в итоге НЕТ поля «$POLE» — портал такой итог не разберёт" + fi + done + + V_ITOGE="$(printf '%s' "$SODERZHANIE" | sed -n 's/.*"storozhey_vsego": *\([0-9][0-9]*\).*/\1/p' | head -1)" + if [ "${V_ITOGE:-нет}" = "$OZHIDAEM" ]; then + zelenyy "итог называет $V_ITOGE сторожей — столько же, сколько ждёт портал" + else + krasnyy "итог называет ${V_ITOGE:-непонятно} сторожей, а портал ждёт $OZHIDAEM" + fi +else + krasnyy "итога НЕТ вовсе: $OTCHYOT_TAM — обход ни разу не доходил до конца" +fi +echo + +# --------------------------------------------- 6. тайное слово ------------- +echo "6. Тайное слово: лежит ли отдельно и закрыто ли от чужих глаз" + +# 🪤 Образец нарочно собран из кусков, а НЕ написан одной строкой: написанный +# целиком, он нашёл бы сам себя в этом же файле и сторож краснел бы вечно. +# Ту же грабку в соседнем стороже автор обошёл скобками — здесь склейкой. +OBRAZETS="TAYNOE_SLOVO""=[A-Za-z0-9_+/=-]\{8,\}" +NASHLOS_SLOVO="$(grep -rIl --exclude='*.log' -- "$OBRAZETS" "$ZDES" 2>/dev/null)" +if [ -z "$NASHLOS_SLOVO" ]; then + zelenyy "в хранилище тайного слова нет — только имя переменной" +else + krasnyy "в хранилище нашлось похожее на записанное тайное слово — так нельзя:" + printf '%s\n' "$NASHLOS_SLOVO" | sed 's/^/ | /' +fi + +if [ "$(na_servere "test -f '$NASTROYKI_TAM' && echo est")" = "est" ]; then + PRAVA="$(na_servere "stat -c '%U %a' '$NASTROYKI_TAM'")" + if [ "$PRAVA" = "ubuntu 600" ]; then + zelenyy "настройки отправки закрыты: $PRAVA" + else + krasnyy "настройки отправки открыты шире, чем надо: «${PRAVA:-непонятно}», ждали «ubuntu 600»" + fi + + # 🔴 Файл на месте — ещё не значит, что дорога до портала есть. Пустые + # PORTAL_URL или TAYNOE_SLOVO означают, что итог ложится файлом и НИКУДА + # не уходит: портал его не увидит и молчание машины не заметит. + # Слово отсюда НЕ читаем и не печатаем — только считаем, пусто или нет. + ZAPOLNENO="$(na_servere "grep -cE '^(PORTAL_URL|TAYNOE_SLOVO)=.+' '$NASTROYKI_TAM'")" + if [ "${ZAPOLNENO:-0}" = "2" ]; then + zelenyy "дорога до портала настроена: адрес и слово на месте" + else + krasnyy "дорога до портала НЕ настроена: заполнено ${ZAPOLNENO:-0} строк из 2. Итог лежит файлом, портал его не получает" + fi +else + krasnyy "настроек отправки на машине НЕТ — итог никуда не уходит, портал его не увидит" +fi +echo + +# --------------------------------------------- 7. проба обхода ------------- +echo "7. Проба обхода на ПОДСТАВНЫХ сторожах" +echo " 🔴 Настоящих сторожей здесь не ломаем: на машине руками не портим" +echo " ничего (Р94). Обходу подкладываются заведомо негодные данные." +echo " 🔴 Чего эта подмена НЕ доказывает: что НАСТОЯЩИЙ сторож, увидев" +echo " настоящую поломку уборки, покраснеет. Это дело самих сторожей, и" +echo " каждого из них показывали красным в своём круге. Здесь доказывается" +echo " только проводка: обход замечает красноту и доносит её до итога." + +VYVOD_PROBY="$( + ssh -o BatchMode=yes -o ConnectTimeout=30 -i "$KLYUCH" "$SERVER" 'bash -s' <<'REMOTE' 2>&1 +set -u +KORZINA="$(mktemp -d)" +trap 'rm -rf -- "$KORZINA"' EXIT +cp /home/ubuntu/lena-golos/sutochnyy-obhod.sh "$KORZINA/" || { echo "ПРОБА: не скопировал обход"; exit 1; } + +sdelat_storozha() { # <имя> <код> <проверок> <красных> + cat > "$KORZINA/proverka-$1.sh" </dev/null 2>&1 +echo "А-код=$?" +echo "А-итог=$(tr -d ' \n' < "$KORZINA/itog.json" 2>/dev/null)" + +# --- случай Б: один подставной сторож КРАСНЫЙ +sdelat_storozha dva 1 9 4 +LENA_OTCHYOT="$KORZINA/itog.json" LENA_ZHURNAL_OBHODA="$KORZINA/zhurnal.log" \ + LENA_NASTROYKI="$KORZINA/net-takogo" bash "$KORZINA/sutochnyy-obhod.sh" >/dev/null 2>&1 +echo "Б-код=$?" +echo "Б-итог=$(tr -d ' \n' < "$KORZINA/itog.json" 2>/dev/null)" + +# --- случай В: обход НИЧЕГО не удалил +echo "В-осталось=$(ls -1 "$KORZINA"/proverka-*.sh 2>/dev/null | wc -l)" + +# --- случай Г: отправка не настроена — обход не падает и говорит об этом +if grep -q 'отправка не настроена' "$KORZINA/zhurnal.log"; then echo "Г=сказал"; else echo "Г=промолчал"; fi +REMOTE +)" + +printf '%s\n' "$VYVOD_PROBY" | sed 's/^/ | /' + +vzyat() { printf '%s\n' "$VYVOD_PROBY" | sed -n "s/^$1=//p" | head -1; } + +if [ "$(vzyat 'А-код')" = "0" ]; then zelenyy "проба А: обход на зелёных отработал"; else krasnyy "проба А: обход не отработал"; fi + +A_ITOG="$(vzyat 'А-итог')" +if printf '%s' "$A_ITOG" | grep -qF '"storozhey_krasnyh":0'; then + zelenyy "проба А: в итоге красных 0" +else + krasnyy "проба А: в итоге красных НЕ 0 — обход выдумал красноту на ровном месте" +fi +if printf '%s' "$A_ITOG" | grep -qF '"storozhey_vsego":2'; then + zelenyy "проба А: обход нашёл обоих подставных сторожей сам, списком файлов" +else + krasnyy "проба А: обход насчитал не двоих — значит сторожей он ищет не так, как думаем" +fi + +B_ITOG="$(vzyat 'Б-итог')" +if printf '%s' "$B_ITOG" | grep -qF '"storozhey_krasnyh":1'; then + zelenyy "проба Б: обход заметил красного сторожа" +else + krasnyy "проба Б: обход НЕ заметил красного сторожа — вся затея бесполезна" +fi +if printf '%s' "$B_ITOG" | grep -qF '"imya":"proverka-dva"' && printf '%s' "$B_ITOG" | grep -qF '"provalov":4'; then + zelenyy "проба Б: итог называет ИМЯ покрасневшего и число его провалов" +else + krasnyy "проба Б: итог не называет, КТО покраснел — по такому письму чинить нечего" +fi + +if [ "$(vzyat 'В-осталось')" = "2" ]; then + zelenyy "проба В: обход не удалил ни одного файла" +else + krasnyy "проба В: после обхода файлов стало «$(vzyat 'В-осталось')» вместо 2 — обход что-то удаляет" +fi + +if [ "$(vzyat 'Г')" = "сказал" ]; then + zelenyy "проба Г: ненастроенная отправка не роняет обход и попадает в журнал" +else + krasnyy "проба Г: про ненастроенную отправку обход промолчал" +fi +echo + +# --------------------------------------------- 8. журнал обхода ------------ +echo "8. Журнал обхода на машине" +POSLEDNYAYA="$(na_servere "tail -3 '$PAPKA_TAM/sutochnyy-obhod.log' 2>/dev/null")" +if [ -n "$POSLEDNYAYA" ]; then + zelenyy "журнал не пуст, последние строки:" + printf '%s\n' "$POSLEDNYAYA" | sed 's/^/ | /' +else + krasnyy "журнал обхода пуст или его нет — обход не ходил ни разу" +fi + +echo +echo "──────────────────────────────────────────────" +if [ "$PROVALOV" -eq 0 ]; then + echo "✅ ЦЕЛО: проверок $PROVERENO, красных $PROVALOV" + exit 0 +else + echo "🔴 БЕДА: проверок $PROVERENO, красных $PROVALOV" + exit 1 +fi diff --git a/bots/lena-golos/proverka-tekstovogo-sleda.sh b/bots/lena-golos/proverka-tekstovogo-sleda.sh index 230f7d0b..fa3f3a8f 100644 --- a/bots/lena-golos/proverka-tekstovogo-sleda.sh +++ b/bots/lena-golos/proverka-tekstovogo-sleda.sh @@ -71,7 +71,32 @@ zelenyy() { PROVERENO=$(( PROVERENO + 1 )); echo " ✅ $1"; } krasnyy() { PROVERENO=$(( PROVERENO + 1 )); PROVALOV=$(( PROVALOV + 1 )); echo " 🔴 $1"; } # Одна дверь на сервер: и проще читать, и легче подменить при пробе. -na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; } +# +# 🔴 У двери две створки. Обычная — по ssh с рабочего места. Вторая, «местная» +# (LENA_MESTNAYA=1), означает «мы УЖЕ на этой машине»: команды идут прямо +# здесь, без ssh. Она нужна суточному обходу sutochnyy-obhod.sh, который +# машина запускает сама: закрытого ssh-ключа у машины нет и заводить его +# нельзя — ключ от самой себя на машине с голосами живых людей это дыра. +# +# 🔴 Что при местной створке ТЕРЯЕТСЯ: проверка 0 «достучались до машины» +# становится бессодержательной, а эталоны берутся из машинной копии папки, +# а не из хранилища. Совпадение копии с хранилищем проверяет отдельный +# сторож proverka-sutochnogo-obhoda.sh — он ходит с рабочего места по ssh. +if [ "${LENA_MESTNAYA:-0}" = "1" ]; then + SERVER="сама машина (без ssh)" + na_servere() { bash -c "$1" 2>/dev/null; } +else + na_servere() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$KLYUCH" "$SERVER" "$1" 2>/dev/null; } +fi + +# Положить файл на сервер. Та же пара створок, что у двери выше. +polozhit_na_server() { # <файл здесь> <путь там> + if [ "${LENA_MESTNAYA:-0}" = "1" ]; then + cp -- "$1" "$2" 2>/dev/null + else + scp -q -o BatchMode=yes -i "$KLYUCH" "$1" "$SERVER:$2" 2>/dev/null + fi +} # Побайтовая сверка со снятием \r: рабочее дерево на Windows добавляет его сам. sverit_pobaytno() { # <файл здесь> <файл там> <как называется> @@ -124,8 +149,7 @@ else if [ -z "$VREMENNAYA" ]; then krasnyy "не вышло завести временную папку на сервере — проба не состоялась" else - if scp -q -o BatchMode=yes -i "$KLYUCH" "$PROBA_ZDES" \ - "$SERVER:$VREMENNAYA/proba-mosta-bez-zvonka.py" 2>/dev/null; then + if polozhit_na_server "$PROBA_ZDES" "$VREMENNAYA/proba-mosta-bez-zvonka.py"; then # Пробу кладём В ПАПКУ ЛЕНЫ ссылкой? Нет: она сама подкладывает свой путь # в поиск ввоза, поэтому запускаем её ИЗ папки Лены, оставив файл во # временной. Мост ввозится по имени most из папки запуска. @@ -424,7 +448,20 @@ echo " 🪤 Образец ищем в скобках — «челове[к]: echo " своя же команда записывается в /var/log/auth.log и в системный журнал." NAYDENO="$(na_servere "sudo grep -rlaE 'челове[к]:' /var/log /home /opt /tmp /srv /root 2>/dev/null | sort")" -ZHDALI="/home/ubuntu/lena-inworld/__pycache__/most.cpython-310.pyc +# 🔴 Два места из списка появились 06.08.2026 вместе с суточным обходом +# (З-0.10) и разобраны поимённо, а не заметены: +# lena-golos/most.py — ЭТАЛОННАЯ копия моста. Это исходный +# текст программы, в нём образец строки, а не чьи-то слова. Ровно по +# той же причине в списке давно стоит lena-inworld/most.py. +# lena-golos/sutochnyy-obhod.log — журнал обхода. В него ложится вывод +# сторожей, а там строки вида «челове[к]: 5 знаков, 1 слов» (образец +# в скобках нарочно — иначе этот файл сам стал бы новым местом) — МЕРА, не +# речь. Место без срока и без уборки заводить было бы нельзя, поэтому +# обход ОБНУЛЯЕТ этот журнал в начале каждого прогона: в нём живёт +# ровно один последний обход и вырасти он не может. +ZHDALI="/home/ubuntu/lena-golos/most.py +/home/ubuntu/lena-golos/sutochnyy-obhod.log +/home/ubuntu/lena-inworld/__pycache__/most.cpython-310.pyc /home/ubuntu/lena-inworld/most-vhodyashchie.log /home/ubuntu/lena-inworld/most.log /home/ubuntu/lena-inworld/most.py diff --git a/bots/lena-golos/sutochnyy-obhod.cron b/bots/lena-golos/sutochnyy-obhod.cron new file mode 100644 index 00000000..caa822a3 --- /dev/null +++ b/bots/lena-golos/sutochnyy-obhod.cron @@ -0,0 +1,58 @@ +# ============================================================================ +# РАСПИСАНИЕ СУТОЧНОГО ОБХОДА СТОРОЖЕЙ — эталон для сверки +# +# Куда кладётся: сервер liderra-render (51.250.1.97), файл +# /etc/cron.d/lena-sutochnyy-obhod +# Когда: 06.08.2026. +# +# Зачем расписание вообще. Сторожа на этой машине проверяют, что уборка +# записей телефонных разговоров с живыми людьми работает и что слова людей не +# текут в журналы. До 06.08.2026 их запускал ТОЛЬКО человек, который про них +# помнил, — то есть, по факту, почти никогда. Уборка могла умереть молча и +# копить чужие голоса месяцами. Этих строк не будет — всё построенное снова +# станет бумагой: обход есть, а звать его некому. +# +# 🔴 ИМЯ ФАЙЛА НА СЕРВЕРЕ — БЕЗ ТОЧКИ, И ЭТО НЕ ПРИДИРКА. Из папки +# /etc/cron.d cron берёт только те файлы, чьё имя состоит из букв, цифр, +# подчёркиваний и дефисов. Файл, в имени которого есть точка, он МОЛЧА +# пропускает — расписание будет лежать на месте и не сработать никогда, и +# ни одной жалобы в журнале не появится. Поэтому в хранилище файл зовётся +# sutochnyy-obhod.cron (человеку видно, что это), а на сервере — +# lena-sutochnyy-obhod, без всякого расширения. +# +# 🔴 Текст на сервере и здесь обязан совпадать ПОБАЙТОВО. Это проверяет +# сторож proverka-sutochnogo-obhoda.sh с рабочего места. +# ============================================================================ + +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Когда ходим: раз в сутки, 02:20 по времени сервера (сервер живёт по UTC), +# это 05:20 по Москве. +# +# Почему именно так: +# - раз в сутки — ровно то, что решил владелец (Р116). Чаще незачем: машина +# делит железо с рендером боевого «Поиска клиентов» (решение Р84); +# - 02:20 UTC — ПОСЛЕ обеих уборок и с запасом: чистка записей ходит в +# 01:40, чистка следа проб в 01:50. Сторожа обязаны смотреть на свежий +# результат уборки, а не заставать её в разгаре. В одну минуту с уборками +# не встаём нарочно: тогда обход мерил бы недоделанное; +# - 05:20 по Москве — робот не звонит, портал не заливает вечернюю порцию, +# людей на машине нет; +# - минута НЕ круглая и не :17 — в :17 каждый час ходит штатный +# run-parts /etc/cron.hourly, и вставать с ним в одну минуту незачем. +# +# От кого ходим: от ubuntu. +# - от него же работают обе Лены и обе уборки; +# - ему принадлежит папка /home/ubuntu/lena-golos, куда пишется итог; +# - root не берём: полные права здесь не нужны ни для чего, обход НИЧЕГО +# не удаляет (решение владельца Р94) — он только читает и считает. +# +# Про flock: два обхода разом не нужны. -n значит «не дожидаться»: если +# предыдущий ещё идёт, новый молча уходит. Пропущенный обход не страшен — +# страшен ЗАСТРЯВШИЙ, а портал это увидит по возрасту итога. +# +# Про хвост команды: обход пишет всё в свой журнал сам, поэтому обычный вывод +# в /dev/null; а если заругается сам bash (а не обход), ругань обязана попасть +# человеку на глаза, а не сгинуть в несуществующей почте — потому 2>> в журнал. +20 2 * * * ubuntu /usr/bin/flock -n /tmp/lena-sutochnyy-obhod.lock /bin/bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh >/dev/null 2>>/home/ubuntu/lena-golos/sutochnyy-obhod.log diff --git a/bots/lena-golos/sutochnyy-obhod.sh b/bots/lena-golos/sutochnyy-obhod.sh new file mode 100644 index 00000000..c1e655fa --- /dev/null +++ b/bots/lena-golos/sutochnyy-obhod.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# СУТОЧНЫЙ ОБХОД СТОРОЖЕЙ — эталон для сверки. +# +# Куда кладётся: сервер liderra-render (51.250.1.97), файл +# /home/ubuntu/lena-golos/sutochnyy-obhod.sh +# Кто зовёт: /etc/cron.d/lena-sutochnyy-obhod (эталон рядом, +# sutochnyy-obhod.cron). Больше НИКТО — руками звать можно, +# но жить обход обязан без человека. +# +# --------------------------------------------------------------------------- +# ЗАЧЕМ ЭТОТ ФАЙЛ ВООБЩЕ СУЩЕСТВУЕТ +# +# На этой машине живут сторожа. Они проверяют, что уборка записей телефонных +# разговоров с живыми людьми вправду работает и что слова этих людей не текут +# в журналы. До 06.08.2026 их запускал ТОЛЬКО человек, который про них помнил. +# +# Это и есть беда. Уборка умирает молча: расписание сломается, скрипт исчезнет, +# права папки съедут — и голоса чужих людей копятся месяцами, пока кто-нибудь +# случайно не запустит сторожа рукой. Это персональные данные и закон. +# +# --------------------------------------------------------------------------- +# 🔴 ГЛАВНАЯ ОПАСНОСТЬ САМОГО ЭТОГО ФАЙЛА: он — новый молчун. +# +# Умри он тихо — и выглядеть это будет как «всё хорошо»: последний итог лежит +# на месте, зелёный, и никто не заметит, что он позавчерашний. Поэтому: +# +# 1. Внутри итога стоит ЕГО СОБСТВЕННАЯ дата (поле sobrano_v), а не время, +# когда портал его получил. Портал считает возраст по ней. Отправь кто-то +# вчерашний файл сегодня — портал увидит вчерашнюю дату и заругается. +# 2. Итог называет, СКОЛЬКО сторожей он гонял. Появится четвёртый сторож, а +# обход по-прежнему будет гонять трёх — портал увидит несовпадение с тем, +# сколько он ждёт, и заругается. Сторожа ищутся списком файлов, а не +# перечислены здесь руками: положили новый — обход его подхватит сам. +# 3. Зелёный итог недельной давности хуже красного сегодняшнего. Возраст +# портал проверяет ВСЕГДА, даже когда внутри всё зелено. +# +# --------------------------------------------------------------------------- +# ЧЕГО ЭТОТ ФАЙЛ НЕ ДЕЛАЕТ И НЕ БУДЕТ +# +# - НИЧЕГО не удаляет (решение владельца Р94). Он только читает и считает. +# - Не перезапускает служб, не трогает Asterisk, не звонит. +# - Не кладёт в итог ни одной строки вывода сторожей — только имя сторожа, +# код и два числа. Причина не в краткости: вывод сторожей содержит куски +# журналов моста, а там могут быть слова живых людей. Портал этих слов +# видеть не должен. Человеку весь вывод лежит рядом, в своём журнале. +# - Не знает тайного слова наизусть: слово читается из отдельного файла с +# правами 600, которого в хранилище нет и не будет. +# +# Запуск рукой (то же самое, что делает расписание): +# bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh +# +# Возвращает 0, если обход СОСТОЯЛСЯ (даже если сторожа покраснели), и 1, если +# состояться не смог: не нашёл сторожей или не сумел записать итог. Красные +# сторожа — не беда обхода, это его работа; беда — молчание. +# --------------------------------------------------------------------------- + +set -u + +ZDES="$(cd "$(dirname "$0")" && pwd)" +OTCHYOT="${LENA_OTCHYOT:-$ZDES/otchyot-storozhey.json}" +ZHURNAL="${LENA_ZHURNAL_OBHODA:-$ZDES/sutochnyy-obhod.log}" +NASTROYKI="${LENA_NASTROYKI:-$ZDES/nastroyki-obhoda}" + +NACHALO_SEK="$(date -u +%s)" +SOBRANO_V="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +MASHINA="$(hostname)" + +skazat() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$ZHURNAL"; } + +# 🔴 Журнал ОБНУЛЯЕТСЯ в начале каждого обхода, а не копится. Причина не в +# аккуратности, а ровно в той беде, ради которой всё это построено. +# В журнал ложится полный вывод сторожей, а среди него — строки вида +# «👤 челове[к]: 5 знаков, 1 слов» (образец нарочно в скобках: иначе этот +# самый файл попадёт в поиск чужого сторожа как новое место с речью, и +# сторож будет прав). Сегодня это МЕРА, а не слова: мост +# нарочно переписан так, чтобы дословной речи не писать. Но место, куда +# что-то похожее на речь ложится БЕЗ СРОКА И БЕЗ УБОРКИ, заводить нельзя — +# именно такие места мы здесь и вылавливаем. Обнуление даёт потолок: в +# журнале живёт ровно один, последний обход, и вырасти он не может. +# Долгая память — не здесь: она в итоге (файл + отметка в портале). +: > "$ZHURNAL" + +skazat "── обход начат ──" + +# ------------------------------------------------- 1. кого гоняем ---------- +# +# Сторожа ищутся списком файлов, а не перечислены здесь. Положили в папку +# нового proverka-*.sh — он попадёт в обход сам, и число сторожей в итоге +# вырастет. Портал сверит это число с тем, сколько ждёт, и заругается, пока +# человек не подтвердит прибавление. Так «сторожей стало четыре, а стучит +# по-прежнему три» перестаёт быть возможным молча. +STOROZHA=() +for F in "$ZDES"/proverka-*.sh; do + [ -f "$F" ] || continue + STOROZHA+=("$F") +done + +if [ "${#STOROZHA[@]}" -eq 0 ]; then + skazat "🔴 сторожей не нашлось вовсе в $ZDES — обход не состоялся" + exit 1 +fi + +skazat "нашёл сторожей: ${#STOROZHA[@]}" + +# ------------------------------------------------- 2. обход ---------------- +# +# nice — машина делит железо с рендером боевого «Поиска клиентов» (решение +# владельца Р84). Обход обязан уступать дорогу всему остальному. +KRASNYH=0 +ZAPISI="" + +for F in "${STOROZHA[@]}"; do + IMYA="$(basename "$F" .sh)" + VYVOD="$(LENA_MESTNAYA=1 nice -n 15 bash "$F" 2>&1)" + KOD=$? + + # Хвостовая строка сторожа: «проверок 26, красных 0». Не разобрали — ставим + # -1: портал прочтёт это как «непонятно» и заругается, а не промолчит. + CHISLA="$(printf '%s\n' "$VYVOD" | sed -n 's/.*проверок \([0-9][0-9]*\), красных \([0-9][0-9]*\).*/\1 \2/p' | tail -1)" + PROVEROK="$(printf '%s' "$CHISLA" | cut -d' ' -f1)" + PROVALOV="$(printf '%s' "$CHISLA" | cut -d' ' -f2)" + [ -n "${PROVEROK:-}" ] || PROVEROK=-1 + [ -n "${PROVALOV:-}" ] || PROVALOV=-1 + + if [ "$KOD" != "0" ] || [ "$PROVALOV" != "0" ]; then + KRASNYH=$(( KRASNYH + 1 )) + fi + + # Весь вывод — человеку в журнал. В итог для портала он НЕ идёт: там бывают + # куски журналов моста, а в них слова живых людей. + { + printf '%s ── сторож %s: код %s, проверок %s, красных %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$IMYA" "$KOD" "$PROVEROK" "$PROVALOV" + printf '%s\n' "$VYVOD" | sed 's/^/ | /' + } >> "$ZHURNAL" + + [ -n "$ZAPISI" ] && ZAPISI="$ZAPISI," + ZAPISI="$ZAPISI + {\"imya\": \"$IMYA\", \"kod\": $KOD, \"proverok\": $PROVEROK, \"provalov\": $PROVALOV}" +done + +# ------------------------------------------------- 3. слепок эталонов ------ +# +# Одно число на всю папку: если хоть один эталон на машине разойдётся с +# хранилищем, слепок изменится. Сам портал сверить его не с чем — сравнивает +# человек и сторож proverka-sutochnogo-obhoda.sh с рабочего места. Но в итоге +# слепок лежит, и «когда именно разошлось» становится видно по истории. +SLEPOK="$( + cd "$ZDES" || exit 0 + for F in *; do + [ -f "$F" ] || continue + case "$F" in otchyot-*|nastroyki-*|*.log|*.otvet) continue ;; esac + sha256sum "$F" + done | LC_ALL=C sort | sha256sum | cut -d' ' -f1 +)" + +DLILOS_MS=$(( ( $(date -u +%s) - NACHALO_SEK ) * 1000 )) + +# ------------------------------------------------- 4. итог файлом ---------- +VREMENNYY="$OTCHYOT.novyy" +cat > "$VREMENNYY" <>"$ZHURNAL" +)" + +if [ "$KOD_OTVETA" = "200" ] || [ "$KOD_OTVETA" = "202" ]; then + skazat "портал принял итог, ответ $KOD_OTVETA" +else + # 🔴 Не роняем обход: итог уже лежит файлом, и это главное. Но и не молчим — + # строка в журнале обязана быть, иначе непринятый стук невидим. + skazat "🔴 портал НЕ принял итог, ответ ${KOD_OTVETA:-нет ответа}: $(head -c 300 "$ZHURNAL.otvet" 2>/dev/null)" +fi + +exit 0 diff --git a/docs/superpowers/priyomka/stroyka-5/otchyot-pomoshchnika-z-0-10-2026-08-06.md b/docs/superpowers/priyomka/stroyka-5/otchyot-pomoshchnika-z-0-10-2026-08-06.md new file mode 100644 index 00000000..3a740f36 --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-5/otchyot-pomoshchnika-z-0-10-2026-08-06.md @@ -0,0 +1,431 @@ +# Отчёт помощника — З-0-10 «Машина проверяет себя сама» + +Дата: 06.08.2026 · ветка `feat/prospects-manual-testing-kp` · база `liderra_testing_stroyka5b` + +## Разбор задания + +Задание разобрано по существу. Картина беды **верна**: три сторожа на машине робота +существуют, работают и не имеют ни одного зовущего, кроме человеческой памяти. + +### Что в задании оказалось неточным — и это меняло устройство работы + +🔴 **«На машине живут три сторожа» — неверно.** Замерено командой: + +```text +$ grep -n "ssh" bots/lena-golos/proverka-*.sh +proverka-raspisaniya.sh:47 ssh ... "$SERVER" "$1" +proverka-tekstovogo-sleda.sh:74 ssh ... "$SERVER" "$1" +proverka-zagotovok.sh:65 ssh ... "$SERVER" "$1" +proverka-chistki.sh (ни одного ssh) +``` + +Три сторожа из четырёх живут **в хранилище на рабочем месте** и ходят на машину +по ssh. На самой машине лежал только `proverka-chistki.sh`. Просто «завести +расписание на машине» было нельзя: закрытого ssh-ключа у машины нет, и заводить +его я отказался — ключ от самой себя на машине, где лежат голоса живых людей, +это дыра, а не удобство. + +Решение: у двери сторожа появилась **вторая створка**. `LENA_MESTNAYA=1` — +«мы уже на этой машине», команды идут прямо здесь, без ssh. Тронуто два файла +по ~15 строк каждый, поведение при обычном запуске не изменилось ни на одну +проверку (замер ниже). + +🔴 **Что при местной створке теряется — сказано вслух в самих файлах:** проверка +«достучались до машины» становится бессодержательной, а эталоны берутся из +**машинной копии**, а не из хранилища. Возникает круговая порука: обход сверял +бы копию сам с собой. Разрывает её только взгляд снаружи — новый сторож +`proverka-sutochnogo-obhoda.sh` ходит с рабочего места по ssh и сверяет обе +стороны побайтово. + +### Ответы на три места, где владелец подозревал себя + +**1. Готовый присмотр за пульсом сюда подходит — но не целиком, а половиной.** + +Подходит **таблица** `scheduler_heartbeats`: итог обхода — ровно такая же +отметка «работа сделана тогда-то и вот с каким исходом». Своей таблицы не +завёл, схему не тронул. + +Не подходит **готовая проверка** `scheduler:check-heartbeats`, и ровно по той +причине, которую владелец сам назвал: она шлёт на `kdv1@bk.ru`, жёстко вписанный +в код, а Р117 требует адрес из настройки. Тот код чужой и боевой, править его +в рамках обзвона нельзя. Плюс она умеет только «пульса нет» и не умеет отличить +«итог протух» от «сторож покраснел» — а для письма человеку это разные беды с +разными действиями. + +Итог: **хранилище готовое, проверка своя.** Опасения «тяну машину в устройство +для ежедневных работ портала» не подтвердились: наружу таблица не торчит, +лишних связей не появилось. + +**2. Молчун вторым слоем был реален — закрыт.** + +Новая команда `lena:proverka-obhoda` записана в +`SchedulerHeartbeatTracker::EXPECTED_INTERVALS`. Значит за ней самой следит уже +построенный часовой `scheduler:check-heartbeats`: умри проверка тихо — штатный +сторож пульса это заметит. Есть тест, который падает, если строку выкинуть. + +🔴 Честная оговорка: письмо второго слоя уйдёт на тот самый `kdv1@bk.ru`. Это +хуже, чем настройка, но лучше, чем ничего, а чинить чужой боевой файл я права +не имею. Названо в «незакрытом». + +**3. Тайное слово в адресе — НЕ годная защита, и я сделал иначе.** + +У поставщика слово стоит в адресе. Адреса целиком попадают в журналы nginx — +слово оттуда утекает всякому, кто журнал читает. Сам портал это уже признал: +рядом с адресным вариантом у поставщика живёт **бессекретный** — подпись тела +`X-Webhook-Signature`, заведённая именно чтобы «секрет не светился в URL». + +Для одностороннего отчёта взял только подпись тела, без слова в адресе. +Это строго лучше и ничего не усложняет. + +## Разведка + +Проверено командами, не на слово. + +| что | замер | сошлось с заданием | +|---|---|---| +| три сторожа зелёные | 26 / 46 / 27 | да | +| четвёртый (заготовки) | 23 | да | +| `app/routes/web.php` | 817 строк, свободен | да | +| `app/routes/console.php` | 462 строки | да | +| дороги с машины в портал нет | в `most.py` только Inworld | да | +| ключа от машины у портала нет | `ls ~/.ssh` на машине — только `authorized_keys` | да | +| почты на машине нет | `which sendmail mail` — пусто | да | +| **три сторожа живут НА машине** | нет: три из четырёх ходят по ssh с рабочего места | **нет** | +| настройка `MONITORING_ALERT_EMAIL` | `config/services.php:339`, падает на `SUPPLIER_ALERT_EMAIL` → `ops@liderra.ru` | да | +| `SchedulerCheckHeartbeats` шлёт на вписанный адрес | `private const ALERT_EMAIL = 'kdv1@bk.ru'` | да | + +Дополнительно замерено: на машине есть `curl`, `openssl`, `flock`, `python3`; +`jq` нет — итог собираю без него. Машина живёт по UTC, up 32 дня, load 0.02. + +## Что сделал + +### На машине + +| что | где | +|---|---| +| папка машинной копии, 12 эталонов побайтово из хранилища | `/home/ubuntu/lena-golos/` | +| суточный обход | `/home/ubuntu/lena-golos/sutochnyy-obhod.sh` | +| расписание, 02:20 UTC = 05:20 МСК, от `ubuntu`, под `flock -n` | `/etc/cron.d/lena-sutochnyy-obhod` | +| настройки отправки, права 600, в хранилище их нет | `/home/ubuntu/lena-golos/nastroyki-obhoda` | + +Обход гоняет **всех** сторожей, каких найдёт в своей папке (`proverka-*.sh`), +и кладёт итог `otchyot-storozhey.json`. Замер живого прогона: **2,2 секунды**, +`nice -n 15`. Внутри итога — его собственная дата, число сторожей, число +красных, слепок эталонов и построчно имя/код/проверок/провалов. + +🔴 **Вывод сторожей в итог не идёт.** Не ради краткости: в выводе бывают куски +журналов моста, а там могут быть слова живых людей. Порталу их видеть незачем. +Человеку весь вывод лежит рядом, в журнале. + +### В портале + +| что | файл | +|---|---| +| приём итога | `app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php` | +| ежедневная проверка + письмо | `app/app/Console/Commands/LenaProverkaObhodaStorozhey.php` | +| письмо | `app/app/Mail/LenaStorozhaTrevogaMail.php` + `emails/lena_storozha_trevoga.blade.php` | +| настройки, отдельным файлом ради параллельных смен | `app/config/storozha_mashiny.php` | +| дверь | `app/routes/web.php`, одна строка + четыре комментария | +| зовущий | `app/routes/console.php`, один блок | +| второй слой присмотра | `app/app/Services/SchedulerHeartbeatTracker.php`, одна строка | +| настройки для выката | `app/.env.example` | + +Схему базы **не трогал**: итог ложится в существующую `scheduler_heartbeats` +под именем `lena:obhod-storozhey`. + +### Сторож и описание + +- `bots/lena-golos/proverka-sutochnogo-obhoda.sh` — 42 проверки. +- Раздел в `bots/lena-golos/README.md` со схемой, таблицей «кто кого зовёт», + порядком доделки выката и оговоркой про подставных сторожей. + +## Ножи + +### Вырезание второго рода — пять названных владельцем + +| допущение | случай мимо него | чем закрыт | +|---|---|---| +| «итог пришёл — значит машина жива» | итог пришёл вчера, сегодня машина молчит | тревога от **возраста**, порог 26 ч. Тест «шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено» | +| «стук дошёл — значит сторожа отработали» | отправлен вчерашний файл | отметка берёт дату **из итога** (`sobrano_v`), не время получения. Тест «считает возраст по дате ИЗ ИТОГА» | +| «письмо уйдёт» | почта портала не работает | 🔴 **не закрыт.** Сказано вслух ниже | +| «слово знает только машина» | слово утекло, шлют зелёный итог | частично: см. ниже | +| «сторожей три» | стало четыре, стучит три | обход ищет сторожей **списком файлов**, итог называет число, портал сверяет с настройкой. Тест «краснеет, если сторожей стало больше» | + +Про **утёкшее слово** честно. Подпись тела строго лучше слова в адресе: слово +не попадает в журналы nginx и тело нельзя подменить. Повтор **старого** +подписанного итога тоже не проходит — у него старая дата, портал заругается по +возрасту. Но подделать **свежий зелёный** итог с утёкшим словом односторонний +канал не мешает никак: односторонний отчёт в принципе не отличает «машину» от +«того, кто знает слово». Чинится только двусторонним каналом, а он отрезан +владельцем по более важной причине. Смягчение: список разрешённых адресов — +подделку придётся слать с 51.250.1.97. + +### Свои добавления к списку + +| допущение | случай мимо него | чем закрыт | +|---|---|---| +| «сторож зелёный — значит проверил» | сторож сломан так, что делает **ноль** проверок и выходит нулём | портал краснеет на `проверок <= 0`. Тест есть | +| «дата в итоге настоящая» | часы машины уехали вперёд / дату подставили | дата дальше 10 минут вперёд — красный. Тест есть | +| «итог разобрался» | новая разновидность файла, портал молча читает нули | `shema != 1` — красный, без попыток «понять». Тест есть | +| «эталон на машине = эталон в хранилище» | обход сверяет копию сам с собой | новый сторож ходит по ssh и сверяет обе стороны побайтово | +| «журнал обхода безобиден» | 🔴 **сработало**, см. ниже | журнал обнуляется каждым обходом | + +### 🔴 Нашёл сверх задания: сам себе завёл новое место для чужой речи + +После установки соседний сторож `proverka-tekstovogo-sleda.sh` **покраснел** +на настоящей проверке «не родилось ли пятое место»: + +```text +🔴 появились НЕЗНАКОМЫЕ места, их надо разобрать поимённо: + | /home/ubuntu/lena-golos/most.py + | /home/ubuntu/lena-golos/sutochnyy-obhod.log +``` + +Разобрано поимённо, а не заметено: + +- `most.py` — эталонная копия **исходного текста** моста, в ней образец строки, + а не чьи-то слова. Ровно поэтому в списке давно стоит `lena-inworld/most.py`. +- `sutochnyy-obhod.log` — мой журнал. Внутри строки вида «челове[к]: 5 знаков, + 1 слов» — **мера**, а не речь. Но место, куда что-то похожее на речь ложится + **без срока и без уборки**, заводить нельзя: именно такие места этот сторож и + ловит, и он был прав. Поэтому обход **обнуляет** журнал в начале каждого + прогона — в нём живёт ровно один последний обход, вырасти он не может. + +Попутно дважды напоролся на грабку, о которой предупреждает автор соседнего +сторожа: мои собственные **комментарии** со словом-образцом делали файлы новыми +«местами с речью». Образец теперь пишется в скобках, как у автора. + +## Четыре мерки + +### 1. Сторож, показанный КРАСНЫМ + +Три ножа, каждый вставлен, показан красным и вынут. + +**Нож А — обход перестаёт замечать красного сторожа.** +В `sutochnyy-obhod.sh` условие красноты заменено на `if false`. Скопировано на +машину, прогнан сторож: + +```text +🔴 проба Б: обход НЕ заметил красного сторожа — вся затея бесполезна +🔴 БЕДА: проверок 42, красных 2 +``` + +Возврат доказан слепком со снятием `\r` с обеих сторон: + +```text +машина: 20f64c39c965d56298a944883307ee8d5061ad11b07c6058dbfe1fa2ebd77d13 +хранилище: 20f64c39c965d56298a944883307ee8d5061ad11b07c6058dbfe1fa2ebd77d13 +``` + +**Нож Б — портал берёт время получения вместо даты из итога.** +`polozhitOtmetku($itog, now(), $bedy)`. Упал тест про пропавшую дату. + +**Нож В — проверка возраста выключена.** `if (false)` вместо +`if ($vozrastChasov > $chasov)`. Упали два теста, в том числе главный: + +```text +🔴 шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено + The expected [App\Mail\LenaStorozhaTrevogaMail] mailable was not sent. +``` + +🔴 **Ножи вскрыли, что один мой сторож был зелёным и не сторожил ничего.** +Тест «считает возраст по дате ИЗ ИТОГА» стоял на голом `diffInMinutes()`, а он +у Carbon 3 знаковый: разница выходила `-1800`, и «меньше двух» проходило +**всегда**. Под ножом Б он остался зелёным. Переписан на модуль в секундах — +под тем же ножом покраснел честно: + +```text +Failed asserting that 108000 is less than 2. +``` + +Это тот самый случай «зелёный не доказывает ничего, пока не видел его красным», +и поймался он только вырезанием. + +### 2. Полный прогон + +Точка отсчёта владельца: 5091 / 0. Замер после моей работы: + +```text +$ DB_DATABASE=liderra_testing_stroyka5b php -d memory_limit=-1 vendor/bin/pest +tests 5161, passed 5156, failed 1, assertions 16238, 1254782 мс +``` + +Проверок стало **5161** — плюс мои 21 и плюс то, что за это время положили +соседи. Красная **одна, и она чужая**: `app/tests/Feature/Obzvon/` +`ObzvonZapisMimoDveriTest.php:198`, падает на +`app/app/Models/ObzvonNumberResultBuilder.php:14` — файл соседнего помощника, +который прямо сейчас в этой же ветке показывает своего сторожа красным. +Ни один из этих файлов я не открывал; они в списке запрещённых. + +Мои 21 отдельно: + +```text +$ ... vendor/bin/pest --filter=StorozhaMashiny +tests 21, passed 21, assertions 50 +``` + +Статанализ (`composer stan`): единственная ошибка — в том же чужом ноже +`app/app/Services/Obzvon/NozhCherezSvyaz.php:14`. В моих файлах ноль. +`vendor/bin/pint --test` по всем моим файлам — passed. +`gitleaks detect` по `bots/lena-golos` — no leaks found. + +`markdownlint` по этому отчёту и по `README.md` — 0 ошибок. + +### 🔴 Нашёл сверх задания: обратные кавычки в этом README не защищают слово + +Крючок перед коммитом **не пустил** первый заход: `cspell` дал 32 жалобы на +`bots/lena-golos/README.md`. Все слова были **уже в обратных кавычках** — то +есть по правилу `cspell.json` проверяться не должны были. + +Разбирался вырезанием, а не рассуждением. Что перепробовал и что каждая проба +опровергла: + +| догадка | проба | итог | +|---|---|---| +| виноват мой блок со схемой | удалил его | 32 — **не он** | +| виновата чётность кавычек забора | вставил лишнюю кавычку | 32 — **не она** | +| виноваты эмодзи и их двойные знаки | вырезал их из раздела | 32 — **не они** | +| файл стал слишком велик | вырезал 150 чужих строк | 32 — **не размер** | +| виновато место в файле | вставил чужое имя внутрь моего раздела | 0 — **не место** | + +Решающая проба: вставил в тот же раздел выдуманное слово в обратных кавычках. + +```text +Проба выдуманного: `qwertzuiopx-abcdefgh` конец. +→ 701:21 - Unknown word (qwertzuiopx) +``` + +Значит **встроенные обратные кавычки в этом файле не прикрывают НИЧЕГО.** +Соседние имена вроде `chistka-zapisey.sh` проходили не потому, что стоят в +кавычках, а потому, что их куски `cspell` узнаёт как слова. Заборы кода при +этом работают исправно — слова внутри них не проверяются. + +Правильная починка — 13 слов в `cspell-words.txt`, и об этом прямо говорит сам +крючок. Но этот файл в запрещённых, и трогать его я не стал. Обошёлся без +конфигов: перенёс все латинские имена из строк и таблиц **внутрь заборов +кода**. Раздел от этого стал ЛУЧШЕ для чтения не-программистом — латиница +собрана в блоки, а в тексте остались русские описания. + +После правки: `cspell` — 0, `markdownlint` — 0, крючок пропустил. + +🔴 **Что осталось не починенным (не моё):** в этом README встроенные кавычки +не работают, а значит **правописание его тела фактически не проверяется** — +любое незнакомое слово в кавычках всплывёт, а любую настоящую опечатку внутри +знакомых кусков он пропустит. Это дефект настройки `cspell.json` (правило про +кавычки стоит в списке выше правила про заборы), а не моего текста. Чинить — +владельцу. + +🔴 **Про сам отчёт.** `docs/superpowers/**` стоит в `ignorePaths` файла +`cspell.json` и в исключениях крючка, то есть проверяется он только вручную. +Прогнал обходом — временной копией в корне. Жалобы те же по природе: +латинские имена файлов, настоящих опечаток ни одной. + +### 3. Две строки про соседей + +- **Моего имени нет ни в одном выводе ни одного сторожа.** +- **Чужие файлы в дереве не тронуты.** Живые чужие правки на момент работы — + `app/app/Models/ObzvonNumberResult.php`, `ObzvonNumberResultBuilder.php`, + `app/app/Services/Obzvon/NozhCherezSvyaz.php`, + `app/tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`. Ни один из них не + открывался и в коммит не пойдёт. + +### 4. Зовущие поимённо + +Искал глазами, не числом. + +| мой кусок | кто зовёт | +|---|---| +| `sutochnyy-obhod.sh` на машине | `/etc/cron.d/lena-sutochnyy-obhod` — расписание, эталон в хранилище | +| `LenaStorozhaWebhookController@receive` | `app/routes/web.php:736` (дверь) + сама машина через `curl` | +| `lena:proverka-obhoda` | `app/routes/console.php:471` (расписание) — единственный настоящий зовущий | +| `lena:proverka-obhoda` как задача под присмотром | `SchedulerHeartbeatTracker::EXPECTED_INTERVALS:62` → часовой `scheduler:check-heartbeats` | +| `LenaStorozhaTrevogaMail` | `LenaProverkaObhodaStorozhey::handle()` | +| `emails/lena_storozha_trevoga` | `LenaStorozhaTrevogaMail::content()` | +| `config/storozha_mashiny.php` | контроллер и команда, шесть мест | +| `proverka-sutochnogo-obhoda.sh` | человек + README. Как и три его соседа | + +🔴 **Один зовущий счётчику не виден вовсе, и это как раз «зов внутри `${…}`»:** +машина зовёт портал строкой `curl -X POST "$PORTAL_URL"`, а `PORTAL_URL` лежит +в файле **вне хранилища**. Никакой поиск по репозиторию эту связь не найдёт. +Поэтому она проверена не поиском, а живым прогоном (см. ниже) и записана в +README таблицей «кто кого зовёт». + +### Дополнительно: «расписание заведено» проверено тем, что оно вправду сработает + +Файл в `/etc/cron.d` — ещё не работающее расписание: под cron нет ни tty, ни +привычного окружения, и молча падает всё, что на них опиралось. Поэтому обход +прогнан **ровно так, как его запустит cron** — с пустым окружением и только +тем `PATH`, что записан в самом файле расписания: + +```text +$ env -i SHELL=/bin/bash PATH=/usr/local/sbin:...:/bin HOME=/home/ubuntu \ + LOGNAME=ubuntu USER=ubuntu \ + /usr/bin/flock -n /tmp/lena-sutochnyy-obhod.lock \ + /bin/bash /home/ubuntu/lena-golos/sutochnyy-obhod.sh +КОД=0 +--- ругань bash --- (пусто) +"storozhey_vsego": 3 +"storozhey_krasnyh": 0 +``` + +Служба `cron` — `active`. Прогон занял те же ~2 секунды, `sudo` внутри сторожей +без tty не спотыкается. + +### Дополнительно: шов между половинками закрыт живым замером + +Проверять только своими выдуманными телами — значит доказать что угодно. Поэтому: + +**Половина машины.** На машине поднят подставной слушатель на 127.0.0.1:8899, +обход настроен на него и прогнан по-настоящему: + +```text +в журнале обхода: портал принял итог, ответ 202 +тело дошло: ДА: тело == файл итога (cmp) +Content-Type: application/json +подпись сошлась: ДА: sha256=cc08487191781784b1aa26492ba1c16a0159ce5d457787ae019c3f0b8b54f6c2 +``` + +**Половина портала.** То самое тело, снятое со слушателя байт в байт, вклеено +в тест `разбирает НАСТОЯЩЕЕ тело, снятое с машины` и скормлено настоящей двери. +Оно принимается, дата берётся верная, а состарившись — поднимает письмо. + +Настройки на машине после пробы возвращены в исходное пустое состояние, +тайное слово пробы нигде не осталось. + +## Что осталось незакрытым + +1. 🔴 **Портальная половина не выкачена — цепочка сегодня разорвана.** + `nastroyki-obhoda` на машине пуст, обход пишет итог файлом и говорит в журнал + «отправка не настроена». Сторож обхода честно краснеет **одной** проверкой из + 42: «дорога до портала НЕ настроена». Это не поломка, а незавершённый выкат; + порядок доделки в четыре шага записан в README. Зелёным сторож станет ровно + тогда, когда выкат завершат — то есть он и есть датчик завершённости. + +2. 🔴 **«Письмо уйдёт» ничем не закрыто.** Умри почта портала — замолчит сам + присмотр, и заметить это будет некому. Односторонний канал этого не лечит в + принципе: лечит только внешний наблюдатель, которого у нас нет. Называю вслух, + как просили, и решения не выдумываю. + +3. **Письмо второго слоя уходит на личную почту.** За `lena:proverka-obhoda` + следит штатный `scheduler:check-heartbeats`, а он шлёт на `kdv1@bk.ru`, + вписанный в код. Чужой боевой файл, в рамках обзвона не трогаю. + +4. **Проверка 1 при местной створке бессодержательна.** Сторож, работающий на + той машине, которую сторожит, не умеет заметить смерть машины целиком. Это + и ловит портал по возрасту итога — но признать разменянное честнее, чем + делать вид, что сторож остался прежним. + +5. **Подставные сторожа не доказывают красноту настоящих.** Проба обхода + показывает только проводку: обход замечает красноту и доносит её поимённо. + Что настоящий сторож покраснеет на настоящей поломке уборки — доказывалось + в их собственных кругах, не здесь. + +6. **`proverka-zagotovok.sh` в обход не входит.** Решил сам: он сторожит + заготовки подбора голоса, а не уборку чужих записей — не ту беду, ради + которой обход заведён. Плюс он файл соседнего круга и сегодня в работе, а + местной створки ему пришлось бы добавлять в трёх местах вместо одного. Порог + включения записан в README: положить в папку, поднять `LENA_STOROZHA_SKOLKO` + до 4, дописать в список эталонов. Пока этого не сделали, обход его не гоняет + и портал об этом молчит — но и не врёт: он ждёт ровно троих. + +7. **Не пушил, на боевой не выкатывал, историю не трогал, схему не менял.**