Files
autocrm/app/Console/Commands/HealthCheckCommand.php
Vasyka 51917bcbaf feat: rate limiting + internal health monitor + secure VAPID note
Rate limiting:
- Shop POST endpoints get per-IP throttles with distinct prefixes so login,
  register, password-email, and password-reset have separate buckets:
  login/register/pw-reset = 5/min, pw-email = 3/min
- OcrInvoiceService gates per-tenant via RateLimiter (30/hour) so a runaway
  uploader can't burn Claude Vision spend

Health monitor (poor-man's monitoring):
- HealthCheckCommand probes DB (SELECT 1), cache write/read, public storage
  write/read, and most-recent backup age. On any failure, pushes a Telegram
  alert via HEALTH_ALERT_BOT_TOKEN/HEALTH_ALERT_CHAT_ID. Dedups identical
  failures within a 30-min window via cache.
- Scheduled every 10 min. Pair with external uptime monitoring (UptimeRobot,
  Better Stack hitting /up) for total-outage coverage.
- .env.example documents the two new env vars.

VAPID secret hygiene:
- credentials.md no longer stores the VAPID_PRIVATE_KEY; the source of truth
  is the Coolify env on the autocrm app. Doc points to where to read it
  (UI or API). Mitigates accidental git leak.

Tests (4 new):
- shop login throttles after 5 attempts (6th = 429); register throttle is
  independent of login (separate prefix); health command runs clean; dedup
  cache path exercised

Full suite: 138 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:37:53 +00:00

123 lines
4.2 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Lightweight self-health probe. Runs on the box, so it can't catch total
* outages — pair with external uptime monitoring (UptimeRobot, Better Stack)
* pointing at /up.
*
* Tests:
* - DB connectivity (central connection: SELECT 1)
* - Cache write/read (Redis if configured)
* - Public storage disk write/read
* - Most recent tenant backup age (warn if > 30h)
*
* On any failure, pushes a short Telegram alert to HEALTH_ALERT_CHAT_ID via
* HEALTH_ALERT_BOT_TOKEN (env). Dedups identical failures within 30 minutes
* via cache to avoid spamming on each cron tick.
*/
class HealthCheckCommand extends Command
{
protected $signature = 'health:check
{--silent : Do not echo OK output (for cron)}';
protected $description = 'Probe DB / cache / storage / backup freshness; alert via Telegram on failure.';
public function handle(): int
{
$issues = [];
try {
DB::connection()->select('SELECT 1');
} catch (\Throwable $e) {
$issues[] = 'DB: ' . substr($e->getMessage(), 0, 120);
}
try {
$stamp = 'hc:' . (string) microtime(true);
Cache::put('health:probe', $stamp, 30);
if (Cache::get('health:probe') !== $stamp) {
$issues[] = 'Cache: write/read mismatch';
}
} catch (\Throwable $e) {
$issues[] = 'Cache: ' . substr($e->getMessage(), 0, 120);
}
try {
$f = 'health/' . md5((string) microtime(true)) . '.txt';
Storage::disk('public')->put($f, 'ok');
if (Storage::disk('public')->get($f) !== 'ok') {
$issues[] = 'Storage: write/read mismatch';
}
Storage::disk('public')->delete($f);
} catch (\Throwable $e) {
$issues[] = 'Storage: ' . substr($e->getMessage(), 0, 120);
}
try {
$newest = collect(Storage::disk('local')->allFiles('backups'))
->map(fn ($f) => Storage::disk('local')->lastModified($f))
->max();
if ($newest && (time() - $newest) > 30 * 3600) {
$age = round((time() - $newest) / 3600, 1);
$issues[] = "Backup: cel mai recent are {$age}h (expectat <30h).";
}
} catch (\Throwable $e) {
// Backup folder might be empty on a fresh install — not an alert.
}
if (empty($issues)) {
if (! $this->option('silent')) {
$this->info('Health OK · ' . now()->toIso8601String());
}
return self::SUCCESS;
}
$signature = md5(implode('|', $issues));
$dedupKey = "health:alert:{$signature}";
if (! Cache::has($dedupKey)) {
$this->pushTelegramAlert($issues);
Cache::put($dedupKey, 1, 30 * 60); // 30-min cooldown per fingerprint
}
foreach ($issues as $i) $this->error($i);
return self::FAILURE;
}
private function pushTelegramAlert(array $issues): void
{
$bot = env('HEALTH_ALERT_BOT_TOKEN');
$chat = env('HEALTH_ALERT_CHAT_ID');
if (! $bot || ! $chat) {
Log::warning('health:check failed but HEALTH_ALERT_BOT_TOKEN/CHAT_ID not set', [
'issues' => $issues,
]);
return;
}
$body = "🚨 <b>AutoCRM health alert</b>\n"
. implode("\n", array_map(fn ($i) => '• ' . htmlspecialchars($i), $issues))
. "\n\n" . config('app.url', 'service.mir.md');
try {
Http::asJson()
->timeout(10)
->post("https://api.telegram.org/bot{$bot}/sendMessage", [
'chat_id' => $chat,
'text' => $body,
'parse_mode' => 'HTML',
]);
} catch (\Throwable $e) {
Log::warning('health:check telegram alert failed', ['err' => $e->getMessage()]);
}
}
}