🔒 feat(deploy): три тома секретов и разрешение upstream в nginx на каждом запросе

Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.

nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-12 14:37:38 +03:00
1 parent 3e790af6e5
commit ac824e192c
7 files changed
+127 -28

No files matched your search

+2 -2
View File
@@ -311,13 +311,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
<details>
<summary><strong>Do not delete the secrets volume</strong></summary>
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
</details>
<details>
<summary><strong>Backup</strong></summary>
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+2 -2
View File
@@ -311,13 +311,13 @@ docker compose ps
<details>
<summary><strong>Не удаляйте том с секретами</strong></summary>
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
</details>
<details>
<summary><strong>Резервная копия</strong></summary>
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
```bash
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
+20 -3
View File
@@ -35,6 +35,8 @@ services:
restart: "no"
volumes:
- chatballs-secrets:/run/chatballs/secrets
- chatballs-secrets-platform:/run/chatballs/secrets/platform
- chatballs-secrets-schema:/run/chatballs/secrets/schema
postgres:
image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev}
@@ -42,18 +44,20 @@ services:
environment:
POSTGRES_DB: ${POSTGRES_DB:-chatballs}
POSTGRES_USER: ${POSTGRES_USER:-chatballs_bootstrap}
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/postgres_password
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_password
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-chatballs_app}
POSTGRES_APP_PASSWORD_FILE: /run/chatballs/secrets/postgres_app_password
POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:-chatballs_platform}
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/postgres_platform_password
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/platform/postgres_platform_password
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:-chatballs_migration}
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/postgres_migration_password
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_migration_password
depends_on:
secrets:
condition: service_completed_successfully
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
- chatballs-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
@@ -81,6 +85,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
CHATBALLS_DB_ROLE: migration
@@ -103,6 +108,8 @@ services:
environment:
CHATBALLS_DB_ROLE: app
volumes:
# Только общий том: паролей platform и migration у публичного процесса
# нет даже в файловой системе (они в своих томах, см. сервис secrets).
- chatballs-secrets:/run/chatballs/secrets:ro
# Локальные файлы — именованный том, а не каталог с хоста. Том наследует
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
@@ -132,6 +139,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_platform:application
@@ -167,6 +175,7 @@ services:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_admin:application
@@ -194,8 +203,12 @@ services:
command: ["python", "manage.py", "run_worker"]
environment:
CHATBALLS_DB_ROLE: app
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
# platform. У backend-app его нет: он работает только ролью app.
CHATBALLS_DB_PLATFORM_ALIAS: "1"
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-media:/app/apps/backend/media
depends_on:
backend-app:
@@ -306,7 +319,11 @@ services:
volumes:
# Секреты инстанса: генерируются при первом старте, живут только здесь.
# Три тома, чтобы каждый процесс видел лишь свои пароли: общий, роли
# platform и роли миграций (вместе с паролем владельца кластера).
chatballs-secrets:
chatballs-secrets-platform:
chatballs-secrets-schema:
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
# не зависит от того, из какого каталога её запустили, и не упирается в
# владельца каталога на хосте.
+13
View File
@@ -112,6 +112,19 @@ cmd_doctor() {
_doctor_report 0 "compose config invalid"
fi
# Разделение томов с секретами: у публичного backend-app не должно быть
# паролей ролей platform и migration даже в файловой системе. Проверяется
# только на работающем стеке; до первого запуска проверять нечего.
if run_compose ps --status running --services 2>/dev/null | grep -qx backend-app; then
if run_compose exec -T backend-app sh -c \
'for f in platform/postgres_platform_password schema/postgres_migration_password schema/postgres_password postgres_platform_password postgres_migration_password postgres_password; do test ! -s "/run/chatballs/secrets/$f" || exit 1; done' \
>/dev/null 2>&1; then
_doctor_report 1 "backend-app sees no platform/migration database passwords"
else
_doctor_report 0 "backend-app can read platform or migration database passwords"
fi
fi
if [[ "$failures" != "0" ]]; then
die "doctor: $failures check(s) failed" 1
fi
+11 -3
View File
@@ -2,6 +2,14 @@ server {
listen 80;
server_name _;
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
# по истечении valid.
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_app backend-app:8000;
root /usr/share/nginx/html;
index index.html;
@@ -15,7 +23,7 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
location = /chat-widget.js {
proxy_pass http://backend-app:8000/chat-widget.js;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -32,7 +40,7 @@ server {
}
location /api/ {
proxy_pass http://backend-app:8000/api/;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -42,7 +50,7 @@ server {
# WebSocket signaling звонков (SPEC-CHATBALLS-0013 §9).
location /ws/ {
proxy_pass http://backend-app:8000/ws/;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+18 -7
View File
@@ -1,3 +1,8 @@
# Имена сервисов разрешаются на каждом запросе через DNS Docker, а не один
# раз при старте nginx: после пересоздания контейнера (compose up, обновление
# образа) адрес меняется, и закэшированный IP уводил бы трафик на другой
# сервис. Для этого upstream задан переменной — с ней nginx резолвит заново
# по истечении valid.
# Vite HMR ходит по websocket на тот же origin, что и приложение, поэтому
# location / должен уметь апгрейдить соединение. Обычные запросы при этом
# закрывают connection как раньше.
@@ -9,6 +14,10 @@ map $http_upgrade $connection_upgrade {
server {
listen 80;
server_name app.localhost localhost *.localhost;
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_app backend-app:8000;
set $web_chat web-chat:5175;
set $frontend frontend:5173;
location = /admin {
return 404;
@@ -19,14 +28,14 @@ server {
}
location /api/ {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
@@ -35,14 +44,14 @@ server {
}
location = /chat-widget.js {
proxy_pass http://backend-app:8000;
proxy_pass http://$backend_app;
proxy_set_header Host $host;
}
location /chat/ {
# Vite React Refresh injects an inline preamble in local development.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://web-chat:5175;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -54,7 +63,7 @@ server {
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
rewrite ^ /chat/call.html break;
proxy_pass http://web-chat:5175;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
}
@@ -63,7 +72,7 @@ server {
# local-only; production keeps the strict CSP from the canonical gateway.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Frame-Options SAMEORIGIN always;
proxy_pass http://frontend:5173;
proxy_pass http://$frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
@@ -76,9 +85,11 @@ server {
server {
listen 80;
server_name platform.localhost;
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_platform backend-platform:8000;
location /api/ {
proxy_pass http://backend-platform:8000;
proxy_pass http://$backend_platform;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+61 -11
View File
@@ -1,19 +1,33 @@
#!/bin/sh
# Секреты инстанса генерируются один раз при первом старте и живут в томе
# chatballs-secrets. В .env, в репозитории и в руках человека их нет: установка
# Секреты инстанса генерируются один раз при первом старте и живут в томах
# chatballs-secrets*. В .env, в репозитории и в руках человека их нет: установка
# — одна команда, всё остальное настраивается в UI.
#
# Томов три, и каждый процесс монтирует только свои:
# $DIR — общие: ключ подписи, ключ шифрования, пароль роли app,
# секрет TURN (том chatballs-secrets);
# $DIR/platform — пароль роли platform (chatballs-secrets-platform):
# платформенная поверхность и воркер;
# $DIR/schema — пароль роли миграций и владельца кластера
# (chatballs-secrets-schema): postgres, init, admin.
# Публичный backend-app видит только общий том — пароли, дающие обход RLS,
# ему недоступны даже при компрометации процесса.
#
# Скрипт идемпотентен: существующие файлы не трогает, поэтому перезапуск и
# обновление стека не меняют пароли уже работающей базы.
# обновление стека не меняют пароли уже работающей базы. Установки, где
# пароли лежали плоско в общем томе, переносятся сюда же: файл копируется в
# свой том, сверяется и только потом удаляется из общего.
set -eu
DIR="${CHATBALLS_SECRETS_DIR:-/run/chatballs/secrets}"
mkdir -p "$DIR"
# Том виден только контейнерам стека, но читают его разные
PLATFORM_DIR="$DIR/platform"
SCHEMA_DIR="$DIR/schema"
mkdir -p "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
# Тома видны только контейнерам стека, но читают их разные
# пользователи (postgres, backend), поэтому права как у docker
# secrets: каталог 0755, файлы 0444. Права выставляются на каждом
# запуске — старые установки чинятся сами.
chmod 755 "$DIR"
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
# 32 байта энтропии в hex. openssl есть в alpine/postgres-образах; /dev/urandom —
# запасной путь, если нет.
@@ -25,9 +39,33 @@ random_secret() {
fi
}
# Перенос плоского файла из общего тома в свой: только если в своём его ещё
# нет. Копия сверяется побайтно, и лишь после этого оригинал удаляется —
# обрыв на любом шаге оставляет как минимум один целый экземпляр.
relocate_secret() {
name="$1"
target_dir="$2"
source="$DIR/$name"
target="$target_dir/$name"
if [ -s "$target" ] || [ ! -s "$source" ]; then
return 0
fi
cp "$source" "$target"
if ! cmp -s "$source" "$target"; then
rm -f "$target"
echo "failed to relocate $name: copy mismatch" >&2
exit 1
fi
chmod 444 "$target"
chmod 644 "$source" 2>/dev/null || true
rm -f "$source"
echo "relocated $name to $(basename "$target_dir")/"
}
ensure_secret() {
name="$1"
file="$DIR/$name"
target_dir="${2:-$DIR}"
file="$target_dir/$name"
if [ -s "$file" ]; then
return 0
fi
@@ -37,14 +75,26 @@ ensure_secret() {
}
fix_permissions() {
chmod 444 "$DIR"/* 2>/dev/null || true
# Только файлы: каталоги platform/ и schema/ — точки монтирования своих
# томов, и без бита исполнения на них postgres и backend не войдут внутрь.
chmod 755 "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"
for d in "$DIR" "$PLATFORM_DIR" "$SCHEMA_DIR"; do
for f in "$d"/*; do
[ -f "$f" ] && chmod 444 "$f"
done
done
return 0
}
relocate_secret postgres_platform_password "$PLATFORM_DIR"
relocate_secret postgres_migration_password "$SCHEMA_DIR"
relocate_secret postgres_password "$SCHEMA_DIR"
ensure_secret secret_key
ensure_secret postgres_password
ensure_secret postgres_password "$SCHEMA_DIR"
ensure_secret postgres_app_password
ensure_secret postgres_platform_password
ensure_secret postgres_migration_password
ensure_secret postgres_platform_password "$PLATFORM_DIR"
ensure_secret postgres_migration_password "$SCHEMA_DIR"
# Общий секрет TURN: его знают приложение и coturn. Человек его не вводит —
# иначе пришлось бы вписывать одно и то же значение в двух местах.
ensure_secret turn_secret