From 4bfe0b7005647c8018c3d01ebd9d3887e69ccdf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 16 Jul 2026 15:04:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(external):=20SSH-=D1=82=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20EXA=20=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BB=D0=B8=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B6=D0=B8=D0=B2=D0=BE=D1=81=D1=82=D0=B8=20(12-?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По вопросу владельца: EXA ходит через обратный SSH-тоннель (services.exa.proxy), и это самое хрупкое звено (ops-машина+Happ+порт 10808). Раньше падение тоннеля выглядело бы как «EXA не отвечает». Теперь ExaTunnelLivenessProbe — отдельная плитка: сквозной запрос ЧЕРЕЗ прокси на нейтральный адрес (exa.tunnel_check_url, дефолт api.ipify.org). Тоннель🟢+EXA🔴 = проблема у EXA; тоннель🔴 = легла наша труба. Дашборд: 12 сервисов. Тесты: 66 моей области зелёные (вкл. 3 пробы тоннеля + 12-keys), Larastan 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/AdminDashboardController.php | 2 +- .../External/ExaTunnelLivenessProbe.php | 42 +++++++++++++++++++ .../External/ExternalBalanceRefresher.php | 2 + app/config/services.php | 3 ++ .../js/views/admin/AdminDashboardView.vue | 4 +- .../External/ExaTunnelLivenessProbeTest.php | 34 +++++++++++++++ .../External/ExternalBalanceRefresherTest.php | 7 ++-- 7 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 app/app/Services/External/ExaTunnelLivenessProbe.php create mode 100644 app/tests/Unit/External/ExaTunnelLivenessProbeTest.php diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index fd86bdd2..758c947b 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -407,7 +407,7 @@ class AdminDashboardController extends Controller /** Известные сервисы плитки — фильтр от осиротевших строк (jivosite и пр.). */ private const KNOWN_SERVICE_KEYS = [ 'dadata', 'supplier', 'yandex_cloud', 'email', 'yookassa', 'captcha', - 'aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', + 'aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', 'exa_tunnel', ]; /** diff --git a/app/app/Services/External/ExaTunnelLivenessProbe.php b/app/app/Services/External/ExaTunnelLivenessProbe.php new file mode 100644 index 00000000..cdc12483 --- /dev/null +++ b/app/app/Services/External/ExaTunnelLivenessProbe.php @@ -0,0 +1,42 @@ + $proxy])->timeout(8)->get($url); + + return $resp->ok() + ? LivenessReading::alive('exa_tunnel', 'тоннель жив') + : LivenessReading::down('exa_tunnel', 'HTTP '.$resp->status()); + } catch (\Throwable $e) { + return LivenessReading::down('exa_tunnel', $e->getMessage()); + } + } +} diff --git a/app/app/Services/External/ExternalBalanceRefresher.php b/app/app/Services/External/ExternalBalanceRefresher.php index da0633d5..4b2c7a54 100644 --- a/app/app/Services/External/ExternalBalanceRefresher.php +++ b/app/app/Services/External/ExternalBalanceRefresher.php @@ -53,6 +53,7 @@ class ExternalBalanceRefresher 'captcha' => ['kind' => 'liveness', 'heavy' => false, 'class' => CaptchaLivenessProbe::class], 'self_render' => ['kind' => 'liveness', 'heavy' => false, 'class' => SelfRenderLivenessProbe::class], 'sales_finder' => ['kind' => 'liveness', 'heavy' => false, 'class' => SalesFinderLivenessProbe::class], + 'exa_tunnel' => ['kind' => 'liveness', 'heavy' => false, 'class' => ExaTunnelLivenessProbe::class], ]; } @@ -218,6 +219,7 @@ class ExternalBalanceRefresher app(CaptchaLivenessProbe::class), app(SelfRenderLivenessProbe::class), app(SalesFinderLivenessProbe::class), + app(ExaTunnelLivenessProbe::class), ]; } diff --git a/app/config/services.php b/app/config/services.php index bf7ceb76..e13f10a8 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -128,6 +128,9 @@ return [ 'balance_url' => env('EXA_BALANCE_URL'), 'red_floor_rub' => (int) env('EXA_RED_FLOOR_RUB', 500), 'amber_floor_rub' => (int) env('EXA_AMBER_FLOOR_RUB', 2000), + // Плитка балансов (16.07): нейтральный адрес для проверки живости SSH-тоннеля + // (запрос через exa.proxy на него). Не EXA — чтобы отделить «тоннель жив» от «EXA жив». + 'tunnel_check_url' => env('EXA_TUNNEL_CHECK_URL', 'https://api.ipify.org'), ], // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index 148a25a9..dc25ef09 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -109,6 +109,7 @@ const SERVICE_LABELS: Record = { email: 'Почта', yookassa: 'ЮKassa', captcha: 'Капча', + exa_tunnel: 'EXA-тоннель', aitunnel: 'AITUNNEL', exa: 'EXA', xfetch: 'xfetch', @@ -122,6 +123,7 @@ const SERVICE_ICONS: Record = { email: '✉️', yookassa: '💳', captcha: '🛡', + exa_tunnel: '🔌', aitunnel: '🤖', exa: '🔎', xfetch: '🌐', @@ -142,7 +144,7 @@ function daysLeftLabel(days: number | null): string { } /** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */ -const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha', 'self_render', 'sales_finder']); +const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha', 'self_render', 'sales_finder', 'exa_tunnel']); function isLivenessOnly(key: string): boolean { return LIVENESS_ONLY_KEYS.has(key); } diff --git a/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php b/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php new file mode 100644 index 00000000..e219ff4a --- /dev/null +++ b/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php @@ -0,0 +1,34 @@ +set('services.exa.proxy', 'socks5h://127.0.0.1:10808'); + config()->set('services.exa.tunnel_check_url', 'https://api.ipify.org'); +}); + +it('серый, когда прокси-тоннель не настроен', function () { + config()->set('services.exa.proxy', ''); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('grey'); +}); + +it('зелёный при 200 через прокси (тоннель жив)', function () { + Http::fake(['api.ipify.org*' => Http::response('1.2.3.4', 200)]); + $r = (new ExaTunnelLivenessProbe)->check(); + expect($r->serviceKey)->toBe('exa_tunnel'); + expect($r->light)->toBe('green'); +}); + +it('красный при 500 и при сетевой ошибке (тоннель лёг)', function () { + Http::fake(['api.ipify.org*' => Http::response('err', 500)]); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('red'); + + Http::fake(fn () => throw new RuntimeException('tunnel down')); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('red'); +}); diff --git a/app/tests/Unit/External/ExternalBalanceRefresherTest.php b/app/tests/Unit/External/ExternalBalanceRefresherTest.php index ef04f173..bd7d59f3 100644 --- a/app/tests/Unit/External/ExternalBalanceRefresherTest.php +++ b/app/tests/Unit/External/ExternalBalanceRefresherTest.php @@ -101,7 +101,7 @@ it('sendAlerts=true шлёт письмо на переход green→red (св Mail::assertSent(ExternalServiceDownMail::class, 1); }); -it('allKeys содержит 11 сервисов и refresh(allKeys) их пишет', function () { +it('allKeys содержит 12 сервисов и refresh(allKeys) их пишет', function () { config()->set('services.aitunnel.key', 'k'); config()->set('services.aitunnel.balance_url', ''); config()->set('services.exa.key', 'k'); @@ -110,13 +110,14 @@ it('allKeys содержит 11 сервисов и refresh(allKeys) их пиш config()->set('services.xfetch.balance_url', ''); config()->set('services.self_render.health_url', 'https://vm.local/h'); config()->set('services.sales_finder.base_url', 'https://lk.liderra.ru/finder/'); + config()->set('services.exa.proxy', 'socks5h://127.0.0.1:10808'); Http::fake(['*' => Http::response('ok', 200)]); $refresher = app(ExternalBalanceRefresher::class); - expect($refresher->allKeys())->toHaveCount(11); + expect($refresher->allKeys())->toHaveCount(12); $refresher->refresh($refresher->allKeys(), force: true, sendAlerts: false); $keys = DB::connection('pgsql_supplier')->table('external_service_balances')->pluck('service_key'); - foreach (['aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder'] as $k) { + foreach (['aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', 'exa_tunnel'] as $k) { expect($keys)->toContain($k); } });