Sudo без пароля и опечатка в пути: как исчез каталог конфигураций
В половине четвёртого утра ночной cron-скрипт должен был тихо удалить конфиги пяти списанных тенантов. Вместо этого он стёр весь каталог конфигураций целиком — и все клиенты одновременно получили 502 при попытке reload. Причина оказалась не в одной ошибке, а в связке из трёх мелочей: passwordless sudo, которую поставили «на время» восемь месяцев назад, скрипт без set -e, и один файл, отредактированный на Windows. Разбираем инцидент по шагам — что видели, какие версии отбрасывали и что изменили, чтобы это не повторилось.
Содержание
Что сломалось
Сервис — мультитенантная платформа на одном VPS: конфиги клиентов лежат в /etc/app/tenants.d/<tenant>/, каждый systemd-юнит подхватывает свой конфиг при старте и по reload. Раз в сутки, в 03:00, cron под пользователем svc-deploy запускает через sudo скрипт очистки — он читает список списанных тенантов из текстового файла и удаляет их каталоги, если с момента отключения прошло больше 90 дней.
В 03:07 скрипт отработал штатно с точки зрения кода возврата — вышел с нулевым статусом, записал в лог «cleanup completed». В 03:20 Prometheus поймал резкий рост node_systemd_unit_state{state="failed"} — сразу по всем юнитам тенантов, не по одному. Алерт ушёл дежурному, тот открыл systemctl status и увидел одинаковую картину на каждом юните: сервис не может стартовать, потому что не находит свой конфиг-файл.
Первая реакция — предположить, что это не «пропал один файл», а что-то более общее: раздел, диск, точка монтирования. Поэтому первые 15–20 минут ушли не на скрипт, а на инфраструктурные проверки.
Что видели в логах и метриках
Порядок проверки был примерно такой:
df -h /etc
mount | grep etc
dmesg -T | tail -100
smartctl -a /dev/sda | grep -i reallocated
journalctl -u 'app-tenant@*' --since "03:00" --until "03:30"
Диск был на месте, df показывал нормальное свободное место, dmesg — ничего про I/O-ошибки, SMART — чисто. journalctl показал одну и ту же ошибку для каждого юнита: Failed to load configuration file '/etc/app/tenants.d/<tenant>/app.conf': No such file or directory. То есть проблема была не на уровне диска или файловой системы — файлов физически не было.
Дальше посмотрели сам каталог:
ls -la /etc/app/tenants.d/
Каталог существовал, но был пуст. Не «частично пуст» — пуст полностью, включая тенантов, которые точно не были в списке на списание. Это сразу отсекло версию «скрипт корректно удалил старые данные, просто список оказался шире ожидаемого» — удалились абсолютно все, независимо от возраста.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие гипотезы отбросили
Прежде чем дойти до реальной причины, проверили и закрыли четыре версии:
- Отказ диска или файловой системы.
smartctl,dmesg,df— всё чисто, ошибок чтения/записи не было. Файлы не «побились», их не стало. - Ночной деплой. Посмотрели историю CI/CD и git log по репозиторию с провижининг-скриптами — за ночь ничего не катилось, последний деплой был днём раньше и не трогал
/etc/app/tenants.d. - Взлом или скомпрометированный доступ. Проверили
/var/log/auth.logна предмет посторонних логинов, новых ключей вauthorized_keys, изменений в/etc/passwd— ничего необычного. Заходов извне не было, все действия — с известного IP из cron. - Гонка при параллельном reload нескольких тенантов. Отбросили сразу: гонка объяснила бы сбой у одного-двух юнитов, а не у всех одновременно и не физическое исчезновение файлов с диска.
Именно проверка auth.log, хоть и не подтвердила взлом, дала правильную зацепку: раз посторонних логинов не было, а команда всё же была выполнена локально — стоило посмотреть, что именно исполнялось через sudo в это время.
Как нашли реальную причину
sudo по умолчанию пишет в syslog каждую выполненную команду — это работает независимо от того, спрашивается пароль или нет:
grep "svc-deploy" /var/log/auth.log | grep "COMMAND=" | grep "03:0"
Строка нашлась:
Sep 3 03:07:41 host sudo: svc-deploy : COMMAND=/bin/rm -rf /etc/app/tenants.d/*
Вот и весь инцидент одной строкой: скрипт очистки выполнил rm -rf не над подкаталогом конкретного тенанта, а над родительским каталогом tenants.d целиком. Дальше вопрос сузился до одного: почему переменная с путём вместо /etc/app/tenants.d/old-tenant-x превратилась в /etc/app/tenants.d.
Здесь же стало ясно, почему это вообще смогло случиться без единой заминки: у svc-deploy в /etc/sudoers.d/deploy стояла строка
svc-deploy ALL=(ALL) NOPASSWD: ALL
Её добавили восемь месяцев назад «на время отладки провижининга» и забыли убрать. NOPASSWD сам по себе ничего не удаляет — но он убирает единственный момент, где человек в интерактивном запуске мог бы остановиться и перечитать команду. В cron это и не требовалось: скрипт запускался без единого живого наблюдателя, ночью, с полными правами root, без вопросов.
Разбор скрипта: где именно была ошибка
Упрощённая версия виновного скрипта:
#!/bin/bash
BASE=/etc/app/tenants.d
LIST=/opt/scripts/decommissioned-tenants.txt
for tenant in $(cat "$LIST"); do
TENANT_DIR="$BASE/$tenant"
if [ ! -d "$TENANT_DIR" ]; then
# легаси-тенанты иногда лежат на уровень выше — подстраховка
TENANT_DIR=$(dirname "$TENANT_DIR")
fi
rm -rf "$TENANT_DIR"/*
done
Два дня назад коллега добавил в decommissioned-tenants.txt новую строку через Блокнот на Windows — файл сохранился с CRLF-переносами. Bash при чтении через $(cat "$LIST") разбивает вывод по пробельным символам, но символ \r (возврат каретки) остаётся приклеенным к концу последнего «слова» в строке. В переменную tenant попало не old-tenant-x, а old-tenant-x\r.
Проверка [ -d "$TENANT_DIR" ] для пути /etc/app/tenants.d/old-tenant-x\r закономерно вернула «не существует» — такого каталога действительно нет. Скрипт ушёл в ветку «подстраховки», задуманную когда-то для другого сценария (легаси-тенанты без вложенной структуры). Но dirname работает с текстовой строкой, а не с реальной файловой системой: он просто отрезает последний сегмент после последнего /. Для строки /etc/app/tenants.d/old-tenant-x\r результатом стал /etc/app/tenants.d — родительский каталог, в котором и лежат конфиги всех остальных тенантов.
Дальше — rm -rf "$TENANT_DIR"/* без единой дополнительной проверки, что за каталог перед ним. Никакого set -euo pipefail, никакой проверки «а не является ли $TENANT_DIR тем самым базовым каталогом», никакого dry-run. Всё выполнилось молча, скрипт завершился с кодом 0, и в лог ушла бодрая строка «cleanup completed» — потому что с точки зрения bash никакой ошибки не произошло.
Важный нюанс, который легко упустить: сама по себе опечатка CRLF — рядовая, такое рано или поздно случается в любой команде. Проблему создала не она, а комбинация из трёх факторов сразу: скрипт без защитных проверок, sudo без пароля и трения, и отсутствие любого «предохранителя» между вычислением пути и его удалением.
Восстановление: как вернули конфиги
Конфиги /etc/app/tenants.d резервировались отдельным заданием на бэкап-сервер каждую ночь в 02:00 — то есть последний целый снапшот был снят за час до инцидента, ещё без потерь. Восстановление шло в три шага:
- Остановили cron на затронутом сервере, чтобы скрипт очистки не мог запуститься повторно, пока идёт разбор:
systemctl stop cron
- Развернули последний снапшот каталога с бэкап-сервера во временную директорию и сверили список тенантов с записью в CMDB / базе провижининга — чтобы восстановить именно актуальный набор, а не случайно откатить и уже списанных тенантов, которые должны были уйти:
rsync -a backup-host:/backups/tenants.d/latest/ /etc/app/tenants.d/
- Перезапустили юниты пострадавших тенантов и прогнали проверочный скрипт, который для каждого тенанта дёргает его health-эндпоинт и сверяет ответ с ожидаемым:
systemctl restart 'app-tenant@*'
От первого алерта до восстановления работоспособности прошло меньше часа — основное время ушло не на сам rsync, а на проверку гипотез до того, как нашли строку в auth.log. Если бы сразу знали, где искать, восстановление заняло бы минут пятнадцать.
Что изменили после инцидента
Правки разделились на три уровня — права доступа, сам скрипт и процесс проверки перед деструктивными операциями.
Sudoers. Убрали NOPASSWD: ALL и заменили на точечное разрешение только для конкретного скрипта с фиксированным путём:
svc-deploy ALL=(root) NOPASSWD: /opt/scripts/cleanup-decommissioned.sh
Так cron по-прежнему работает без интерактивного пароля (это и раньше было оправданно для автоматизации), но svc-deploy больше не может через sudo выполнить произвольную команду — только этот один скрипт. Отдельно завели Defaults log_output для этого правила, чтобы полный вывод команды логировался, а не только сам факт запуска.
Скрипт. Убрали опасную «подстраховку» с dirname, добавили set -euo pipefail, привели чтение списка к безопасному виду и добавили проверку, что путь на удаление лежит строго внутри $BASE и не совпадает с ним самим:
#!/bin/bash
set -euo pipefail
BASE=/etc/app/tenants.d
LIST=/opt/scripts/decommissioned-tenants.txt
while IFS= read -r tenant; do
tenant=$(printf '%s' "$tenant" | tr -d '\r' | xargs)
[ -z "$tenant" ] && continue
TENANT_DIR="$BASE/$tenant"
case "$TENANT_DIR" in
"$BASE"/*) : ;;
*) echo "skip: path outside base: $TENANT_DIR" >&2; continue ;;
esac
[ -d "$TENANT_DIR" ] || { echo "skip: not found: $TENANT_DIR" >&2; continue; }
mv "$TENANT_DIR" "/var/tmp/trash/$(date +%Y%m%d)-$tenant"
done < "$LIST"
Ключевое изменение — rm -rf заменили на перенос в «корзину» с датой в имени. Отдельным cron-заданием раз в неделю чистится всё, что старше 14 дней в /var/tmp/trash. Скорость восстановления в случае повторной ошибки — секунды, mv вместо повторного разворачивания бэкапа.
Процесс. Добавили «предохранитель по объёму»: если скрипт за один запуск должен удалить (перенести) больше двух тенантов сразу, он останавливается и требует ручного подтверждения — потому что штатно списывают тенантов по одному-два в месяц, а не десятками разом. Также завели отдельный алерт на резкое падение количества подкаталогов в /etc/app/tenants.d — раньше такого метрика просто не было, мониторинг следил только за состоянием юнитов, а не за содержимым каталога, который их кормит.
Отдельно прошлись по остальным sudoers-файлам на других серверах — выяснилось, что похожая строка NOPASSWD: ALL осталась ещё на двух хостах, тоже «временно», тоже забытая. О том, как выдавать команде доступ без раздачи root целиком, у нас есть отдельный разбор — стоит свериться с ним, если в sudoers завелись похожие строки.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему просто не убрали NOPASSWD совсем и не поставили пароль на каждый вызов sudo?
Потому что тогда cron не смог бы выполнить скрипт без человека — автоматизация сломалась бы полностью. Правильный компромисс — не «пароль или его отсутствие», а сужение прав: NOPASSWD только на конкретную команду с конкретным путём, а не на ALL.
Разве set -euo pipefail не решил бы всё сразу, без остальных правок?
Частично. Он остановил бы скрипт на первой настоящей ошибке, но в этом случае ошибок с точки зрения кода возврата не было — dirname и rm -rf отработали «успешно», просто не над тем каталогом. set -e — обязательный минимум, но не замена проверке «путь лежит внутри ожидаемого базового каталога».
Как вообще проверить, что бэкап, который спас в этом случае, реально рабочий, а не просто существует?
Регулярным восстановлением на отдельном стенде, а не только фактом, что задание бэкапа завершилось без ошибок. Мы отдельно разбирали, как проверить, что бэкап рабочий — тот же чек-лист используем и для каталога конфигов, не только для баз данных.
Можно ли было обойтись без полного восстановления из бэкапа, если бы заметили быстрее?
Да — если бы grep по auth.log сделали в первые пять минут, а не через двадцать, восстановление свелось бы к тому же rsync, но без часа на проверку гипотез. Быстрее найти причину помогает именно проверенный порядок действий, а не везение; у нас есть общий разбор того, как писать разбор инцидента — тот же порядок проверки логов до догадок работает и здесь.
А что если бы бэкапа не было вообще?
Тогда пришлось бы восстанавливать конфиги вручную из провижининг-системы — по записям в базе регенерировать app.conf для каждого тенанта скриптом первичной настройки. Это заняло бы часы, а не минуты, и часть кастомных правок, сделанных вручную вне провижининга, могла бы не восстановиться вовсе. Про то, что можно спасти после случайного rm -rf, если бэкапа нет или он старый, — в отдельной статье про rm -rf не туда.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →