backlog: secrets-env-to-file разложена по приложениям

- Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia,
  gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer
  отпал — файловых секретов не умеет ни meilisearch, ни pocketbase.
- Матрица механизмов со ссылками на код уехала в
  docs/drafts/secrets-file-support.md, родитель — на кладбище.
This commit is contained in:
av
2026-07-25 12:25:50 +03:00
parent f9a8c64377
commit fbd0a66f44
14 changed files with 384 additions and 100 deletions
+203
View File
@@ -0,0 +1,203 @@
# Файловые секреты: что умеет каждое приложение
Дата: 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](https://github.com/outline/outline/pull/11906),
merged 2026-03-30), в v1.9.0 переписано на ленивый Proxy (PR
[#12889](https://github.com/outline/outline/pull/12889)). Проверено бисекцией:
в v1.6.1 механизма нет, в v1.7.0 есть.
- Конвенция описана в
[.env.sample](https://github.com/outline/outline/blob/v1.9.2/.env.sample)
строки 4–18, включая правило приоритета: заданы обе — побеждает прямая
переменная.
- Пароль БД: `DATABASE_URL_FILE` выносит **весь** URL. Чтобы вынести только
пароль, нужны раздельные `DATABASE_HOST/PORT/NAME/USER` + `DATABASE_PASSWORD`,
они взаимоисключимы с `DATABASE_URL` через `@CannotUseWith`
([env.ts L95L143](https://github.com/outline/outline/blob/v1.9.2/server/env.ts)).
- Сайдкар `postgres:16.3` — штатный `POSTGRES_PASSWORD_FILE`.
### wakapi `2.17.5`
Два независимых пути.
- `loadSecretFiles()`
([config.go L829](https://github.com/muety/wakapi/blob/2.17.5/config/config.go))
— generic: проходит по всему окружению, для любой переменной с суффиксом
`_FILE` читает файл, `TrimSpace`, кладёт в базовое имя, файловую снимает.
Заданы обе — процесс падает с «both environment variables are set».
- Порядок в `Load()`: `loadSecretFiles()``renameEnvVars()``configor.Load`,
поэтому `_FILE` работает и для OIDC-переменных в configor-формате.
- История: PR [#679](https://github.com/muety/wakapi/pull/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](https://github.com/go-gitea/gitea/blob/v1.27.0/modules/setting/config_env.go),
PR [#24832](https://github.com/go-gitea/gitea/pull/24832), с 1.20).
Оговорка, которая режет пользу: `environment-to-ini` **записывает содержимое
файла в `app.ini` открытым текстом**, то есть секрет всё равно оседает на
хостовом диске в `<data_dir>/gitea/conf/app.ini`. Открытая проблема апстрима
[#35316](https://github.com/go-gitea/gitea/issues/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](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/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](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/gramps_webapi/app.py)).
Обходной путь: `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](https://github.com/authelia/authelia/blob/v4.39.20/internal/configuration/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-фильтр](https://www.authelia.com/configuration/methods/files/)
(`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](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/config/config.js),
[providerConfig.js L82](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/oidc/providerConfig.js),
[service.js L20](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/ai-assistant/service.js)).
Греп по репозиторию на теге даёт только `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](https://github.com/meilisearch/meilisearch/blob/v1.20.0/crates/meilisearch/src/option.rs)).
Апстрим отклонил запрос —
[discussion #201](https://github.com/orgs/meilisearch/discussions/201), 2023.
Клиентская сторона (`wanderer_db`, `wanderer_web`) тоже читает `os.Getenv`.
- `POCKETBASE_ENCRYPTION_KEY` — это **не** штатный механизм pocketbase
(`--encryptionEnv`), а собственная переменная wanderer, читается `os.Getenv` в
четырёх местах
([db/main.go](https://github.com/Flomp/wanderer/blob/v0.18.3/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`.