feat(ops): мониторинг + pre-flight + WAF +/api threshold (incident 2026-05-22)

Инцидент 22.05.2026: liderra.ru 500 Server Error. Корень — повреждённый
APP_KEY в .env (24 строки с CRLF + дубль ключа от key:generate). Каскад:
Laravel не парсил .env → fallback на default sqlite/database cache →
sqlite-файла нет → 500 на каждом HTTP-запросе; liderra-queue в
бесконечном activating-loop'е (Restart=always без лимитов).

Файлы (все LF через локальный .gitattributes — защита от CRLF-инцидента):

  liderra-precheck.sh — pre-flight гейт (15 проверок: CRLF в .env, длина
    APP_KEY, decrypt(encrypt) round-trip, PG/Redis ping, config-cache
    свежее .env, pending migrations, HTTP smoke). exit 1 при любом провале.

  liderra-healthcheck.sh + cron */2 — проверка портала каждые 2 минуты;
    2 подряд провала (~4 мин downtime) → email DOWN; первый 200 после
    DOWN → email RECOVERED.

  liderra-queue.service — Restart=on-failure, StartLimitBurst=5/5min,
    OnFailure=liderra-queue-alert.service. Очередь больше не крутится в
    бесконечном крэше — после 5 крашей systemd останавливает + шлёт email.

  liderra-queue-alert.service + liderra-systemd-alert.sh — отправка email
    при окончательном fail системного юнита (status + journalctl tail).

  msmtprc.template — шаблон для /etc/msmtprc (placeholder
    __MAIL_PASSWORD__ подставляется из app/.env MAIL_PASSWORD).

Установлено на /var/www/liderra/app (тест-сервер YC):
  /etc/msmtprc, /usr/local/bin/liderra-*.sh,
  /etc/cron.d/liderra-healthcheck, /etc/systemd/system/liderra-queue*.service.
  Тестовое письмо на kdv1@bk.ru доставлено (smtpstatus=250).

