From fbd0a66f44b8d038300c5b4fb371c6f1f947881b Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 25 Jul 2026 12:25:50 +0300 Subject: [PATCH] =?UTF-8?q?backlog:=20secrets-env-to-file=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia, gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer отпал — файловых секретов не умеет ни meilisearch, ни pocketbase. - Матрица механизмов со ссылками на код уехала в docs/drafts/secrets-file-support.md, родитель — на кладбище. --- docs/backlog/CLOSED.md | 2 + docs/backlog/README.md | 7 +- docs/backlog/narrow-secret-scopes.md | 2 +- docs/backlog/no-secrets-in-playbook-output.md | 9 +- docs/backlog/secrets-env-to-file.md | 91 -------- docs/backlog/secrets-file-authelia.md | 32 +++ docs/backlog/secrets-file-gitea.md | 24 +++ docs/backlog/secrets-file-gramps.md | 26 +++ docs/backlog/secrets-file-outline.md | 24 +++ docs/backlog/secrets-file-tududi.md | 27 +++ docs/backlog/secrets-file-wakapi.md | 28 +++ docs/backlog/service-users-docker-group.md | 4 +- docs/backlog/vault-password-storage.md | 5 +- docs/drafts/secrets-file-support.md | 203 ++++++++++++++++++ 14 files changed, 384 insertions(+), 100 deletions(-) create mode 100644 docs/backlog/CLOSED.md delete mode 100644 docs/backlog/secrets-env-to-file.md create mode 100644 docs/backlog/secrets-file-authelia.md create mode 100644 docs/backlog/secrets-file-gitea.md create mode 100644 docs/backlog/secrets-file-gramps.md create mode 100644 docs/backlog/secrets-file-outline.md create mode 100644 docs/backlog/secrets-file-tududi.md create mode 100644 docs/backlog/secrets-file-wakapi.md create mode 100644 docs/drafts/secrets-file-support.md diff --git a/docs/backlog/CLOSED.md b/docs/backlog/CLOSED.md new file mode 100644 index 0000000..c8c6ad5 --- /dev/null +++ b/docs/backlog/CLOSED.md @@ -0,0 +1,2 @@ +# Кладбище беклога +- 2026-07-25 `secrets-env-to-file` — Секреты приложений через `*_FILE`, а не inline `environment:`. Причина: разложена по приложениям на secrets-file-outline/-wakapi/-authelia/-gramps/-gitea/-tududi; матрица поддержки уехала в docs/drafts/secrets-file-support.md, wanderer отпал — файловых секретов не умеет. Был приоритет: средний. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c9f4826..28df5d9 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -25,7 +25,6 @@ _(sec-ревью)_ — три независимых security-ревью на м ## Средний -- [Секреты приложений через `*_FILE`, а не inline `environment:`](secrets-env-to-file.md) — outline/wakapi/tududi/gitea/gramps/tuwunel держат секреты в env; видны в `docker inspect` и `/proc` _(sec-ревью)_ - [Захардить мониторинг-стек (netdata / dozzle / goaccess)](harden-monitoring-stack.md) — netdata/dozzle: лишние привилегии + docker.sock + доверие Remote-User в общей сети; socket-proxy + monitoring_network _(sec-ревью)_ - [Ограничить эндпоинт `/metrics` Miniflux](miniflux-metrics-restrict.md) — `METRICS_ALLOWED_NETWORKS=0.0.0.0/0` + прокси без ограничения пути отдаёт метрики публично _(sec-ревью)_ - [Закрыть calibre-web forward-auth и проверить дефолтные креды](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_ @@ -39,6 +38,10 @@ _(sec-ревью)_ — три независимых security-ревью на м - [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_ - [Локальная сборка образов вместо Yandex Container Registry](local-image-build.md) — homepage/transcriber/remembos тянут образы из cr.yandex, ради `docker login` в vault лежит OAuth-токен всего аккаунта; роль `app_image` уже обкатана в umbar - [Вывести из оборота секреты с широкими правами](narrow-secret-scopes.md) — один `yc_oauth_token` открывает весь аккаунт Yandex Cloud; после инцидента 24 июля цена утечки одной переменной должна быть ограничена одним сервисом +- [outline: секреты из файлов](secrets-file-outline.md) — умеет generic `_FILE` для всех пяти секретов; единственное приложение, где выносится вообще всё, включая пароль БД +- [wakapi: секреты из файлов](secrets-file-wakapi.md) — salt, SMTP и OIDC выносятся либо через generic `_FILE`, либо через `config.yml`; для OIDC второй путь надёжнее из-за бага с `=` в значении +- [authelia: секреты из файлов](secrets-file-authelia.md) — пять полей закрывает `_FILE`, jwks-ключ и client secret — только template-фильтр; заодно четыре client secret можно перевести в хэш и перестать считать секретами +- [gramps: секретный ключ из файла](secrets-file-gramps.md) — entrypoint читает `/app/secret/secret`, если переменная не задана, и этот путь уже смонтирован — самая дешёвая правка из всей серии ## Низкий @@ -50,3 +53,5 @@ _(sec-ревью)_ — три независимых security-ревью на м - [Фоновая зачистка стиля и конфигурации Ansible](ansible-style-nits.md) — sudoers.d, профиль ansible-lint, `ansible.cfg`, кавычки, `cache_valid_time` _(ансибл-ревью)_ - [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_ - [[idea] Где хранить пароль ansible-vault](vault-password-storage.md) — пароль открытым текстом рядом с репо, `ansible.cfg` подставляет его в любую ansible-команду; вариант — доставать из KeePassXC +- [gitea: пароль SMTP из файла](secrets-file-gitea.md) — умеет `GITEA__mailer__PASSWD__FILE`, но пишет значение открытым текстом в `app.ini` — выигрыш только против `docker inspect` +- [tududi: секреты в смонтированный `.env`](secrets-file-tududi.md) — файловых секретов не умеет вовсе; единственный путь — смонтировать `.env` под `dotenv`, чтобы значения не попадали в `Config.Env` diff --git a/docs/backlog/narrow-secret-scopes.md b/docs/backlog/narrow-secret-scopes.md index ea21d5e..bda4b3a 100644 --- a/docs/backlog/narrow-secret-scopes.md +++ b/docs/backlog/narrow-secret-scopes.md @@ -39,4 +39,4 @@ transcriber и remembos. Радиус прав несопоставим с за Связано: group_vars/all/secrets.yml, playbook-homepage.yml, playbook-transcriber.yml, playbook-remembos.yml, [vault-password-storage](vault-password-storage.md), -[secrets-env-to-file](secrets-env-to-file.md). +[справка по файловым секретам](../drafts/secrets-file-support.md). diff --git a/docs/backlog/no-secrets-in-playbook-output.md b/docs/backlog/no-secrets-in-playbook-output.md index f2c3ed1..4768615 100644 --- a/docs/backlog/no-secrets-in-playbook-output.md +++ b/docs/backlog/no-secrets-in-playbook-output.md @@ -28,7 +28,7 @@ miniflux деплоится молча, а остальные — нет. - **apprise** — `server.template.cfg`: токен бота Telegram, SMTP - **remembos** — `config.template.toml`: токены memos и Telegram - **outline, tududi, wakapi, wanderer, gramps, gitea** — секреты в - `environment:` docker-compose, см. [secrets-env-to-file](secrets-env-to-file.md) + `environment:` docker-compose, см. серию задач `secrets-file-*` Чисто: miniflux (образец), memos, calibre, tuwunel, dashboard, caddy, netdata, dozzle, goaccess, rssbridge, homepage. @@ -48,8 +48,8 @@ dozzle, goaccess, rssbridge, homepage. для файлов, состоящих из одних секретов, слабее. - **Структурный вынос** — секрет живёт в отдельном файле, который рендерит роль `secrets` под `no_log`, а основной файл ссылается на путь. Тогда дифф основного - файла остаётся полезным. Это [secrets-env-to-file](secrets-env-to-file.md), но - она про `environment:` и не покрывает конфиги. + файла остаётся полезным. Это серия `secrets-file-*`, но она про `environment:` + и не покрывает конфиги. - **Убрать `--diff` из `inv pl`** или выставить глобальный `ANSIBLE_NO_LOG` — режет обзор на всех задачах разом ради десятка проблемных. Не годится. @@ -60,7 +60,8 @@ dozzle, goaccess, rssbridge, homepage. 2. Где файл смешанный (compose, конфиг authelia) — вернуться и вынести секреты в отдельные файлы, сняв `no_log` с основного: он нужен ради диффа, который сейчас и теряется. Что именно умеет каждое приложение — матрица в - [secrets-env-to-file](secrets-env-to-file.md), сверенная по исходникам. Для + [справке](../drafts/secrets-file-support.md), сверенная по исходникам, и + задачи `secrets-file-*` по одной на приложение. Для authelia (конфиг на тысячу строк, дифф там самый ценный) ответ такой: пять полей закрываются `AUTHELIA_..._FILE`, оставшиеся два — только template-фильтром, а четыре `client_secret` штатно хранятся хэшем и тогда diff --git a/docs/backlog/secrets-env-to-file.md b/docs/backlog/secrets-env-to-file.md deleted file mode 100644 index ce7088b..0000000 --- a/docs/backlog/secrets-env-to-file.md +++ /dev/null @@ -1,91 +0,0 @@ -# Секреты приложений через `*_FILE`, а не inline `environment:` - -**Приоритет:** средний - -miniflux сделан правильно — роль `secrets` кладёт каждую переменную vault в -отдельный файл, каталог монтируется `:ro`, а compose ссылается на `*_FILE` -(`files/miniflux/docker-compose.template.yml`). Остальные держат секреты прямыми -значениями в `environment:`: их читает любой член группы `docker`, любой процесс -с доступом к `/proc//environ`, и они попадают в `docker inspect`. - -tuwunel из списка убран: в его compose секретов нет (сверено 2026-07-25). memos и -calibre тоже чистые. - -## Матрица поддержки (проверено по исходникам, 2026-07-25) - -Проверялись **закреплённые в compose версии**, не `main`. - -| Приложение | Секрет | Файл | Механизм | -|---|---|---|---| -| **outline** `1.9.2` | `SECRET_KEY`, `UTILS_SECRET`, OIDC, SMTP | да | generic: `environment.ts` оборачивает `process.env` в Proxy, пустая переменная читается из `_FILE`. С v1.7.0 | -| | пароль 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/`, -то есть **тот же способ доставки**, что и `*_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). Связка с -[service-users-docker-group](service-users-docker-group.md). - -Связано: files/outline, files/wakapi, files/tududi, files/wanderer, files/gramps, -files/gitea, files/authelia, files/miniflux (образец), roles/secrets. diff --git a/docs/backlog/secrets-file-authelia.md b/docs/backlog/secrets-file-authelia.md new file mode 100644 index 0000000..8bdbe89 --- /dev/null +++ b/docs/backlog/secrets-file-authelia.md @@ -0,0 +1,32 @@ +# authelia: секреты из файлов + +**Приоритет:** Средний + +В `configuration.template.yml` прямыми значениями стоят семь позиций: +jwt-секрет валидации, секрет сессий, ключ шифрования хранилища, hmac-секрет OIDC, +приватный jwks-ключ, четыре `client_secret` и пароль SMTP. Файл на тысячу строк — +дифф по нему самый ценный в репозитории, и именно его сейчас приходится глушить +целиком. + +Пять полей закрываются переменными `AUTHELIA_..._FILE`. Оставшиеся два — +`jwks[].key` и `client_secret` клиентов — через `_FILE` невозможны в принципе: +валидатор отвергает ключи, содержащие `[]`. Их покрывает template-фильтр +(`X_AUTHELIA_CONFIG_FILTERS=template` + `{{ secret "путь" }}`). Механизмы +несовместимы в одном поле, поэтому проще выбрать фильтр — он берёт все семь +позиций разом, `_FILE` только пять. + +Отдельная и более дешёвая половина задачи: четыре `client_secret` штатно хранятся +**хэшем** `$pbkdf2-sha512$...` — плейнтекст с 4.38 вызывает warning валидатора и +нужен только при `client_secret_jwt`, чего у наших клиентов нет. После перевода +эти поля перестают быть секретами вообще. Генератор уже есть — +`inv authelia-gen-secret-and-hash`, проверка — `inv authelia-validate-config`. + +Готово, когда в отрендеренном конфиге не осталось секретов открытым текстом и +`no_log` с задачи снят — дифф конфига снова читаем. + +Разбор по каждому полю, версии и ссылки на код: +[справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/authelia, tasks.py (`authelia-*`), roles/secrets, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/secrets-file-gitea.md b/docs/backlog/secrets-file-gitea.md new file mode 100644 index 0000000..78e9708 --- /dev/null +++ b/docs/backlog/secrets-file-gitea.md @@ -0,0 +1,24 @@ +# gitea: пароль SMTP из файла + +**Приоритет:** Низкий + +Единственный секрет gitea в compose — пароль SMTP. Механизм есть: +`GITEA__mailer__PASSWD__FILE` работает для любого ключа `app.ini` начиная с 1.20, +у нас 1.27.0. + +Приоритет низкий из-за оговорки, которая съедает половину пользы: +`environment-to-ini` **записывает содержимое файла в `app.ini` открытым текстом**, +то есть пароль всё равно оседает на диске в `/gitea/conf/app.ini`. Это +открытая проблема апстрима (#35316), исправления нет. Выигрыш реальный, но +частичный — секрет уходит из `environment:`, `docker inspect` и окружения +процесса, а с диска не уходит. + +Готово, когда `docker inspect` контейнера gitea не показывает пароль SMTP. Что он +остался в `app.ini` — известное ограничение, а не незакрытая часть задачи. + +Механизм и ссылки на код: +[справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/gitea, roles/secrets, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/secrets-file-gramps.md b/docs/backlog/secrets-file-gramps.md new file mode 100644 index 0000000..803c5c8 --- /dev/null +++ b/docs/backlog/secrets-file-gramps.md @@ -0,0 +1,26 @@ +# gramps: секретный ключ из файла + +**Приоритет:** Средний + +`GRAMPSWEB_SECRET_KEY` стоит прямым значением в `environment:`. Если переменную +не задавать, entrypoint образа читает ключ из файла `/app/secret/secret` — и этот +путь **уже смонтирован** в нашем compose как `gramps_secret`. То есть правка +сводится к «записать значение в файл и убрать переменную»: самая дешёвая из всей +серии. Entrypoint общий у `gramps_app` и `gramps_celery`, оба читают тот же файл, +расхождения не будет. + +Пароль SMTP так не выносится: конфиг читается через `from_prefixed_env`, никакой +обработки `_FILE` нет. Обходной путь существует (Flask исполняет `config.cfg` как +Python, значит значение можно прочитать из файла прямо в нём), но он держится на +детали реализации и нигде не обещан — брать только если решим, что оно того +стоит. Пока пароль SMTP остаётся в env и закрывается `no_log`. + +Готово, когда `docker inspect` контейнеров gramps не показывает секретный ключ +приложения. + +Механизм и ссылки на код: +[справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/gramps, roles/secrets, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/secrets-file-outline.md b/docs/backlog/secrets-file-outline.md new file mode 100644 index 0000000..ca546aa --- /dev/null +++ b/docs/backlog/secrets-file-outline.md @@ -0,0 +1,24 @@ +# outline: секреты из файлов + +**Приоритет:** Средний + +Сейчас в `environment:` стоят прямыми значениями пароль postgres (внутри +`DATABASE_URL`), `SECRET_KEY`, `UTILS_SECRET`, секрет OIDC и пароль SMTP. Outline +`1.9.2` умеет generic-механизм: пустая переменная читается из `_FILE`, +поэтому все пять закрываются по образцу miniflux — роль `secrets` + монтирование +каталога `:ro`. + +Отдельный шаг внутри задачи — пароль БД: `DATABASE_URL_FILE` выносит **весь** URL +одним куском, и чтобы вынести именно пароль, нужен переход на раздельные +`DATABASE_HOST/PORT/NAME/USER` (они взаимоисключимы с `DATABASE_URL`). Заодно +сайдкар postgres переводится на штатный `POSTGRES_PASSWORD_FILE`. + +Готово, когда `docker inspect` контейнеров outline и его postgres не показывает +ни одного секрета, а в compose-шаблоне не осталось jinja-переменных с секретами — +и, как следствие, дифф этого шаблона снова можно смотреть без `no_log`. + +Механизм и ссылки на код: [справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/outline, files/miniflux (образец), roles/secrets, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/secrets-file-tududi.md b/docs/backlog/secrets-file-tududi.md new file mode 100644 index 0000000..bd2efbc --- /dev/null +++ b/docs/backlog/secrets-file-tududi.md @@ -0,0 +1,27 @@ +# tududi: секреты в смонтированный `.env` + +**Приоритет:** Низкий + +Tududi `1.2.4` не умеет файловых секретов вообще: session secret, секрет OIDC, +ключ LLM и пароль SMTP читаются напрямую из `process.env`, конфиг-файла и +CLI-опций нет. Проверено грепом по репозиторию на теге, не по докам. + +Единственный доступный путь — не `env_file:` (при нём значения всё равно попадают +в `Config.Env`), а **смонтированный файл**: `backend/app.js` вызывает `dotenv`, а +рабочая директория контейнера — `/app/backend`, значит `.env` можно положить туда +томом. Тогда секреты не видны в `docker inspect`, но остаются в окружении +процесса и в `/proc//environ`. + +Приоритет низкий именно поэтому: механизм нештатный (держится на том, что +`dotenv` остаётся в prod-зависимостях образа), а закрывает только один вектор из +трёх. Если апстрим когда-нибудь добавит `_FILE` — задача переписывается на него. + +Готово, когда `docker inspect` контейнера tududi не показывает ни одного из +четырёх секретов. + +Разбор и ссылки на код: +[справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/tududi, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/secrets-file-wakapi.md b/docs/backlog/secrets-file-wakapi.md new file mode 100644 index 0000000..3d9abe7 --- /dev/null +++ b/docs/backlog/secrets-file-wakapi.md @@ -0,0 +1,28 @@ +# wakapi: секреты из файлов + +**Приоритет:** Средний + +Сейчас в `environment:` прямыми значениями стоят salt паролей и пароль SMTP, +а в закомментированном блоке — секрет OIDC. Wakapi `2.17.5` даёт два пути, и +выбрать надо один: + +- **generic `_FILE`** — `loadSecretFiles()` разворачивает любую переменную с + таким суффиксом. Ближе к образцу miniflux, меньше движений. +- **`config.yml`**, смонтированный в `/app/config.yml` — покрывает те же три + поля декларативно. + +Для OIDC-секрета второй путь надёжнее: `renameEnvVars()` режет значение по +первому `=`, то есть секрет с base64-паддингом через env приедет битым. Там же +при включении OIDC надо поправить имена переменных — в 2.17.5 разбирается +`WAKAPI_OIDC_PROVIDERS_0_*`, а у нас закомментирован устаревший вариант, который +просто молча игнорируется. + +Готово, когда `docker inspect` контейнера wakapi не показывает ни salt, ни пароль +SMTP, а дифф шаблона можно смотреть без `no_log`. + +Механизм, версии и ссылки на код: +[справка по файловым секретам](../drafts/secrets-file-support.md). +Из декомпозиции `secrets-env-to-file` (2026-07-25). + +Связано: files/wakapi, files/miniflux (образец), roles/secrets, +[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md). diff --git a/docs/backlog/service-users-docker-group.md b/docs/backlog/service-users-docker-group.md index b1930ce..a2ce6ab 100644 --- a/docs/backlog/service-users-docker-group.md +++ b/docs/backlog/service-users-docker-group.md @@ -13,6 +13,8 @@ gitea и т.д.) фактически все имеют root — изоляци тем — лучше через socket-proxy). За `primary_user` группу оставить для ручных операций (`inv ssh`). -Из sec-ревью (fable, 2026-07-13). Усиливает риск из [secrets-env-to-file](secrets-env-to-file.md). +Из sec-ревью (fable, 2026-07-13). Усиливает риск из серии `secrets-file-*` +(секреты в `environment:` контейнеров): членство в группе `docker` — как раз тот +доступ, которым такие переменные и вычитываются. Связано: все playbook-*.yml (owner_extra_groups), roles/owner, playbook-docker.yml. diff --git a/docs/backlog/vault-password-storage.md b/docs/backlog/vault-password-storage.md index 51023bb..3205cfe 100644 --- a/docs/backlog/vault-password-storage.md +++ b/docs/backlog/vault-password-storage.md @@ -39,8 +39,9 @@ enforcement-слой применяет снаружи. Что смена хра случайного дампа, от копии `~` в чужих руках, или от агента. Ответы ведут к разным вариантам, а третий не решается на этом уровне вообще. -Смежное: [secrets-env-to-file](secrets-env-to-file.md) — тот же вопрос слоем ниже -(секреты в `environment:` контейнеров). Отдельно не заведена, но обсуждалась, +Смежное: серия `secrets-file-*` — тот же вопрос слоем ниже (секреты в +`environment:` контейнеров), разбор механизмов в +[справке](../drafts/secrets-file-support.md). Отдельно не заведена, но обсуждалась, идея скоупинга: вернуть `group_vars/all/secrets.yml` в `vars_files`, оставив `main.yml` в `group_vars` — это режет потолок утечки с 53 переменных до ~11, ценой возврата boilerplate в плейбуки. diff --git a/docs/drafts/secrets-file-support.md b/docs/drafts/secrets-file-support.md new file mode 100644 index 0000000..4ce2e54 --- /dev/null +++ b/docs/drafts/secrets-file-support.md @@ -0,0 +1,203 @@ +# Файловые секреты: что умеет каждое приложение + +Дата: 2026-07-25. Статус: справка по итогам проверки исходников (не план работ). + +Проверялись **закреплённые в наших compose-файлах версии**, не `main`. Источник — +код парсинга конфига в репозиториях приложений; доки использовались как +подтверждение, а не как основание. Из этой справки выросли задачи +`secrets-file-*` в беклоге, по одной на приложение. + +## Зачем + +Секреты стоят прямыми значениями в `environment:` docker-compose. Такие +переменные читает любой член группы `docker`, любой процесс с доступом к +`/proc//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 `_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: если переменная +пуста, читается `_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` открытым текстом**, то есть секрет всё равно оседает на +хостовом диске в `/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/`, то +есть **тот же способ доставки**, что и `*_FILE`. Приложение, умеющее только +переменную окружения, из `/run/secrets` не прочитает: ограничение в приложении, а +не в доставке. На матрицу выше не влияет. + +Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux +сейчас `{{ secrets_dir }}:/secrets:ro` висит на обоих сервисах, и postgres видит +все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение +поверх образца, а не замена ему. `uid`/`gid`/`mode` вне swarm игнорируются — +права остаются с хост-файла, их ставит роль `secrets`.