- Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia, gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer отпал — файловых секретов не умеет ни meilisearch, ни pocketbase. - Матрица механизмов со ссылками на код уехала в docs/drafts/secrets-file-support.md, родитель — на кладбище.
204 lines
15 KiB
Markdown
204 lines
15 KiB
Markdown
# Файловые секреты: что умеет каждое приложение
|
||
|
||
Дата: 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`.
|