Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений

This commit is contained in:
mi
2026-08-19 18:24:00 +03:00
parent bbef7a30c9
commit c7a80e7256
103 changed files with 3457 additions and 3725 deletions
@@ -0,0 +1,5 @@
*.sh text eol=lf
preflight.sh text eol=lf
secrets/han-compose text eol=lf
secrets/han-secrets text eol=lf
*.service text eol=lf
@@ -1,826 +1,11 @@
# Подробная инструкция по развертыванию и запуску HAN Chat
# Legacy deployment guide — не использовать в production
Эта инструкция описывает первый запуск **текущего legacy/stub проекта ВМ1** на одной виртуальной
машине с Ubuntu 24.04. Она не разворачивает target ВМ2 Processing и не подтверждает production-готовность Message Safety v2. Все команды предполагают, что проект расположен в
`/opt/han-chat/backend`, а команды Docker Compose выполняются из этого каталога.
Этот файл сохранён только как совместимый указатель для старых ссылок.
Single-VM/stub команды удалены: они выдавали `deploy` root-equivalent доступ к
Docker, изменяли production-каталог и не обеспечивали target cutover ВМ1→ВМ2.
PostgreSQL и Selectel S3 не запускаются в Docker Compose: их необходимо создать
заранее как внешние управляемые сервисы. Из интернета должны быть доступны только
порты 80 и 443 виртуальной машины.
Для fresh production ВМ1 используйте только
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
## 1. Что потребуется до начала работы
Подготовьте:
1. Виртуальную машину с Ubuntu 24.04 и минимум 4 vCPU, 8 ГБ RAM и 40 ГБ диска.
2. SSH-доступ к VM пользователем с правом `sudo`.
3. Домен, например `chat.example.ru`, и возможность изменить его DNS.
4. Управляемый PostgreSQL, доступный VM по приватной сети.
5. Три приватных бакета Selectel S3.
6. Учетные данные приложения Bitrix24.
7. При необходимости — удаленный OTLP-бэкенд для телеметрии.
8. Локальную копию каталога `HAN_chat_specification/codebase/backend` либо URL
Git-репозитория, из которого его можно получить.
Для первого тестового запуска допустимы mock OTP, заглушка Message Safety и
заглушка bitrix-sync. Они не являются полноценными production-реализациями.
Целевой cutover выполняется по `modules/module-10-deployment-runbook.md`: самостоятельная ВМ2, root Compose/systemd unit, собственный nginx с public exact CRM webhook `80/443` и private Message Safety listener `8443`, раздельные TLS-контуры, secrets/IAM, egress allow-list и local OTEL Collector. Не переносите команды этого single-VM guide на ВМ2 без VM2-specific manifests.
## 2. Первичный вход на VM
Подключитесь к созданной VM облачным пользователем:
```sh
ssh <cloud-user>@<VM_IP>
```
Проверьте версию ОС:
```sh
cat /etc/os-release
```
Должна использоваться Ubuntu 24.04 или более новая версия.
## 3. Передача и запуск скрипта настройки VM
Сначала передайте на VM только подготовительный скрипт. Например, с локального
компьютера:
```sh
scp deployment/scripts/setup-vm.sh <cloud-user>@<VM_IP>:/tmp/setup-vm.sh
```
На VM выполните:
```sh
chmod +x /tmp/setup-vm.sh
sudo /tmp/setup-vm.sh
```
Скрипт:
- обновит Ubuntu и установит базовые пакеты;
- создаст пользователя `deploy`;
- установит Docker Engine и Docker Compose;
- настроит UFW, fail2ban и цепочку `DOCKER-USER`;
- откроет только SSH, HTTP и HTTPS;
- создаст `/opt/han-chat/backend`;
- создаст swap;
- включит автоматические обновления безопасности;
- отключит парольный SSH-вход и X11 forwarding;
- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей.
Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до
блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход
пользователем `root` и SSH TCP forwarding; включайте этот режим только после
проверки входа пользователем `deploy` по ключу в отдельной сессии.
Если SSH работает на нестандартном порту или имя внешнего интерфейса известно
заранее, передайте параметры:
```sh
sudo SSH_PORT=2222 EXTERNAL_IF=ens3 /tmp/setup-vm.sh
```
После завершения выйдите из SSH-сессии: членство `deploy` в группе `docker`
начинает действовать только после нового входа.
```sh
exit
ssh deploy@<VM_IP>
docker version
docker compose version
```
## 4. Копирование проекта на VM
### Вариант A — через Git
Это предпочтительный вариант: Git применит правило LF для shell-скриптов.
```sh
git clone <URL_РЕПОЗИТОРИЯ> /tmp/han-chat-source
cp -a /tmp/han-chat-source/HAN_chat_specification/codebase/backend/. \
/opt/han-chat/backend/
cd /opt/han-chat/backend
```
Если `HAN_chat_specification` является корнем репозитория:
```sh
cp -a /tmp/han-chat-source/codebase/backend/. /opt/han-chat/backend/
```
### Вариант B — архивом с локального компьютера
Создайте архив именно из содержимого каталога `backend`, включая скрытые файлы:
```sh
tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz .
scp han-chat-backend.tar.gz deploy@<VM_IP>:/tmp/
```
На VM:
```sh
cd /opt/han-chat/backend
tar -xzf /tmp/han-chat-backend.tar.gz
# Обязательно при копировании с Windows:
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
```
Проверьте наличие точки запуска:
```sh
test -f /opt/han-chat/backend/docker-compose.yml
test -f /opt/han-chat/backend/.env.example
```
## 5. Настройка DNS и сетевого доступа
Создайте DNS-запись:
```text
chat.example.ru A <ПУБЛИЧНЫЙ_IP_VM>
```
Дождитесь обновления DNS:
```sh
getent ahostsv4 chat.example.ru
```
В облачной группе безопасности VM разрешите входящие подключения:
- TCP 80 из интернета;
- TCP 443 из интернета;
- SSH только из доверенной сети или с административного IP.
Не открывайте наружу порты 6379, 4317, 4318, 8000, 8080 и 9000.
В группе безопасности PostgreSQL разрешите входящий трафик на порт PostgreSQL
только от приватного адреса или группы безопасности VM.
## 6. Подготовка управляемого PostgreSQL
Создайте одну базу данных:
```text
han_chat
```
В ней нужны схемы:
```text
han_app
bitrix_local
bitrix_sync
message_safety
keycloak
```
Для текущей MVP-реализации используются следующие пользователи:
```text
han_app
bitrix_local_app
bitrix_sync_user
message_safety_app
keycloak_user
```
Создать пользователей и схемы можно через панель провайдера либо от имени
администратора PostgreSQL. Пример SQL:
```sql
CREATE ROLE han_app LOGIN PASSWORD '<HAN_APP_PASSWORD>';
CREATE ROLE bitrix_local_app LOGIN PASSWORD '<BITRIX_LOCAL_PASSWORD>';
CREATE ROLE bitrix_sync_user LOGIN PASSWORD '<BITRIX_SYNC_PASSWORD>';
CREATE ROLE message_safety_app LOGIN PASSWORD '<SAFETY_PASSWORD>';
CREATE ROLE keycloak_user LOGIN PASSWORD '<KEYCLOAK_PASSWORD>';
CREATE SCHEMA IF NOT EXISTS han_app AUTHORIZATION han_app;
CREATE SCHEMA IF NOT EXISTS bitrix_local AUTHORIZATION bitrix_local_app;
CREATE SCHEMA IF NOT EXISTS bitrix_sync AUTHORIZATION bitrix_sync_user;
CREATE SCHEMA IF NOT EXISTS message_safety AUTHORIZATION message_safety_app;
CREATE SCHEMA IF NOT EXISTS keycloak AUTHORIZATION keycloak_user;
GRANT CONNECT ON DATABASE han_chat TO
han_app, bitrix_local_app, bitrix_sync_user, message_safety_app, keycloak_user;
```
Текущие migration jobs используют те же DSN, что и сервисы. Поэтому владельцы
схем должны иметь право создавать таблицы в своих схемах. Для более строгого
production-разделения migration/runtime ролей потребуется отдельная настройка
DSN и прав, которой в текущем `.env.example` нет.
Скачайте CA-сертификат PostgreSQL у провайдера и поместите его на VM:
```sh
mkdir -p /opt/han-chat/backend/secrets/pg
cp /путь/к/ca.pem /opt/han-chat/backend/secrets/pg/ca.pem
chmod 644 /opt/han-chat/backend/secrets/pg/ca.pem
```
CA-сертификат не является секретом. Права `644` нужны, чтобы его могли прочитать
контейнеры, работающие не от root.
Проверьте сетевую доступность:
```sh
nc -vz <PG_HOST> 6432
```
Замените `6432` на фактический порт провайдера.
## 7. Подготовка Selectel S3
Создайте три приватных бакета:
```text
han-chat-quarantine
han-chat-attachments
han-chat-documents
```
Создайте две пары ключей:
1. Ключ API с правом чтения и записи в бакеты.
2. Отдельный ключ Message Safety только с правом чтения карантина.
Для бакетов запретите публичный доступ. Для браузерной загрузки настройте CORS:
- Allowed origin: `https://chat.example.ru`;
- Methods: `PUT`, `GET`, `HEAD`;
- Headers: `Content-Type`, `x-amz-*`;
- Expose header: `ETag`.
Для карантина задайте lifecycle удаления объектов с запасом относительно
`MESSAGE_SAFETY_TASK_TTL_SEC`.
## 8. Создание файла окружения
`.env` содержит только несекретную конфигурацию. На VM:
```sh
cd /opt/han-chat/backend
umask 077
cp .env.example .env
chmod 600 .env
nano .env
```
Замените несекретные адреса `example.*`. Не добавляйте в `.env` пароли, токены,
ключи, credential-bearing DSN или пути `*_FILE`. Установите отдельно проверенный
launcher `deployment/secrets/han-secrets` из ops-пакета. Его интерфейс:
```sh
deployment/secrets/han-secrets run --config .env -- <command>
```
Launcher читает `SECRETS_SOURCE=file|selectel`, устанавливает
`HAN_SECRETS_ACTIVE=1`, выдаёт значения только дочернему процессу и не печатает
их. Рекомендуемый `HAN_RUNTIME_SECRET_MANIFEST` содержит только пары
`SECRET_KEY=/absolute/protected/path`; файлы имеют mode `0400`/`0600`.
### 8.1. Основные адреса
Для домена `chat.example.ru`:
```dotenv
APP_ENV=production-like
RELEASE_VERSION=2026-07-13-1
PUBLIC_HOST=chat.example.ru
PUBLIC_WEB_URL=https://chat.example.ru
PUBLIC_API_URL=https://chat.example.ru/api
PUBLIC_AUTH_URL=https://chat.example.ru/auth
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
KEYCLOAK_REALM=han-chat
KEYCLOAK_AUDIENCE=han-chat-api
```
### 8.2. PostgreSQL
В `.env` укажите только host, port, database и путь к публичному CA:
```dotenv
HAN_PG_HOST=<PG_HOST>
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
KEYCLOAK_DB_SCHEMA=keycloak
```
Все service DSN, включая JDBC и backup DSN, формирует secret backend. Runtime
validator проверяет `verify-full`, `sslrootcert` и запрет `options/currentSchema`
без вывода строк подключения.
Порт `5433` используется с PgBouncer в режиме `session`. Не добавляйте
`options=-csearch_path...` или JDBC-параметр `currentSchema`: они передают
startup parameter `search_path`, который Selectel PgBouncer отклоняет. Для
Keycloak схема задаётся отдельно через `KEYCLOAK_DB_SCHEMA`.
Для каждой сервисной роли заранее задайте database-level `search_path`.
Если пароль содержит `@`, `:`, `/`, `?`, `#` или `%`, его необходимо
URL-кодировать внутри PostgreSQL URL.
### 8.3. Подготовка runtime-секретов
Генерируйте секреты вне shell history средствами secret manager. Не выполняйте
`export TOKEN=...` и не вставляйте значения в команды. Имена обязательных
runtime-переменных определены в `scripts/validate-env`; парные токены связываются
в secret backend.
```sh
openssl rand -hex 32
```
Для `BITRIX_TOKEN_ENCRYPTION_KEY` нужен URL-safe Base64 ключ ровно из 32 байт:
```sh
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
```
Ни один из этих секретов не добавляется в `.env`. Пары проверяются runtime
validator без вывода значений.
```dotenv
BITRIX_LOCAL_APP_INTERNAL_TOKEN=<TOKEN_A>
BITRIX_INTERNAL_API_TOKEN=<TOKEN_A>
BITRIX_API_FORWARD_TOKEN=<TOKEN_B>
BITRIX_API_INBOX_TOKEN=<TOKEN_B>
```
Остальные токены должны быть разными:
```dotenv
MESSAGE_SAFETY_SERVICE_TOKEN=<UNIQUE_TOKEN>
BITRIX_SYNC_SERVICE_TOKEN=<UNIQUE_TOKEN>
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<UNIQUE_TOKEN>
CURSOR_HMAC_SECRET=<UNIQUE_TOKEN>
KEYCLOAK_OTP_HMAC_KEY=<UNIQUE_TOKEN_НЕ_КОРОЧЕ_32_БАЙТ>
BITRIX_TOKEN_ENCRYPTION_KEY=<URLSAFE_BASE64_KEY>
KEYCLOAK_ADMIN_PASSWORD=<UNIQUE_ADMIN_PASSWORD>
```
### 8.4. Redis
Создайте три разных пароля в secret backend; там же сформируйте Redis URL.
Следующий блок описывает логический контракт и не является содержимым `.env`:
```dotenv
REDIS_API_PASSWORD=<REDIS_API_PASSWORD>
REDIS_SAFETY_PASSWORD=<REDIS_SAFETY_PASSWORD>
REDIS_HEALTH_PASSWORD=<REDIS_HEALTH_PASSWORD>
REDIS_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/0
REDIS_REALTIME_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/1
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:6379/2
```
### 8.5. Mock OTP
В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
```
`KEYCLOAK_OTP_MOCK_CODE` хранится только в secret backend. Не используйте mock
как production-механизм доставки OTP.
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal.
### 8.6. S3
```dotenv
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
```
Обе пары S3 credentials хранятся только в secret backend.
API backend принудительно использует virtual-hosted addressing:
`https://<bucket>.s3.storage.selcloud.ru/<object-key>`. Это обязательно для
браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не
используйте. DNS и исходящий HTTPS с ВМ должны разрешать поддомены бакетов.
### 8.7. Bitrix24
До установки локального приложения загрузите credentials в secret backend.
В `.env` остаются только несекретные connector/public URL параметры:
```dotenv
BITRIX_CONNECTOR_ID=han_mobile_app
BITRIX_OPEN_LINE_ID=8
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
```
`BITRIX_APPLICATION_TOKEN` сохраняется в secret backend после создания
приложения и никогда не помещается в `.env`.
### 8.8. TLS и наблюдаемость
До выпуска сертификата оставьте в `.env` целевые значения:
```dotenv
NGINX_TLS_ENABLED=true
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
ACME_EMAIL=<ADMIN_EMAIL>
```
Если удаленный OTLP-бэкенд пока не выбран, укажите временные непубличные значения
и примите ограничение: Collector будет пытаться отправлять телеметрию и сохранять
ее в ограниченной очереди. Перед production-запуском задайте реальный endpoint.
## 9. Проверка окружения и конфигурации Compose
Выполните:
```sh
cd /opt/han-chat/backend
./scripts/validate-env .env
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
sudo deployment/secrets/han-compose config --services
python3 -m unittest discover -s tests -v
```
Не переходите к следующему шагу, пока все команды не завершатся успешно.
Посмотрите итоговую конфигурацию портов:
```sh
docker compose --env-file .env config | grep -n 'published:'
```
Публиковаться должны только 80 и 443 у nginx.
## 10. Сборка образов
Соберите все локальные образы:
```sh
cd /opt/han-chat/backend
docker compose --env-file .env build --pull
```
Проверьте список:
```sh
docker compose --env-file .env images
```
Сборка Keycloak включает Java OTP SPI, а сборка `frontend-static` экспортирует
тестовый Expo Web frontend.
## 11. Миграции БД и начальные настройки
Перед миграциями создайте backup/PITR marker в панели провайдера PostgreSQL.
Затем:
```sh
cd /opt/han-chat/backend
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
deployment/scripts/seed.sh
```
Скрипт применит миграции `han_app`, `bitrix_local` и baseline `bitrix_sync`, после
чего загрузит `deployment/app-settings.production-like.yaml`.
Повторный запуск seed должен быть безопасным:
```sh
deployment/scripts/seed.sh
```
## 12. Запуск внутренних сервисов
Сначала запустите Redis:
```sh
docker compose --env-file .env up -d redis
docker compose --env-file .env ps redis
```
Затем Keycloak и OpenTelemetry:
```sh
docker compose --env-file .env up -d keycloak otel-collector
docker compose --env-file .env ps keycloak otel-collector
```
Первый запуск Keycloak может занять несколько минут: он создаст свои таблицы и
импортирует realm `han-chat`.
После готовности Keycloak:
```sh
docker compose --env-file .env up -d message-safety
docker compose --env-file .env up -d api-backend
docker compose --env-file .env up -d bitrix-local-app bitrix-sync
docker compose --env-file .env up -d \
delivery-worker safety-recovery-worker cleanup-worker
docker compose --env-file .env ps
```
Если сервис не становится healthy:
```sh
docker compose --env-file .env logs --tail=200 <SERVICE_NAME>
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
```
### 12.1. Повторная раскатка upstream при уже работающем nginx
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить
новый IP, а работающий nginx продолжит использовать старый и вернёт `502
Connection refused`.
После пересоздания `api-backend`, `keycloak`, `sms-service` или
`bitrix-local-app` обязательно выполните:
```sh
docker compose --env-file .env up -d --wait \
api-backend keycloak sms-service bitrix-local-app
docker compose --env-file .env exec -T nginx \
nginx -t -c /tmp/nginx.conf
docker compose --env-file .env kill -s HUP nginx
curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq
curl -fsS \
"https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" |
jq
```
Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая
конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а
контейнер использует read-only filesystem.
## 13. Первоначальный выпуск TLS-сертификата
Для ACME требуется работающий nginx по HTTP. В `.env` оставьте
`NGINX_TLS_ENABLED=true`, но первый nginx запустите с временным переопределением:
```sh
NGINX_TLS_ENABLED=false \
docker compose --env-file .env up -d frontend-static nginx
```
Проверьте HTTP:
```sh
curl -I http://chat.example.ru/
```
Сначала рекомендуется проверить Certbot через staging:
```sh
docker compose --env-file .env --profile certbot run --rm certbot certonly \
--staging \
--webroot -w /var/www/certbot \
-d chat.example.ru \
--cert-name chat.example.ru-staging \
--email <ADMIN_EMAIL> \
--agree-tos --no-eff-email --non-interactive
```
После успешного staging-теста выпустите рабочий сертификат с основным cert-name
без `--staging`:
```sh
docker compose --env-file .env --profile certbot run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d chat.example.ru \
--cert-name chat.example.ru \
--email <ADMIN_EMAIL> \
--agree-tos --no-eff-email --non-interactive
```
Пересоздайте nginx уже с TLS:
```sh
docker compose --env-file .env up -d --force-recreate nginx
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
curl -I https://chat.example.ru/
```
Повторно запустите VM setup, чтобы он обнаружил проект и установил systemd-таймер
продления сертификата:
```sh
sudo /opt/han-chat/backend/deployment/scripts/setup-vm.sh
systemctl status han-chat-ssl-renew.timer
```
## 14. Запуск всего контура
Теперь можно привести весь проект к состоянию, описанному Compose:
```sh
cd /opt/han-chat/backend
docker compose --env-file .env up -d
docker compose --env-file .env ps
```
Проверьте, что контейнеры не перезапускаются:
```sh
docker compose --env-file .env ps
docker compose --env-file .env logs --since=10m
```
## 15. Публичная проверка
Запустите smoke-тест:
```sh
cd /opt/han-chat/backend
deployment/scripts/smoke.sh
```
Также вручную проверьте:
```sh
curl -fsS https://chat.example.ru/api/v1/public/app-config | jq
curl -fsS https://chat.example.ru/api/v1/public/content | jq
curl -fsS \
https://chat.example.ru/auth/realms/han-chat/.well-known/openid-configuration | jq
```
Внутренний API не должен быть опубликован:
```sh
curl -i https://chat.example.ru/internal/safety/v2/messages/check
```
Ожидаемый статус — `404`.
Откройте в браузере:
```text
https://chat.example.ru/
```
Для тестовой авторизации получите mock code утверждённым защищённым способом,
не читая его из `.env` и не помещая в shell history.
## 16. Подключение Bitrix24
В настройках локального приложения Bitrix24 задайте HTTPS-адреса:
```text
Установка: https://chat.example.ru/bitrix/install
Обработчик: https://chat.example.ru/bitrix/handler
Placement: https://chat.example.ru/bitrix/placement
```
После установки:
1. Получите и сохраните application token.
2. Сохраните его как `BITRIX_APPLICATION_TOKEN` в secret backend.
3. Пересоздайте сервис:
```sh
docker compose --env-file .env up -d --force-recreate bitrix-local-app
docker compose --env-file .env logs --tail=200 bitrix-local-app
```
Проверьте коннектор `han_mobile_app` и Открытую линию 8.
## 17. Включение SSH hardening
Только после успешного входа пользователем `deploy` по ключу в отдельной сессии:
```sh
sudo HARDEN_SSH=true \
/opt/han-chat/backend/deployment/scripts/setup-vm.sh
```
Не закрывайте текущую SSH-сессию, пока не проверили новый вход.
## 18. Обычный перезапуск проекта
Для штатного запуска после перезагрузки VM:
```sh
cd /opt/han-chat/backend
docker compose --env-file .env up -d
docker compose --env-file .env ps
```
Для перезапуска одного сервиса:
```sh
docker compose --env-file .env restart api-backend
```
После изменения `.env` используйте пересоздание, а не `restart`:
```sh
docker compose --env-file .env up -d --force-recreate <SERVICE_NAME>
```
## 19. Обновление версии проекта
Перед обновлением:
1. Создайте backup/PITR marker PostgreSQL.
2. Сохраните текущие image digests.
3. Получите новый код.
4. Проверьте несекретный `.env` и runtime secret set.
5. Пересоберите образы.
6. Примените миграции и seed.
7. Пересоздайте сервисы.
Команды:
```sh
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env build --pull
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
deployment/scripts/seed.sh
docker compose --env-file .env up -d
deployment/scripts/smoke.sh
```
## 20. Диагностика
Состояние сервисов:
```sh
docker compose --env-file .env ps
```
Все логи:
```sh
docker compose --env-file .env logs --tail=300
```
Логи конкретного сервиса:
```sh
docker compose --env-file .env logs -f api-backend
```
Проверка firewall:
```sh
sudo ufw status verbose
sudo iptables -L HAN-CHAT-DOCKER -n -v
```
Проверка сертификата:
```sh
openssl s_client -connect chat.example.ru:443 -servername chat.example.ru \
</dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
```
Проверка свободного места:
```sh
df -h
docker system df
```
Не выполняйте `docker compose down -v`: эта команда удалит именованные volumes.
Не выполняйте Alembic downgrade. Для отката используйте
`deployment/scripts/rollback.sh` и инструкции из `RUNBOOK.ru.md`.
## 21. Когда развертывание можно считать завершенным
Проект запущен корректно, если:
- `docker compose ps` показывает healthy для критических сервисов;
- `deployment/scripts/smoke.sh` завершается успешно;
- открывается тестовый frontend;
- проходит авторизация через mock OTP;
- отправляются текстовые и файловые сообщения;
- внутренние URL возвращают 404 снаружи;
- TLS-сертификат действителен;
- логи не содержат токены, PII и тексты сообщений;
- настроены резервное копирование PostgreSQL и продление TLS.
Для формальной production-like приемки после этого пройдите контрольные этапы
из `deployment/RUNBOOK.ru.md`.
Production через этот legacy guide или stubs не поддерживается. In-place
обновление legacy VM запрещено.
+5 -328
View File
@@ -1,330 +1,7 @@
# HAN Chat production-like deployment runbook
# Legacy pointer
This is the executable checklist for the single-VM contour. PostgreSQL and S3
are managed external services. Never use `docker compose down -v`, an Alembic
downgrade, or a mutable image tag during deployment.
The authoritative executable fresh-production VM1 procedure is:
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
## VM2 Processing is a separate host
Do not run this backend/VM1 setup script on VM2. VM2 has its own bootstrap:
`codebase/services/deployment/scripts/setup-vm.sh`, and its authoritative
operator checklist is `codebase/services/deployment/RUNBOOK.ru.md`.
The VM2 ownership boundary is intentionally different from the legacy VM1
script: `deploy` is **not** a member of the `docker` group. Root owns
`/opt/han-chat/services`, Compose, units, helpers, `.env`, allow-lists and
secret mappings. Deploy may write only to `/var/lib/han-deploy/incoming` and
may invoke exact systemd/safety-mode commands installed in sudoers.
The separate `admin` account is break-glass only: it has its own Ed25519 key
and a separate local sudo password. Root, deploy and admin keys must differ.
Initial VM2 bootstrap commands:
```sh
# Local operator workstation: upload only the reviewed setup script.
scp codebase/services/deployment/scripts/setup-vm.sh \
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
# VM2 root: install host packages/roles/firewalls; this does not start Compose.
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
/root/setup-vm2.sh
```
Generate and upload the two public keys before this command; never copy the
root key into either account. Set the admin sudo password with `passwd admin`.
Keep the root session open and verify both key-based logins plus `sudo -v` as
admin in separate sessions. Only then rerun as VM2 root with
`HARDEN_SSH=true SKIP_APT_UPGRADE=true` to disable direct root SSH.
Release transfer is performed as deploy, while activation and installation
remain root operations:
```sh
# deploy: receive and inspect only.
cd /var/lib/han-deploy/incoming
sha256sum vm2-services-<RELEASE>.tar.gz
tar -tzf vm2-services-<RELEASE>.tar.gz
# root: verify the operator-provided digest, activate root-owned files,
# then rerun setup-vm.sh so it installs fixed helpers and systemd units.
printf '%s %s\n' '<EXPECTED_SHA256>' \
/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz | sha256sum --check -
ARCHIVE=/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then exit 1; fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then exit 1; fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then exit 1; fi
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar -xzf "$ARCHIVE" \
-C "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env --chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
After root configures `.env`, Selectel encrypted credentials, loader mapping,
TLS and CIDR allow-lists, root synchronizes secrets, runs preflight/migrations
and performs the first start. Subsequent routine operations available to
deploy are limited to:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
Exact archive activation, file installation, credential creation, migration
and first-start commands are documented in the VM2 Russian runbook referenced
above. They must not be replaced with direct Docker access for deploy.
## Gate 0 — decisions and ownership
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
## Gate 1 — VPC, DNS and security groups
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
## Gate 2 — VM hardening
On a fresh Ubuntu 24.04 VM, run:
```sh
sudo deployment/scripts/setup-vm.sh
```
The script disables password SSH and X11 forwarding by default, then locks the
local `root` and `deploy` passwords after checking authorized keys. Before
setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding,
verify key-based deploy access in a separate SSH session.
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
## Gate 3 — managed PostgreSQL
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
have separate migration/runtime roles with tested negative grants.
- [ ] Migration tested against an empty DB and a clone of the previous release.
## Gate 4 — Selectel S3
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
## Gate 5 — immutable release
- [ ] Checkout is detached at the approved SHA and working tree is clean.
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
- [ ] Root `docker-compose.yml` is the only deployment entry point.
## Gate 6 — environment and secrets
```sh
cp .env.example .env
# Replace non-secret configuration placeholders only.
./scripts/validate-env .env
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
```
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` contains no secret keys or credential-bearing DSNs.
- [ ] `deployment/secrets/han-secrets` sets `HAN_SECRETS_ACTIVE=1`, does not log values,
and optionally exposes a paths-only `HAN_RUNTIME_SECRET_MANIFEST`.
- [ ] Runtime token pairs match, PG verifies TLS, public URLs are HTTPS.
- [ ] Mock OTP risk is accepted and runtime secrets are unique >=128-bit values.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash.
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
## Gate 7 — images and static frontend
```sh
docker compose --env-file .env pull
docker compose --env-file .env build --pull frontend-static nginx redis
docker compose --env-file .env run --rm frontend-static
```
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
- [ ] Build artifacts contain no secrets or unintended source maps.
- [ ] At least 30% VM disk remains free.
## Gate 8 — topology
```sh
docker compose --env-file .env config --services
python3 -m unittest discover -s tests -v
```
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
- [ ] `backend` and `observability` are internal networks.
## Gate 9 — ACME/TLS bootstrap
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
```sh
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
docker compose --profile certbot run --rm certbot certonly \
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
docker compose --env-file .env up -d --force-recreate nginx
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
```
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
## Gate 10 — migrations and seed
Create a provider PITR marker, then:
```sh
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
deployment/scripts/seed.sh
```
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
- [ ] Seed succeeds twice and mandatory settings contain no secret.
- [ ] Schema remains backward-compatible with the previous images.
## Gate 11 — Keycloak
```sh
docker compose up -d keycloak
docker compose ps keycloak
```
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
## Gate 12 — ordered startup and readiness
```sh
docker compose up -d redis
docker compose up -d keycloak otel-collector
docker compose up -d message-safety
docker compose up -d api-backend
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
docker compose ps
```
Nginx resolves Docker upstream names when its configuration is loaded. After
recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`,
wait for readiness, validate the active `/tmp/nginx.conf`, and signal the
master process with `HUP` as shown above. Do not use bare `nginx -t` or
`nginx -s reload`: they target the default config/PID under read-only
`/var/run`, not the running Nginx instance.
- [ ] No restart loop/OOM; critical readiness is green.
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
## Gate 13 — Bitrix24
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
## Gate 14 — smoke and E2E
```sh
deployment/scripts/smoke.sh
```
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
- [ ] Closed-network `producer_test` Create/Cancel smoke passes; identical Create returns `200`, changed payload returns `409`, and the external internal route returns `404`.
- [ ] Expire advisory locking and first download of any linked document are verified; hiding is one-time and an existing `date_expired` is preserved.
- [ ] Logs contain no PII, message body, token or presigned query.
## Gate 15 — observability
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
For local acceptance only, start the redacted debug collector with:
`docker compose --profile observability-local up -d otel-collector-local`.
## Gate 16 — open traffic
- [ ] Gates 015 are signed; fresh backup/PITR evidence and previous images exist.
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
- [ ] No active page; on-call and product owner accept stub limitations.
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
## Backup and restore
Provider backup/PITR is authoritative. A supplemental verified logical dump:
```sh
deployment/scripts/backup.sh /opt/han-chat/backups
```
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
Redis may be restored empty; its AOF/RDB is not a business backup.
## Rollback
Only roll back to images compatible with the current schema:
```sh
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
deployment/scripts/smoke.sh
```
Rollback reuses the current runtime secret set and non-secret config. Do not
create or restore an environment snapshot.
## Real SMS rollout addendum
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility.
Never run Alembic downgrade. After a backward-incompatible migration choose a
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
The former single-VM/stub checklist has been removed. It must not be used for
production deployment or VM1→VM2 cutover.
@@ -0,0 +1,477 @@
# Fresh production runbook ВМ1 HAN Chat
Это единственный исполняемый production-runbook ВМ1. Он предназначен только
для новой Ubuntu 24.04 VM: in-place преобразование старой single-VM/stub
инсталляции запрещено. Команды выполняет оператор; repository automation их не
запускает.
Запрещены: реальные секреты в репозитории, `.env`, argv/history или логах;
доступ `deploy` к Docker; mutable image tags; `docker compose down -v`;
Alembic downgrade; автоматический fallback Selectel → локальный файл.
## 0. Участники, переменные и stop conditions
- локальная машина: создаёт проверенный архив и SHA-256;
- `root`: bootstrap, активация release, config/secrets/TLS, миграции и первый
запуск;
- `deploy`: пишет только в `/var/lib/han-deploy/incoming`, затем использует
exact systemd/status/log commands;
- `admin`: персональная break-glass роль с отдельным ключом и sudo-паролем.
До окна работ зафиксируйте: `<GIT_SHA>`, image digests, `<EXPECTED_SHA256>`,
DNS/IP ВМ1, private DNS/SAN ВМ2, ops CIDR, PG/S3 inventory, PITR marker,
RPO/RTO, on-call, approver и предыдущий совместимый release.
Stop condition: любой placeholder, незакрытый preflight, несовпавший digest,
невалидный TLS, broad SG/sudo, неизвестная Alembic revision, stub/local Safety,
неуспешный negative probe или отсутствие rollback evidence.
## 1. Bootstrap новой ВМ1
На локальной машине создайте две разные Ed25519 key pairs. Private keys не
передаются на VM и не должны совпадать с bootstrap root key:
```powershell
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_deploy `
-C "han-vm1-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_admin `
-C "han-vm1-break-glass-admin"
```
Передайте setup и только public keys во временный root-каталог. В уже открытой
bootstrap root-сессии:
```sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /tmp/han_vm1_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /tmp/han_vm1_admin.pub /root/bootstrap/admin.pub
install -m 0700 -o root -g root /tmp/setup-vm.sh /root/setup-vm1.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
/root/setup-vm1.sh
passwd admin
```
Setup устанавливает host packages, Docker/Compose, UFW, fail2ban, security
updates, `DOCKER-USER`, swap и роли. Он не запускает Compose. Cloud SG должен
разрешать `80/443` из интернета. На bootstrap-этапе UFW временно разрешает SSH
из любой сети; после настройки WireGuard закройте public SSH и разрешите его
только через WireGuard. PG принимает TLS только от SG/private IP ВМ1. `6379`,
`4317/4318`, `8000`, `8080`, `9000` наружу запрещены.
Не закрывая root-сессию, проверьте в двух новых сессиях:
```powershell
ssh -i C:\Users\<USER>\.ssh\han_vm1_deploy deploy@<VM1_IP>
ssh -i C:\Users\<USER>\.ssh\han_vm1_admin admin@<VM1_IP>
```
В admin-сессии:
```sh
sudo -v
sudo -i
id
exit
```
Только после успеха обоих SSH-входов и admin sudo повторите в исходной
root-сессии:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/root/setup-vm1.sh
```
Откройте ещё по одной новой deploy/admin сессии после reload. Подтвердите
`PermitRootLogin no`, `AllowUsers deploy admin`, key-only auth и отсутствие
forwarding. Только затем закрывайте bootstrap root session. Root-only public
key files сохраните до установки helpers из первого release; private keys на
VM отсутствуют. Доступ cloud console/recovery остаётся break-glass.
## 2. Сборка и передача immutable release
На локальной машине checkout должен быть exact detached `<GIT_SHA>`, tree —
clean. Архив содержит один корень `backend`, не содержит `.env`, credentials,
caches и private keys:
```powershell
$Release = "<GIT_SHA>"
tar --exclude=backend/.env `
--exclude='backend/**/__pycache__' `
--exclude='backend/**/.pytest_cache' `
--exclude='backend/**/.ruff_cache' `
-czf "vm1-backend-$Release.tar.gz" `
-C .\VM1_app\codebase backend
Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256
scp "vm1-backend-$Release.tar.gz" `
deploy@<VM1_IP>:/var/lib/han-deploy/incoming/
```
Под `deploy` разрешены только inventory/checksum:
```sh
RELEASE='<GIT_SHA>'
cd /var/lib/han-deploy/incoming
sha256sum "vm1-backend-${RELEASE}.tar.gz"
tar -tzf "vm1-backend-${RELEASE}.tar.gz"
```
## 3. Root-активация: SHA и anti-traversal
Под `admin`, затем `sudo -i`. Не распаковывайте недоверенный архив до всех
проверок:
```sh
RELEASE='<GIT_SHA>'
EXPECTED_SHA256='<SHA256_FROM_APPROVED_WORKSTATION>'
ARCHIVE="/var/lib/han-deploy/incoming/vm1-backend-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^backend/\.env$)'; then
echo 'unsafe path or .env' >&2; exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^backend(/|$)' | grep -q .; then
echo 'archive has files outside backend' >&2; exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then
echo 'symlink/hardlink is forbidden' >&2; exit 1
fi
TARGET="/opt/han-chat/releases/${RELEASE}"
test ! -e "$TARGET"
install -d -m 0755 -o root -g root "$TARGET"
tar --extract --gzip --file "$ARCHIVE" --directory "$TARGET" \
--no-same-owner --no-same-permissions
test -f "$TARGET/backend/docker-compose.yml"
chmod 0755 \
"$TARGET/backend/deployment/scripts/setup-vm.sh" \
"$TARGET/backend/deployment/preflight.sh" \
"$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" \
"$TARGET/backend/deployment/secrets/han-compose" \
"$TARGET/backend/deployment/secrets/han-secrets"
test -x "$TARGET/backend/deployment/scripts/setup-vm.sh"
test -x "$TARGET/backend/deployment/preflight.sh"
test -x "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh"
test -x "$TARGET/backend/deployment/secrets/han-compose"
test -x "$TARGET/backend/deployment/secrets/han-secrets"
if find "$TARGET/backend" -type l -print -quit | grep -q .; then exit 1; fi
chown -R root:root "$TARGET"
chmod -R go-w "$TARGET"
ln -s "releases/${RELEASE}" /opt/han-chat/.current-new
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
```
Executable modes поставляет release manifest/git. Нельзя применять blanket
`--chmod=F644` или рекурсивный `chmod`, снимающий executable bit. Сохраните
SHA-256 архива и release SHA в change record.
Повторите setup из активного release с теми же public key files. Он
установит root-owned launcher, secret unit, stack unit, TLS hook и sudoers, но
не запустит приложение:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh
visudo -cf /etc/sudoers.d/deploy
sudo -l -U deploy
rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub
```
Убедитесь, что в выводе нет wildcard, shell/editor/cp/chmod/docker и что
`deploy` не входит в `docker`, `sudo`, `lxd`, `adm`, `systemd-journal`.
Для повторного setup после будущего release заново передайте только проверенные
public keys в root-only временный каталог и удалите их после выполнения.
## 4. Несекретный config и secrets
Под root создайте `/etc/han/vm1.env` из reviewed production template. Config
живёт вне immutable release и не меняется при rollback. В нём только
несекретные значения и paths; `APP_ENV=production`,
`SECRETS_SOURCE=selectel`, все images pinned `@sha256:...`,
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443`,
`MESSAGE_SAFETY_EXTRA_HOST=<VM2_PRIVATE_DNS>=<VM2_PRIVATE_IP>`,
`MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem`,
`NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem`,
`NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem`.
```sh
cd /opt/han-chat/current/backend
install -m 0600 -o root -g root .env.example /etc/han/vm1.env
editor /etc/han/vm1.env
./scripts/validate-env /etc/han/vm1.env
install -m 0600 -o root -g root deployment/secrets/config.example.json \
/etc/han/secrets/production.selectel.json
editor /etc/han/secrets/production.selectel.json
```
Mapping должен содержать только VM1 secrets. Убедитесь, что в нём отсутствуют
legacy local `message-safety`/`bitrix-sync` consumers, Redis DB2 и credentials
сервисов ВМ2; отдельный IAM principal ВМ1 получает read-only только к
перечисленным remote names. Пароли/DSN/token/S3 keys не помещаются в
`/etc/han/vm1.env`.
Создайте encrypted systemd credential без значения в argv/history:
```sh
read -rsp 'Selectel VM1 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/production.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/production.selectel-password.cred
chmod 0600 /etc/han/credentials/production.selectel-password.cred
```
Подтвердите token pairs без печати значений средствами validator. Не делайте
`cat` runtime secret files. Emergency file mode — отдельная root-only
процедура без automatic fallback.
## 5. Managed PostgreSQL и S3
Установите provider CA вне release:
```sh
install -m 0644 -o root -g root /tmp/<PG_CA_FILE> \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<PG_CA_FILE>
```
Runtime и migration roles разделены для `han_app`, `bitrix_local`, `sms`,
`keycloak`; runtime не имеет DDL/ownership. DSN проверяет hostname и chain.
Включены encryption, deletion protection, backup/PITR и alerts. Перед
миграциями создаётся provider PITR marker.
S3: private encrypted buckets documents/attachments/quarantine; exact browser
origin CORS; prefix-scoped VM1 key; versioning/lifecycle; public ACL off.
Read-only quarantine key Safety принадлежит IAM ВМ2 и не копируется на ВМ1.
Проверьте negative access к чужому prefix/bucket.
## 6. Public TLS и internal VM2 CA
Установите внутренний CA, которым ВМ1 проверяет SAN ВМ2:
```sh
install -m 0644 -o root -g root /tmp/<VM2_INTERNAL_CA_FILE> \
/etc/han/ca/vm2-internal-ca.pem
openssl x509 -in /etc/han/ca/vm2-internal-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<VM2_INTERNAL_CA_FILE>
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS> -verify_hostname <VM2_PRIVATE_DNS> \
-CAfile /etc/han/ca/vm2-internal-ca.pem -verify_return_error </dev/null
```
Для initial public certificate DNS уже указывает на ВМ1, `80` свободен:
```sh
PUBLIC_HOST='<PUBLIC_HOST>'
ACME_EMAIL='<OPS_EMAIL>'
certbot certonly --standalone --preferred-challenges http --staging \
-d "$PUBLIC_HOST" --cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" --cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
```
При первом копировании nginx ещё не работает, поэтому deploy hook до запуска
не вызывается. Скопируйте initial pair теми же root ownership и mode, затем
preflight проверит pair:
```sh
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
/var/lib/han-chat/public-tls/fullchain.pem
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
/var/lib/han-chat/public-tls/privkey.pem
```
В production Compose nginx обязан монтировать только
`/var/lib/han-chat/public-tls` read-only, не `/etc/letsencrypt`. Если это ещё
legacy named volume, preflight/Compose review — блокер, не workaround.
## 7. Secret sync и static preflight
```sh
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
/opt/han-chat/current/backend/deployment/preflight.sh
/usr/local/sbin/han-vm1-compose config --quiet
/usr/local/sbin/han-vm1-compose config --services
/usr/local/sbin/han-vm1-compose config --images
```
В списке production services после VM2 cutover нет local `message-safety`,
`bitrix-sync`, Redis DB2 и test stubs. Только nginx публикует `80/443`; все
images immutable. Не сохраняйте resolved Compose с secret paths/metadata в
общедоступный файл.
## 8. Миграции и seed
После PITR marker под root:
```sh
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-sms
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
```
Проверьте current/head каждой схемы, идемпотентность seed и negative DDL от
runtime roles. Миграции ВМ1 `han_app` precede dependent VM2 sync cutover.
Временные cross-schema grants выдаёт owner и отзывает после проверки. Unknown
revision, multiple heads или incompatible schema — stop; ручной `stamp` и
downgrade запрещены.
## 9. Упорядоченный первый запуск
Первый запуск выполняет root через фиксированный launcher:
```sh
/usr/local/sbin/han-vm1-compose up -d redis otel-queue-init otel-collector
/usr/local/sbin/han-vm1-compose up -d keycloak sms-service
/usr/local/sbin/han-vm1-compose up -d api-backend bitrix-local-app
/usr/local/sbin/han-vm1-compose up -d \
sms-worker delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
/usr/local/sbin/han-vm1-compose up -d frontend-static
/usr/local/sbin/han-vm1-compose up -d nginx
/usr/local/sbin/han-vm1-compose ps
```
Дождитесь health, затем:
```sh
/usr/local/sbin/han-vm1-compose exec -T nginx nginx -t -c /tmp/nginx.conf
NGINX_ID=$(/usr/local/sbin/han-vm1-compose ps --status running --quiet nginx)
test -n "$NGINX_ID"
docker kill --signal HUP "$NGINX_ID" >/dev/null
unset NGINX_ID
systemctl enable han-secrets@production.service han-stack@production.service
systemctl start han-stack@production.service
```
`han-stack@production` становится единственным routine lifecycle interface.
После изменения secret source сначала explicit restart secret unit, затем
stack unit. Обновление active/exited oneshot всегда требует `restart`.
## 10. Smoke, firewall и cutover
С внешней машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
```
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
allow/deny/pending/timeout. Safety status `stub` не принимается.
До переключения `MESSAGE_SAFETY_URL` ВМ2 должна закрыть собственные gates.
После переключения подтвердите private CA/SAN, service token, `text|links|files`
capabilities и fail-closed timeout. Local Safety/Redis DB2 не оставляются как
fallback. Cutover ВМ2 Bitrix sync — отдельное окно.
Проверьте `DOCKER-USER` live counters внешним positive `80/443` и negative
port/source probe:
```sh
iptables -L HAN-CHAT-DOCKER -n -v
systemctl restart han-chat-docker-firewall.service
systemctl restart docker.service
systemctl restart han-chat-docker-firewall.service
iptables -S HAN-CHAT-DOCKER
```
Allow rules должны использовать `--ctorigdstport 80/443`; UFW INPUT counter не
является доказательством published Docker ports.
## 11. Certbot, observability и reboot gate
После запуска nginx переключите renewal на webroot, который Compose монтирует
из `/var/lib/han-chat/acme`, затем:
```sh
certbot reconfigure --cert-name '<PUBLIC_HOST>' \
--authenticator webroot --webroot-path /var/lib/han-chat/acme
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl list-timers certbot.timer
```
Hook должен завершаться `0` с пустым stderr на success, атомарно обновлять
staging, выполнять config test и HUP.
Проверьте logs/metrics/traces, request ID через nginx/API/VM2, alerts для
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
token/PII markers и докажите их отсутствие в logs/traces.
Перед reboot проверьте admin SSH и provider console:
```sh
systemctl is-enabled docker.service han-chat-docker-firewall.service \
han-secrets@production.service han-stack@production.service certbot.timer
systemctl reboot
```
После reconnect повторите status, stack `ps`, public/private TLS, smoke,
negative ports и firewall counters. Без reboot gate deployment не завершён.
## 12. Rollback и disaster recovery
Application rollback допускается только на предыдущий root-owned release,
совместимый с текущей schema. Секреты и `/etc/han/vm1.env` остаются текущими:
```sh
PREVIOUS='<PREVIOUS_COMPATIBLE_GIT_SHA>'
test -d "/opt/han-chat/releases/${PREVIOUS}/backend"
ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
systemctl daemon-reload
systemctl restart han-secrets@production.service
/opt/han-chat/current/backend/deployment/preflight.sh
systemctl restart han-stack@production.service
```
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
active images, evidence или volumes. После incompatible migration используйте
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
window; Redis восстанавливается пустым и прогревается из PG.
Потеря ВМ1: создайте новую VM этим runbook, восстановите DNS/SG, root-owned
approved release, VM1 IAM secrets, managed PG/S3 и public TLS; ВМ2 не
пересоздавайте. До cutover держите traffic закрытым. Ежеквартально делайте
isolated restore rehearsal, не направляя production DNS/Bitrix callbacks, и
фиксируйте фактические RPO/RTO.
При компрометации host не «очищайте» VM: изолируйте, сохраните evidence,
ротируйте доступные secrets/tokens и reprovision из trusted image.
## 13. Production acceptance record
Сохраните без secret values: release/archive SHA-256, image digests, schema
heads, realm/config version, PG PITR marker, TLS fingerprints/expiry, systemd
status, positive/negative firewall probes, public/private smokes, VM2 cutover
approval, observability canary, reboot result, rollback/restore rehearsal и
подписи Operations/Security/Product.
@@ -1,271 +1,9 @@
# Инструкция по развертыванию HAN Chat в production-like окружении
# Legacy runbook — указатель
Это исполняемый чек-лист для контура на одной виртуальной машине. PostgreSQL и S3
используются как внешние управляемые сервисы. Во время развертывания запрещено
использовать `docker compose down -v`, откат миграций Alembic и изменяемые теги образов.
Исполняемый fresh production runbook ВМ1:
[`RUNBOOK.production.ru.md`](RUNBOOK.production.ru.md).
Подробная пошаговая инструкция для первого запуска находится в
`deployment/DEPLOYMENT_GUIDE.ru.md`.
## Этап 0 — решения и зоны ответственности
- [ ] Зафиксированы SHA/дайджесты релиза, окно обслуживания, дежурный и ответственный за откат.
- [ ] Согласованы RPO/RTO; начальные цели: RPO PostgreSQL не более 15 минут и RTO не более 4 часов.
- [ ] Выбран удаленный OTLP-бэкенд либо принято ограничение на использование только отладочного контура.
- [ ] Явно приняты риски mock OTP, заглушки Safety и заглушки bitrix-sync.
## Этап 1 — VPC, DNS и группы безопасности
- [ ] Управляемый PostgreSQL имеет только приватную точку доступа и принимает трафик от группы безопасности VM.
- [ ] Из интернета доступны только TCP-порты VM 80/443; SSH ограничен VPN или CIDR администраторов.
- [ ] Порты 6379, 4317/4318, 8000, 8080 и 9000 закрыты для внешнего доступа.
- [ ] DNS-запись `A` для `PUBLIC_HOST` указывает на VM; исходящий HTTPS доступен.
## Этап 2 — защита виртуальной машины
На новой Ubuntu 24.04 можно выполнить подготовительный скрипт:
```sh
sudo deployment/scripts/setup-vm.sh
```
Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после
проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением
`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding,
обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии.
- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска.
- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены.
- [ ] Правила UFW/облачной группы безопасности и политика `DOCKER-USER` сохраняются после перезагрузки.
- [ ] Docker Engine и Compose поддерживают `include` и полную форму `env_file`.
## Этап 3 — управляемый PostgreSQL
- [ ] Включены ежедневные резервные копии, PITR, защита от удаления, шифрование и оповещения.
- [ ] CA-сертификат провайдера установлен по пути `PG_CA_HOST_PATH`; все DSN используют `verify-full`.
- [ ] Для схем `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
созданы отдельные роли миграций и выполнения; запрет лишних прав проверен тестами.
- [ ] Миграции проверены на пустой БД и на клоне БД предыдущего релиза.
## Этап 4 — Selectel S3
- [ ] Бакеты карантина, вложений и документов закрыты от публичного доступа и зашифрованы.
- [ ] Права API ограничены префиксами; учетные данные Safety имеют доступ к карантину только на чтение.
- [ ] CORS бакетов разрешает только точный HTTPS-origin браузера и необходимые заголовки PUT.
- [ ] Срок хранения карантина превышает время Safety polling/recovery; политика хранения данных согласована.
## Этап 5 — неизменяемый релиз
- [ ] Репозиторий переключен на утвержденный SHA в detached-режиме; рабочее дерево чистое.
- [ ] Образы сервисов неизменяемы и просканированы; нерешенных критических и высоких уязвимостей нет.
- [ ] Корневой `docker-compose.yml` является единственной точкой запуска.
## Этап 6 — окружение и секреты
```sh
cp .env.example .env
# Замените только несекретные placeholders.
./scripts/validate-env .env
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
```
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` не содержит secret keys и DSN с credentials.
- [ ] `deployment/secrets/han-secrets` устанавливает `HAN_SECRETS_ACTIVE=1`,
не пишет значения в лог и при возможности передаёт paths-only manifest
через `HAN_RUNTIME_SECRET_MANIFEST`.
- [ ] Runtime-пары токенов совпадают, PostgreSQL проверяет TLS.
- [ ] Риск mock OTP принят; runtime-секреты уникальны и содержат не менее 128 бит энтропии.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash.
- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы.
## Этап 7 — образы и статический frontend
```sh
docker compose --env-file .env pull
docker compose --env-file .env build --pull frontend-static nginx redis
docker compose --env-file .env run --rm frontend-static
```
- [ ] Экспорт frontend проверен и просканирован, затем скопирован сервисом `frontend-static` в именованный volume.
- [ ] Артефакты сборки не содержат секретов и непредусмотренных source map.
- [ ] На диске VM остается не менее 30% свободного места.
## Этап 8 — топология
```sh
docker compose --env-file .env config --services
python3 -m unittest discover -s tests -v
```
- [ ] Только nginx публикует `80:80` и `443:443`; сервиса PostgreSQL в Compose нет.
- [ ] Присутствуют volumes Redis AOF/RDB/ACL и постоянной очереди OTEL.
- [ ] Сети `backend` и `observability` являются внутренними.
## Этап 9 — первоначальная настройка ACME/TLS
Установите `NGINX_TLS_ENABLED=false` только для команды первоначального запуска:
```sh
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
docker compose --profile certbot run --rm certbot certonly \
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
docker compose --env-file .env up -d --force-recreate nginx
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
```
Сначала выполните проверку с параметром Certbot `--staging`. Установите systemd-таймер,
запускающий `deployment/scripts/ssl-renew.sh` дважды в сутки, и проверьте
`certbot renew --dry-run`. Включайте HSTS только после проверки цепочки сертификатов,
имени хоста, перенаправления и поддержки TLS 1.2/1.3.
## Этап 10 — миграции и начальные данные
Создайте у провайдера точку восстановления PITR, затем выполните:
```sh
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
deployment/scripts/seed.sh
```
- [ ] Активны ожидаемые ревизии Alembic; runtime-пользователи не выполняли DDL.
- [ ] Повторный seed завершается успешно; обязательные настройки не содержат секретов.
- [ ] Схема остается обратно совместимой с образами предыдущего релиза.
## Этап 11 — Keycloak
```sh
docker compose up -d keycloak
docker compose ps keycloak
```
- [ ] Issuer discovery/JWKS точно совпадает с публичным HTTPS URL `/auth`.
- [ ] Frontend-клиент является публичным PKCE S256; implicit, password и social flows отключены.
- [ ] Неверный или повторно использованный OTP и превышение лимитов безопасно отклоняются; settings bridge работает fail-closed.
- [ ] При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` initial send и resend требуют свежий SmartCaptcha token; техническая недоступность Yandex подтверждена как fail-open в логах.
- [ ] CSP login-страницы содержит `smartcaptcha.cloud.yandex.ru`/`yastatic.net`, а `/auth/realms/master/protocol/openid-connect/3p-cookies/step2.html` и Admin Console работают без CAPTCHA CSP.
- [ ] Временный администратор удален либо его пароль изменен; для именного администратора включена MFA.
Если предыдущая попытка сохранила custom CSP в realm, сбросьте только это поле через `kcadm`; `.env` как shell-файл не загружать:
```sh
docker compose exec -T keycloak sh -lc '
set -eu
cfg=/tmp/han-kcadm.config
/opt/keycloak/bin/kcadm.sh config credentials --config "$cfg" \
--server http://127.0.0.1:8080/auth --realm master \
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" \
--password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
/opt/keycloak/bin/kcadm.sh update realms/han-chat --config "$cfg" \
-s "browserSecurityHeaders.contentSecurityPolicy="
rm -f "$cfg"
'
```
## Этап 12 — последовательный запуск и готовность
```sh
docker compose up -d redis
docker compose up -d keycloak otel-collector
docker compose up -d message-safety
docker compose up -d api-backend
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
docker compose ps
```
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
любого пересоздания `api-backend`, `keycloak`, `sms-service` или
`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий
`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше.
Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к
дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx.
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
- [ ] Внешний запрос `/internal/*` возвращает 404; OTEL принимает телеметрию.
## Этап 13 — Bitrix24
- [ ] URL установки, обработчика и placement используют точные публичные HTTPS-пути.
- [ ] Коннектор `han_mobile_app` активен в Открытой линии 8; события привязаны однократно.
- [ ] OAuth зашифрован; callback-, application- и service-токены не попадают в логи.
- [ ] Исходящие сообщения и ответы оператора идемпотентны; внутренний статус не опубликован наружу.
## Этап 14 — smoke- и E2E-тесты
```sh
deployment/scripts/smoke.sh
```
- [ ] Успешны сценарии гостя, OTP/PKCE/bootstrap/session, обновления токена и выхода.
- [ ] Проверены Safety allow/deny/pending/timeout и один параллельный медленный poll.
- [ ] Проверены карантин, перенос и удаление файлов, скачивание только владельцем и аудит.
- [ ] Проверены переподключение WS с REST-сверкой, 404 при обращении к чужому ресурсу, идемпотентность и 429.
- [ ] От имени `producer_test` выполнены Create и Cancel через закрытый `/internal/notifications/v1/*`; тот же Create вернул `200`, изменённый payload — `409`, внешний запрос — `404`.
- [ ] Проверены expire job с advisory lock и первое скачивание любого связанного документа: уведомление скрывается один раз, а исходный `date_expired` не перезаписывается.
- [ ] Логи не содержат PII, текстов сообщений, токенов и query-параметров presigned URL.
## Этап 15 — наблюдаемость
- [ ] Известный request ID связывает трассировку nginx, API и downstream-сервисов; UX ID не используется как label.
- [ ] Все три сигнала поступают в выбранный бэкенд; SLO-запросы и оповещения проверены.
- [ ] При недоступности удаленного сервиса ограниченная постоянная очередь заполняется и опустошается без остановки бизнес-функций.
- [ ] Тестовые секреты и PII отсутствуют; проверены метрики перезапуска, потерь, отказов и очереди Collector.
Только для локальной приемки запустите отладочный Collector с удалением чувствительных данных:
`docker compose --profile observability-local up -d otel-collector-local`.
## Этап 16 — открытие трафика
- [ ] Этапы 0–15 подписаны; имеются свежие подтверждения backup/PITR и предыдущие образы.
- [ ] HSTS включен; дайджесты релиза, версии схем и realm зафиксированы.
- [ ] Активных инцидентов нет; дежурный и владелец продукта приняли ограничения заглушек.
- [ ] В течение 60 минут контролируются 5xx, auth, доставка, БД, Redis, OOM, очередь OTEL и Bitrix.
## Резервное копирование и восстановление
Основным механизмом являются backup/PITR провайдера. Дополнительный проверенный логический дамп:
```sh
deployment/scripts/backup.sh /opt/han-chat/backups
```
Ежеквартально восстанавливайте PostgreSQL и S3 в изолированной VPC, развертывайте те же
дайджесты образов, не направляйте туда production DNS и callbacks Bitrix, выполняйте
smoke-тесты и фиксируйте фактические RPO/RTO. Redis можно восстановить пустым:
его AOF/RDB не является резервной копией бизнес-данных.
## Откат
Откатывайтесь только на образы, совместимые с текущей схемой:
```sh
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
deployment/scripts/smoke.sh
```
Откат использует текущие runtime-секреты и текущий несекретный config. Snapshot
старого `.env` не создаётся и не восстанавливается.
## Дополнение: rollout реальной SMS-авторизации
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости.
Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
с неопределенным статусом не было отправлено повторно.
Старый single-VM/stub порядок намеренно удалён. Он не является основанием для
production acceptance, rollback или cutover Message Safety на ВМ2. Не
восстанавливайте из истории команды прямого Docker-доступа пользователя
`deploy`.
@@ -32,8 +32,8 @@ x-api-job-environment: &api-job-environment
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth}
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api}
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-https://processing.internal:8443}
MESSAGE_SAFETY_API_PREFIX: ${MESSAGE_SAFETY_API_PREFIX:-/internal/safety/v2}
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
@@ -46,9 +46,22 @@ x-api-job-environment: &api-job-environment
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32}
x-python-job-runtime: &python-job-runtime
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 512m
cpus: 0.5
ulimits:
core: {soft: 0, hard: 0}
services:
migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
<<: *python-job-runtime
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: DATABASE_URL
@@ -60,12 +73,10 @@ services:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-bitrix-local:
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
<<: *python-job-runtime
image: ${BITRIX_LOCAL_APP_IMAGE:?BITRIX_LOCAL_APP_IMAGE must be pinned by digest}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: BITRIX_DATABASE_URL
@@ -77,29 +88,10 @@ services:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-bitrix-sync:
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL
BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url
secrets:
- bitrix_sync_database_url
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-sms:
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
<<: *python-job-runtime
image: ${SMS_SERVICE_IMAGE:?SMS_SERVICE_IMAGE must be pinned by digest}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: SMS_DATABASE_URL
@@ -111,12 +103,10 @@ services:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
seed-settings:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
<<: *python-job-runtime
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
profiles: ["ops"]
environment: *api-job-environment
secrets: *api-job-secrets
@@ -133,15 +123,17 @@ services:
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
toolbox:
image: curlimages/curl:8.11.1
image: ${TOOLBOX_IMAGE:?TOOLBOX_IMAGE must be pinned by digest}
profiles: ["ops"]
entrypoint: ["sleep", "infinity"]
networks: [backend, observability, egress]
restart: "no"
read_only: true
tmpfs: ["/tmp:size=16m,mode=1777"]
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
pids_limit: 32
mem_limit: 64m
cpus: 0.25
@@ -0,0 +1,33 @@
[Unit]
Description=HAN Chat VM1 root Compose stack (%i)
Requires=docker.service han-secrets@%i.service
After=docker.service han-secrets@%i.service network-online.target
Wants=network-online.target
ConditionPathIsDirectory=/opt/han-chat/current/backend
[Service]
Type=oneshot
RemainAfterExit=yes
User=root
Group=root
WorkingDirectory=/opt/han-chat/current/backend
ExecStart=/usr/local/sbin/han-vm1-compose up -d --remove-orphans
ExecReload=/usr/local/sbin/han-vm1-compose up -d --remove-orphans
ExecStop=/usr/local/sbin/han-vm1-compose stop
TimeoutStartSec=600
TimeoutStopSec=180
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
LimitCORE=0
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,292 @@
#!/bin/sh
set -eu
ROOT=${1:-/opt/han-chat/current/backend}
ENV_FILE=${2:-/etc/han/vm1.env}
MANIFEST=${3:-/run/han-chat/secrets/manifest}
failures=0
fail() {
echo "FAIL: $*" >&2
failures=$((failures + 1))
}
value() {
awk -F= -v key="$1" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE"
}
[ "$(id -u)" -eq 0 ] || fail "preflight must run as root"
[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing"
[ -f "$ENV_FILE" ] || fail ".env is missing"
[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing"
[ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link"
[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] ||
fail "active release link must be root:root"
nginx_compose="$ROOT/nginx/docker-compose.yml"
[ -f "$nginx_compose" ] || fail "nginx compose fragment is missing"
if [ -f "$nginx_compose" ]; then
grep -Fq '/var/lib/han-chat/public-tls' "$nginx_compose" &&
grep -Fq '/run/tls' "$nginx_compose" ||
fail "nginx must mount host public-tls staging at /run/tls"
grep -Fq '/var/lib/han-chat/acme' "$nginx_compose" ||
fail "nginx must mount host ACME webroot"
! grep -Fq '/etc/letsencrypt' "$nginx_compose" ||
fail "nginx/certbot compose must not mount the root Certbot tree"
! grep -Eq '^[[:space:]]+certbot:' "$nginx_compose" ||
fail "certbot must not be a Compose service"
! grep -Eq 'nginx-(certs|acme)' "$nginx_compose" ||
fail "legacy nginx TLS named volumes are forbidden"
fi
! grep -Eq '^[[:space:]]+nginx-(certs|acme)' "$ROOT/docker-compose.yml" ||
fail "legacy nginx TLS named volumes are forbidden"
if [ -d "$ROOT" ]; then
if find "$ROOT" -type l -print -quit | grep -q .; then
fail "active release contains a symlink"
fi
while IFS= read -r protected; do
[ -f "$protected" ] || {
fail "protected deployment file is missing: $protected"
continue
}
[ "$(/usr/bin/stat -c '%U:%G' "$protected")" = root:root ] ||
fail "$protected must be root:root"
mode=$(/usr/bin/stat -c '%A' "$protected")
case "$mode" in
??????w???|????????w?) fail "$protected is writable by group/other" ;;
esac
done <<EOF
$ROOT/docker-compose.yml
$ROOT/deployment/preflight.sh
$ROOT/deployment/han-stack@.service
$ROOT/deployment/scripts/tls-deploy-hook.sh
$ROOT/deployment/secrets/han-compose
$ROOT/deployment/secrets/han-secrets
EOF
fi
for executable in \
"$ROOT/deployment/preflight.sh" \
"$ROOT/deployment/scripts/tls-deploy-hook.sh" \
"$ROOT/deployment/secrets/han-compose" \
"$ROOT/deployment/secrets/han-secrets"
do
[ -x "$executable" ] || fail "required executable is not executable: $executable"
if [ -f "$executable" ] && LC_ALL=C grep -q "$(printf '\r')" "$executable"; then
fail "CRLF is forbidden in executable: $executable"
fi
done
if [ -f "$ENV_FILE" ]; then
if grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then
fail ".env contains a secret-shaped assignment"
fi
if grep -Eq '=<[^>]+>|change-me|example\.(com|org|net)|\.invalid([:/]|$)' "$ENV_FILE"; then
fail ".env contains placeholders"
fi
[ "$(value APP_ENV)" = production ] || fail "APP_ENV must be production"
[ "$(value SECRETS_SOURCE)" = selectel ] || fail "production SECRETS_SOURCE must be selectel"
[ "$(value FRONTEND_DEV_PROXY_ENABLED)" = false ] ||
fail "FRONTEND_DEV_PROXY_ENABLED must be false"
[ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true"
[ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80"
[ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443"
[ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] ||
fail "nginx certificate must use staged /run/tls/fullchain.pem"
[ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] ||
fail "nginx key must use staged /run/tls/privkey.pem"
safety_url=$(value MESSAGE_SAFETY_URL)
echo "$safety_url" | grep -Eq '^https://[A-Za-z0-9.-]+:8443$' ||
fail "MESSAGE_SAFETY_URL must be private HTTPS VM2 :8443"
echo "$safety_url" | grep -Eq '(message-safety|localhost|127\.0\.0\.1)' &&
fail "MESSAGE_SAFETY_URL must not point to a local/stub service"
safety_extra_host=$(value MESSAGE_SAFETY_EXTRA_HOST)
if ! python3 - "$safety_url" "$safety_extra_host" <<'PY'
import ipaddress
import socket
import sys
from urllib.parse import urlparse
host = urlparse(sys.argv[1]).hostname
try:
mapped_host, mapped_ip = sys.argv[2].rsplit("=", 1)
mapped_address = ipaddress.ip_address(mapped_ip)
except (IndexError, ValueError):
raise SystemExit(1)
allowed = (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("fc00::/7"),
)
if (
not host
or mapped_host != host
or not any(mapped_address in network for network in allowed)
):
raise SystemExit(1)
try:
addresses = {
ipaddress.ip_address(item[4][0])
for item in socket.getaddrinfo(host, 8443, type=socket.SOCK_STREAM)
}
except OSError:
raise SystemExit(1)
if (
not addresses
or mapped_address not in addresses
or any(not any(address in network for network in allowed) for address in addresses)
):
raise SystemExit(1)
PY
then
fail "MESSAGE_SAFETY_URL hostname must resolve only to private VPC addresses and MESSAGE_SAFETY_EXTRA_HOST must map the same address"
fi
safety_ca=$(value MESSAGE_SAFETY_CA_HOST_PATH)
[ -n "$safety_ca" ] || fail "MESSAGE_SAFETY_CA_HOST_PATH is required"
[ -f "$safety_ca" ] || fail "VM2 internal CA file is missing"
pg_ca=$(value PG_CA_HOST_PATH)
[ -f "$pg_ca" ] || fail "managed PostgreSQL CA file is missing"
for image_key in \
API_BACKEND_IMAGE \
BITRIX_LOCAL_APP_IMAGE \
FRONTEND_STATIC_IMAGE \
KEYCLOAK_IMAGE \
NGINX_IMAGE \
OTEL_COLLECTOR_IMAGE \
OTEL_QUEUE_INIT_IMAGE \
REDIS_IMAGE \
SMS_SERVICE_IMAGE \
TOOLBOX_IMAGE
do
image=$(value "$image_key")
echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' ||
fail "$image_key must be pinned by sha256 digest"
case "$image" in
registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
fail "$image_key still contains the example image/digest"
;;
esac
done
fi
resolved_config=$(mktemp)
resolved_services=$(mktemp)
resolved_images=$(mktemp)
trap 'rm -f "$resolved_config" "$resolved_services" "$resolved_images"' EXIT HUP INT TERM
if ! command -v docker >/dev/null 2>&1; then
fail "docker is required to resolve production Compose"
elif [ -f "$ENV_FILE" ]; then
if ! docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
config >"$resolved_config"; then
fail "production Compose does not resolve"
else
docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
config --services >"$resolved_services" ||
fail "cannot enumerate resolved production services"
docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" \
config --images >"$resolved_images" ||
fail "cannot enumerate resolved production images"
while IFS= read -r image; do
[ -n "$image" ] || continue
echo "$image" | grep -Eq '@sha256:[0-9a-f]{64}$' ||
fail "resolved production image is not digest-pinned: $image"
case "$image" in
registry.example.ru/*|*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
fail "resolved production image still contains an example value: $image"
;;
esac
done <"$resolved_images"
[ -s "$resolved_images" ] || fail "resolved production image set is empty"
for forbidden_service in certbot message-safety bitrix-sync otel-collector-local; do
! grep -Fxq "$forbidden_service" "$resolved_services" ||
fail "local/legacy service resolved in production: $forbidden_service"
done
published_services=$(
awk '
/^services:$/ { in_services=1; next }
in_services && /^[^ ]/ { in_services=0 }
in_services && /^ [A-Za-z0-9_.-]+:$/ {
service=$1
sub(/:$/, "", service)
next
}
in_services && /^ ports:$/ { print service }
' "$resolved_config"
)
[ "$published_services" = nginx ] ||
fail "only nginx may publish production host ports (found: ${published_services:-none})"
fi
fi
tls_dir=/var/lib/han-chat/public-tls
getent group han-nginx-tls | awk -F: '$3 == 11001 {found=1} END {exit !found}' ||
fail "han-nginx-tls group with GID 11001 is missing"
[ "$(/usr/bin/stat -c '%U:%G:%a' "$tls_dir" 2>/dev/null || true)" = root:han-nginx-tls:750 ] ||
fail "public TLS directory must be root:han-nginx-tls 0750"
for tls_file in fullchain.pem privkey.pem; do
path="$tls_dir/$tls_file"
[ -s "$path" ] || {
fail "public TLS file is missing: $path"
continue
}
[ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = root:han-nginx-tls:640 ] ||
fail "$path must be root:han-nginx-tls 0640"
done
if [ -s "$tls_dir/fullchain.pem" ] && [ -s "$tls_dir/privkey.pem" ]; then
cert_public=$(
openssl x509 -in "$tls_dir/fullchain.pem" -pubkey -noout 2>/dev/null |
openssl pkey -pubin -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
) || cert_public=
key_public=$(
openssl pkey -in "$tls_dir/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
) || key_public=
[ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] ||
fail "public TLS certificate/private key are invalid or do not match"
fi
required_secrets='
DATABASE_URL
REDIS_URL
REDIS_REALTIME_URL
MESSAGE_SAFETY_SERVICE_TOKEN
BITRIX_DATABASE_URL
SMS_DATABASE_URL
KEYCLOAK_DB_PASSWORD
KEYCLOAK_ADMIN_PASSWORD
SELECTEL_S3_ACCESS_KEY
SELECTEL_S3_SECRET_KEY
OTEL_REMOTE_AUTH_HEADER'
if [ -f "$MANIFEST" ]; then
old_ifs=$IFS
IFS='
'
for name in $required_secrets; do
[ -n "$name" ] || continue
path=$(awk -F= -v key="$name" '$1 == key {print substr($0, index($0, "=") + 1)}' "$MANIFEST")
[ -n "$path" ] || {
fail "manifest is missing $name"
continue
}
[ -f "$path" ] || fail "secret file is missing for $name"
done
IFS=$old_ifs
fi
if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then
"$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" ||
fail "config/runtime validator rejected the production inputs"
else
fail "config/runtime validator or its inputs are unavailable"
fi
if [ "$failures" -ne 0 ]; then
echo "preflight: $failures failure(s); VM1 deployment remains closed" >&2
exit 1
fi
echo "preflight: static VM1 production gates passed"
@@ -25,10 +25,8 @@ fi
docker compose --env-file "$CONFIG_FILE" config --quiet
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms
echo "Migrations completed; record revisions in release evidence."
@@ -0,0 +1,189 @@
#!/bin/sh
# Print frontend build-time env derived from deployment .env.
# Usage:
# ./deployment/scripts/print-frontend-env.sh
# CONFIG_FILE=/opt/han-chat/backend/.env ./deployment/scripts/print-frontend-env.sh --check-oidc
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
CHECK_OIDC=0
FRONTEND_CLIENT_ID=${FRONTEND_CLIENT_ID:-han-chat-frontend}
for arg in "$@"; do
case "$arg" in
--check-oidc) CHECK_OIDC=1 ;;
-h|--help)
cat <<'EOF'
Usage: print-frontend-env.sh [--check-oidc]
Reads deployment .env and prints EXPO_PUBLIC_* build args for frontend-test-site.
Environment:
CONFIG_FILE path to .env (default: .env)
FRONTEND_CLIENT_ID OIDC client id (default: han-chat-frontend)
Options:
--check-oidc curl OpenID discovery using derived auth base URL
EOF
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 64
;;
esac
done
[ -f "$CONFIG_FILE" ] || {
echo "Config file not found: $CONFIG_FILE" >&2
exit 66
}
env_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import sys
from pathlib import Path
path, wanted = sys.argv[1:]
found = False
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value)
found = True
break
if not found:
raise SystemExit(f"missing environment variable: {wanted}")
PY
}
env_value_or_default() {
python3 - "$CONFIG_FILE" "$1" "$2" <<'PY'
import sys
from pathlib import Path
path, wanted, default = sys.argv[1:]
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value or default)
break
else:
print(default)
PY
}
strip_trailing_slash() {
python3 - "$1" <<'PY'
import sys
print(sys.argv[1].rstrip("/"))
PY
}
PUBLIC_HOST=$(env_value PUBLIC_HOST)
PUBLIC_WEB_URL=$(strip_trailing_slash "$(env_value PUBLIC_WEB_URL)")
PUBLIC_AUTH_URL=$(strip_trailing_slash "$(env_value_or_default PUBLIC_AUTH_URL "")")
KEYCLOAK_PUBLIC_URL=$(strip_trailing_slash "$(env_value KEYCLOAK_PUBLIC_URL)")
KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM)
APP_ENV=$(env_value_or_default APP_ENV production-like)
FRONTEND_STATIC_IMAGE=$(env_value_or_default FRONTEND_STATIC_IMAGE "")
if [ -z "$PUBLIC_AUTH_URL" ]; then
PUBLIC_AUTH_URL="$KEYCLOAK_PUBLIC_URL"
fi
EXPO_PUBLIC_API_BASE_URL="$PUBLIC_WEB_URL"
EXPO_PUBLIC_AUTH_BASE_URL="$PUBLIC_AUTH_URL"
EXPO_PUBLIC_KEYCLOAK_REALM="$KEYCLOAK_REALM"
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID="$FRONTEND_CLIENT_ID"
EXPO_PUBLIC_APP_ENV="$APP_ENV"
OIDC_ISSUER="${EXPO_PUBLIC_AUTH_BASE_URL}/realms/${EXPO_PUBLIC_KEYCLOAK_REALM}"
OIDC_DISCOVERY="${OIDC_ISSUER}/.well-known/openid-configuration"
errors=0
warn() {
printf 'WARN: %s\n' "$1" >&2
errors=$((errors + 1))
}
for name in \
EXPO_PUBLIC_API_BASE_URL \
EXPO_PUBLIC_AUTH_BASE_URL \
EXPO_PUBLIC_KEYCLOAK_REALM \
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID; do
eval "value=\$$name"
case "$value" in
""|http://localhost:*|https://chat.example.ru*|https://tohin.ru*)
warn "$name looks unset or still uses example/default value: '$value'"
;;
esac
done
printf '%s\n' "=== deployment .env (source: ${CONFIG_FILE}) ==="
printf '%s=%s\n' PUBLIC_HOST "$PUBLIC_HOST"
printf '%s=%s\n' PUBLIC_WEB_URL "$PUBLIC_WEB_URL"
printf '%s=%s\n' PUBLIC_AUTH_URL "$PUBLIC_AUTH_URL"
printf '%s=%s\n' KEYCLOAK_PUBLIC_URL "$KEYCLOAK_PUBLIC_URL"
printf '%s=%s\n' KEYCLOAK_REALM "$KEYCLOAK_REALM"
printf '%s=%s\n' APP_ENV "$APP_ENV"
printf '%s=%s\n' FRONTEND_STATIC_IMAGE "${FRONTEND_STATIC_IMAGE:-<not set>}"
printf '\n%s\n' "=== frontend build-time env (EXPO_PUBLIC_*) ==="
printf '%s=%s\n' EXPO_PUBLIC_API_BASE_URL "$EXPO_PUBLIC_API_BASE_URL"
printf '%s=%s\n' EXPO_PUBLIC_AUTH_BASE_URL "$EXPO_PUBLIC_AUTH_BASE_URL"
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_REALM "$EXPO_PUBLIC_KEYCLOAK_REALM"
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_CLIENT_ID "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID"
printf '%s=%s\n' EXPO_PUBLIC_APP_ENV "$EXPO_PUBLIC_APP_ENV"
printf '\n%s\n' "=== derived OIDC ==="
printf '%s=%s\n' OIDC_ISSUER "$OIDC_ISSUER"
printf '%s=%s\n' OIDC_DISCOVERY "$OIDC_DISCOVERY"
printf '%s=%s\n' AUTH_CALLBACK "${PUBLIC_WEB_URL}/auth/callback"
printf '\n%s\n' "=== docker build (frontend-test-site) ==="
cat <<EOF
docker build --target static \\
-t han-chat-frontend-static:local \\
--build-arg EXPO_PUBLIC_API_BASE_URL=${EXPO_PUBLIC_API_BASE_URL} \\
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=${EXPO_PUBLIC_AUTH_BASE_URL} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=${EXPO_PUBLIC_KEYCLOAK_REALM} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=${EXPO_PUBLIC_KEYCLOAK_CLIENT_ID} \\
--build-arg EXPO_PUBLIC_APP_ENV=${EXPO_PUBLIC_APP_ENV} \\
frontend-test-site
EOF
printf '\n%s\n' "=== redeploy static into compose volume ==="
cat <<'EOF'
docker compose --env-file .env up -d --force-recreate frontend-static
docker compose --env-file .env up -d --force-recreate nginx
EOF
if [ "$CHECK_OIDC" -eq 1 ]; then
printf '\n%s\n' "=== OIDC discovery check ==="
curl -fsS "$OIDC_DISCOVERY" | python3 - <<'PY'
import json, sys
doc = json.load(sys.stdin)
print("issuer=", doc.get("issuer"))
print("authorization_endpoint=", doc.get("authorization_endpoint"))
PY
fi
if [ "$errors" -gt 0 ]; then
printf '\nFound %s warning(s). Fix .env or rebuild frontend with the command above.\n' "$errors" >&2
exit 1
fi
printf '\nOK: frontend build env looks consistent with %s\n' "$CONFIG_FILE"
@@ -1,51 +1,33 @@
#!/usr/bin/env bash
# Первичная подготовка Ubuntu 24.04 для HAN Chat.
# Fresh Ubuntu 24.04 bootstrap for production VM1. This script never starts
# Compose/application containers and never creates DNS, PG, S3, TLS or secrets.
# Run twice: first with HARDEN_SSH=false, then only after independent deploy and
# admin login/sudo checks with HARDEN_SSH=true.
#
# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты,
# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта.
# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает
# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты.
# Required:
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub
#
# Запуск на свежей VM:
# chmod +x deployment/scripts/setup-vm.sh
# sudo deployment/scripts/setup-vm.sh
#
# Основные параметры:
# DEPLOY_USER=deploy
# DEPLOY_DIR=/opt/han-chat/backend
# SSH_PORT=22
# TIMEZONE=Europe/Moscow
# SWAP_SIZE_GB=4
# EXTERNAL_IF=ens3
# PUBLIC_DOCKER_PORTS=80,443
# COPY_SSH_KEYS=true
# HARDEN_SSH=false
# LOCK_ACCOUNT_PASSWORDS=true
# HSTS_MAX_AGE_SECONDS=31536000
# RESET_UFW=false
# SKIP_APT_UPGRADE=false
#
# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
# запрещает прямой root-вход и SSH TCP forwarding.
# Optional: SSH_PORT=22 EXTERNAL_IF=ens3 SWAP_SIZE_GB=4 HARDEN_SSH=false
set -Eeuo pipefail
IFS=$'\n\t'
DEPLOY_USER="${DEPLOY_USER:-deploy}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}"
ADMIN_USER="${ADMIN_USER:-admin}"
DEPLOY_AUTHORIZED_KEY_FILE="${DEPLOY_AUTHORIZED_KEY_FILE:-}"
ADMIN_AUTHORIZED_KEY_FILE="${ADMIN_AUTHORIZED_KEY_FILE:-}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/current/backend}"
INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}"
SSH_PORT="${SSH_PORT:-22}"
TIMEZONE="${TIMEZONE:-Europe/Moscow}"
SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}"
EXTERNAL_IF="${EXTERNAL_IF:-}"
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
HARDEN_SSH="${HARDEN_SSH:-false}"
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}"
RESET_UFW="${RESET_UFW:-false}"
RESET_UFW="${RESET_UFW:-true}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
@@ -73,17 +55,26 @@ require_root() {
}
validate_parameters() {
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
[[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем"
[[ "$DEPLOY_USER" == deploy ]] || die "Production unit/sudoers требуют DEPLOY_USER=deploy"
[[ "$ADMIN_USER" == admin ]] || die "Break-glass роль должна называться admin"
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "Задайте безопасный абсолютный DEPLOY_AUTHORIZED_KEY_FILE"
[[ "$ADMIN_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "Задайте безопасный абсолютный ADMIN_AUTHORIZED_KEY_FILE"
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" != "$ADMIN_AUTHORIZED_KEY_FILE" ]] \
|| die "deploy и admin должны использовать разные key files"
[[ "$DEPLOY_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный DEPLOY_DIR"
[[ "$INCOMING_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Некорректный INCOMING_DIR"
[[ "$DEPLOY_DIR" != "$INCOMING_DIR" ]] || die "Production и incoming должны различаться"
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом"
((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
[[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом"
[[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \
|| die "HSTS_MAX_AGE_SECONDS должен быть целым числом"
((HSTS_MAX_AGE_SECONDS >= 31536000)) \
|| die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000"
[[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
[[ "$HARDEN_SSH" == true || "$HARDEN_SSH" == false ]] || die "Некорректный HARDEN_SSH"
[[ "$LOCK_ACCOUNT_PASSWORDS" == true || "$LOCK_ACCOUNT_PASSWORDS" == false ]] \
|| die "Некорректный LOCK_ACCOUNT_PASSWORDS"
[[ "$RESET_UFW" == true || "$RESET_UFW" == false ]] || die "Некорректный RESET_UFW"
[[ "$SKIP_APT_UPGRADE" == true || "$SKIP_APT_UPGRADE" == false ]] \
|| die "Некорректный SKIP_APT_UPGRADE"
}
check_os() {
@@ -107,7 +98,7 @@ update_system() {
apt-get install -y \
ca-certificates \
curl \
dos2unix \
certbot \
fail2ban \
git \
gnupg \
@@ -117,11 +108,10 @@ update_system() {
netcat-openbsd \
openssl \
python3 \
python3-venv \
rsync \
sudo \
unattended-upgrades \
ufw
apt-get autoremove -y
}
configure_time() {
@@ -130,65 +120,79 @@ configure_time() {
timedatectl set-ntp true
}
create_deploy_user() {
step "Пользователь развертывания"
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$DEPLOY_USER"
log "Создан пользователь ${DEPLOY_USER}"
else
log "Пользователь ${DEPLOY_USER} уже существует"
fi
install_authorized_key() {
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
[[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать один ключ"
ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH key ${source}"
grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \
|| die "Разрешены только Ed25519 ключи"
install -d -m 0700 -o "$user" -g "$user" "/home/${user}/.ssh"
install -m 0600 -o "$user" -g "$user" "$source" "$target"
}
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
"/home/${DEPLOY_USER}/.ssh"
local source_user="${SUDO_USER:-}"
local source_keys=""
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
if [[ -n "$source_user" && "$source_user" != "root" ]]; then
source_keys="/home/${source_user}/.ssh/authorized_keys"
elif [[ -s /root/.ssh/authorized_keys ]]; then
source_user="root"
source_keys="/root/.ssh/authorized_keys"
fi
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys"
log "SSH-ключи скопированы от ${source_user}"
fi
if [[ ! -s "$target_keys" ]]; then
log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys"
create_host_roles() {
step "Роли deploy и break-glass admin"
id "$DEPLOY_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$DEPLOY_USER"
id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER"
install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE"
install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE"
local deploy_key admin_key group
deploy_key="$(awk '{print $2}' "$DEPLOY_AUTHORIZED_KEY_FILE")"
admin_key="$(awk '{print $2}' "$ADMIN_AUTHORIZED_KEY_FILE")"
[[ "$deploy_key" != "$admin_key" ]] || die "deploy/admin используют один ключ"
if [[ -s /root/.ssh/authorized_keys ]] &&
{ grep -Fq "$deploy_key" /root/.ssh/authorized_keys ||
grep -Fq "$admin_key" /root/.ssh/authorized_keys; }; then
die "deploy/admin key совпадает с root key"
fi
for group in docker sudo lxd adm systemd-journal; do
if getent group "$group" >/dev/null &&
id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$group"; then
gpasswd -d "$DEPLOY_USER" "$group"
fi
done
usermod -aG sudo "$ADMIN_USER"
for group in docker lxd; do
if getent group "$group" >/dev/null &&
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$group"; then
gpasswd -d "$ADMIN_USER" "$group"
fi
done
}
configure_account_passwords() {
step "Блокировка локальных паролей привилегированных учетных записей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
return
step "Пароли host-ролей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then
passwd --lock root
passwd --lock "$DEPLOY_USER"
fi
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст"
passwd --lock root
passwd --lock "$DEPLOY_USER"
log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен"
local status
status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')"
if [[ "$HARDEN_SSH" == true && "$status" != P ]]; then
die "До HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}"
fi
[[ "$status" == P ]] || log "ПРЕДУПРЕЖДЕНИЕ: задайте sudo-пароль admin"
}
configure_layout() {
step "Каталоги HAN Chat"
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
"${DEPLOY_DIR}/secrets" \
"${DEPLOY_DIR}/secrets/pg" \
"${DEPLOY_DIR}/backups"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
chmod 600 "$env_file"
step "Root-owned releases и incoming staging"
install -d -m 0755 -o root -g root /opt/han-chat /opt/han-chat/releases
install -d -m 0755 -o root -g root /var/lib/han-deploy
install -d -m 0750 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$INCOMING_DIR"
install -d -m 0700 -o root -g root /etc/han /etc/han/secrets /etc/han/credentials
install -d -m 0755 -o root -g root /etc/han/ca /var/lib/han-chat/acme
if [[ -f /etc/han/vm1.env ]]; then
[[ "$(stat -c '%U:%G:%a' /etc/han/vm1.env)" == root:root:600 ]] \
|| die "/etc/han/vm1.env должен быть root:root 0600"
fi
if [[ -d "$DEPLOY_DIR" ]]; then
if find "$DEPLOY_DIR" ! -user root -print -quit | grep -q .; then
die "Активный release содержит файлы не root-owned"
fi
if find "$DEPLOY_DIR" -perm /022 -print -quit | grep -q .; then
die "Активный release доступен на запись group/other"
fi
fi
}
@@ -261,7 +265,6 @@ install_docker() {
EOF
systemctl enable --now docker
systemctl restart docker
usermod -aG docker "$DEPLOY_USER"
docker compose version >/dev/null \
|| die "Docker Compose plugin не установлен"
@@ -276,8 +279,7 @@ configure_ufw() {
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH'
ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit'
ufw allow "$SSH_PORT"/tcp comment 'HAN VM1 SSH temporary public access'
ufw allow 80/tcp comment 'HAN Chat HTTP'
ufw allow 443/tcp comment 'HAN Chat HTTPS'
ufw logging medium
@@ -317,7 +319,6 @@ configure_docker_firewall() {
step "Фильтрация опубликованных Docker-портов"
cat >/etc/default/han-chat-docker-firewall <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
EOF
cat >/usr/local/sbin/han-chat-docker-firewall <<'FIREWALL'
@@ -341,14 +342,10 @@ iptables -F HAN-CHAT-DOCKER
iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A HAN-CHAT-DOCKER -i lo -j RETURN
IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS"
for port in "${ports[@]}"; do
[[ "$port" =~ ^[0-9]+$ ]] || {
echo "Некорректный порт: $port" >&2
exit 1
}
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN
done
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 80 -j RETURN
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 443 -j RETURN
# Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge.
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
@@ -384,7 +381,8 @@ EOF
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
EOF
systemctl daemon-reload
systemctl enable --now han-chat-docker-firewall.service
systemctl enable han-chat-docker-firewall.service
systemctl restart han-chat-docker-firewall.service
}
configure_ssh() {
@@ -396,6 +394,9 @@ configure_ssh() {
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 120
@@ -403,13 +404,13 @@ ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PermitRootLogin no
AllowTcpForwarding no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
log "Root SSH отключён; разрешены deploy и admin"
else
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
log "Root SSH пока не отключён: выполните двухфазную проверку"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
@@ -417,40 +418,45 @@ EOF
systemctl reload ssh
}
configure_application_security() {
step "Безопасные HTTP-заголовки приложения"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
else
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file"
fi
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
chmod 600 "$env_file"
log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}"
else
log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию"
fi
install_deploy_sudoers() {
step "Exact sudoers для deploy"
cat >/etc/sudoers.d/deploy <<'EOF'
Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service
Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
EOF
chmod 0440 /etc/sudoers.d/deploy
visudo -cf /etc/sudoers.d/deploy >/dev/null || die "Некорректный sudoers"
}
install_secret_loader_if_possible() {
step "Загрузчик секретов"
install_release_helpers_if_possible() {
step "Root-owned helpers, TLS hook и units"
local source_dir="${DEPLOY_DIR}/deployment/secrets"
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
local deployment="${DEPLOY_DIR}/deployment"
local tls_group=han-nginx-tls tls_gid=11001
if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" ||
! -f "${source_dir}/secrets_loader.py" ||
! -f "${deployment}/han-stack@.service" ]]; then
log "Активный релиз отсутствует; повторите после root-активации"
return
fi
chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose"
install -d -m 0700 -o root -g root \
/etc/han \
/etc/han/secrets \
/etc/han/credentials
install -d -m 0755 -o root -g root \
/usr/local/lib/han-secrets \
/usr/local/share/doc/han-secrets
if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then
die "Внутри активного release обнаружен symlink"
fi
[[ -x "${deployment}/preflight.sh" ]] || die "preflight.sh не executable"
[[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable"
[[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable"
[[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable"
if getent group "$tls_group" >/dev/null; then
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|| die "han-nginx-tls имеет неожиданный GID"
elif getent group "$tls_gid" >/dev/null; then
die "GID ${tls_gid} уже занят"
else
groupadd --system --gid "$tls_gid" "$tls_group"
fi
install -d -m 0750 -o root -g "$tls_group" /var/lib/han-chat/public-tls
install -d -m 0755 -o root -g root /usr/local/lib/han-secrets /usr/local/share/doc/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/secrets_loader.py" \
/usr/local/lib/han-secrets/secrets_loader.py
@@ -459,56 +465,23 @@ install_secret_loader_if_possible() {
/usr/local/lib/han-secrets/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/han-compose" \
/usr/local/bin/han-compose
/usr/local/sbin/han-vm1-compose
install -m 0644 -o root -g root \
"${source_dir}/han-secrets@.service" \
/etc/systemd/system/han-secrets@.service
install -m 0644 -o root -g root \
"${deployment}/han-stack@.service" \
/etc/systemd/system/han-stack@.service
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
install -m 0755 -o root -g root \
"${deployment}/scripts/tls-deploy-hook.sh" \
/etc/letsencrypt/renewal-hooks/deploy/han-chat-nginx
install -m 0644 -o root -g root \
"${source_dir}/SELECTEL_RUNBOOK.ru.md" \
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then
install -m 0600 -o root -g root \
"${source_dir}/config.example.json" \
/etc/han/secrets/production.selectel.json.example
fi
systemctl daemon-reload
log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md"
}
install_ssl_timer_if_possible() {
step "Таймер продления TLS"
local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh"
if [[ ! -x "$renew_script" ]]; then
log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске"
return
fi
cat >/etc/systemd/system/han-chat-ssl-renew.service <<EOF
[Unit]
Description=Renew HAN Chat TLS certificate
After=docker.service
[Service]
Type=oneshot
User=${DEPLOY_USER}
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${renew_script}
EOF
cat >/etc/systemd/system/han-chat-ssl-renew.timer <<'EOF'
[Unit]
Description=Run HAN Chat TLS renewal twice daily
[Timer]
OnCalendar=*-*-* 03,15:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now han-chat-ssl-renew.timer
systemctl enable certbot.timer
log "Helpers установлены; application units не запущены"
}
verify() {
@@ -520,7 +493,21 @@ verify() {
iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null \
|| { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; }
docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; }
[[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; failed=1; }
if id -nG "$DEPLOY_USER" | tr ' ' '\n' |
grep -Eq '^(docker|sudo|lxd|adm|systemd-journal)$'; then
log "FAIL: deploy состоит в привилегированной группе"; failed=1
fi
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx sudo \
|| { log "FAIL: admin не состоит в sudo"; failed=1; }
[[ "$(stat -c '%U:%G:%a' "$INCOMING_DIR")" == deploy:deploy:750 ]] \
|| { log "FAIL: неверные права incoming"; failed=1; }
local external_if="${EXTERNAL_IF:-}"
[[ -n "$external_if" ]] || external_if="$(ip -4 route show default | awk '{print $5; exit}')"
for port in 80 443; do
iptables -C HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport "$port" -j RETURN 2>/dev/null \
|| { log "FAIL: нет original host port ${port}"; failed=1; }
done
((failed == 0)) || die "Базовая проверка VM не пройдена"
log "Базовая проверка VM пройдена"
}
@@ -529,35 +516,17 @@ summary() {
step "Настройка VM завершена"
cat <<EOF | tee -a "$LOG_FILE"
Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${LOG_FILE}
Deploy: ${DEPLOY_USER} (без docker/sudo/lxd/adm/journal)
Break-glass: ${ADMIN_USER} (sudo с отдельным паролем)
Incoming: ${INCOMING_DIR}
Активный release: ${DEPLOY_DIR}
Public ingress: 80,443
SSH ingress: ${SSH_PORT}/tcp из любой сети (временно)
Root SSH hardening: ${HARDEN_SSH}
Лог: ${LOG_FILE}
Следующие действия:
1. Проверьте вход в новой SSH-сессии:
ssh ${DEPLOY_USER}@<VM_IP>
2. Скопируйте содержимое codebase/backend в:
${DEPLOY_DIR}
3. Поместите CA PostgreSQL:
${DEPLOY_DIR}/secrets/pg/ca.pem
4. Создайте только несекретный config:
cd ${DEPLOY_DIR}
cp .env.example .env
chmod 600 .env
./scripts/validate-env .env
5. Настройте Selectel, encrypted bootstrap credential и fallback map:
deployment/secrets/SELECTEL_RUNBOOK.ru.md
6. Выполняйте Compose только через:
sudo deployment/secrets/han-compose <command>
7. Продолжите с Gate 7 в:
deployment/RUNBOOK.ru.md
8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов.
Важно: членство в группе docker начнет действовать после нового входа в систему.
Продолжайте только по deployment/RUNBOOK.production.ru.md.
Скрипт не запускал Compose и прикладные сервисы.
EOF
}
@@ -570,7 +539,7 @@ main() {
check_os
update_system
configure_time
create_deploy_user
create_host_roles
configure_account_passwords
configure_layout
configure_swap
@@ -581,9 +550,8 @@ main() {
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
configure_application_security
install_secret_loader_if_possible
install_ssl_timer_if_possible
install_deploy_sudoers
install_release_helpers_if_possible
verify
summary
}
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
COMPOSE=/usr/local/sbin/han-vm1-compose
TLS_DIR=/var/lib/han-chat/public-tls
TLS_GROUP=han-nginx-tls
lineage=${RENEWED_LINEAGE:?Certbot did not provide RENEWED_LINEAGE}
test -s "$lineage/fullchain.pem"
test -s "$lineage/privkey.pem"
test -d "$TLS_DIR"
getent group "$TLS_GROUP" >/dev/null
staging=$(mktemp -d "${TLS_DIR}/.renew.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT HUP INT TERM
install -m 0640 -o root -g "$TLS_GROUP" \
"$lineage/fullchain.pem" "$staging/fullchain.pem"
install -m 0640 -o root -g "$TLS_GROUP" \
"$lineage/privkey.pem" "$staging/privkey.pem"
cert_public=$(
openssl x509 -in "$staging/fullchain.pem" -pubkey -noout |
openssl pkey -pubin -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
)
key_public=$(
openssl pkey -in "$staging/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
)
[ "$cert_public" = "$key_public" ] || {
echo "renewed certificate and private key do not match" >&2
exit 1
}
mv -f "$staging/fullchain.pem" "$TLS_DIR/fullchain.pem"
mv -f "$staging/privkey.pem" "$TLS_DIR/privkey.pem"
rmdir "$staging"
trap - EXIT HUP INT TERM
container=$("$COMPOSE" ps --status running --quiet nginx)
[ -n "$container" ] || {
echo "HAN VM1 nginx is not running" >&2
exit 1
}
if ! output=$("$COMPOSE" exec -T nginx nginx -t -c /tmp/nginx.conf 2>&1); then
printf '%s\n' "$output" >&2
exit 1
fi
/usr/bin/docker kill --signal HUP "$container" >/dev/null
@@ -44,19 +44,19 @@ TLS и redirect policy отключать нельзя.
## 2. Каталог секретов
Скопируйте `config.example.json` в
`/etc/han/secrets/production-like.selectel.json` и замените account, username,
`/etc/han/secrets/production.selectel.json` и замените account, username,
project, region и `remote`. Каноническое имя слева обязано совпадать с
Compose/validator; `remote` — неизменяемый ключ в Selectel.
Используйте консервативные provider keys с дефисами, например:
- `han-chat-prod-pg-han-app-dsn`, `han-chat-prod-pg-bitrix-local-dsn`,
`han-chat-prod-pg-bitrix-sync-dsn`, `han-chat-prod-pg-sms-dsn`,
`han-chat-prod-pg-sms-dsn`,
`han-chat-prod-pg-keycloak-password`, `han-chat-prod-pg-backup-dsn`;
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-safety-password`,
`han-chat-prod-redis-health-password`, а также три credential-bearing URL;
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-health-password`,
а также credential-bearing URL только для Redis DB0/DB1;
- `han-chat-prod-message-safety-token`, `han-chat-prod-bitrix-internal-token`,
`han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`,
`han-chat-prod-bitrix-forward-token`,
`han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`;
- Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock
действительно включён;
@@ -79,25 +79,25 @@ S3 и Bitrix sync credentials до появления потребляющего
## 3. Установка на ВМ
Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта
устанавливает loader, launcher, systemd template и этот runbook. Вручную:
Повторный запуск `deployment/scripts/setup-vm.sh` после root-активации release
устанавливает loader, launcher, systemd units и этот runbook. Production
установка вручную не поддерживается: точные owner/mode и пути задаёт setup.
Ожидаемые артефакты:
```sh
sudo install -d -m 0700 /etc/han/secrets /etc/han/credentials
sudo install -d -m 0755 /usr/local/lib/han-secrets
sudo install -m 0750 secrets_loader.py han-secrets /usr/local/lib/han-secrets/
sudo install -m 0750 han-compose /usr/local/bin/han-compose
sudo install -m 0644 han-secrets@.service /etc/systemd/system/
sudo install -m 0600 config.example.json \
/etc/han/secrets/production-like.selectel.json
test -x /usr/local/lib/han-secrets/han-secrets
test -x /usr/local/sbin/han-vm1-compose
test -f /etc/systemd/system/han-secrets@.service
test -f /etc/systemd/system/han-stack@.service
```
Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры.
`/etc/han/vm1.env` принадлежит root (`0600`) и содержит только несекретные
параметры. Config находится вне immutable release.
Штатный режим:
```dotenv
SECRETS_SOURCE=selectel
APP_ENV=production-like
APP_ENV=production
```
## 4. Bootstrap credential
@@ -116,23 +116,21 @@ sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred
## 5. Проверка и запуск
Все команды, которым нужны Compose secrets, запускайте от root через wrapper:
Secret sync и preflight запускает root. `deploy` не вызывает Docker/launcher:
```sh
cd /opt/han-chat/backend
sudo ./scripts/validate-env .env
sudo systemctl daemon-reload
sudo systemctl enable han-secrets@production.service
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
cd /opt/han-chat/current/backend
./scripts/validate-env /etc/han/vm1.env
systemctl restart han-secrets@production.service
./scripts/validate-env /etc/han/vm1.env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
sudo deployment/secrets/han-compose up -d --wait
deployment/preflight.sh
/usr/local/sbin/han-vm1-compose config --quiet
```
Selectel sync запускается именно unit-файлом: только он предоставляет
расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`.
`han-compose` и ops-скрипты используют уже синхронизированный manifest и
`han-vm1-compose` и ops-скрипты используют уже синхронизированный manifest и
отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с
runtime state. После смены source, provider version или JSON-карты сначала
выполняйте `systemctl restart han-secrets@production.service`.
@@ -142,27 +140,17 @@ runtime state. После смены source, provider version или JSON-кар
приёмки должна подтвердить отсутствие canary value в `docker inspect`, stdout,
json logs, traces и shell history.
На выделенной только под HAN Chat ВМ после canary и проверки file fallback
установите fail-closed ordering:
```sh
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
sudo install -m 0644 \
deployment/secrets/docker-han-secrets.conf.example \
/etc/systemd/system/docker.service.d/han-secrets.conf
sudo systemctl daemon-reload
sudo systemctl restart docker
```
После этого проведите reboot rehearsal: materializer должен завершиться до
autorestart контейнеров. Ошибка Selectel намеренно блокирует Docker. На ВМ с
другими workloads такой глобальный `Requires=` запрещён: нужен отдельный Docker
Fail-closed ordering задают `han-stack@production.service` и
`han-secrets@production.service`. Глобальную зависимость Docker daemon от
секретов не устанавливайте. После настройки проведите reboot rehearsal:
materializer должен завершиться до root stack unit. На ВМ с другими workloads
тем более запрещено связывать весь Docker
daemon/VM, иначе fail-closed HAN остановит несвязанные системы.
## 6. Явный file fallback
Подготовьте отдельную карту
`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту,
`/etc/han/secrets/production.file.json`: скопируйте Selectel-карту,
установите `"mode": "file"`, удалите `selectel` и `http`, добавьте:
```json
@@ -178,7 +166,7 @@ shell: запрещены `export`, substitutions, multiline, неизвестн
ключи. Файл — `root:root 0600`.
При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем
явно измените `.env`:
явно измените `/etc/han/vm1.env`:
```dotenv
SECRETS_SOURCE=file
@@ -198,7 +186,7 @@ validate/recreate через wrappers. После восстановления S
4. Сделайте версию current, удалите pin, снова sync и пересоздайте только
потребителей.
5. Для rollback активируйте предыдущую provider version; не храните snapshot
старого `.env`.
старого `/etc/han/vm1.env`.
Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые
значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна
@@ -26,11 +26,6 @@
"consumers": ["bitrix-local-app", "bitrix-local-migrate"],
"max_bytes": 4096
},
"BITRIX_SYNC_DATABASE_URL": {
"remote": "BITRIX_SYNC_DATABASE_URL",
"consumers": ["bitrix-sync", "bitrix-sync-migrate"],
"max_bytes": 4096
},
"SMS_DATABASE_URL": {
"remote": "SMS_DATABASE_URL",
"consumers": ["sms-service", "sms-worker", "sms-migrate"],
@@ -68,7 +63,7 @@
},
"BITRIX_TOKEN_ENCRYPTION_KEY": {
"remote": "BITRIX_TOKEN_ENCRYPTION_KEY",
"consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"],
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"REDIS_API_PASSWORD": {
@@ -86,16 +81,6 @@
"consumers": ["api-backend"],
"max_bytes": 4096
},
"REDIS_SAFETY_PASSWORD": {
"remote": "REDIS_SAFETY_PASSWORD",
"consumers": ["redis"],
"max_bytes": 1024
},
"MESSAGE_SAFETY_REDIS_URL": {
"remote": "MESSAGE_SAFETY_REDIS_URL",
"consumers": ["message-safety", "safety-recovery-worker"],
"max_bytes": 4096
},
"REDIS_HEALTH_PASSWORD": {
"remote": "REDIS_HEALTH_PASSWORD",
"consumers": ["redis", "redis-exporter"],
@@ -103,7 +88,7 @@
},
"MESSAGE_SAFETY_SERVICE_TOKEN": {
"remote": "MESSAGE_SAFETY_SERVICE_TOKEN",
"consumers": ["api-backend", "message-safety"],
"consumers": ["api-backend"],
"max_bytes": 1024
},
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": {
@@ -126,11 +111,6 @@
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_SYNC_SERVICE_TOKEN": {
"remote": "BITRIX_SYNC_SERVICE_TOKEN",
"consumers": ["api-backend", "bitrix-sync"],
"max_bytes": 1024
},
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": {
"remote": "KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
"consumers": ["api-backend", "keycloak"],
@@ -1,13 +1,13 @@
#!/bin/sh
set -eu
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/backend}
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}
SOURCE_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
[ ! -f "$SOURCE_DIR/../../docker-compose.yml" ] || \
DEPLOY_DIR=$(CDPATH= cd -- "$SOURCE_DIR/../.." && pwd)
cd "$DEPLOY_DIR"
CONFIG_FILE=${CONFIG_FILE:-.env}
CONFIG_FILE=${CONFIG_FILE:-/etc/han/vm1.env}
LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}
[ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets
@@ -1,18 +1,19 @@
[Unit]
Description=Materialize HAN service secrets (%i)
Description=Materialize HAN VM1 service secrets (%i)
Documentation=file:/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
Wants=network-online.target
After=network-online.target
Before=han-stack@%i.service
ConditionPathIsDirectory=/opt/han-chat/current/backend
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
RuntimeDirectory=han-chat/secrets
RuntimeDirectory=han-chat han-chat/secrets
RuntimeDirectoryMode=0700
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /opt/han-chat/backend/.env
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /etc/han/vm1.env
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred
RemainAfterExit=yes
StandardOutput=null