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