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:
Дмитрий
2026-08-06 22:31:13 +03:00
parent 6e775055d9
commit 67195f0bf8
17 changed files with 2216 additions and 5 deletions
+14
View File
@@ -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;
}
}
}
+51
View File
@@ -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
];
/**
+72
View File
@@ -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 (Лидерра).
+13
View File
@@ -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));
+7
View File
@@ -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')");
});
+175
View File
@@ -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` с сервера
+22 -1
View File
@@ -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
+41 -4
View File
@@ -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
+58
View File
@@ -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
+225
View File
@@ -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. **Не пушил, на боевой не выкатывал, историю не трогал, схему не менял.**