backlog: матрица поддержки файловых секретов по приложениям

- Проверено по исходникам закреплённых версий: outline, wakapi, gitea,
  gramps и authelia умеют брать секреты из файла, tududi и wanderer — нет.
- Отмечено, что `secrets-env-to-file` переросла в эпик: разбивать по
  приложениям отдельным заходом.
This commit is contained in:
av
2026-07-25 12:20:20 +03:00
parent 3b846536f9
commit f9a8c64377
2 changed files with 82 additions and 21 deletions
@@ -59,8 +59,14 @@ dozzle, goaccess, rssbridge, homepage.
независимо от того, поедут ли структурные задачи. независимо от того, поедут ли структурные задачи.
2. Где файл смешанный (compose, конфиг authelia) — вернуться и вынести секреты в 2. Где файл смешанный (compose, конфиг authelia) — вернуться и вынести секреты в
отдельные файлы, сняв `no_log` с основного: он нужен ради диффа, который сейчас отдельные файлы, сняв `no_log` с основного: он нужен ради диффа, который сейчас
и теряется. Для authelia отдельно проверить, что из девяти секретов умеет и теряется. Что именно умеет каждое приложение — матрица в
file-based подстановку — конфиг на тысячу строк, дифф там ценный. [secrets-env-to-file](secrets-env-to-file.md), сверенная по исходникам. Для
authelia (конфиг на тысячу строк, дифф там самый ценный) ответ такой: пять
полей закрываются `AUTHELIA_..._FILE`, оставшиеся два — только
template-фильтром, а четыре `client_secret` штатно хранятся хэшем и тогда
вообще перестают быть секретами. Для tududi и wanderer ответа нет: они не
умеют читать секреты из файлов, и `no_log` для них — не первый шаг, а
единственный.
3. Защита от дрейфа: проверка в lefthook или CI — если шаблон ссылается на 3. Защита от дрейфа: проверка в lefthook или CI — если шаблон ссылается на
переменную, которой нет в открытых `group_vars`/`vars`, задача, которая его переменную, которой нет в открытых `group_vars`/`vars`, задача, которая его
рендерит, обязана иметь `no_log`. Иначе следующее приложение заведут по рендерит, обязана иметь `no_log`. Иначе следующее приложение заведут по
+74 -19
View File
@@ -5,32 +5,87 @@
miniflux сделан правильно — роль `secrets` кладёт каждую переменную vault в miniflux сделан правильно — роль `secrets` кладёт каждую переменную vault в
отдельный файл, каталог монтируется `:ro`, а compose ссылается на `*_FILE` отдельный файл, каталог монтируется `:ro`, а compose ссылается на `*_FILE`
(`files/miniflux/docker-compose.template.yml`). Остальные держат секреты прямыми (`files/miniflux/docker-compose.template.yml`). Остальные держат секреты прямыми
значениями в `environment:`: значениями в `environment:`: их читает любой член группы `docker`, любой процесс
с доступом к `/proc/<pid>/environ`, и они попадают в `docker inspect`.
- **outline** — пароль postgres, `SECRET_KEY`, `UTILS_SECRET`, секрет OIDC, SMTP
- **tududi** — session secret, секрет OIDC, ключ LLM, SMTP
- **wakapi** — salt паролей, секрет OIDC, SMTP
- **wanderer** — мастер-ключ meilisearch, ключ шифрования pocketbase
- **gramps** — `gramps_secret_key`, SMTP
- **gitea** — только SMTP (`postbox_pass`)
tuwunel из списка убран: в его compose секретов нет (сверено 2026-07-25). memos и tuwunel из списка убран: в его compose секретов нет (сверено 2026-07-25). memos и
calibre тоже чистые. calibre тоже чистые.
Такие переменные читает любой член группы `docker`, любой процесс с доступом к ## Матрица поддержки (проверено по исходникам, 2026-07-25)
`/proc/<pid>/environ`, и они попадают в `docker inspect`. Где приложение умеет
`*_FILE` или конфиг-файл — постепенно перевести по образцу miniflux.
Второй мотив, найденный позже: пока секрет стоит прямым значением в шаблоне, Проверялись **закреплённые в compose версии**, не `main`.
`ansible-playbook --diff` печатает его в терминал при каждом изменении файла.
Вынос в отдельный файл чинит это структурно — дифф compose остаётся читаемым, а | Приложение | Секрет | Файл | Механизм |
секретный файл рендерит роль `secrets` под `no_log`. Но покрывает только |---|---|---|---|
`environment:`; конфиги (authelia, bifrost, backups, remembos) и общий запрет на | **outline** `1.9.2` | `SECRET_KEY`, `UTILS_SECRET`, OIDC, SMTP | да | generic: `environment.ts` оборачивает `process.env` в Proxy, пустая переменная читается из `<NAME>_FILE`. С v1.7.0 |
вывод — в [no-secrets-in-playbook-output](no-secrets-in-playbook-output.md), и | | пароль postgres | да, с оговоркой | `DATABASE_URL_FILE` выносит URL целиком; чтобы вынести только пароль — раздельные `DATABASE_HOST/PORT/NAME/USER` + `DATABASE_PASSWORD_FILE` (взаимоисключимы с `DATABASE_URL`) |
она идёт первой: там дёшево и закрывает всё сразу. | | пароль сайдкара postgres | да | штатный `POSTGRES_PASSWORD_FILE` официального образа |
| **wakapi** `2.17.5` | salt, SMTP, OIDC | да | два пути: generic `_FILE` в `loadSecretFiles()` (с 2.17.2, в README список устарел) либо `config.yml` в `/app/config.yml` |
| **gitea** `1.27.0` | SMTP | да, частично | `GITEA__mailer__PASSWD__FILE`, работает для любого ключа `app.ini`, с 1.20 |
| **gramps** `26.7.0` | `GRAMPSWEB_SECRET_KEY` | да | entrypoint читает `/app/secret/secret`, если переменная не задана. **Путь уже смонтирован** в нашем compose |
| | SMTP | нет | обходной путь есть, но недокументированный: `config.cfg` исполняется Flask как Python, значит `EMAIL_HOST_PASSWORD = open(...).read()` сработает |
| **authelia** `4.39.20` | jwt-секрет, session, ключ хранилища, hmac OIDC, SMTP | да | `AUTHELIA_..._FILE` |
| | jwks-ключ, `client_secret` × 4 | нет через `_FILE` | `IsSecretKey()` отвергает ключи с `[]`. Покрывается template-фильтром: `X_AUTHELIA_CONFIG_FILTERS=template` + `{{ secret "путь" }}` (с 4.38) |
| **tududi** `1.2.4` | все четыре | **нет** | всё через `process.env`; ни `_FILE`, ни конфига, ни CLI |
| **wanderer** `0.18.3` | мастер-ключ meilisearch | **нет** | только CLI/env/инлайн в `config.toml`; апстрим отклонил запрос в 2023 |
| | ключ шифрования pocketbase | **нет** | собственная переменная wanderer, не штатный `--encryptionEnv` pocketbase; ENTRYPOINT в exec-форме, подстановку не сделать |
## Что из этого следует
Самое дешёвое — **gramps**: каталог `/app/secret` уже смонтирован, достаточно
записать туда значение и убрать переменную из `environment:`. Оба сервиса
(`gramps_app` и `gramps_celery`) читают тот же файл через общий entrypoint.
**Authelia** выгоднее переводить не на `_FILE`, а целиком на template-фильтр:
`_FILE` покрывает пять полей из семи, фильтр — все разом, и смешивать их в одном
поле нельзя (`errFmtSecretAlreadyDefined`). Отдельная находка: четыре
`client_secret` штатно хранятся **хэшем** `$pbkdf2-sha512$...` (у нас уже есть
`inv authelia-gen-secret-and-hash`), плейнтекст с 4.38 вызывает warning
валидатора. То есть эти поля можно вообще перестать считать секретами.
**Gitea** даёт частичный выигрыш: секрет уходит из `environment:`, `docker
inspect` и окружения процесса, но `environment-to-ini` пишет его открытым
текстом в `app.ini` на диске — открытая проблема апстрима (#35316).
**tududi и wanderer не умеют ничего.** Для них остаётся либо принять env, либо
перенести переменные в файл на диске: у tududi `backend/app.js` зовёт `dotenv`
при рабочей директории `/app/backend`, поэтому смонтированный туда `.env` уберёт
секреты хотя бы из `docker inspect` — в отличие от `env_file:`, где они попадают
в `Config.Env`. У wanderer доступен только `env_file:`, то есть косметика.
Попутная находка в закомментированном OIDC-блоке wakapi (ловушка на будущее, не
живой баг): имена `WAKAPI_OIDC_PROVIDER_*` устарели, в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_(\d+)_(...)`; кроме того `renameEnvVars()` режет строку по
первому `=`, так что секрет с base64-паддингом через env поедет битым. Оба
довода — за `config.yml`.
## 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`.
## Границы задачи
Она про то, где секрет **лежит в рантайме** (env против файла). Про то, чтобы он
не печатался в терминал при деплое, — [no-secrets-in-playbook-output](no-secrets-in-playbook-output.md),
и она идёт первой: `no_log` дёшев, закрывает все приложения разом и не зависит от
этой матрицы. Для tududi и wanderer он вообще единственный ответ.
Матрица показала, что задача переросла в эпик: каждая строка — отдельное
приложение со своим механизмом, и каждое мерджится независимо и само по себе
даёт пользу. Договорились разбить по приложениям отдельным заходом (2026-07-25);
до этого задачу в работу не брать целиком.
Из sec-ревью (fable, 2026-07-13). Связка с Из sec-ревью (fable, 2026-07-13). Связка с
[service-users-docker-group](service-users-docker-group.md). [service-users-docker-group](service-users-docker-group.md).
Связано: files/outline, files/wakapi, files/tududi, files/wanderer, files/gramps, Связано: files/outline, files/wakapi, files/tududi, files/wanderer, files/gramps,
files/gitea, files/miniflux (образец), roles/secrets. files/gitea, files/authelia, files/miniflux (образец), roles/secrets.