diff --git a/README.md b/README.md
index d702d56..b3eab63 100644
--- a/README.md
+++ b/README.md
@@ -311,13 +311,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
Do not delete the secrets volume
-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.
Backup
-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
diff --git a/README.ru.md b/README.ru.md
index 87671f2..dd1e05e 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -311,13 +311,13 @@ docker compose ps
Не удаляйте том с секретами
-В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
+В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
Резервная копия
-Копируйте тома `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
diff --git a/compose.yaml b/compose.yaml
index 58d1bde..d4dd501 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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:
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
# не зависит от того, из какого каталога её запустили, и не упирается в
# владельца каталога на хосте.
diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh
index 7c46e6d..d6de996 100644
--- a/deploy/cli/lib/doctor.sh
+++ b/deploy/cli/lib/doctor.sh
@@ -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
diff --git a/deploy/nginx/frontend.production.conf b/deploy/nginx/frontend.production.conf
index 5a37758..0e81c35 100644
--- a/deploy/nginx/frontend.production.conf
+++ b/deploy/nginx/frontend.production.conf
@@ -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;
diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf
index c1a0f79..9fd7bc8 100644
--- a/deploy/nginx/local.conf
+++ b/deploy/nginx/local.conf
@@ -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;
diff --git a/deploy/secrets/generate-instance-secrets.sh b/deploy/secrets/generate-instance-secrets.sh
index 01358c4..40c0a5b 100644
--- a/deploy/secrets/generate-instance-secrets.sh
+++ b/deploy/secrets/generate-instance-secrets.sh
@@ -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