Секреты сервиса в systemd: credentials вместо EnvironmentFile

6 мин чтения

Практический способ передать токен Linux-сервису через зашифрованные credentials systemd, проверить совместимость, запуск и ротацию без вывода секрета в журнал.

Как зашифрованный секрет попадает в процесс сервиса
  1. ПодготовкаАдминистратор временно помещает значение в закрытый файл под /run, не передавая его в командной строке.
  2. Шифрованиеsystemd-creds создаёт аутентифицированный контейнер, связанный с ключом хоста и при наличии TPM2 — с оборудованием.
  3. АктивацияLoadCredentialEncrypted расшифровывает значение только при запуске unit и размещает его в каталоге credentials сервиса.
  4. ЧтениеПриложение получает путь через %d или CREDENTIALS_DIRECTORY; само значение не попадает в Environment и аргументы процесса.

EnvironmentFile= удобен для обычных настроек, но пароль или токен в нём остаётся простым файлом, а значение после загрузки становится переменной окружения процесса. Для чувствительных данных у systemd есть отдельный механизм credentials: сервис получает секрет как закрытый файл только на время своей работы.

Ниже — практический вариант с LoadCredentialEncrypted=. Он подходит для токена API, пароля к внешнему сервису или закрытого ключевого материала небольшого размера. В примере само значение не появляется в unit-файле, командной строке, выводе systemctl status и журнале.

Сначала проверяем версию systemd

Поддержка каталога credentials и LoadCredential= появилась в systemd 247, а утилита systemd-creds с шифрованием — в версии 250. Поэтому первым делом проверяем фактическую версию на сервере:

systemd --version
systemd-creds --version

Если второй команды нет или версия ниже 250, не добавляйте LoadCredentialEncrypted=: старый менеджер не сможет разобрать unit. Сначала обновите систему штатным способом либо используйте временный вариант с закрытым файлом и LoadCredential=, понимая, что исходник останется на диске в открытом виде.

Почему credentials лучше переменной с секретом

systemd создаёт для unit отдельный каталог, доступный пользователю сервиса и root. Путь к нему доступен как $CREDENTIALS_DIRECTORY, а внутри unit его можно записать короче — спецификатором %d. В переменную окружения приложения передаётся только путь, а не содержимое токена.

Это важно по двум причинам:

  • секрет не наследуется дочерними процессами как обычная переменная окружения;
  • при файловой изоляции каталог credentials не виден другим сервисам.

Credentials существуют только во время активности unit, доступны только для чтения и по возможности размещаются в неподкачиваемой памяти. Шифрование защищает значение при хранении: systemd расшифровывает его непосредственно при запуске сервиса.

Создаём зашифрованный credential

Готовим закрытый временный файл

Не вводите токен после echo: он может остаться в истории оболочки или попасть в аудит команд. Создадим файл с правами только для root в /run, отредактируем его и сразу после шифрования удалим:

sudo install -d -m 0700 /etc/credstore.encrypted
sudo install -m 0600 /dev/null /run/report-api-token
sudoedit /run/report-api-token

Имя report-api-token — не секрет, а назначение credential. Оно должно оставаться одинаковым в команде шифрования и в unit.

Шифруем и удаляем открытый текст

sudo systemd-creds encrypt \
  --name=report-api-token \
  /run/report-api-token \
  /etc/credstore.encrypted/report-api-token

sudo rm -f /run/report-api-token
sudo chmod 0600 /etc/credstore.encrypted/report-api-token

В режиме по умолчанию systemd-creds выбирает доступный защищённый ключ: ключ хоста, TPM2 либо их сочетание. Не применяйте --with-key=null для рабочего секрета — этот режим не обеспечивает конфиденциальность и аутентичность.

Зашифрованный файл нельзя бездумно переносить между серверами. Если контейнер связан с ключом хоста или TPM2, расшифровать его должна именно та установка, для которой он был создан.

Подключаем credential к сервису

Предположим, приложение умеет читать токен из файла, путь к которому задаётся переменной APP_TOKEN_FILE. Unit может выглядеть так:

[Unit]
Description=Report worker
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=report-worker
Group=report-worker

