Files
pet-project-server/docs/drafts/secrets-file-support.md
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

204 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Файловые секреты: что умеет каждое приложение
Дата: 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`.