WAF (ModSecurity OWASP CRS 3.3.5) уже было правило 1900200 от A8 infosec
(разрешает PUT/PATCH/DELETE — добавлено в 06:00). Дополнительно:
  /etc/nginx/modsec/liderra-exclusions.conf id:1900300 — для /api/*
  поднят порог inbound_anomaly_score_threshold с 5 до 10 (чтобы edge-case
  JSON-payloads не давали false-positive: PATCH/DELETE и так дают +5 в CRS).

Verification: 9/9 GREEN.
  Smoke: liderra.ru → 200, PATCH/DELETE /api/* → 419 (Laravel CSRF, не 403 WAF).
  Services: php-fpm/queue/nginx/postgres/redis — все active.
  Pre-flight: 15/15 ✓ (был бы DOWN-сигнализатор сегодня за 5 секунд).
  Laravel production.ERROR за последние 10 минут: 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-22 11:10:31 +03:00
parent 000822d687
commit 365d1a0a93
7 changed files with 262 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Эти файлы заливаются на Linux-сервер через scp.
# CRLF здесь = повтор инцидента 22.05.2026 (битый .env).
* text eol=lf
*.sh text eol=lf
*.service text eol=lf
*.template text eol=lf
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# liderra-healthcheck.sh — проверка здоровья портала + email-алёрт на kdv1@bk.ru.
# Cron: каждые 2 минуты.
# Логика: 2 подряд провала (~4 мин даунтайма) → DOWN-алёрт; первый успех после DOWN → UP-алёрт.
set -u
ALERT_TO="kdv1@bk.ru"
URL="https://127.0.0.1/"
HOST_HDR="liderra.ru"
STATE_DIR="/var/lib/liderra"
STATE_FILE="$STATE_DIR/healthcheck.state"
LOG="/var/log/liderra-healthcheck.log"
DOWN_THRESHOLD=2 # подряд провалов до DOWN-алёрта
TIMEOUT=10
mkdir -p "$STATE_DIR" 2>/dev/null || true
touch "$STATE_FILE" "$LOG" 2>/dev/null || true
# Текущее состояние: DOWN_COUNT и LAST_STATE (up|down)
DOWN_COUNT=$(awk -F= '/^down_count=/{print $2}' "$STATE_FILE" 2>/dev/null)
DOWN_COUNT="${DOWN_COUNT:-0}"
LAST_STATE=$(awk -F= '/^last_state=/{print $2}' "$STATE_FILE" 2>/dev/null)
LAST_STATE="${LAST_STATE:-up}"
HTTP_CODE=$(curl -sS -k --max-time "$TIMEOUT" -H "Host: $HOST_HDR" -o /dev/null -w "%{http_code}" "$URL" 2>/dev/null || echo "000")
NOW=$(date '+%Y-%m-%d %H:%M:%S')
write_state() {
cat > "$STATE_FILE" <<EOF
down_count=$1
last_state=$2
last_check=$NOW
last_code=$HTTP_CODE
EOF
}
send_mail() {
local subj="$1"
local body="$2"
{
printf 'Subject: %s\n' "$subj"
printf 'From: verify@liderra.ru\n'
printf 'To: %s\n' "$ALERT_TO"
printf 'MIME-Version: 1.0\n'
printf 'Content-Type: text/plain; charset=utf-8\n\n'
printf '%s\n' "$body"
} | msmtp -a yandex "$ALERT_TO" >> "$LOG" 2>&1
}
# Считаем 5xx, 000 (timeout/refused) и любые сетевые ошибки как «провал».
# 200/301/302/401/403 — портал отвечает (даже 401 — это «жив»).
if [[ "$HTTP_CODE" =~ ^[23] ]] || [[ "$HTTP_CODE" =~ ^(301|302|401|403)$ ]]; then
STATE="up"
else
STATE="down"
fi
if [[ "$STATE" == "down" ]]; then
DOWN_COUNT=$((DOWN_COUNT + 1))
echo "[$NOW] DOWN code=$HTTP_CODE count=$DOWN_COUNT" >> "$LOG"
if [[ "$DOWN_COUNT" -ge "$DOWN_THRESHOLD" ]] && [[ "$LAST_STATE" == "up" ]]; then
TAIL_ERR=$(grep -E 'production\.ERROR' /var/www/liderra/app/storage/logs/laravel.log 2>/dev/null | tail -3 | cut -c1-400)
NGX=$(tail -5 /var/log/nginx/error.log 2>/dev/null)
send_mail "[Лидерра ПАДЕНИЕ] liderra.ru недоступен — HTTP $HTTP_CODE" "Портал liderra.ru недоступен.
Время: $NOW
HTTP-код: $HTTP_CODE
Провалов подряд: $DOWN_COUNT (порог $DOWN_THRESHOLD)
Последние ошибки Laravel:
$TAIL_ERR
Последние строки nginx error.log:
$NGX
Сервер: ssh ubuntu@111.88.246.137
"
write_state "$DOWN_COUNT" "down"
else
write_state "$DOWN_COUNT" "$LAST_STATE"
fi
else
if [[ "$LAST_STATE" == "down" ]]; then
echo "[$NOW] RECOVERED code=$HTTP_CODE" >> "$LOG"
send_mail "[Лидерра восстановлен] liderra.ru снова работает — HTTP $HTTP_CODE" "Портал liderra.ru снова отвечает.
Время восстановления: $NOW
HTTP-код: $HTTP_CODE
"
fi
write_state 0 "up"
fi
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# liderra-precheck.sh — pre-flight гейт перед/после деплоя.
# Проверяет .env, ключи, БД, Redis, шифрование. exit 1 при любом провале.
# Запускать руками после scp файлов / git pull, до systemctl restart.
set -e
APP_DIR="/var/www/liderra/app"
ENV="$APP_DIR/.env"
FAIL=0
red() { printf '\033[31m✗ %s\033[0m\n' "$1"; FAIL=1; }
green() { printf '\033[32m✓ %s\033[0m\n' "$1"; }
yellow(){ printf '\033[33m! %s\033[0m\n' "$1"; }
echo "=== liderra pre-flight check ==="
# 1. .env existence + perms
if [[ ! -f "$ENV" ]]; then red "$ENV отсутствует"; exit 1; fi
if [[ "$(stat -c '%U' "$ENV")" != "www-data" ]]; then yellow ".env owner $(stat -c '%U:%G %a' "$ENV") — должен быть www-data:www-data 640"; fi
green ".env существует"
# 2. CRLF check
CRLF=$(grep -c $'\r' "$ENV" || true)
if [[ "$CRLF" -gt 0 ]]; then red ".env содержит $CRLF строк с CRLF — Laravel сломает значения. Запусти: sudo sed -i 's/\\r\$//' $ENV"; else green ".env без CRLF"; fi
# 3. APP_KEY
KEY=$(grep '^APP_KEY=' "$ENV" | head -1 | cut -d= -f2-)
if [[ -z "$KEY" ]]; then red "APP_KEY пустой";
elif [[ "$KEY" != base64:* ]]; then red "APP_KEY без префикса base64: → '$KEY'";
elif [[ "${#KEY}" -ne 51 ]]; then red "APP_KEY длина ${#KEY}, должна быть 51 (base64: + 44 символа base64)";
else green "APP_KEY корректный (${#KEY} символов)"; fi
# 4. Дубль APP_KEY
DUP=$(grep -c '^APP_KEY=' "$ENV" || true)
if [[ "$DUP" -gt 1 ]]; then red "В .env $DUP строк APP_KEY= — должна быть одна"; fi
# 5. Critical drivers
for VAR in APP_ENV DB_CONNECTION SESSION_DRIVER CACHE_STORE QUEUE_CONNECTION REDIS_HOST; do
V=$(grep "^${VAR}=" "$ENV" | head -1 | cut -d= -f2-)
if [[ -z "$V" ]]; then yellow "$VAR не задан (берётся дефолт)"; else green "$VAR=$V"; fi
done
# 6. PostgreSQL connection
cd "$APP_DIR"
if sudo -u www-data php -r "require 'vendor/autoload.php'; \$app=require 'bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); try { DB::select('SELECT 1'); echo 'PG_OK'; } catch (Throwable \$e) { echo 'PG_FAIL: '.\$e->getMessage(); }" 2>/dev/null | grep -q "PG_OK"; then
green "PostgreSQL доступен"
else
red "PostgreSQL недоступен — проверь DB_HOST/DB_PASSWORD"
fi
# 7. Redis ping
if sudo -u www-data php -r "require 'vendor/autoload.php'; \$app=require 'bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); try { \$r=Illuminate\Support\Facades\Redis::ping(); echo strpos(strtolower((string)\$r ?: 'pong'),'pong')!==false || \$r===true ? 'REDIS_OK' : 'REDIS_FAIL'; } catch (Throwable \$e) { echo 'REDIS_FAIL: '.\$e->getMessage(); }" 2>/dev/null | grep -q "REDIS_OK"; then
green "Redis доступен"
else
red "Redis недоступен — проверь REDIS_HOST"
fi
# 8. Encryption round-trip (главная защита от инцидента 22.05.2026)
if [[ "$(sudo -u www-data php artisan tinker --execute "echo decrypt(encrypt('ping'));" 2>/dev/null | tail -1)" == "ping" ]]; then
green "Шифрование работает (decrypt(encrypt) round-trip)"
else
red "Шифрование сломано — APP_KEY невалидный, портал упадёт на 500"
fi
# 9. config-cache не stale
if [[ -f "$APP_DIR/bootstrap/cache/config.php" ]]; then
CACHE_AGE=$(( $(date +%s) - $(stat -c %Y "$APP_DIR/bootstrap/cache/config.php") ))
ENV_AGE=$(( $(date +%s) - $(stat -c %Y "$ENV") ))
if [[ "$ENV_AGE" -lt "$CACHE_AGE" ]]; then
red "config.php кэш СТАРШЕ чем .env — запусти: sudo -u www-data php artisan config:cache"
else
green "config-кэш свежее .env"
fi
else
yellow "config-кэш отсутствует (норма для dev, но в prod лучше иметь)"
fi
# 10. Pending migrations
if cd "$APP_DIR" && sudo -u www-data php artisan migrate:status 2>/dev/null | grep -q "Pending"; then
yellow "Есть pending миграции — запусти: sudo -u www-data php artisan migrate --force"
else
green "Миграции применены"
fi
# 11. HTTP smoke
HTTP=$(curl -sS -k --max-time 5 -H "Host: liderra.ru" -o /dev/null -w "%{http_code}" https://127.0.0.1/ 2>/dev/null || echo "000")
case "$HTTP" in
200|301|302|401|403) green "HTTP smoke: $HTTP" ;;
*) red "HTTP smoke: $HTTP — портал не отвечает" ;;
esac
echo
if [[ "$FAIL" -eq 0 ]]; then
green "ВСЁ ОК — можно перезапускать сервисы (sudo systemctl reload php8.3-fpm; sudo systemctl restart liderra-queue)"
exit 0
else
red "ЕСТЬ ПРОВАЛЫ — НЕ ЗАПУСКАЙ systemctl restart, сначала почини выше"
exit 1
fi
@@ -0,0 +1,6 @@
[Unit]
Description=Liderra queue alert (sends email on liderra-queue failure)
[Service]
Type=oneshot
ExecStart=/usr/local/bin/liderra-systemd-alert.sh liderra-queue
@@ -0,0 +1,19 @@
[Unit]
Description=Liderra queue worker
After=redis-server.service postgresql.service network.target
# Перейти в failed после 5 крашей за 5 минут — иначе systemd крутит бесконечно
StartLimitIntervalSec=300
StartLimitBurst=5
# При окончательном fail — запустить алёрт-юнит
OnFailure=liderra-queue-alert.service
[Service]
User=www-data
Group=www-data
Restart=on-failure
RestartSec=10
WorkingDirectory=/var/www/liderra/app
ExecStart=/usr/bin/php /var/www/liderra/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# liderra-systemd-alert.sh — отправка email-алёрта при упавшем systemd-юните.
# Используется как OnFailure= для liderra-queue.service.
set -u
UNIT="${1:-unknown}"
ALERT_TO="kdv1@bk.ru"
NOW=$(date '+%Y-%m-%d %H:%M:%S')
STATUS=$(systemctl status "$UNIT" --no-pager -l 2>&1 | head -30)
JOURNAL=$(journalctl -u "$UNIT" -n 30 --no-pager 2>&1)
{
printf 'Subject: [Лидерра-мониторинг] systemd-юнит упал: %s\n' "$UNIT"
printf 'From: verify@liderra.ru\n'
printf 'To: %s\n' "$ALERT_TO"
printf 'MIME-Version: 1.0\n'
printf 'Content-Type: text/plain; charset=utf-8\n\n'
printf 'Юнит %s окончательно упал (5 крашей за 5 минут — превышен лимит).\n\n' "$UNIT"
printf 'Время: %s\n' "$NOW"
printf 'Сервер: ssh ubuntu@111.88.246.137\n\n'
printf '=== systemctl status ===\n%s\n\n' "$STATUS"
printf '=== journalctl (последние 30 строк) ===\n%s\n' "$JOURNAL"
} | msmtp -a yandex "$ALERT_TO" 2>>/var/log/msmtp.log
+15
View File
@@ -0,0 +1,15 @@
defaults
auth on
tls on
tls_starttls off
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile /var/log/msmtp.log
account yandex
host smtp.yandex.ru
port 465
from verify@liderra.ru
user verify@liderra.ru
password __MAIL_PASSWORD__
account default : yandex