LoadCredentialEncrypted=report-api-token:/etc/credstore.encrypted/report-api-token
Environment=APP_TOKEN_FILE=%d/report-api-token

ExecStartPre=/usr/bin/test -s %d/report-api-token
ExecStart=/usr/local/bin/report-worker

PrivateMounts=yes
NoNewPrivileges=yes
Restart=on-failure

[Install]
WantedBy=multi-user.target

ExecStartPre= проверяет, что расшифрованный файл существует и не пуст, но не печатает его содержимое. Если приложение само понимает $CREDENTIALS_DIRECTORY, строка Environment= не нужна. Если ему требуется аргумент с путём, передавайте путь к файлу, но не подставляйте значение через $(cat ...): секрет окажется в списке процессов.

PrivateMounts=yes добавляет файловую изоляцию. Она особенно полезна сервису, который работает с credentials; некоторые более строгие параметры sandboxing уже включают отдельное пространство монтирования косвенно.

Проверяем unit до запуска

Сначала проверяем синтаксис, затем перечитываем конфигурацию и запускаем сервис:

sudo systemd-analyze verify /etc/systemd/system/report-worker.service
sudo systemctl daemon-reload
sudo systemctl start report-worker.service

Результат проверяем без вывода самого credential:

systemctl show report-worker.service \
  -p ActiveState -p SubState -p MainPID

sudo systemctl status report-worker.service --no-pager
sudo journalctl -u report-worker.service --since "-10 min" --no-pager

Ожидаемый признак успеха — ActiveState=active и ненулевой MainPID. Если credential отсутствует, не расшифровывается или пуст, unit завершится на этапе подготовки либо на ExecStartPre.

Диагностика типичных ошибок

Unknown key name LoadCredentialEncrypted

Менеджер systemd слишком старый. Сверьте systemd --version и не ориентируйтесь только на версию дистрибутива или пакета из документации.

Не удалось расшифровать credential

Проверьте журнал unit и три условия:

  • файл создан на текущем хосте или зашифрован совместимым способом;
  • встроенное имя report-api-token совпадает с именем в LoadCredentialEncrypted=;
  • у service manager есть доступ к зашифрованному файлу.

Не используйте systemd-creds cat в диагностических скриптах и не направляйте расшифрованное значение в journal: эта команда предназначена для вывода содержимого.

Сервис стартует вручную, но не через systemd

Проверьте, что приложение действительно читает APP_TOKEN_FILE, а не ожидает сам токен. Также учитывайте пользователя из User= и рабочий каталог. Общий маршрут такой диагностики разобран в статье «Служба systemd не запускается: короткий маршрут диагностики».

Безопасная ротация токена

Новый credential лучше подготовить рядом со старым, проверить возможность расшифрования без вывода содержимого, затем заменить файл атомарно и перезапустить сервис:

sudo install -m 0600 /dev/null /run/report-api-token
sudoedit /run/report-api-token

sudo systemd-creds encrypt \
  --name=report-api-token \
  /run/report-api-token \
  /etc/credstore.encrypted/report-api-token.new

sudo systemd-creds decrypt \
  --name=report-api-token \
  /etc/credstore.encrypted/report-api-token.new \
  /dev/null

sudo rm -f /run/report-api-token
sudo mv /etc/credstore.encrypted/report-api-token.new \
  /etc/credstore.encrypted/report-api-token
sudo systemctl restart report-worker.service

После перезапуска повторите проверку ActiveState, SubState и журнала. Старый процесс не получит новое значение автоматически: credentials неизменяемы в течение одного запуска unit.

Что считать готовым результатом

  • unit проходит systemd-analyze verify;
  • сервис активен, а предварительная проверка непустого credential завершилась успешно;
  • секрет отсутствует в unit, аргументах процесса и журнале;
  • зашифрованный файл лежит отдельно от кода приложения;
  • ротация выполняется заменой контейнера и контролируемым перезапуском.

Credentials решают задачу доставки секрета, но не заменяют остальные меры защиты unit. После настройки полезно отдельно проверить ограничения пользователя, файловой системы и capabilities. Для периодических операций лучше не встраивать расписание в скрипт, а использовать отдельный timer — пример есть в материале «Таймер systemd: расписание отдельно от команды».

Первичные источники

Источники и дальнейшее чтение