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:
@@ -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 L95–L143](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 L100–141](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 L99–121](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`.
|
||||
Reference in New Issue
Block a user