mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🔒 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:
1 parent
3e790af6e5
commit
ac824e192c
7 files changed
+127
-28
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
|
||||
# не зависит от того, из какого каталога её запустили, и не упирается в
|
||||
# владельца каталога на хосте.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user