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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user