Files
pet-project-server/docs/drafts/secrets-file-support.md
T
av fbd0a66f44 backlog: secrets-env-to-file разложена по приложениям
- Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia,
  gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer
  отпал — файловых секретов не умеет ни meilisearch, ни pocketbase.
- Матрица механизмов со ссылками на код уехала в
  docs/drafts/secrets-file-support.md, родитель — на кладбище.
2026-07-25 12:25:50 +03:00

15 KiB
Raw Blame History

Файловые секреты: что умеет каждое приложение

Дата: 2026-07-25. Статус: справка по итогам проверки исходников (не план работ).

Проверялись закреплённые в наших compose-файлах версии, не main. Источник — код парсинга конфига в репозиториях приложений; доки использовались как подтверждение, а не как основание. Из этой справки выросли задачи secrets-file-* в беклоге, по одной на приложение.

Зачем

Секреты стоят прямыми значениями в environment: docker-compose. Такие переменные читает любой член группы docker, любой процесс с доступом к /proc/<pid>/environ, и они попадают в docker inspect. Цель — там, где приложение умеет, брать секрет из файла.

Образец — miniflux: роль secrets кладёт каждую переменную vault в отдельный файл, каталог монтируется :ro, compose ссылается на *_FILE (files/miniflux/docker-compose.template.yml).

Отдельная и более важная цель — чтобы секреты не печатались в терминал при деплое — этой справкой не решается: там работает no_log, см. задачу no-secrets-in-playbook-output. Для tududi и wanderer no_log — единственный доступный ответ.

Матрица

Приложение Секрет Файл Механизм
outline 1.9.2 SECRET_KEY, UTILS_SECRET, OIDC, SMTP да generic <NAME>_FILE
пароль postgres да, с оговоркой только вместе с переходом на раздельные DATABASE_*
пароль сайдкара postgres да штатный POSTGRES_PASSWORD_FILE
wakapi 2.17.5 salt, SMTP, OIDC да generic _FILE либо config.yml
gitea 1.27.0 SMTP да, частично GITEA__mailer__PASSWD__FILE, но значение оседает в app.ini
gramps 26.7.0 GRAMPSWEB_SECRET_KEY да файл /app/secret/secret, путь уже смонтирован
SMTP нет только недокументированный трюк с config.cfg
authelia 4.39.20 jwt, session, ключ хранилища, hmac OIDC, SMTP да AUTHELIA_..._FILE
jwks-ключ, client_secret × 4 нет через _FILE только template-фильтр; секреты клиентов лучше хэшировать
tududi 1.2.4 все четыре нет обходной путь — смонтированный .env
wanderer 0.18.3 ключ meilisearch, ключ pocketbase нет ничего

Подробности по приложениям

outline 1.9.2

