feat обзвон: машина робота проверяет себя сама, портал следит за свежестью
Сторожа уборки записей телефонных разговоров запускал только человек, который про них помнил. Уборка могла умереть молча, и голоса чужих людей копились бы месяцами при обещанном сроке хранения. Теперь машина раз в сутки гоняет своих сторожей сама и стучится в портал с итогом. Портал хранит итог вместе с ЕГО СОБСТВЕННОЙ датой и шлёт письмо, когда итог красный ИЛИ протух. Зелёный итог недельной давности хуже красного сегодняшнего, поэтому возраст проверяется всегда. Решение владельца Р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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\LenaStorozhaTrevogaMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневная проверка суточного обхода сторожей на машине робота (З-0.10).
|
||||
*
|
||||
* Что проверяем — ровно две вещи, и вторая важнее первой:
|
||||
* 1. ИТОГ КРАСНЫЙ — какой-то сторож на машине покраснел.
|
||||
* 2. ИТОГ ПРОТУХ — обход не приходил дольше положенного.
|
||||
*
|
||||
* 🔴 Пункт 2 — сердце всей затеи. Зелёный итог недельной давности ХУЖЕ
|
||||
* красного сегодняшнего: он усыпляет. Умри обход на машине тихо — и без
|
||||
* проверки возраста портал вечно показывал бы последний зелёный итог, а
|
||||
* записи чужих голосов копились бы месяцами. Проверка возраста работает
|
||||
* ВСЕГДА, даже когда внутри итога всё зелено.
|
||||
*
|
||||
* Письмо уходит на адрес ИЗ НАСТРОЙКИ (решение владельца Р117):
|
||||
* MONITORING_ALERT_EMAIL → SUPPLIER_ALERT_EMAIL → ops@liderra.ru.
|
||||
* 🔴 Не повторяем здешнюю беду: соседний сторож пульса (SchedulerCheckHeartbeats)
|
||||
* шлёт на личную почту, ЖЁСТКО ВПИСАННУЮ в код. Тот код чужой и боевой, мы его
|
||||
* не трогаем, но и не копируем.
|
||||
*
|
||||
* При здоровье письма НЕТ. Ежедневное «всё хорошо» перестают читать через
|
||||
* неделю, и тогда пропадает и настоящая тревога.
|
||||
*
|
||||
* Кто зовёт: routes/console.php, ежедневно. Сама эта команда записана в
|
||||
* SchedulerHeartbeatTracker::EXPECTED_INTERVALS — то есть за ней самой следит
|
||||
* штатный часовой сторож пульса. Иначе мы завели бы молчуна вторым слоем:
|
||||
* портал ждёт стука, а что проверка стука жива — не проверяет никто.
|
||||
*/
|
||||
final class LenaProverkaObhodaStorozhey extends Command
|
||||
{
|
||||
protected $signature = 'lena:proverka-obhoda {--suho : только сказать, письма не слать}';
|
||||
|
||||
protected $description = 'Проверяет суточный обход сторожей на машине робота: свежесть итога и красноту сторожей';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$imya = (string) config('storozha_mashiny.imya_otmetki');
|
||||
$chasov = (int) config('storozha_mashiny.svezhest_chasov');
|
||||
|
||||
$stroka = DB::connection('pgsql_supplier')
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Приём суточного итога сторожей с машины робота (З-0.10).
|
||||
*
|
||||
* URL: POST /api/webhook/lena-storozha
|
||||
*
|
||||
* Кто стучится: /home/ubuntu/lena-golos/sutochnyy-obhod.sh на машине
|
||||
* 51.250.1.97, раз в сутки в 02:20 UTC (05:20 МСК), по расписанию
|
||||
* /etc/cron.d/lena-sutochnyy-obhod. Эталоны обоих файлов — bots/lena-golos/.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ СТУЧИТСЯ МАШИНА, А НЕ ПОРТАЛ ХОДИТ (решение владельца Р116).
|
||||
* «Портал сам ходит на машину» пришлось бы оплатить ключом от машины, лежащим
|
||||
* на боевом портале, — а на той машине голоса живых людей. «Машина шлёт письмо
|
||||
* сама» отпало иначе: почтовых программ на ней нет, и молчание такой машины не
|
||||
* заметил бы НИКТО.
|
||||
*
|
||||
* Защита — как у вебхука поставщика, но с одной поправкой в лучшую сторону:
|
||||
* 1. Подпись тела (X-Storozh-Podpis: sha256=…, HMAC-SHA256 на тайном слове).
|
||||
* Слово в АДРЕС не кладём: адреса целиком текут в журналы nginx, тело —
|
||||
* нет. У поставщика слово в адресе осталось от старого уговора, повторять
|
||||
* это здесь незачем.
|
||||
* 2. Список разрешённых адресов. На бою пустой список = закрыто.
|
||||
* Не сошлось — 404, чтобы не подтверждать существование двери.
|
||||
*
|
||||
* 🔴 ЧТО ЗДЕСЬ САМОЕ ВАЖНОЕ И ЛЕГКО ПОТЕРЯТЬ ПРИ ПРАВКЕ.
|
||||
* В отметку кладётся дата ИЗ САМОГО ИТОГА (sobrano_v), а НЕ время получения.
|
||||
* Иначе вся затея разваливается: кто-то отправит вчерашний файл сегодня —
|
||||
* и портал спокойно запишет «свежий», хотя сторожа не запускались вовсе.
|
||||
* Возраст обязан считаться по дате сборки итога.
|
||||
*
|
||||
* Итог складывается в scheduler_heartbeats — таблицу отметок пульса, которая
|
||||
* у портала уже есть. Своей таблицы не заводим: итог обхода — ровно такая же
|
||||
* отметка «эта работа была сделана тогда-то и вот с каким исходом».
|
||||
*/
|
||||
class LenaStorozhaWebhookController extends Controller
|
||||
{
|
||||
/** Насколько дата итога может опережать наши часы, прежде чем мы ей не поверим. */
|
||||
private const ZAPAS_VPERYOD_MINUT = 10;
|
||||
|
||||
public function receive(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->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<string, mixed> $itog
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string, mixed> $itog
|
||||
* @param list<string> $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<string>|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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Тревога по суточному обходу сторожей на машине робота (З-0.10).
|
||||
*
|
||||
* Шлётся только когда плохо: итог протух ИЛИ сторож покраснел. При здоровье
|
||||
* письма нет — ежедневное «всё хорошо» перестают читать, и вместе с ним
|
||||
* перестают замечать настоящую беду.
|
||||
*
|
||||
* Адрес получателя берётся из настройки (решение владельца Р117), не отсюда.
|
||||
*/
|
||||
final class LenaStorozhaTrevogaMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $prichina,
|
||||
public readonly string $podrobno,
|
||||
public readonly ?string $sobranoV,
|
||||
public readonly ?int $vozrastChasov,
|
||||
public readonly int $skolkoNeudach,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: '[Лидерра HIGH] Сторожа машины робота: '.$this->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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Сторожа машины робота (З-0.10)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| На машине робота (51.250.1.97) живут сторожа. Они проверяют, что уборка
|
||||
| записей телефонных разговоров с ЖИВЫМИ ЛЮДЬМИ вправду работает и что слова
|
||||
| этих людей не текут в журналы. Раз в сутки машина сама гоняет их всех и
|
||||
| стучится сюда с итогом (решение владельца Р116: стучится машина, портал на
|
||||
| машину не ходит — ключа от машины с чужими голосами у портала быть не должно).
|
||||
|
|
||||
| 🔴 Тайного слова в коде НЕТ и быть не может. Только окружение.
|
||||
|
|
||||
| Отдельный файл настроек, а не блок в services.php, нарочно: services.php
|
||||
| правят сразу несколько смен, и лишний повод столкнуться нам не нужен.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
| Тайное слово, которым машина подписывает тело итога (HMAC-SHA256).
|
||||
|
|
||||
| 🔴 Слово в АДРЕС не кладём. Адреса целиком попадают в журналы nginx, и
|
||||
| слово оттуда утечёт кому угодно. Тело с подписью в журналы не
|
||||
| попадает. Это и есть разница с приёмом лидов от поставщика, где
|
||||
| слово в адресе осталось от старого уговора.
|
||||
|
|
||||
| Пустое или короче 32 знаков — приём выключен полностью, любой стук
|
||||
| получит 404. Так дорога не открывается «сама собой» на новой машине.
|
||||
*/
|
||||
'tainoe_slovo' => (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',
|
||||
];
|
||||
@@ -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 (Лидерра).
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\LenaStorozhaTrevogaMail;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
const SLOVO = 'слово-для-пробы-не-короче-тридцати-двух-знаков-0123456789';
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::connection('pgsql_supplier')->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')");
|
||||
});
|
||||
@@ -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` с сервера
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" <<EOF
|
||||
#!/bin/bash
|
||||
echo "✅ ЦЕЛО: проверок $3, красных $4"
|
||||
exit $2
|
||||
EOF
|
||||
chmod +x "$KORZINA/proverka-$1.sh"
|
||||
}
|
||||
|
||||
# --- случай А: все подставные зелёные
|
||||
sdelat_storozha odin 0 7 0
|
||||
sdelat_storozha dva 0 9 0
|
||||
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)"
|
||||
|
||||
# --- случай Б: один подставной сторож КРАСНЫЙ
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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" <<JSON
|
||||
{
|
||||
"shema": 1,
|
||||
"sobrano_v": "$SOBRANO_V",
|
||||
"mashina": "$MASHINA",
|
||||
"dlilos_ms": $DLILOS_MS,
|
||||
"storozhey_vsego": ${#STOROZHA[@]},
|
||||
"storozhey_krasnyh": $KRASNYH,
|
||||
"slepok_etalonov": "$SLEPOK",
|
||||
"storozha": [$ZAPISI
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! mv -f "$VREMENNYY" "$OTCHYOT"; then
|
||||
skazat "🔴 не смог записать итог в $OTCHYOT — обход не состоялся"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
skazat "итог записан: сторожей ${#STOROZHA[@]}, красных $KRASNYH, слепок ${SLEPOK:0:12}"
|
||||
|
||||
# ------------------------------------------------- 5. стук в портал --------
|
||||
#
|
||||
# 🔴 Портал сам на эту машину НЕ ходит и ходить не будет: у него нет и не
|
||||
# должно быть ключа от машины, где лежат голоса живых людей (решение
|
||||
# владельца Р116). Стучится машина.
|
||||
#
|
||||
# Дорога та же, какой уже пользуется поставщик: подпись тела на тайном слове.
|
||||
# Слово в адрес НЕ кладём — адреса текут в журналы nginx целиком, а тело с
|
||||
# подписью не течёт никуда. Слово лежит в отдельном файле с правами 600,
|
||||
# рядом с этим скриптом, и в хранилище его нет.
|
||||
if [ ! -f "$NASTROYKI" ]; then
|
||||
skazat "отправка не настроена: нет $NASTROYKI. Итог лежит файлом, портал его не получит."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
PORTAL_URL=""
|
||||
TAYNOE_SLOVO=""
|
||||
. "$NASTROYKI"
|
||||
|
||||
if [ -z "$PORTAL_URL" ] || [ -z "$TAYNOE_SLOVO" ]; then
|
||||
skazat "отправка не настроена: в $NASTROYKI пусто PORTAL_URL или TAYNOE_SLOVO."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PODPIS="$(openssl dgst -sha256 -hmac "$TAYNOE_SLOVO" -r < "$OTCHYOT" | cut -d' ' -f1)"
|
||||
|
||||
KOD_OTVETA="$(
|
||||
curl -sS -m 30 -o "$ZHURNAL.otvet" -w '%{http_code}' \
|
||||
-X POST "$PORTAL_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Storozh-Podpis: sha256=$PODPIS" \
|
||||
--data-binary "@$OTCHYOT" 2>>"$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
|
||||
@@ -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. **Не пушил, на боевой не выкатывал, историю не трогал, схему не менял.**
|
||||
Reference in New Issue
Block a user