server/utils/environment.ts оборачивает process.env в Proxy: если переменная пуста, читается <NAME>_FILE как путь к файлу, содержимое обрезается по пробелам. Весь server/env.ts и плагины читают именно через него, поэтому механизм покрывает любую переменную, включая OIDC-плагин.

  • Появилось в v1.7.0 (PR #11906, merged 2026-03-30), в v1.9.0 переписано на ленивый Proxy (PR #12889). Проверено бисекцией: в v1.6.1 механизма нет, в v1.7.0 есть.
  • Конвенция описана в .env.sample строки 4–18, включая правило приоритета: заданы обе — побеждает прямая переменная.
  • Пароль БД: DATABASE_URL_FILE выносит весь URL. Чтобы вынести только пароль, нужны раздельные DATABASE_HOST/PORT/NAME/USER + DATABASE_PASSWORD, они взаимоисключимы с DATABASE_URL через @CannotUseWith (env.ts L95L143).
  • Сайдкар postgres:16.3 — штатный POSTGRES_PASSWORD_FILE.

wakapi 2.17.5

Два независимых пути.

  • loadSecretFiles() (config.go L829) — generic: проходит по всему окружению, для любой переменной с суффиксом _FILE читает файл, TrimSpace, кладёт в базовое имя, файловую снимает. Заданы обе — процесс падает с «both environment variables are set».
  • Порядок в Load(): loadSecretFiles()renameEnvVars()configor.Load, поэтому _FILE работает и для OIDC-переменных в configor-формате.
  • История: PR #679 (релиз 2.12.1) сделал это в entrypoint.sh для пяти переменных; коммит a45732b2 (релиз 2.17.2, переход на distroless) перенёс логику в Go и сделал универсальной. README (L92) перечисляет только три переменные — список устарел относительно кода.
  • config.yml покрывает все три поля: security.password_salt, security.oidc[], mail.smtp.password. Образ кладёт config.default.yml в /app/config.yml, туда же монтируется свой. Env перекрывает значения из yaml.

Две ловушки в нашем закомментированном OIDC-блоке (не живой баг, но при включении выстрелит):

  1. Имена WAKAPI_OIDC_PROVIDER_CLIENT_ID устарели — в 2.17.5 разбирается WAKAPI_OIDC_PROVIDERS_(\d+)_([A-Z_]+), старые молча игнорируются.
  2. renameEnvVars() разбирает строку как strings.Split(e, "=") и берёт parts[1] — значение обрезается по первому =, то есть секрет с base64-паддингом через env поедет битым.

Оба довода — за config.yml для OIDC-секрета.

gitea 1.27.0

GITEA__mailer__PASSWD__FILE=/path — механизм универсальный, работает для любого ключа app.ini, хвостовой перевод строки обрезается (config_env.go L100141, PR #24832, с 1.20).

Оговорка, которая режет пользу: environment-to-ini записывает содержимое файла в app.ini открытым текстом, то есть секрет всё равно оседает на хостовом диске в <data_dir>/gitea/conf/app.ini. Открытая проблема апстрима #35316 (ранее #25316), PR нет. Выигрыш реальный, но частичный: уходит из environment:, docker inspect и окружения процесса — не с диска.

gramps 26.7.0 (gramps-web-api v3.18.0)

Если GRAMPSWEB_SECRET_KEY не задана, entrypoint образа читает ключ из файла /app/secret/secret (и генерирует, если файла нет) — docker-entrypoint.sh. Этот путь у нас уже смонтирован как gramps_secret. Entrypoint общий у gramps_app и gramps_celery через YAML merge key, оба читают тот же файл.

SMTP-пароль файлового источника не имеет: конфиг читается через app.config.from_prefixed_env(prefix="GRAMPSWEB"), никакой обработки _FILE (app.py L99121). Обходной путь: Dockerfile задаёт GRAMPS_API_CONFIG=/app/config/config.cfg, а from_envvar() во Flask — это from_pyfile(), то есть config.cfg исполняется как Python, и EMAIL_HOST_PASSWORD = open("/run/secrets/smtp").read().strip() сработает. Механизм рабочий, но держится на детали реализации Flask и нигде не обещан.

authelia 4.39.20

IsSecretKey() возвращает false для любого ключа, содержащего [] (элемент списка), и требует, чтобы ключ оканчивался на key/secret/password/token/ certificate_chain (helpers.go). Отсюда деление:

  • Умеют _FILE (5 полей): identity_validation.reset_password.jwt_secret, session.secret, storage.encryption_key, identity_providers.oidc.hmac_secret, notifier.smtp.password.
  • Не умеют (2 позиции): identity_providers.oidc.jwks[].key и client_secret четырёх клиентов — оба содержат [].

Для них есть template-фильтр (X_AUTHELIA_CONFIG_FILTERS=template, с 4.38): {{ secret "/path" | nindent 10 }}. Механизмы несовместимы в одном поле — если задано и _FILE, и значение в конфиге, Authelia падает с errFmtSecretAlreadyDefined. Поэтому проще выбрать один: фильтр покрывает все семь позиций, _FILE — только пять.

Отдельно про client_secret: штатная форма хранения — PHC-дайджест $pbkdf2-sha512$310000$..., плейнтекст с 4.38 вызывает warning валидатора (errFmtOIDCClientInvalidSecretPlainText). Плейнтекст обязателен только при client_secret_jwt или симметричном шифровании JWT — у наших четырёх клиентов (miniflux, wakapi, tududi — дефолт; outline — access_token_signed_response_alg: none) ничего такого нет. То есть эти поля можно перестать считать секретами: в конфиге хэш, сам секрет живёт в vault и в конфиге клиентского приложения. Генератор уже есть — inv authelia-gen-secret-and-hash.

tududi 1.2.4 — не умеет

Все четыре секрета читаются напрямую из process.env (config.js L94, L40, providerConfig.js L82, service.js L20). Греп по репозиторию на теге даёт только DB_FILE и FILE_UPLOAD_LIMIT_MB; ни readFileSync для секретов, ни конфиг-файла, ни CLI-опции нет. Собственная дока по OIDC советует «consider Docker secrets» — это совет про хранение, читать файлы приложение не умеет.

Обходной путь: backend/app.js вызывает require('dotenv').config(), рабочая директория контейнера — /app/backend, значит смонтированный туда .env подхватится. Это не per-secret файлы, но значения не попадают в Config.Env — в отличие от env_file:, где попадают. dotenv не перезаписывает уже установленные переменные окружения.

wanderer 0.18.3 — не умеет ничего

  • MEILI_MASTER_KEY (meilisearch v1.20.0): только CLI --master-key, env или инлайн в config.toml; _FILE нет (option.rs). Апстрим отклонил запрос — discussion #201, 2023. Клиентская сторона (wanderer_db, wanderer_web) тоже читает os.Getenv.
  • POCKETBASE_ENCRYPTION_KEY — это не штатный механизм pocketbase (--encryptionEnv), а собственная переменная wanderer, читается os.Getenv в четырёх местах (db/main.go). ENTRYPOINT в exec-форме, подстановку через шелл не сделать. Найденный в поиске POCKETBASE_ENCRYPTION_KEY_FILE — фича стороннего образа adrianmusante/pocketbase, к flomp/wanderer-db отношения не имеет.

Доступен только env_file:, где значения всё равно попадают в Config.Env, то есть косметика. Задачи по wanderer не заведено.

Docker secrets — покрытия не расширяет

secrets: в compose вне swarm — это bind-mount файла в /run/secrets/<name>, то есть тот же способ доставки, что и *_FILE. Приложение, умеющее только переменную окружения, из /run/secrets не прочитает: ограничение в приложении, а не в доставке. На матрицу выше не влияет.

Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux сейчас {{ secrets_dir }}:/secrets:ro висит на обоих сервисах, и postgres видит все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение поверх образца, а не замена ему. uid/gid/mode вне swarm игнорируются — права остаются с хост-файла, их ставит роль secrets.